chore: redesign article localization before implementation
This commit is contained in:
1 parent
99a74dc3a2
commit
8c4a1825ab
31 files changed
+3365
-882
No files matched your search
@@ -35,6 +35,6 @@ Every state has a desktop and a mobile frame, and the theme switch gives light a
|
|||||||
- The Back-office board still shows the owner, admin and user navigation. Writer frames use the restricted `user` navigation of `AppFrame`.
|
- The Back-office board still shows the owner, admin and user navigation. Writer frames use the restricted `user` navigation of `AppFrame`.
|
||||||
- The Schema board group 8 (Permissions) is superseded by the role editor, and `/settings/permissions` redirects to `/settings/roles`.
|
- The Schema board group 8 (Permissions) is superseded by the role editor, and `/settings/permissions` redirects to `/settings/roles`.
|
||||||
|
|
||||||
## Article localization baseline (2026-10-31)
|
## Article localization baseline (refreshed 2026-11-01)
|
||||||
|
|
||||||
Start with [localization.md](localization.md), then [localization-tables.md](localization-tables.md). Target states are indexed in `screens/localization-*.md`. New references: `source/RenewCMS Screens Localization.dc.html`, `LocaleFrame.dc.html`, `locale-spec.js`, and `LocalizationShell.dc.html`. The shell is isolated so existing boards retain their original AppFrame reference. This imports design artifacts only; no production behavior or visual gate has been verified. The localization handoff supersedes older title immutability and per-site slug rules only for slices that explicitly accept those changes.
|
Start with [localization.md](localization.md) (authority, integration rules, open decisions), then [localization-tables.md](localization-tables.md) (generated from `source/locale-spec.js`) and [localization-corrections.md](localization-corrections.md) (what the last design pass rendered and left open). States are indexed in `states.md` and `screens/localization-*.md` (8 files, 169 states in 15 groups; L6 and L9 are release 2). References: `source/RenewCMS Screens Localization.dc.html`, `LocaleFrame.dc.html`, `locale-spec.js`, `locale-fixtures.js` and `LocalizationShell.dc.html`. The shell is isolated so existing boards keep their original AppFrame. The LocaleLive prototype was not imported. This is a target design; no production behavior or visual gate has been verified.
|
||||||
+19
-18
@@ -169,22 +169,23 @@ Accessibility of the tri-state cell:
|
|||||||
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
|
| BlockedDialog | New | UModal with dismissible false, no close button; UButton primary for the next step | Profile |
|
||||||
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
|
Specs: field heights 40 (44 mobile), buttons 36 (44 mobile), card padding 16, section gap 16. Colors from tokens only. Destructive confirm is disabled at 40% opacity until valid. Implemented in AccountCards.dc.html and the AppFrame modal.
|
||||||
|
|
||||||
## Localization components
|
## Localization components (spec)
|
||||||
|
| Component | Built from (Nuxt UI v4) | Notes |
|
||||||
All new components use existing theme tokens, square corners, 32px desktop and 44px mobile action targets unless the referenced frame specifies otherwise. Read `localization.md` and the matching `screens/localization-*.md` first.
|
|
||||||
|
|
||||||
| Component | Built from | Props, slots and responsive behavior |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| LanguageMenu | UButton + UDropdownMenu, UBadge, UIcon | Readable versions and allowed creation actions; one menu item per action, no nested buttons; bounded scrolling; full language names and selected marker; keyboard focus returns to trigger |
|
| LanguageMenu | UDropdownMenu with a UButton trigger | Menu button with menu; rows carry badges and a Read only tag; no Add translation row without create permission |
|
||||||
| LocalizedTitle | UForm + UFormField + UInput | Per-version value, language, direction, validation and dirty state; readonly text without update permission; title remains 3–200 characters |
|
| CapabilityGate | none (logic) | Renders each action from capabilities[]; never from the role name |
|
||||||
| TranslationDialog | UModal + UForm + UFormField + USelectMenu + UInput + URadioGroup + UAlert + UButton | Language, readable source, title, empty/copy choice; scrollable body; mobile bottom-sheet geometry; preserve entries on errors |
|
| EditorHeader | UButton, UBadge, UDropdownMenu | Title row, then language, badges and actions. At most one filled button. Collapse order and mobile bottom bar in the header priority table |
|
||||||
| SourcePane | Custom pane using UButton, UDropdownMenu, UIcon | Readonly article HTML; dir/lang on content; independent overflow; mobile source/edit switch; closing preserves draft and scroll |
|
| TitleInput | UInput (heading size) | Label "Title (French)"; bounds 3 to 200; counter; helper under the field |
|
||||||
| FreshnessBadge | UBadge variant="outline" + refresh icon | Separate from publication badge; text communicates state without color alone |
|
| ActionOverflow | UDropdownMenu (desktop), UDrawer (mobile) | Disabled items carry a visible reason |
|
||||||
| ReviewBanner | UAlert + UButton | Capability-gated review, reference and tracked-source actions; unavailable/deleted source states |
|
| StatusBadge | UBadge variant soft | Publication: solid icon plus word |
|
||||||
| MobileFilterSheet | USlideover side="bottom" + UFormField + USelectMenu + UButton | Site, language, status, author, SEO; apply/clear; active removable chips; URL persistence |
|
| FreshnessBadge | UBadge variant outline | Refresh icon plus word; icon-only at 1024 with aria-label |
|
||||||
| LocaleTabs | UTabs + UButton + UIcon | Bounded horizontal scrolling, edge cues and named scroll buttons; active tab reachable on keyboard and touch |
|
| SourcePane | UCard | Read-only reference with own scroll and dir/lang. Dashed outline and "Temporary reference" tag |
|
||||||
| SiteLanguageList | UTable desktop, responsive cards mobile; UDropdownMenu + UBadge | Owner-only mutations; readable metadata without mutation controls; default/disabled markers |
|
| TrackedSourceBadge | UBadge variant soft color info | Distinct from the temporary reference |
|
||||||
| LocalePager | Existing AppPaginator | Page/size and explicit story/version units; retain existing allowed sizes |
|
| ConflictDialog | UModal (desktop), UDrawer (mobile) | Four options; Keep my changes is the only filled button |
|
||||||
| LocalizationConfirm | Existing confirm dialog using UModal, UAlert, UButton | Language-specific scope; loading/failure; no disclosure of unreadable siblings |
|
| CoverageChips | UBadge variant outline | Collapse after 5 (1440) or 4 (1024, 390) with a +N chip; dashed for missing |
|
||||||
|
| ListFilters | USelectMenu, UInput, UDrawer on mobile | Language filter groups Enabled and Disabled |
|
||||||
Capability checks belong in existing access helpers/computed state. A generic CapabilityGate framework is not required. Reuse existing typed API client, toast, modal, media and editor components rather than reproducing the mockup helper implementations.
|
| UnitLabel | plain text, aria-live polite | "8 articles", "4 French versions", "4 articles not in French" |
|
||||||
|
| RowMenu | UDropdownMenu / UDrawer | Same actions on every width |
|
||||||
|
| SocialLocaleField | UInput, UBadge | Derived, Set, Not set |
|
||||||
|
| PublicUrlPattern | UInput, UAlert | Live preview from a real article, validation messages |
|
||||||
|
| MoveArticlePanel | USelectMenu, UModal | Note lists readable versions only |
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Localization corrections and completion report
|
||||||
|
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Fixtures: locale-fixtures.js. Spec: locale-spec.js. LocaleLive and the Handoff board were not touched.
|
||||||
|
|
||||||
|
## State counts
|
||||||
|
169 states in 15 groups (was 109). L1 25, L1b 9, L2 7, L3 4, L4 10, L5 25, L5b 9, L6 12 (release 2), L7 16, L8 13, L9 11 (release 2), L10 5, L11 7, L11b 8, L12 8. 33 states are also drawn at 1024. The full ID table is in states.md, and the board lists IDs, selectors and fixtures per state.
|
||||||
|
|
||||||
|
IDs: 99 existing states keep their group, frame and selector. 70 are new. Ten existing states were rekeyed without changing meaning: the seven permission states moved from frame ed to perm (L5b), and the three move failures moved from L11 to L11b. Legacy IDs (loc.perm.*, loc.dup.*, loc.lang.*, loc.src.*, loc.review.changed, loc.conflict, loc.slug.published, loc.url.unset, loc.seo.noFallback) were kept as the canonical ID of the state they map to; the loc.lN.xxx alias is listed beside each. Nothing was removed without a replacement.
|
||||||
|
|
||||||
|
## Sections to files and states
|
||||||
|
| Section | Files | State IDs |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 Permissions | LocaleFrame, locale-spec.js | loc.perm.readonly, loc.l5b.roMenu, loc.perm.editNoPublish, loc.perm.publishNoEdit, loc.perm.siblingReadOnly, loc.perm.siblingHidden, loc.perm.createNotAll, loc.l5b.noAdd, loc.l5b.withAdd, loc.l5.cleanOnline |
|
||||||
|
| 2 Header, actions, title | LocaleFrame, AppFrame (edHeader, edMobBar slots), spec tables Editor header priority, Action matrix, Title field states | loc.l5.default, dirty, dirtyPublish, cleanOnline, onlineDirty, saving, saved, saveFail, pubFail, ovf, focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong, titleSaving, failed; loc.l8.unpublishDirty |
|
||||||
|
| 3 Conflict | LocaleFrame | loc.conflict, loc.l11.conflictRestored, reloadConfirm, conflict2, download, restored, session |
|
||||||
|
| 4 List | LocaleFrame, fixtures view() | loc.l7.all, page2, fr, frMissing, emptyMissing, noResults, frLoading, listFail, limited, review, author, langFilter, rowMenu, filters, tabsScroll, long |
|
||||||
|
| 5 Languages and setup | LocaleFrame, routes.md | loc.l1.* (reenableDone, reenableFail, broader, defaultFail, removeFail, stale, social, socialEdit, socialInvalid, readonly), loc.l1b.*, loc.l2.noLocale, inProgress |
|
||||||
|
| 6 Move, delete, add translation | LocaleFrame | loc.l11b.*, loc.l8.deleteStoryOk, deleteStory, loc.l4.long, unreadable, langOff |
|
||||||
|
| 7 Review and reference (release 2) | LocaleFrame | loc.l9.*, loc.l6.* (arRef, narrow at 1024, rtl) |
|
||||||
|
| 8 Metadata, API, surfaces | LocaleFrame | loc.l10.*, loc.l12.* |
|
||||||
|
| 9 Fixtures | locale-fixtures.js, board tables Fixture index and Fixture totals check | all |
|
||||||
|
| 10 Accessibility | spec table Accessibility and interaction, board table Contrast | all |
|
||||||
|
| 11 Docs | routes.md, states.md, components.md, README.md, screens/localization-*.md (8 files, generated from the spec) | all |
|
||||||
|
|
||||||
|
## Rendered and inspected
|
||||||
|
I rendered the frame at full size and looked at these. Everything else is specified in the spec and the board, and drawn by the same code, but I did not look at it.
|
||||||
|
- 1440 light: ed.dirtyPublish, perm.ro, perm.p6, ed.menu, ed.titleLong, pd.unpublishDirty, pm.conflict, pm.moveSelect, fr.review, li.all, li.long, li.author, li.limited, sl.social, sl.broader, su.ready, pu.encoded, seo (default), api, sf.home, src.split.
|
||||||
|
- 1024: ed.dirtyPublish (dark), ed.cleanOnline, src.rtl (dark), li.long, li.all.
|
||||||
|
- 390: ed.dirtyPublish, ed.pubFail, ed.ovf, pm.conflict (all four options and the primary visible without scrolling), at.long (primary stays at the bottom), li.filters, li.long, li.rowMenu, li.page2, src.mobileSrc, sl.multi (dark), perm.ro (dark).
|
||||||
|
- Dark: li.all 1440, ed.dirtyPublish 1024, src.rtl 1024, sl.multi 390, perm.ro 390.
|
||||||
|
- The board's computed tables (fixture totals, state counts, contrast) were read in the browser. Every total matches the fixture. Contrast passes for every badge, chip and selected variant in light and dark. Disabled controls at 40% (2.6:1 light) are exempt.
|
||||||
|
|
||||||
|
## Not completed
|
||||||
|
- Most states were not looked at. Only the list above was rendered and inspected. 1024 frames exist for 33 states and I looked at five.
|
||||||
|
- Icons come from an online icon service and did not load in my screenshots, so icon presence and placement were not checked visually.
|
||||||
|
- Header widths between 1024 and 1440 were not drawn; the collapse order is specified and shown at the two ends.
|
||||||
|
- The mobile Languages rows are taller than needed (social locale wraps onto its own line). Not tuned.
|
||||||
|
- Nuxt UI components are named in components.md; the frames are built from the design tokens, as before.
|
||||||
|
- Archive, Notes and Gallery appear in single states, not as full lists.
|
||||||
|
- Setup frames still list all sites, although the route is per site (see below).
|
||||||
|
- LocaleLive and the Handoff board still describe the old behaviour.
|
||||||
|
|
||||||
|
## Documents that disagreed, and what I aligned to
|
||||||
|
- Route: routes.md and the board said /settings/languages; the brief says /settings/sites/:key/languages. Aligned to the brief. The setup page lists every site, so :key is the site it was opened from.
|
||||||
|
- Wording: the brief says "story" never appears in the UI, but section 1 uses "Delete story" and "You can't delete this story." I used "article" everywhere ("Delete article", "You can't delete this article."). Rule not changed; only the word.
|
||||||
|
- Count: the brief's example "5 French versions" disagrees with the decided fixture (fr 4). Aligned to the fixture: "4 French versions".
|
||||||
|
- routes.md still listed list params mode and needs_review and the old public route GET /v1/public/site/{key}/article/{slug}?lang=. Removed; aligned to tab and the two final public routes.
|
||||||
|
- states.md said the frame was not yet updated for the permission states; replaced by the generated table.
|
||||||
|
- The previous 1024 frames were a 1440 frame clipped to 1024. AppFrame now takes a width, so 1024 frames are real.
|
||||||
|
- Section 2 asks for a "Retry publish" action; the banner and the primary both use that label.
|
||||||
|
|
||||||
|
## Rules I believe are wrong
|
||||||
|
None changed. One to watch: Save over theirs sits at equal weight with Reload theirs, as required. If both stay unfilled, a user may not see that Save over theirs is the destructive one. The explanation text carries that. Left as is.
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Fresh-context implementation prompt
|
|
||||||
|
|
||||||
Paste the following into a new Codex chat in the RenewCMS workspace.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Implement article localization in RenewCMS using our Claude Code /slice workflow, adapted to your available agent tools. Act as orchestrator; do not implement the whole feature in one coder pass.
|
|
||||||
|
|
||||||
Read first:
|
|
||||||
- CLAUDE.md and CODING_STANDARDS.md.
|
|
||||||
- .claude/commands/slice.md.
|
|
||||||
- .claude/agents/specifier.md, coder.md, cleaner.md, architect.md, hardener.md, qa.md.
|
|
||||||
- Any applicable AGENTS.md instructions.
|
|
||||||
- docs/design/README.md and docs/design/localization.md.
|
|
||||||
- docs/design/localization-tables.md, relevant screens/localization-*.md, routes.md, states.md, components.md.
|
|
||||||
|
|
||||||
The user authorized importing the latest Claude Design localization output into docs/design in the previous chat. That import is complete. docs/design/localization.md records corrections and precedence, including compatibility, direct saving of online content, locale handling, conflict semantics and limitations. Treat it as the integration handoff. It does not mean Gherkin scenarios have been accepted.
|
|
||||||
|
|
||||||
The imported visual sources are:
|
|
||||||
- docs/design/source/RenewCMS Screens Localization.dc.html
|
|
||||||
- docs/design/source/LocaleFrame.dc.html
|
|
||||||
- docs/design/source/locale-spec.js
|
|
||||||
- docs/design/source/LocalizationShell.dc.html
|
|
||||||
|
|
||||||
LocalizationShell isolates the exported shell from older boards; production must reuse the existing SPA shell. Existing shared design boards were not overwritten. The connected localization prototype is unfinished and imported frames have not been visually validated. Do not claim visual parity or waive visual gates. New screen files enumerate 109 target states across 12 groups. The visual checker may need explicit source/group/viewport integration as a separately scoped change.
|
|
||||||
|
|
||||||
Use fresh subagents for stages. Run dependent stages strictly sequentially and hand off actual accepted scenario text, paths, exact changed-file scope and prior reports. Claude's Agent/TodoWrite/AskUserQuestion/Read/Bash names should be mapped to available equivalents. Do not assume Sonnet/Haiku models or PushNotification exist here. Keep reports on disk under a task-specific tmp directory. Report unavailable tools honestly.
|
|
||||||
|
|
||||||
Start by inspecting the current repository and relevant existing feature pairs. Propose a concise slice roadmap grounded in the handoff, then run specifier for the first bounded foundational slice. Choose a useful complete unit, not all localization storage, UI and review at once.
|
|
||||||
|
|
||||||
Present the first slice's Gherkin and companion QA draft for explicit acceptance. Do not write accepted feature files or start coder before acceptance; this is required by .claude/commands/slice.md and .claude/agents/specifier.md. Explain that source when presenting the approval step. After acceptance, verify the promised files exist under features/ before proceeding.
|
|
||||||
|
|
||||||
Then follow /slice faithfully:
|
|
||||||
- coder uses red/green/refactor and acceptance tests.
|
|
||||||
- Measure changed lines, files and layer crossings; select fast/full path.
|
|
||||||
- cleaner and architect only under the documented gates; architect is read-only.
|
|
||||||
- One scoped mutation pass owned by the appropriate stage, through task mutate only; hardener reuses its results on the full path.
|
|
||||||
- hardener verifies boundaries, tests, scenario fidelity, and required visual conformance.
|
|
||||||
- qa audits final handoffs and accepted-scenario coverage and runs required final checks.
|
|
||||||
- Follow documented retry limits and report blockers rather than inventing a PASS.
|
|
||||||
|
|
||||||
Preserve these constraints:
|
|
||||||
- Git read-only; no commits, checkout, reset or branch writes.
|
|
||||||
- No source comments except required tool directives.
|
|
||||||
- No secret, .env or real database reads.
|
|
||||||
- Dependency direction stays api → useCases → domain ← persistence/mailer.
|
|
||||||
- Existing API shapes and anonymous/public article listing behavior stay compatible unless a slice explicitly accepts a change.
|
|
||||||
- Regional locales are preserved; language setup never translates or guesses groups.
|
|
||||||
- Every version has independent permissions and publishing; unreadable siblings stay undisclosed.
|
|
||||||
- Saving an online version updates live content under existing update permission; no unpublished-revision workflow is being added.
|
|
||||||
- Optional reference language and tracked review source are separate.
|
|
||||||
- Use existing Nuxt UI components, typed API client and design tokens.
|
|
||||||
- docs/design is read-only during implementation stages. Surface remaining contradictions during specification and obtain a concrete design decision; do not improvise in coder or grant visual exceptions yourself.
|
|
||||||
|
|
||||||
Do not ask me to restate the feature or return to Claude Design. Use the imported handoff and repository. Ask only for material unresolved product decisions or the explicit scenario acceptance required by our workflow.
|
|
||||||
```
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Localization board tables
|
# Localization board tables
|
||||||
|
|
||||||
Imported from `source/locale-spec.js`. Read `localization.md` first: its explicit corrections supersede conflicting text below. API additions are proposals to be specified and accepted through `/slice`, not existing endpoints.
|
Generated from `source/locale-spec.js`. Read `localization.md` first: its explicit corrections supersede conflicting text below. API additions are proposals to be specified and accepted through `/slice`, not existing endpoints.
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
| List: language selected | One row per version; Not in French tab lists stories | Units always stated | Tabs and counts | lang, tab params | Initial |
|
| List: language selected | One row per version; Not in French tab lists stories | Units always stated | Tabs and counts | lang, tab params | Initial |
|
||||||
| Search | All languages: every accessible title. Language: that language only | Predictable | Search | Access-filtered | Initial |
|
| Search | All languages: every accessible title. Language: that language only | Predictable | Search | Access-filtered | Initial |
|
||||||
| Publishing | Per version; copy always names the language; no bulk publish | Prevents accidental scope | Publish button copy | None | Initial |
|
| Publishing | Per version; copy always names the language; no bulk publish | Prevents accidental scope | Publish button copy | None | Initial |
|
||||||
| Deleting | Per version. Last version deletes the story. Story delete needs permission on all | Explicit scope | Delete dialog | None | Initial |
|
| Deleting | Per version. Last version deletes the story. Article delete needs permission on all | Explicit scope | Delete dialog | None | Initial |
|
||||||
| Freshness | Needs review when the source revision changes after the reviewed revision; Mark as reviewed records it | Reliable, not timestamp-based | None | content_revision on versions; reviewed_source_revision | Initial |
|
| Freshness | Needs review when the source revision changes after the reviewed revision; Mark as reviewed records it | Reliable, not timestamp-based | None | content_revision on versions; reviewed_source_revision | Initial |
|
||||||
| Revision-bumping fields | Title, body, excerpt, slug, image alt text, SEO and social text. Not authors, cover, site, publication | Only translatable content counts | None | Revision incremented in same write | Initial |
|
| Revision-bumping fields | Title, body, excerpt, slug, image alt text, SEO and social text. Not authors, cover, site, publication | Only translatable content counts | None | Revision incremented in same write | Initial |
|
||||||
| Site move | Moves the whole story; destination must enable every language; slug conflicts block | No splitting or merging | Site select in editor | Move endpoint, validation | Initial |
|
| Site move | Moves the whole story; destination must enable every language; slug conflicts block | No splitting or merging | Site select in editor | Move endpoint, validation | Initial |
|
||||||
@@ -36,17 +36,22 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
| Missing vs hidden | "Not in French" lists stories where French is absent or unreadable to the user. Add French translation on a story with an unreadable French returns a neutral 409 (see Visibility policy) | Discovery cannot leak existence | List | Conflict check ignores read permission; response is neutral | Initial |
|
| Missing vs hidden | "Not in French" lists stories where French is absent or unreadable to the user. Add French translation on a story with an unreadable French returns a neutral 409 (see Visibility policy) | Discovery cannot leak existence | List | Conflict check ignores read permission; response is neutral | Initial |
|
||||||
| Source-copy access | Read access to the chosen source is rechecked at submit, inside the transaction | Access can change while the dialog is open | Add translation | Neutral 403/404 | Initial |
|
| Source-copy access | Read access to the chosen source is rechecked at submit, inside the transaction | Access can change while the dialog is open | Add translation | Neutral 403/404 | Initial |
|
||||||
| Disabled languages | Versions stay readable and editable; publishing blocked until re-enabled; filter lists them under Disabled | No silent unpublishing | Site languages | enabled flag per site language | Initial |
|
| Disabled languages | Versions stay readable and editable; publishing blocked until re-enabled; filter lists them under Disabled | No silent unpublishing | Site languages | enabled flag per site language | Initial |
|
||||||
|
| Header priority | Left to right: title input (own row), language dropdown, publication badge, status badge (unsaved, saving, saved, failed), freshness badge, Read only tag; then reference button, Unpublish, Save, primary, overflow. At most one filled button. Collapse order: reference label, Unpublish to overflow, freshness to icon | The primary action is never hidden | Editor header | None | Initial |
|
||||||
|
| Public URL pattern | Per-site owner setting with {lang} and {slug}; live preview from a real article; unset state links from "Public URL not configured" | No fabricated website URLs | Settings → Sites | public_url_pattern on site | Initial |
|
||||||
|
| Social locale per language | Per-language owner field on Languages: Derived (from the tag), Set (typed), Not set. Format language_REGION | Never guess a region | Languages page | social_locale per site language | Initial |
|
||||||
|
| Release split | Source reference (L6) and Freshness and review (L9) are release 2. Frames are drawn in full so they can be checked | Smaller first release | None | None | Release 2 |
|
||||||
|
| UI wording | The UI says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing copy. Internal notes may say story | Plain language | All localization copy | None | Initial |
|
||||||
|
|
||||||
## Capability matrix (per version, enforced server-side)
|
## Capability matrix (per version, enforced server-side)
|
||||||
|
|
||||||
| Case | Read | Edit | Publish | Delete | Create translation | Review | UI result |
|
| Case | Read | Edit | Publish | Delete | Create translation | Review | UI result |
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
| P1 Read-only version | Yes | No | No | No | If can create on site | No | Banner "You can read this version. You can't edit it." No Save, Publish, Delete; formatting toolbar hidden; fields read-only; source reference still available |
|
| P1 Read-only version | Yes | No | No | No | If can create on site | No | Banner "You can read this version. You can't edit it." No Save, Publish, Delete; formatting toolbar hidden; fields read-only; the reference action remains |
|
||||||
| P2 Edit, no publish | Yes | Yes | No | Per scope | Per scope | If can edit | Save shown; Publish hidden; hint "Publishing needs the publish permission" |
|
| P2 Edit, no publish | Yes | Yes | No | Per scope | Per scope | If can edit | Save shown; Publish hidden; hint "Publishing needs the publish permission" |
|
||||||
| P3 Publish, no edit | Yes | No | Yes | Per scope | Per scope | No | Publish/Unpublish shown, fields read-only (only if existing permissions allow publish without edit) |
|
| P3 Publish, no edit | Yes | No | Yes | Per scope | Per scope | No | Publish/Unpublish shown, fields read-only (only if existing permissions allow publish without edit) |
|
||||||
| P4 Sibling readable, not editable | Yes | No | No | No | n/a | n/a | Opens read-only; row tag "Read only" |
|
| P4 Sibling readable, not editable | Yes | No | No | No | n/a | n/a | Opens read-only; row tag "Read only" |
|
||||||
| P5 Sibling not readable | No | No | No | No | n/a | n/a | Row absent; no count, no placeholder |
|
| P5 Sibling not readable | No | No | No | No | n/a | n/a | Row absent; no count, no placeholder |
|
||||||
| P6 Can create, not modify all | Yes | Own versions | Own | Own | Yes | Own | Story delete and move disabled: "You can't delete this story." Delete this version stays available if allowed |
|
| P6 Can create, not modify all | Yes | Own versions | Own | Own | Yes | Own | Delete article and Move article disabled with the reason "You can't delete this article." Delete French version stays available if allowed |
|
||||||
| Owner / Admin | Yes | Yes | Yes | Yes | Yes | Yes | All readable versions; not inferred from role label alone |
|
| Owner / Admin | Yes | Yes | Yes | Yes | Yes | Yes | All readable versions; not inferred from role label alone |
|
||||||
|
|
||||||
## Field ownership, copy and fallback
|
## Field ownership, copy and fallback
|
||||||
@@ -69,31 +74,41 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
|
|
||||||
| Capability | Status | Request | Errors |
|
| Capability | Status | Request | Errors |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| List articles | Existing, extended | GET /v1/article/?page=&size=&q=&status=&site=&seo= (existing). Proposed: lang, tab=all / missing / review (same names as the URL; no separate mode or needs_review) | 400 invalid page/size (SPA never shows it) |
|
| List articles | Existing, extended | GET /v1/article/?page=&size=&q=&status=&site=&seo= (existing). Proposed: lang, tab=all\|missing\|review (same names as the URL; no separate mode or needs_review) | 400 invalid page/size (SPA never shows it) |
|
||||||
| Get article | Existing, extended | GET /v1/article/{id}. Proposed fields: lang, story_id, capabilities[], translations[] (readable only), source { article_id, revision, status: tracked / inaccessible / deleted } | 404 for hidden or deleted |
|
| Get article | Existing, extended | GET /v1/article/{id}. Proposed fields: lang, story_id, capabilities[], translations[] (readable only), source { article_id, revision, status: tracked\|inaccessible\|deleted } | 404 for hidden or deleted |
|
||||||
| Create article | Existing, extended | POST /v1/article/. Proposed: lang, story_id, source_article_id, copy (routes to translation rules) | 409 language_unavailable · 422 language_not_enabled |
|
| Create article | Existing, extended | POST /v1/article/. Proposed: lang, story_id, source_article_id, copy (routes to translation rules) | 409 language_unavailable · 422 language_not_enabled |
|
||||||
| Edit | Existing, extended | PATCH /v1/article/{id}. Proposed: if_revision for overwrite recheck | 409 revision_conflict { current_revision } |
|
| Edit | Existing, extended | PATCH /v1/article/{id}. Proposed: if_revision for overwrite recheck | 409 revision_conflict { current_revision } |
|
||||||
| Publish / unpublish | Existing | PATCH /v1/article/{id}/publish, /unpublish. Per version | 403 missing_permission · 422 language_disabled |
|
| Publish / unpublish | Existing | PATCH /v1/article/{id}/publish, /unpublish. Per version | 403 missing_permission · 422 language_disabled |
|
||||||
| Delete | Existing | DELETE /v1/article/{id}. Last version deletes the story | 403 · 404 |
|
| Delete | Existing | DELETE /v1/article/{id}. Last version deletes the story | 403 · 404 |
|
||||||
| Add translation | Proposed | POST /v1/article/{id}/translations { lang, title, source_article_id, copy }. Path id is any readable version; source_article_id selects the copy source; source_revision captured in the transaction | 409 language_unavailable (neutral) · 404 (neutral) · 422 · 403 |
|
| Add translation | Proposed | POST /v1/article/{id}/translations { lang, title, source_article_id, copy }. Path id is any readable version; source_article_id selects the copy source; source_revision captured in the transaction | 409 language_unavailable (neutral) · 404 (neutral) · 422 · 403 |
|
||||||
| Mark reviewed | Proposed | POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } | 409 source_changed · translation_changed · source_replaced · 403 |
|
| Mark reviewed | Proposed | POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } | 409 source_changed · translation_changed · source_replaced · 403 |
|
||||||
| Change tracked source | Proposed | PATCH /v1/article/{id}/source { source_article_id / null } | 422 source_cycle · 422 source_other_story · 403 |
|
| Change tracked source | Proposed | PATCH /v1/article/{id}/source { source_article_id\|null } | 422 source_cycle · 422 source_other_story · 403 |
|
||||||
| Site languages | Proposed | GET/PUT /v1/site/{key}/languages { default, languages[{ lang, enabled }] }, atomic | 422 default_not_enabled · no_enabled_language · 409 language_in_use |
|
| Site languages | Proposed | GET/PUT /v1/site/{key}/languages { default, languages[{ lang, enabled }] }, atomic | 422 default_not_enabled · no_enabled_language · 409 language_in_use · 409 languages_changed |
|
||||||
| Setup | Proposed | GET/POST /v1/language/setup { sites:[{ key, lang }] }, idempotent per site key; articles created during setup get the site default; retry after failure is safe | 403 · 409 setup_in_progress |
|
| Setup | Proposed | GET/POST /v1/language/setup { sites:[{ key, lang }] }, idempotent per site key; articles created during setup get the site default; retry after failure is safe. UI route /settings/sites/:key/languages/setup | 403 · 409 setup_in_progress |
|
||||||
| Move story | Proposed | POST /v1/story/{story_id}/move { site }, atomic, revalidates | 409 slug_conflict · 422 language_not_enabled · 403 (neutral) |
|
| Move story | Proposed | POST /v1/story/{story_id}/move { site }, atomic, revalidates | 409 slug_conflict · 422 language_not_enabled · 403 (neutral) |
|
||||||
| Publish with unsaved edits | Client sequence | PATCH /v1/article/{id} (save) then PATCH /v1/article/{id}/publish. The second call is sent only if the first returns 200. No new endpoint | Save failure: nothing published, edits stay unsaved |
|
| Publish with unsaved edits | Client sequence | PATCH /v1/article/{id} (save) then PATCH /v1/article/{id}/publish. The second call is sent only if the first returns 200. No new endpoint | Save failure: nothing published, edits stay unsaved |
|
||||||
| Public article (two requests) | Proposed | Exact: GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Resolve: GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. Existing article-ID links unchanged | See Public lookup |
|
| Public article (two requests) | Proposed | Exact: GET /v1/public/site/{key}/lang/{lang}/article/{slug}. Resolve: GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. Existing article-ID links unchanged | See Public lookup |
|
||||||
|
| Public URL pattern | Proposed | GET/PUT /v1/site/{key} { public_url_pattern }. Placeholders {lang}, {slug}. Owner only | 422 unknown_placeholder · invalid_scheme · not_encoded · missing_slug · 403 |
|
||||||
|
| Social locale | Proposed | PUT /v1/site/{key}/languages { languages[{ lang, enabled, social_locale\|null }] } | 422 invalid_social_locale |
|
||||||
|
|
||||||
## Accessibility and interaction
|
## Accessibility and interaction
|
||||||
|
|
||||||
| Control | Accessible name and keyboard | Focus | Announcement |
|
| Control | Accessible name and keyboard | Focus | Announcement |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Language control | "Language: French. Switch version". Enter or Down opens a menu; arrows move; Enter selects; Escape closes | Into list on open; back to control on close | Polite: "Editing French version" |
|
| Language control | "Language: French. Switch version". Enter or Down opens a menu; arrows move; Enter selects; Escape closes | Into the list on open; back to the control on close | Polite: "Editing French version" |
|
||||||
|
| Title input | Label "Title (French)"; aria-describedby points to the help or error text; invalid sets aria-invalid and the message is an alert | Stays in the field; on Try again, back to the field | Polite: "Saving title", "Title saved"; errors are announced |
|
||||||
|
| Save, Save and publish, Retry publish | Buttons named by their text including the language ("Save and publish French"); busy state sets aria-busy; Tab order: title, language, status, reference, Unpublish, Save, primary, More actions | After save, stays on the button; after a failed save, on Try saving again | Polite: "French saved"; "Saved as a draft. Publishing failed. Try publishing again." |
|
||||||
|
| More actions menu or sheet | Menu button "More actions"; items are menuitems; disabled items use aria-disabled and show the reason as text | First enabled item on open; back to More actions on close | None |
|
||||||
|
| Reference button | "Show source reference" or "Hide source reference", icon-only at 1024 with the same name | Moves into the pane on show; back to the button on hide | Polite: "Showing English reference" |
|
||||||
|
| Conflict dialog and sheet | role dialog, modal; options are buttons in order Keep my changes, Download my version, Reload theirs, Save over theirs; Escape equals Keep my changes | First button (Keep my changes) on open; back to the title on close | Polite: "Download started" |
|
||||||
|
| Move panel and dialogs | Site select is a listbox named "Site"; the note is the description of the confirm button | Into the list; back to the site select | Polite: "Moved to Atelier" |
|
||||||
| Add translation dialog | Focus trapped; Escape cancels unless creating | First field; back to the control | Polite: "French draft created" |
|
| Add translation dialog | Focus trapped; Escape cancels unless creating | First field; back to the control | Polite: "French draft created" |
|
||||||
| Source segment (mobile) | Two-button radiogroup, arrows switch | Stays on the segment | Polite: "Showing English source" |
|
| Source segment (mobile) | Two-button radiogroup, arrows switch | Stays on the segment | Polite: "Showing English source" |
|
||||||
| Coverage chips | aria-label per chip: "French: draft, needs review" | Row, not chips | None |
|
| Coverage chips | aria-label per chip: "French: draft, needs review". The "+3" chip is named "3 more languages: …" | Row, not chips. Dashed chips are buttons on desktop only when the user can create | None |
|
||||||
| Dialogs on mobile | Bottom sheet, grabber, 44 px targets | As dialogs elsewhere | None |
|
| List unit label | aria-live polite: "4 articles not in French" | None | Polite on every filter change |
|
||||||
| Reduced motion | No slide transitions on popover or sheets | | |
|
| Status without color | Publication badges are solid with an icon (Published: check, Draft: pencil). Freshness badges are outlined with a refresh icon and a word. Save status always has an icon and a word | None | None |
|
||||||
|
| Touch targets | On mobile every button, menu row, chip button and segment is at least 44 px high; the bottom bar keeps the primary visible | None | None |
|
||||||
|
| Reduced motion | No slide or fade transitions on popovers, sheets and the tab strip scroll when prefers-reduced-motion is set; the saving spinner becomes a static icon with the word "Saving" | None | None |
|
||||||
|
|
||||||
## Acceptance scenarios
|
## Acceptance scenarios
|
||||||
|
|
||||||
@@ -117,22 +132,23 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
|
|
||||||
| Item | Note |
|
| Item | Note |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Home | Recent articles show the default-language title with a language code when not default. No other change. |
|
| Home | Recent articles show the default-language title with a language code when not default. States loc.l12.home, loc.l12.homeFr |
|
||||||
| Activity | Events gain the language: "Published French version of …". Existing events unchanged. |
|
| Activity | Events gain the language: "Published French version of …". State loc.l12.activity |
|
||||||
| Article menu | Publish and Delete labels name the language on multilingual sites. |
|
| Article menu | Publish and Delete labels name the language on multilingual sites. State loc.l12.menu |
|
||||||
| API URL copy | Copies the version URL with ?lang= on multilingual sites. |
|
| API URL copy | Copies the version URL with ?lang=. State loc.l12.apiCopy |
|
||||||
| SEO preview | Shows the localized slug and canonical. |
|
| SEO preview | Shows the localized address and canonical. State loc.l12.seoPreview |
|
||||||
| Media usage | Counts a shared image once; lists versions that use it. |
|
| Media usage | Counts a shared image once; lists versions that use it. State loc.l12.media |
|
||||||
| Verification limit | Repository files (docs/design, docs/api/articles.md, EditorView.vue, ArticleView.vue, NewArticleModal.vue, ArticleDetails.vue, useArticleDraft.ts, articleDomain.go, siteDomain.go, ResolveArticleSEO.go, features/*.feature) were not accessible. Statements about them are assumptions marked verify in repo. The .claude/design paths do not exist; the same files are at the project root. |
|
| Site text fallback | A site-level text (for example a site description) is a fallback only for articles in the same language. A French article can use the French site text; an Arabic article with no Arabic site text gets "No fallback" and the tag is left out. State loc.l12.siteFallback |
|
||||||
| Decided | Slug policy, hidden-version disclosure, Home counting stories, and review storing source_revision only (no text diff) are decided in the other tables. |
|
| Verification limit | Repository files were not accessible. Statements about them are assumptions marked verify in repo. |
|
||||||
|
| Decided | Slug policy, hidden-version disclosure, Home counting articles, and review storing the source version only (no text diff) are decided in the other tables. |
|
||||||
|
|
||||||
## Visibility and anti-enumeration policy
|
## Visibility and anti-enumeration policy
|
||||||
|
|
||||||
| Situation | User can read it | User cannot read it |
|
| Situation | User can read it | User cannot read it |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Duplicate translation | "French already exists for this story." Action: Open French version | "French can't be created for this story." No Open action, no title, no hint a version exists |
|
| Duplicate translation | "French already exists for this article." Action: Open French version | "French can't be created for this article." No Open action, no title, no hint a version exists |
|
||||||
| Create conflict response | 409 language_exists with article_id | 409 language_unavailable; identical body for exists-unreadable, disabled and lost race |
|
| Create conflict response | 409 language_exists with article_id | 409 language_unavailable; identical body for exists-unreadable, disabled and lost race |
|
||||||
| Story delete confirmation | Names readable versions: "Delete English and French?" | Unreadable versions are not named or counted. If any blocks deletion: "You can't delete this story." plus Delete this version |
|
| Article delete confirmation | Names readable versions: "Delete English and French?" | Unreadable versions are not named or counted. If any blocks deletion: "You can't delete this article." plus Delete this version |
|
||||||
| Source unavailable | Deleted: "The source was deleted." | "The source isn't available to you." |
|
| Source unavailable | Deleted: "The source was deleted." | "The source isn't available to you." |
|
||||||
| Missing translation tab | Row shown; Add French translation | Same row; a 409 shows the neutral copy |
|
| Missing translation tab | Row shown; Add French translation | Same row; a 409 shows the neutral copy |
|
||||||
| Counts and summaries | Readable versions only | Never included |
|
| Counts and summaries | Readable versions only | Never included |
|
||||||
@@ -174,30 +190,30 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
| Filters | Status, Author, SEO, Needs review: a story matches if any readable version matches (language mode: the version) | | | | |
|
| Filters | Status, Author, SEO, Needs review: a story matches if any readable version matches (language mode: the version) | | | | |
|
||||||
| Story Author, Updated | Union of readable authors; latest readable update | | | | |
|
| Story Author, Updated | Union of readable authors; latest readable update | | | | |
|
||||||
| Differing sites | Language filter lists the union; Missing excludes sites that do not enable the language | | | | |
|
| Differing sites | Language filter lists the union; Missing excludes sites that do not enable the language | | | | |
|
||||||
| Dataset (locale-fixtures.js) | Journal: 8 stories, 17 versions, page size 5. Enabled en (default), fr, de, ar; disabled es, it. Versions: en 7 (6 published), fr 4 (2 published, 2 Needs review), de 4 (3 published), ar 1, es 1, it 0. 4 stories have no French version. Needs translation 7, Needs review 2. Every list, tab count, summary and pager is computed from this one fixture. Separate fixtures: Press (3 stories, all translated, nothing-missing state), Atelier (8 languages, long labels), Journal before French (prototype start). | | | | |
|
| Dataset (locale-fixtures.js) | See the Fixture index. Journal: 8 articles, 17 versions, page size 5 (page 1 shows 5, page 2 shows 3). Enabled en (default), fr, de, ar; disabled es, it. Versions: en 7 (6 published), fr 4 (2 published, 2 Needs review), de 4 (3 published), ar 1, es 1, it 0. 4 articles have no French version. Needs translation 7, Needs review 2. Every list, tab count, unit label and pager is computed from locale-fixtures.js. | | | | |
|
||||||
|
|
||||||
## Review source and revision semantics
|
## Review source and revision semantics
|
||||||
|
|
||||||
| Topic | Decision |
|
| Topic | Decision |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Record | { article_id, source_article_id, source_revision, translation_revision, reviewed_by, reviewed_at } |
|
| Record | { article_id, source_article_id, source_revision, translation_revision, reviewed_by, reviewed_at } |
|
||||||
| Confirmation guard | Dialog carries translation_revision. If either revision moved: 409 source_changed or translation_changed; dialog says "This changed since you opened it. Review again." Nothing is marked |
|
| Confirmation guard | The dialog carries the versions the user saw. If either moved, the dialog says "The English source changed while you were reviewing. Review again." or "French was changed by someone else while you were reviewing. Review again." Nothing is marked |
|
||||||
| Change tracked source | Explicit action Change tracked source. Result: Needs review. Earlier reviews are kept as history |
|
| Change tracked source | Explicit action Change tracked source. Result: Needs review. Earlier reviews are kept as history |
|
||||||
| Cycles | Prevented (422 source_cycle). Source must be in the same story |
|
| Cycles | Prevented (422 source_cycle). Source must be in the same story |
|
||||||
| Access loss vs deletion | Access lost: "The source isn't available to you right now." history kept, Mark reviewed disabled. Deleted: "Source deleted" with Choose a new source |
|
| Access loss vs deletion | Access lost: "The source isn't available to you right now." history kept, Mark reviewed disabled. Deleted: "Source deleted" with Choose a new source |
|
||||||
| Source replacement | Pick another readable version of the story; freshness becomes Needs review |
|
| Source replacement | Pick another readable version of the story; freshness becomes Needs review |
|
||||||
| What changed | Changed-field summary only (Title, Body, Excerpt, SEO) from field-level revision flags. No text diff this release; label "Changed fields" |
|
| What changed | Changed-field summary only, shown as "Changed fields: Title, Body", with the source version date and the translation version date. No text diff and no revision numbers in the UI |
|
||||||
| Concurrent edits | Mark reviewed applies only to the revisions the user saw |
|
| Concurrent edits | Mark reviewed applies only to the revisions the user saw |
|
||||||
|
|
||||||
## Title, slug and concurrent edit flows
|
## Title, slug and concurrent edit flows
|
||||||
|
|
||||||
| Case | Behavior |
|
| Case | Behavior |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Title editing | Inline input in heading style. States: pristine, editing, invalid ("Enter a title"), saving, failed (value kept), unsaved. Accessible label "Title (French)" |
|
| Title editing | Inline input in heading style. States: pristine, focus, editing, unsaved, invalid ("Enter a title"), too short, too long, saving, failed (value kept). Bounds 3 to 200 characters. Accessible label "Title (French)". Editing the title never changes the slug |
|
||||||
| Slug | See Slug policy. Edit slug lives in Search and sharing |
|
| Slug | See Slug policy. Edit slug lives in Search and sharing and in the overflow menu; its note says "Editing the title never changes the slug." |
|
||||||
| Conflict | "Someone else saved this version." Choices: Keep my changes (nothing sent), Download my version (title and body as text), Save over theirs (sends if_revision with the revision fetched now; a newer save gives a new 409), Reload theirs (confirm: "This discards your unsaved changes.") |
|
| Conflict | "Someone else saved this version at <time>." Choices: Keep my changes (filled; nothing sent), Download my version, Reload theirs (confirm: "This discards your unsaved changes."), Save over theirs (sends the version fetched now; a newer save gives a new conflict) |
|
||||||
| Overwrite scope | "Replaces the title, body and search fields of this version only. Other languages are untouched." |
|
| Overwrite scope | "Replaces the title, body and search fields of this version only. Other languages are untouched." |
|
||||||
| Session expiry | Draft kept locally per article id (title, body, search fields, base revision) until saved or discarded. After login: "Restored unsaved changes from <time>." |
|
| Session expiry | Draft kept locally per article (title, body, search fields) until saved or discarded. After login: "Restored unsaved changes from <time>." |
|
||||||
|
|
||||||
## Language configuration lifecycle
|
## Language configuration lifecycle
|
||||||
|
|
||||||
@@ -215,7 +231,127 @@ Imported from `source/locale-spec.js`. Read `localization.md` first: its explici
|
|||||||
|
|
||||||
| Item | Spec |
|
| Item | Spec |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Filter sheet | Site, Status, Author, SEO, Language; Apply, Clear all; chips with "Remove filter Status: Draft" |
|
| Filter sheet | Site, Language, Status, Author, SEO; Clear all and "Show N results"; active filters show as chips with "Remove filter Status: Draft" and Clear all |
|
||||||
| Tabs | Scrolls with edge fade and buttons "Scroll tabs left/right"; Not in French always reachable |
|
| Tabs | Scrolls with edge fade and buttons "Scroll tabs left/right"; Not in French is always reachable |
|
||||||
| Pager | Same pager and page size as desktop; URL keeps page and size |
|
| Pager | Same page size as desktop (5); "Page 1 of 2"; the URL keeps page and size |
|
||||||
| Prototype flows | Wired in RenewCMS Prototype, flow Localization: F1 enable French, F2 create translation, F3 edit beside English, F4 save and publish French, F5 edit English, F6 freshness warning, F7 review, F8 missing tab, F9 switch with unsaved, F10 delete one version, F11 restricted user. Starts from the Journal-before-French fixture |
|
| Row actions | Identical capability on every width: see Row actions. Mobile uses the row menu button (44 px), desktop shows the same button (28 px) |
|
||||||
|
| Prototype flows | Not part of this pass. The LocaleLive prototype was not touched |
|
||||||
|
|
||||||
|
## Editor header priority
|
||||||
|
|
||||||
|
| Order | Item | 1440 | 1024 | 390 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Title input (own row, heading style) | Full width | Full width | Full width |
|
||||||
|
| 2 | Language dropdown | Visible | Visible | Visible, 44 px |
|
||||||
|
| 3 | Publication badge | Visible | Visible | Visible |
|
||||||
|
| 4 | Primary action (the only filled button) | Rightmost before More actions | Same | Fixed bottom bar, fills the width left by Save and More |
|
||||||
|
| 5 | Status badge (Unsaved changes, Saving, Saved, Save failed) | Visible | Visible | Visible |
|
||||||
|
| 6 | Save (secondary) | Visible | Visible | Bottom bar, next to the primary |
|
||||||
|
| 7 | Freshness badge | Icon and word | Collapses first: icon only, name in aria-label | Wraps under the language row |
|
||||||
|
| 8 | Read only tag | Visible | Visible | Visible |
|
||||||
|
| 9 | Reference button | Icon and label | Collapses second: icon only | More actions sheet |
|
||||||
|
| 10 | Unpublish (secondary) | Visible | Collapses third: More actions menu | More actions sheet |
|
||||||
|
| 11 | More actions | Edit slug, Move article, Delete versions | Same plus Unpublish | Same plus reference and Unpublish |
|
||||||
|
|
||||||
|
## Action matrix
|
||||||
|
|
||||||
|
| State | Permissions | Primary (filled) | Secondary | Notes |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Clean draft | edit + publish | Publish French | Save (off) | loc.l5.default |
|
||||||
|
| Clean draft | edit only | None | Save (off) | Hint: Publishing needs the publish permission |
|
||||||
|
| Dirty draft | edit only | Save | None | loc.l5.dirty; "Saving keeps it as a draft." |
|
||||||
|
| Dirty draft | edit + publish | Save and publish French | Save | loc.l5.dirtyPublish |
|
||||||
|
| Clean online | edit + publish | None | Unpublish French, Save (off) | loc.l5.cleanOnline; "This version is online. Saving updates the live page right away." |
|
||||||
|
| Dirty online | edit + publish | Save | Unpublish French | loc.l5.onlineDirty |
|
||||||
|
| Saving | any | Saving… (busy) | None | loc.l5.saving |
|
||||||
|
| Saved | any | Publish French if allowed | Save (off) | loc.l5.saved |
|
||||||
|
| Save failed | edit | Try saving again | None | loc.l5.saveFail; nothing published |
|
||||||
|
| Publish failed after save | edit + publish | Retry publish | Save (off) | loc.l5.pubFail; "Saved as a draft. Publishing failed. Try publishing again." |
|
||||||
|
| Unpublish with edits | publish | Unchanged | Unpublish French | loc.l8.unpublishDirty; saved version goes offline, local edits stay, nothing is saved |
|
||||||
|
| Read-only | read | None | None | Reference button remains |
|
||||||
|
| Publish without edit | publish | Publish French | None | Fields read-only |
|
||||||
|
|
||||||
|
## Title field states
|
||||||
|
|
||||||
|
| State | Look | Message | Save |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Pristine | Heading-size text, neutral border | None | Off |
|
||||||
|
| Focus | Primary outline | "Changing the title doesn't change the address." | Off |
|
||||||
|
| Editing | Primary border, counter n / 200 | Same note | On |
|
||||||
|
| Unsaved | Neutral border, Unsaved changes badge | Same note | On |
|
||||||
|
| Invalid | Error border and icon | "Enter a title" | Blocked |
|
||||||
|
| Too short | Error border, counter | "Use at least 3 characters" | Blocked |
|
||||||
|
| Too long | Error border, counter 203 / 200 | "Use 200 characters or fewer" | Blocked |
|
||||||
|
| Saving | Spinner | "Saving title…" | Busy |
|
||||||
|
| Save failed | Error border and icon | "Couldn't save the title. Your text is kept." Try again | On |
|
||||||
|
|
||||||
|
## Row actions (same on every width)
|
||||||
|
|
||||||
|
| Action | Shown when | Desktop | Mobile |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Open <language> | Always, for the version the row opens | Row click and menu | Row tap and menu |
|
||||||
|
| Open another language… | More than one readable version | Menu | Menu |
|
||||||
|
| Add translation… | User can create and a language is missing | Menu (dashed chip button as a shortcut) | Menu |
|
||||||
|
| Publish / Unpublish <language> | User can publish | Menu | Menu |
|
||||||
|
| Copy API URL | Always | Menu | Menu |
|
||||||
|
| Delete <language> version | User can delete | Menu | Menu |
|
||||||
|
|
||||||
|
## Coverage chips for many languages
|
||||||
|
|
||||||
|
| Width | Chips shown | Overflow | Dashed missing chips |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1440 | 5 | "+3" chip for 8 languages; named "3 more languages: …" | Last in order; buttons only if the user can create; hidden otherwise |
|
||||||
|
| 1024 | 4 (narrower column) | "+4" | Same |
|
||||||
|
| 390 | 4, under the title | "+4" | Not buttons; added from the row menu |
|
||||||
|
| Order | Default language, other existing versions, disabled-language versions, then missing | | Never shown for languages the user can't create |
|
||||||
|
|
||||||
|
## URL state (article list)
|
||||||
|
|
||||||
|
| Param | Values | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| site | Site key | First site |
|
||||||
|
| lang | Language code; absent means all languages | all |
|
||||||
|
| tab | all, missing, review (Published and Drafts are the status filter) | all |
|
||||||
|
| q | Search text | empty |
|
||||||
|
| status | online, draft | any |
|
||||||
|
| author | Author name | any |
|
||||||
|
| seo | attention | any |
|
||||||
|
| page | Page number, starting at 1; filter changes reset it | 1 |
|
||||||
|
| size | Rows per page | 5 in the fixture |
|
||||||
|
|
||||||
|
## Public URL pattern validation
|
||||||
|
|
||||||
|
| Case | Example | Message |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Valid | https://journal.example.com/{lang}/{slug} | Preview: https://journal.example.com/fr/comment-nous-chiffrons-une-renovation |
|
||||||
|
| Unknown placeholder | https://journal.example.com/{language}/{slug} | Unknown placeholder {language}. Use {lang} and {slug}. |
|
||||||
|
| Bad scheme | ftp://journal.example.com/{lang}/{slug} | Start with https:// or http://. |
|
||||||
|
| Not encoded | https://journal.example.com/my pages/{lang}/{slug} | Remove spaces and characters that need encoding. Use letters, digits, - and _. |
|
||||||
|
| Missing {slug} | https://journal.example.com/{lang} | Add {slug} so each article has its own address. |
|
||||||
|
| Unset | | Public URL not configured, with a link to this setting |
|
||||||
|
|
||||||
|
## Social locale per language
|
||||||
|
|
||||||
|
| State | Label | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Derived | Derived: pt_BR | The language tag has a region. Not editable except by setting a value |
|
||||||
|
| Set | Set: fr_FR | An owner typed it. Format language_REGION |
|
||||||
|
| Not set | Not set | No region available. og:locale and og:locale:alternate are left out |
|
||||||
|
| Invalid | fr-FR | Use letters and one underscore, for example fr_FR. |
|
||||||
|
|
||||||
|
## Move article
|
||||||
|
|
||||||
|
| Case | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Entry point | The site select in the editor details |
|
||||||
|
| Note | "Moves all language versions of this article." Lists readable versions; a version the user can read but not edit is tagged Read only; unreadable versions are not listed or counted |
|
||||||
|
| Confirm | "Move to Atelier?" with the same list |
|
||||||
|
| Failures | Language missing in the destination (disabled languages count), address conflict (names only readable conflicting versions), no permission (neutral) |
|
||||||
|
|
||||||
|
## Release 2 groups
|
||||||
|
|
||||||
|
| Group | Release | Why |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| L6 Source reference | Release 2 | Drawn in full so the checker has frames |
|
||||||
|
| L9 Freshness and review | Release 2 | Drawn in full so the checker has frames |
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# Article localization implementation handoff
|
# Article localization implementation handoff
|
||||||
|
|
||||||
Baseline imported 2026-10-31 from `.claude/design`. This is a target design, not a statement that the feature exists. The user authorized this import and reconciliation. Feature and QA acceptance still happen per slice before implementation.
|
Baseline imported 2026-10-31 and refreshed 2026-11-01 (completion pass) from `.claude/design`. This is a target design, not a statement that the feature exists. The user authorized this import and reconciliation. Feature and QA acceptance still happen per slice before implementation.
|
||||||
|
|
||||||
## Authority and imported artifacts
|
## Authority and imported artifacts
|
||||||
|
|
||||||
For localization, use this document for integration rules and corrections, `localization-tables.md` for the imported product tables, and `screens/localization-*.md` for the state inventory. Use `source/LocaleFrame.dc.html` for layout and rendered copy. This document supersedes conflicting board notes and older title/slug assumptions. Existing accepted `features/` behavior remains binding until a new accepted slice explicitly changes it. Report any remaining conflict at specification time; do not silently change behavior to match a mockup.
|
For localization, use this document for integration rules and corrections, `localization-tables.md` (generated from `source/locale-spec.js`) for the product tables, `screens/localization-*.md` and `states.md` for the state inventory, and `localization-corrections.md` for what the last design pass rendered, inspected and left open. Use `source/LocaleFrame.dc.html` for layout and rendered copy. This document supersedes conflicting board notes and older title/slug assumptions. Existing accepted `features/` behavior remains binding until a new accepted slice explicitly changes it. Report any remaining conflict at specification time; do not silently change behavior to match a mockup.
|
||||||
|
|
||||||
Imported sources are the Localization board, LocaleFrame, locale-spec.js, and LocalizationShell. LocalizationShell is a snapshot of the exporter’s AppFrame, renamed only in LocaleFrame's import. Existing AppFrame and other board sources were not overwritten. Do not introduce a second application shell in production: continue using DashboardShell and existing editor components.
|
Imported sources are the Localization board, LocaleFrame, locale-spec.js, locale-fixtures.js (the single fixture set every list, count and pager derives from), and LocalizationShell. LocalizationShell is a snapshot of the exporter’s AppFrame with the optional editor-header slots and width option added, renamed only in LocaleFrame's import. Existing AppFrame and other board sources were not overwritten. Do not introduce a second application shell in production: continue using DashboardShell and existing editor components.
|
||||||
|
|
||||||
The exported README and correction report are stale and were not imported. The connected Prototype and Handoff boards remain unchanged and do not demonstrate localization. This written handoff replaces the missing localization handoff, not the missing interactive prototype. No rendered visual parity, keyboard flow, browser console check or production test is claimed by this import.
|
The LocaleLive prototype was deliberately not imported. The Prototype and Handoff boards remain unchanged and do not demonstrate localization. This written handoff replaces the missing localization handoff, not the missing interactive prototype. No rendered visual parity, keyboard flow, browser console check or production test is claimed by this import.
|
||||||
|
|
||||||
## Product model
|
## Product model
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ Content revision changes include title/body/excerpt/slug/alt/SEO/social text. Au
|
|||||||
|
|
||||||
## Visual validation and implementation gaps
|
## Visual validation and implementation gaps
|
||||||
|
|
||||||
The board contains 12 groups and 109 states. Screen files map stable IDs to exported selectors. Markups are illustrations, not production accessible controls. Implement actual Nuxt UI input/menu/tab behavior. Retain the full existing editor toolbar; the shortened mockup toolbar does not authorize removing features.
|
The board contains 15 groups and 169 states (L6 and L9 are release 2). Screen files map stable IDs to exported selectors. Markups are illustrations, not production accessible controls. Implement actual Nuxt UI input/menu/tab behavior. Retain the full existing editor toolbar; the shortened mockup toolbar does not authorize removing features.
|
||||||
|
|
||||||
The existing visual checker must be extended to discover localization's groups, state tables and 1024px reference where needed, with scoped fixtures and API mocks. Do not claim `task visual:check` can already verify the import. No threshold increases or exceptions are authorized. Clock-sensitive skips only apply to states whose rendering actually depends on current time, not whole features containing dates.
|
The existing visual checker must be extended to discover localization's groups, state tables and 1024px reference where needed, with scoped fixtures and API mocks. Do not claim `task visual:check` can already verify the import. No threshold increases or exceptions are authorized. Clock-sensitive skips only apply to states whose rendering actually depends on current time, not whole features containing dates.
|
||||||
|
|
||||||
@@ -101,3 +101,18 @@ Known board limitations to settle in the relevant accepted slice: partial sample
|
|||||||
9. Atomic story moves/deletion and integration regression coverage.
|
9. Atomic story moves/deletion and integration regression coverage.
|
||||||
|
|
||||||
Split further to keep accepted slices reviewable. Include required backend support before each SPA slice. Each slice follows specifier acceptance → coder → size-gated cleaner/architect → hardener → qa. This order is planning guidance, not acceptance of Gherkin or permission to skip a gate.
|
Split further to keep accepted slices reviewable. Include required backend support before each SPA slice. Each slice follows specifier acceptance → coder → size-gated cleaner/architect → hardener → qa. This order is planning guidance, not acceptance of Gherkin or permission to skip a gate.
|
||||||
|
|
||||||
|
## Open decisions and known design gaps (2026-11-01)
|
||||||
|
|
||||||
|
Settle these during specification of the slice named, before coding.
|
||||||
|
|
||||||
|
| Topic | Gap | Slice |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Setup route | The route is `/settings/sites/:key/languages/setup` but the page lists every site, and the proposed setup API is global. Decide one global page reached from a per-site banner, or a per-site page. | Languages and setup |
|
||||||
|
| Article-level delete copy | The design uses "article" for the whole group of translations and for a single version. "Delete article" must say it removes all translations, and the dialog names the readable languages. Needs a copy change in the frames. | Editor |
|
||||||
|
| Conflict dialog weight | "Save over theirs" and "Reload theirs" have equal weight. Add a destructive cue to "Save over theirs". Needs a frame change. | Editor |
|
||||||
|
| Visual coverage | About 45 of the 169 states were rendered and inspected by the designer (see `localization-corrections.md`). Icons were never checked. Unseen frames may be broken; the visual check will reveal them. | All SPA slices |
|
||||||
|
| Header widths | Editor header widths between 1024 and 1440 are specified, not drawn. The checker captures 1440, 1024 and 390 only. | Editor |
|
||||||
|
| Mobile Languages rows | Rows are taller than needed. Tune in implementation. | Languages and setup |
|
||||||
|
| Partial fixtures | Archive, Notes and Gallery exist only as single states. | Languages and setup |
|
||||||
|
|
||||||
+17
-3
@@ -82,8 +82,22 @@ Needs backend:
|
|||||||
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
|
| /profile | Gains change email, Your data, Danger zone | Any verified user | POST /v1/auth/email/change, POST /v1/auth/email/resend, DELETE /v1/auth/email/change, GET /v1/user/me/export, DELETE /v1/user/me |
|
||||||
| /login?reason=account-deleted | One-time message after deletion | Public | none |
|
| /login?reason=account-deleted | One-time message after deletion | Public | none |
|
||||||
|
|
||||||
## Localization target routes
|
## Localization (proposed; existing routes unchanged)
|
||||||
|
Existing: /articles, /article/:id/edit, GET/POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}.
|
||||||
|
|
||||||
See [localization.md](localization.md) for authoritative API and compatibility decisions. SPA: `/settings/sites/:key/languages`, `/articles?site=&lang=&tab=all|missing|review&q=&status=&author=&seo=&page=&size=`, `/article/:id/edit?ref=:sourceId`, and `?dialog=add-translation&lang=fr`. Existing `/article/new` entry remains; do not introduce `/articles/new`. Existing guards and per-version permissions remain binding. Language settings are owner-managed; readable language metadata for editors uses the permitted site metadata surface. `author` filtering is a proposed extension, not an existing API capability.
|
| Route | Purpose | Access |
|
||||||
|
|---|---|---|
|
||||||
|
| /settings/sites/:key/languages | Site languages, default, disable, re-enable, remove, social locale per language. The only languages route (no global Languages page) | Owner edits, others read |
|
||||||
|
| /settings/sites/:key/languages/setup | First-time setup. :key is the site it was opened from; the page lists every site with unlabelled articles | Owner |
|
||||||
|
| /settings/sites/:key/public-url | Public URL pattern (placeholders {lang}, {slug}) | Owner edits, others read |
|
||||||
|
| /articles?site&lang&tab=all\|missing\|review&q&status&author&seo&page&size | Article list. Defaults omitted; filter changes reset page | Per article permission |
|
||||||
|
| /article/:id/edit | Editor, one id per language version | Per version capability |
|
||||||
|
| /article/:id/edit?ref=:sourceId | Temporary source reference (release 2). Does not change the tracked source | Read access to the reference |
|
||||||
|
| /article/:id/edit?dialog=add-translation&lang=fr | Add translation dialog | create-translation |
|
||||||
|
| /article/:id/edit?panel=seo | Search and sharing panel | edit |
|
||||||
|
|
||||||
Use only `lang` and `tab` for localization list selection; no `mode` or `needs_review` synonym. No language means grouped story mode for authenticated editorial lists; `lang` selects versions; `tab=missing` selects stories without a readable selected-language version; `tab=review` selects needs-review results in the active mode. Public listing compatibility is preserved separately.
|
Removed: /settings/languages and /settings/languages/setup (replaced by the site-scoped routes above). The old list params `mode` and `needs_review` are gone; `tab` replaces them.
|
||||||
|
|
||||||
|
Proposed API: GET /v1/article/ adds lang, tab=all|missing|review. GET /v1/article/{id} adds lang, story_id, capabilities[], translations[] (readable only), source. POST /v1/article/ adds lang, story_id, source_article_id, copy. POST /v1/article/{id}/translations. POST /v1/article/{id}/review (release 2). PATCH /v1/article/{id}/source (release 2). GET/PUT /v1/site/{key}/languages (adds social_locale per language). GET/PUT /v1/site/{key} adds public_url_pattern. GET/POST /v1/language/setup. POST /v1/story/{story_id}/move. PATCH /v1/article/{id} accepts if_revision (conflict response). Public: GET /v1/public/site/{key}/lang/{lang}/article/{slug} and GET /v1/public/site/{key}/story/{story_id}?lang=&fallback=default. The earlier GET /v1/public/site/{key}/article/{slug}?lang= is removed.
|
||||||
|
Field name is `lang` everywhere; the public list of other versions is `alternates`, the editor list is `translations`.
|
||||||
|
Details: see Localization board tables (Public lookup and URLs, Visibility policy).
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Add a translation
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L4 Add a translation".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/article/{id}/edit?dialog=add-translation&lang=fr`
|
|
||||||
|
|
||||||
### Add a translation (group "L4 Add a translation", 8 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Copy from source | Editor → language menu → Add translation | `loc.l4.form` | `at:form` |
|
|
||||||
| 2 | Start empty | Title only | `loc.l4.empty` | `at:empty` |
|
|
||||||
| 3 | Creating | Submit in flight | `loc.l4.saving` | `at:saving` |
|
|
||||||
| 4 | Exists, readable | 409 language_exists, Open offered | `loc.l4.dup` | `at:dup` |
|
|
||||||
| 5 | Exists, not readable | Neutral 409, no Open | `loc.l4.dupHidden` | `at:dupHidden` |
|
|
||||||
| 6 | Failed | 5xx, entries kept | `loc.l4.fail` | `at:fail` |
|
|
||||||
| 7 | Source unavailable | Source deleted or access lost mid-flow | `loc.l4.gone` | `at:gone` |
|
|
||||||
| 8 | Long dialog | Long title, many authors and images; body scrolls | `loc.l4.long` | `at:long` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Article list and coverage
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L7 Article list and coverage".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size`
|
|
||||||
|
|
||||||
### Article list and coverage (group "L7 Article list and coverage", 11 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | All languages | Stories | `loc.l7.all` | `li:all` |
|
|
||||||
| 2 | French selected | Versions | `loc.l7.fr` | `li:fr` |
|
|
||||||
| 3 | Not in French | Missing-translation discovery | `loc.l7.frMissing` | `li:frMissing` |
|
|
||||||
| 4 | Nothing missing | Separate fixture: Press (3 stories, all translated). Empty; no summary, no pager | `loc.l7.emptyMissing` | `li:emptyMissing` |
|
|
||||||
| 5 | No results | Search "zinc"; every count 0 | `loc.l7.noResults` | `li:noResults` |
|
|
||||||
| 6 | Loading | | `loc.l7.frLoading` | `li:frLoading` |
|
|
||||||
| 7 | Failed | Filters kept | `loc.l7.listFail` | `li:listFail` |
|
|
||||||
| 8 | Limited access | No dashed missing chips | `loc.l7.limited` | `li:limited` |
|
|
||||||
| 9 | Mobile filter sheet | Journal, French, Draft: 2 results | `loc.l7.filters` | `li:filters` |
|
|
||||||
| 10 | Tabs scrolled | Not in French reachable; fade and chevrons | `loc.l7.tabsScroll` | `li:tabsScroll` |
|
|
||||||
| 11 | Long labels, many languages, filters | Separate fixture: Atelier (8 languages), active filter chips | `loc.l7.long` | `li:long` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Localization: conflicts and article moves
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L11 Conflicts and sessions
|
||||||
|
Route: editor
|
||||||
|
API: existing 401/403/404/409 patterns
|
||||||
|
|
||||||
|
The conflict dialog gives four choices. Keep my changes is the only filled button; the other three are secondary and equal. "Someone else saved this version at <time>". No revision numbers. On mobile the dialog is a bottom sheet with Keep my changes first, so it needs no scrolling at 390 px.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l11.session | Session expired | Draft kept locally | LocaleFrame{s:{v:"pm",x:"session"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.restored | Restored after login | Editor shows "Restored unsaved changes from 14:18" | LocaleFrame{s:{v:"pm",x:"restored"}} | 1440, 390 | Journal |
|
||||||
|
| loc.conflict | Concurrent update | Four choices, local work kept | LocaleFrame{s:{v:"pm",x:"conflict"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.conflictRestored | Concurrent update after session expiry | Restored changes notice above the choices | LocaleFrame{s:{v:"pm",x:"conflictRestored"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.reloadConfirm | Reload theirs: confirm | "This discards your unsaved changes." | LocaleFrame{s:{v:"pm",x:"reloadConfirm"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.conflict2 | Second conflict | Save over theirs failed again; nothing overwritten | LocaleFrame{s:{v:"pm",x:"conflict2"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.download | Download started | Toast; changes still in the editor | LocaleFrame{s:{v:"pm",x:"download"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L11b Move article to another site
|
||||||
|
Route: editor → site select
|
||||||
|
API: proposed: POST /v1/story/{story_id}/move { site }
|
||||||
|
|
||||||
|
Entry point is the site select in the editor details. Articles move as a whole, only when the destination enables every language they contain (disabled ones included) and the user can edit all versions. The note lists readable versions only; versions the user can't read are not listed or counted. Moves are atomic and revalidate permissions and conflicts in the transaction.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l11b.moveSelect | Site select | Note lists all four versions, one tagged Read only | LocaleFrame{s:{v:"pm",x:"moveSelect"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveSelectHidden | Site select, hidden version | Arabic not readable: not listed or counted | LocaleFrame{s:{v:"pm",x:"moveSelectHidden"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveConfirm | Confirm | Move to Atelier? | LocaleFrame{s:{v:"pm",x:"moveConfirm"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveBusy | In progress | Dialog locked | LocaleFrame{s:{v:"pm",x:"moveBusy"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveDone | Moved | Toast | LocaleFrame{s:{v:"pm",x:"moveDone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.move | Language missing in destination | Notes enables English only | LocaleFrame{s:{v:"pm",x:"move"}} | 1440, 390 | Journal, Notes |
|
||||||
|
| loc.l11b.moveSlug | Address conflict | Names the conflicting readable version only | LocaleFrame{s:{v:"pm",x:"moveSlug"}} | 1440, 390 | Journal, Atelier |
|
||||||
|
| loc.l11b.movePerm | No permission | Neutral | LocaleFrame{s:{v:"pm",x:"movePerm"}} | 1440, 390 | Journal |
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Permissions, conflicts, site moves
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L11 Permissions, conflicts, site moves".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `various`
|
|
||||||
|
|
||||||
### Permissions, conflicts, site moves (group "L11 Permissions, conflicts, site moves", 5 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Session expired | Draft kept locally | `loc.l11.session` | `pm:session` |
|
|
||||||
| 2 | Concurrent update | Four choices, local work kept | `loc.l11.conflict` | `pm:conflict` |
|
|
||||||
| 3 | Move: language missing | | `loc.l11.move` | `pm:move` |
|
|
||||||
| 4 | Move: address conflict | | `loc.l11.moveSlug` | `pm:moveSlug` |
|
|
||||||
| 5 | Move: no permission | Neutral | `loc.l11.movePerm` | `pm:movePerm` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,34 +1,76 @@
|
|||||||
# Editor: language navigation
|
# Localization: editor header, permissions, publishing
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L5 Editor: language navigation".
|
## L5 Editor: header, actions and title
|
||||||
|
Route: /article/{id}/edit (one id per version)
|
||||||
|
API: proposed: GET /v1/article/{id} → translations[], capabilities[]
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
Header priority and the action matrix are in the tables below. One filled primary button at most: Save and publish when allowed, dirty and offline; otherwise Save when dirty; otherwise Publish when clean and offline. Save stays separate and secondary. Desktop collapse order as width shrinks (1440 → 1024): reference button loses its label, Unpublish moves to the overflow menu, the freshness badge becomes icon-only. Mobile: title, language, publication and dirty badges stay at the top; Save, primary and an overflow button sit in a fixed bottom bar; reference, Unpublish, Edit slug, Move and Delete go to the overflow sheet. The language control is a menu button with a menu (not a listbox).
|
||||||
|
|
||||||
Route: `/article/{id}/edit (one id per version)`
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l5.default | Clean draft | Open a draft you can publish; title pristine | LocaleFrame{s:{v:"ed",x:"default"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.dirty | Dirty draft | Can edit, can't publish: Save is primary | LocaleFrame{s:{v:"ed",x:"dirty"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.dirtyPublish | Dirty draft, publish allowed | Save and publish French is primary; Save secondary | LocaleFrame{s:{v:"ed",x:"dirtyPublish"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.cleanOnline | Clean online | Unpublish only; no primary | LocaleFrame{s:{v:"ed",x:"cleanOnline"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.onlineDirty | Dirty online | Save updates the live page; Unpublish stays | LocaleFrame{s:{v:"ed",x:"onlineDirty"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.saving | Saving | Save in flight | LocaleFrame{s:{v:"ed",x:"saving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.saved | Saved | Toast | LocaleFrame{s:{v:"ed",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.saveFail | Save failed | Stays unsaved; Try saving again; nothing published | LocaleFrame{s:{v:"ed",x:"saveFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.pubFail | Publish failed after save | Saved as a draft; Retry publish; edits shown as saved | LocaleFrame{s:{v:"ed",x:"pubFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.ovf | Overflow menu | More actions (sheet on mobile) | LocaleFrame{s:{v:"ed",x:"ovf"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.menu | Menu: four languages | Open menu | LocaleFrame{s:{v:"ed",x:"menu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.two | Menu: two languages | Two-language site | LocaleFrame{s:{v:"ed",x:"two"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.many | Menu: many languages | Scrolls inside the viewport, with filter | LocaleFrame{s:{v:"ed",x:"many"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.unsaved | Switch with unsaved changes | Choose English while dirty | LocaleFrame{s:{v:"ed",x:"unsaved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.loading | Loading version | Slow switch | LocaleFrame{s:{v:"ed",x:"loading"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.navFail | Switch failed | Stays on current version | LocaleFrame{s:{v:"ed",x:"navFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.gone | Version unavailable | Deleted or no access (neutral) | LocaleFrame{s:{v:"ed",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.focus | Title: focus | Field focused, not changed | LocaleFrame{s:{v:"ed",x:"focus"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleEdit | Title: editing | Typing; counter shown | LocaleFrame{s:{v:"ed",x:"titleEdit"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleUnsaved | Title: unsaved | Changed, focus left | LocaleFrame{s:{v:"ed",x:"titleUnsaved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.invalid | Title: invalid | Empty: "Enter a title" | LocaleFrame{s:{v:"ed",x:"invalid"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleShort | Title: too short | Under 3 characters | LocaleFrame{s:{v:"ed",x:"titleShort"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleLong | Title: too long | Over 200 characters | LocaleFrame{s:{v:"ed",x:"titleLong"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleSaving | Title: saving | Save in flight | LocaleFrame{s:{v:"ed",x:"titleSaving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.failed | Title: save failed | Text kept, still unsaved, Try again | LocaleFrame{s:{v:"ed",x:"failed"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
### Editor: language navigation (group "L5 Editor: language navigation", 18 states)
|
## L5b Permission states (per version)
|
||||||
|
Route: /article/{id}/edit
|
||||||
|
API: capabilities[] on GET /v1/article/{id}; every PATCH/DELETE rechecked on the server
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
Every action renders from its own capability (read, edit, publish, delete, create translation, review). See the capability matrix. Missing-translation rows appear only for users who can create; unreadable siblings are absent with no placeholder and no count. The formatting toolbar is hidden whenever edit is missing.
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Default (French draft) | Editable title, language menu, badges | `loc.l5.default` | `ed:default` |
|
|
||||||
| 2 | Menu: four languages | Open menu | `loc.l5.menu` | `ed:menu` |
|
|
||||||
| 3 | Menu: two languages | Two-language site | `loc.l5.two` | `ed:two` |
|
|
||||||
| 4 | Menu: many languages | Scrolls inside the viewport, with filter | `loc.l5.many` | `ed:many` |
|
|
||||||
| 5 | Unsaved changes | Dirty badge; Save enabled | `loc.l5.dirty` | `ed:dirty` |
|
|
||||||
| 6 | Unsaved draft: Save and publish | Button reads Save and publish French; Save stays separate | `loc.l5.dirtyPublish` | `ed:dirtyPublish` |
|
|
||||||
| 7 | Unsaved online version | Save updates the live page; Unpublish stays available | `loc.l5.onlineDirty` | `ed:onlineDirty` |
|
|
||||||
| 8 | Save failed | Stays unsaved; Try saving again; nothing published | `loc.l5.saveFail` | `ed:saveFail` |
|
|
||||||
| 9 | Switch with unsaved changes | Choose English while dirty | `loc.l5.unsaved` | `ed:unsaved` |
|
|
||||||
| 10 | Saving | Save in flight | `loc.l5.saving` | `ed:saving` |
|
|
||||||
| 11 | Saved | Toast | `loc.l5.saved` | `ed:saved` |
|
|
||||||
| 12 | Loading version | Slow switch | `loc.l5.loading` | `ed:loading` |
|
|
||||||
| 13 | Switch failed | Stays on current version | `loc.l5.navFail` | `ed:navFail` |
|
|
||||||
| 14 | Version unavailable | Deleted or no access (neutral) | `loc.l5.gone` | `ed:gone` |
|
|
||||||
| 15 | Title: editing | Focus | `loc.l5.focus` | `ed:focus` |
|
|
||||||
| 16 | Title: invalid | Empty | `loc.l5.invalid` | `ed:invalid` |
|
|
||||||
| 17 | Title: saving | | `loc.l5.titleSaving` | `ed:titleSaving` |
|
|
||||||
| 18 | Title: save failed | Text kept, still unsaved, Try again | `loc.l5.failed` | `ed:failed` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.perm.readonly | P1 Read-only version | Banner, read-only fields, no Save, Publish, Delete, toolbar; reference stays | LocaleFrame{s:{v:"perm",x:"ro"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5b.roMenu | P1 menu | No Add translation row | LocaleFrame{s:{v:"perm",x:"roMenu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.editNoPublish | P2 Edit, no publish | Save only; hint about the publish permission | LocaleFrame{s:{v:"perm",x:"p2"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.perm.publishNoEdit | P3 Publish, no edit | Publish only; fields read-only | LocaleFrame{s:{v:"perm",x:"p3"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.perm.siblingReadOnly | P4 Sibling readable, not editable | Arabic row tagged Read only | LocaleFrame{s:{v:"perm",x:"p4"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.siblingHidden | P5 Sibling not readable | German absent, no count, no placeholder | LocaleFrame{s:{v:"perm",x:"p5"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.createNotAll | P6 Can create, not modify all | Delete article and Move disabled with the reason; Delete French version available | LocaleFrame{s:{v:"perm",x:"p6"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5b.noAdd | Menu without Add translation | No create permission; Arabic is missing but offers nothing | LocaleFrame{s:{v:"perm",x:"noAdd"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5b.withAdd | Menu with Add translation | Same article, user may create | LocaleFrame{s:{v:"perm",x:"withAdd"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
## L8 Publishing and deletion
|
||||||
|
Route: same as the editor
|
||||||
|
API: existing PATCH /v1/article/{id}/publish and /unpublish and DELETE per article id
|
||||||
|
|
||||||
|
Every action names its language. No bulk publish. With unsaved edits the primary reads Save and publish and saves first; if the save fails nothing is published and the edits stay unsaved; if the save works and publishing fails the edits show as saved. Unpublish never saves: the saved version goes offline and local edits stay in the editor. Saving an online version updates the live page immediately, and the editor says so. Deletion confirmations name only readable versions.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l8.publish | Publish French | Confirm, English untouched | LocaleFrame{s:{v:"pd",x:"publish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.publishDisabled | Publishing blocked: language disabled | French disabled on the site | LocaleFrame{s:{v:"pd",x:"publishDisabled"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.saveAndPublish | Save and publish French | Unsaved edits: saves first, then publishes | LocaleFrame{s:{v:"pd",x:"saveAndPublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.saveFailPublish | Save failed, nothing published | Toast; edits kept and retryable | LocaleFrame{s:{v:"pd",x:"saveFailPublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.unpublish | Unpublish French | Confirm | LocaleFrame{s:{v:"pd",x:"unpublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.unpublishDirty | Unpublish with unsaved edits | Saved version goes offline; edits kept; nothing saved | LocaleFrame{s:{v:"pd",x:"unpublishDirty"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.delete | Delete French version | Confirm | LocaleFrame{s:{v:"pd",x:"delete"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteSource | Delete a source version | Warns about review tracking | LocaleFrame{s:{v:"pd",x:"deleteSource"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteLast | Delete the only version | Deletes the article | LocaleFrame{s:{v:"pd",x:"deleteLast"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteStoryOk | Delete article (all readable versions) | Names readable versions only | LocaleFrame{s:{v:"pd",x:"deleteStoryOk"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteStory | Delete article blocked | Neutral, no version named | LocaleFrame{s:{v:"pd",x:"deleteStory"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.done | Published | Toast | LocaleFrame{s:{v:"pd",x:"done"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.fail | Publish failed | Toast | LocaleFrame{s:{v:"pd",x:"fail"}} | 1440, 390 | Journal |
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Site languages
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L1 Site languages".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/settings/languages`
|
|
||||||
|
|
||||||
### Site languages (group "L1 Site languages", 16 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | `loc.l1.single` | `sl:single` |
|
|
||||||
| 2 | Multilingual | Six languages, two disabled (Spanish, Italian) | `loc.l1.multi` | `sl:multi` |
|
|
||||||
| 3 | Loading | Request in flight | `loc.l1.loading` | `sl:loading` |
|
|
||||||
| 4 | Load failed | Network or server error | `loc.l1.error` | `sl:error` |
|
|
||||||
| 5 | Add a language | Add language | `loc.l1.add` | `sl:add` |
|
|
||||||
| 6 | Add failed | Server error, selection kept | `loc.l1.saveFail` | `sl:saveFail` |
|
|
||||||
| 7 | Duplicate language | Already enabled | `loc.l1.adddup` | `sl:adddup` |
|
|
||||||
| 8 | Make default | Row menu | `loc.l1.default` | `sl:default` |
|
|
||||||
| 9 | Disable with content | Row menu → Disable German | `loc.l1.disable` | `sl:disable` |
|
|
||||||
| 10 | Disable default blocked | Disable English | `loc.l1.disableDefault` | `sl:disableDefault` |
|
|
||||||
| 11 | Disable last blocked | Only one enabled | `loc.l1.disableLast` | `sl:disableLast` |
|
|
||||||
| 12 | Re-enable | Spanish row → Re-enable | `loc.l1.reenable` | `sl:reenable` |
|
|
||||||
| 13 | Remove (no content) | Italian (0 versions) | `loc.l1.remove` | `sl:remove` |
|
|
||||||
| 14 | Remove blocked | Spanish has versions | `loc.l1.removeBlocked` | `sl:removeBlocked` |
|
|
||||||
| 15 | Read-only (403) | Non-owner: list stays readable; no Add language, Re-enable or row menus | `loc.l1.readonly` | `sl:readonly` |
|
|
||||||
| 16 | Saved | After Add language | `loc.l1.saved` | `sl:saved` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Localization: article list and coverage
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L7 Article list and coverage
|
||||||
|
Route: /articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size
|
||||||
|
API: proposed: lang, tab=all|missing|review on GET /v1/article/ (same names as the URL)
|
||||||
|
|
||||||
|
Journal: 8 articles, 17 versions, page size 5. The list header shows the unit: "8 articles" (all languages, one row per article with its translations), "4 French versions" (language selected), "4 articles not in French" (missing tab), "7 articles need translation", "2 articles need review". Coverage chips: default language first, then other existing versions, then disabled-language versions, then dashed missing chips (only for languages the user can create). Chips collapse after 5 at 1440 and 4 at 1024 and 390, leaving a "+N" chip. Row actions are the same on every width: Open, Open another language, Add translation, Publish or Unpublish, Copy API URL, Delete version; every row has the row menu button. All state is in the URL.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l7.all | All languages, page 1 | 5 of 8 articles | LocaleFrame{s:{v:"li",x:"all"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.page2 | All languages, page 2 (last) | 3 of 8 articles | LocaleFrame{s:{v:"li",x:"page2"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.fr | French selected | 4 French versions | LocaleFrame{s:{v:"li",x:"fr"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.frMissing | Not in French | 4 articles not in French | LocaleFrame{s:{v:"li",x:"frMissing"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.emptyMissing | Nothing missing | Press: all articles translated; no summary, no pager | LocaleFrame{s:{v:"li",x:"emptyMissing"}} | 1440, 1024, 390 | Press |
|
||||||
|
| loc.l7.noResults | No results | Search "zinc"; every count 0 | LocaleFrame{s:{v:"li",x:"noResults"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.frLoading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"frLoading"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.listFail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"listFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.limited | Limited access | Reads English only, can't create: no dashed chips | LocaleFrame{s:{v:"li",x:"limited"}} | 1440, 1024, 390 | Journal (limited scope) |
|
||||||
|
| loc.l7.review | Needs review tab | 2 articles need review | LocaleFrame{s:{v:"li",x:"review"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.author | Author filter | Marta Alves: 3 articles, chips and Clear all | LocaleFrame{s:{v:"li",x:"author"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.langFilter | Language filter | Enabled and Disabled groups | LocaleFrame{s:{v:"li",x:"langFilter"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.rowMenu | Row menu | Same actions on desktop and mobile | LocaleFrame{s:{v:"li",x:"rowMenu"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.filters | Mobile filter sheet | Journal, French, Draft: 2 results | LocaleFrame{s:{v:"li",x:"filters"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.tabsScroll | Tabs scrolled | Not in French reachable; fade and chevrons | LocaleFrame{s:{v:"li",x:"tabsScroll"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l7.long | Eight languages, long labels, filters | Atelier: chips collapse to +3, +4, +4; Draft and SEO filters | LocaleFrame{s:{v:"li",x:"long"}} | 1440, 1024, 390 | Atelier |
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Localization: metadata and public API
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L10 Metadata and public API
|
||||||
|
Route: Search and sharing panel · /integration
|
||||||
|
API: existing slug, SEO fields; proposed alternates[], lang; exact lookup by site + lang + slug; resolution by story_id
|
||||||
|
|
||||||
|
Slug is unique per (site, lang). Exact lookup is by site + language + slug and never falls back. Language resolution and fallback use the stable story_id. Website URLs come from the site's Public URL pattern; without it the panel says "Public URL not configured" and links to the setting. Canonical is the localized page itself unless an override is set. SEO labels: "Fallback: French title", "No fallback", "Empty. English site default not used."
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.seo.noFallback | Search and sharing: French | Localized slug, canonical, alternates, fallback labels, Edit slug | LocaleFrame{s:{v:"seo",x:""}} | 1440, 390 | Journal |
|
||||||
|
| loc.slug.published | Edit slug | Published version: warning about old links | LocaleFrame{s:{v:"seo",x:"editSlug"}} | 1440, 390 | Journal |
|
||||||
|
| loc.url.unset | Public URL not configured | No pattern, no fabricated URL | LocaleFrame{s:{v:"seo",x:"unset"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l10.noSocial | Social locale not set | fr has no region and none is set: og:locale omitted | LocaleFrame{s:{v:"seo",x:"noSocial"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l10.api | Integration: languages | Exact lookup, resolve by article group, errors | LocaleFrame{s:{v:"api",x:""}} | 1440, 390 | Journal |
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Metadata and public API
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L10 Metadata and public API".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `Search and sharing panel · /integration`
|
|
||||||
|
|
||||||
### Metadata and public API (group "L10 Metadata and public API", 4 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Search and sharing: French | Localized slug, canonical, alternates | `loc.l10.seo` | `seo:` |
|
|
||||||
| 2 | Public URL not configured | No pattern, no fabricated URL | `loc.l10.unset` | `seo:unset` |
|
|
||||||
| 3 | Social locale not set | fr has no region and none is configured: og:locale omitted | `loc.l10.noSocial` | `seo:noSocial` |
|
|
||||||
| 4 | Integration: languages | Proposed requests | `loc.l10.api` | `api:` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
# New article
|
# Localization: new article and add translation
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L3 New article".
|
## L3 New article
|
||||||
|
Route: /articles (New article modal)
|
||||||
|
API: existing POST /v1/article/ + proposed lang, story_id
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
Single-language sites show no language field. Multilingual sites show Language and a New article / Translation of an existing article choice. The translation path uses the same endpoint and rules as Add translation. The picker lists only articles the user can read; the chosen version is the source.
|
||||||
|
|
||||||
Route: `/articles (New article modal)`
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l3.single | Single language | Title and site only | LocaleFrame{s:{v:"na",x:"single"}} | 1440, 390 | Archive |
|
||||||
|
| loc.l3.multi | Multilingual: new article | Language defaults to the site default | LocaleFrame{s:{v:"na",x:"multi"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l3.translation | Translation of an article | Article picker + language | LocaleFrame{s:{v:"na",x:"translation"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l3.sitechange | Site changed | Selected language not offered by the new site | LocaleFrame{s:{v:"na",x:"sitechange"}} | 1440, 390 | Journal, Notes |
|
||||||
|
|
||||||
### New article (group "L3 New article", 4 states)
|
## L4 Add a translation
|
||||||
|
Route: /article/{id}/edit?dialog=add-translation&lang=fr
|
||||||
|
API: proposed: POST /v1/article/{id}/translations
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
Always creates a draft. Source: the user picks which readable version to copy from (default: the default-language version if readable, else the first readable). The source version is captured in the same transaction as the copy. Copied text stays in the source language until edited; the version starts as Needs review. Start empty: no tracked source, so freshness shows No source. The primary action stays in the sticky footer on mobile; the body scrolls.
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Single language | Title and site only | `loc.l3.single` | `na:single` |
|
|
||||||
| 2 | Multilingual: new story | Language defaults to the site default | `loc.l3.multi` | `na:multi` |
|
|
||||||
| 3 | Translation of a story | Story picker + language | `loc.l3.translation` | `na:translation` |
|
|
||||||
| 4 | Site changed | Selected language not offered by new site | `loc.l3.sitechange` | `na:sitechange` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
| loc.l4.form | Copy from source | Editor → language menu → Add translation | LocaleFrame{s:{v:"at",x:"form"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.empty | Start empty | Title only | LocaleFrame{s:{v:"at",x:"empty"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.saving | Creating | Submit in flight | LocaleFrame{s:{v:"at",x:"saving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.dup.readable | Exists, readable | Conflict; Open offered | LocaleFrame{s:{v:"at",x:"dup"}} | 1440, 390 | Journal |
|
||||||
|
| loc.dup.hidden | Exists, not readable | Neutral error, no Open | LocaleFrame{s:{v:"at",x:"dupHidden"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.fail | Failed | Server error, entries kept | LocaleFrame{s:{v:"at",x:"fail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.gone | Source deleted | Deleted while the dialog was open | LocaleFrame{s:{v:"at",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.unreadable | Source no longer readable | Access lost between opening and submitting | LocaleFrame{s:{v:"at",x:"unreadable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.langOff | Language disabled mid-flow | An owner disabled French while the dialog was open | LocaleFrame{s:{v:"at",x:"langOff"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.long | Long dialog | Long title, many authors and images; body scrolls, footer stays | LocaleFrame{s:{v:"at",x:"long"}} | 1440, 390 | Journal |
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Permission states (per version)
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L5b Permission states (per version)".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/article/{id}/edit`
|
|
||||||
|
|
||||||
### Permission states (per version) (group "L5b Permission states (per version)", 7 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Read-only version | No Save, Publish, Delete, toolbar | `loc.l5b.ro` | `ed:ro` |
|
|
||||||
| 2 | Read-only: menu | No Add translation row | `loc.l5b.roMenu` | `ed:roMenu` |
|
|
||||||
| 3 | Edit, no publish | Save only | `loc.l5b.p2` | `ed:p2` |
|
|
||||||
| 4 | Publish, no edit | Publish only; fields read-only | `loc.l5b.p3` | `ed:p3` |
|
|
||||||
| 5 | Sibling readable, not editable | German tagged Read only | `loc.l5b.p4` | `ed:p4` |
|
|
||||||
| 6 | Sibling not readable | German absent, no count | `loc.l5b.p5` | `ed:p5` |
|
|
||||||
| 7 | Can create, not modify all | Story delete and move disabled | `loc.l5b.p6` | `ed:p6` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Publishing and deletion
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L8 Publishing and deletion".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `same as editor`
|
|
||||||
|
|
||||||
### Publishing and deletion (group "L8 Publishing and deletion", 10 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Publish French | Confirm, English untouched | `loc.l8.publish` | `pd:publish` |
|
|
||||||
| 2 | Save and publish French | Unsaved edits: one action saves first, then publishes | `loc.l8.saveAndPublish` | `pd:saveAndPublish` |
|
|
||||||
| 3 | Save failed, nothing published | Toast; edits kept and retryable | `loc.l8.saveFailPublish` | `pd:saveFailPublish` |
|
|
||||||
| 4 | Unpublish French | Confirm | `loc.l8.unpublish` | `pd:unpublish` |
|
|
||||||
| 5 | Delete French version | Confirm | `loc.l8.delete` | `pd:delete` |
|
|
||||||
| 6 | Delete a source version | Warns about review reference | `loc.l8.deleteSource` | `pd:deleteSource` |
|
|
||||||
| 7 | Delete last version | Deletes the story | `loc.l8.deleteLast` | `pd:deleteLast` |
|
|
||||||
| 8 | Delete story blocked | Neutral, no version named | `loc.l8.deleteStory` | `pd:deleteStory` |
|
|
||||||
| 9 | Published | Toast | `loc.l8.done` | `pd:done` |
|
|
||||||
| 10 | Publish failed | Toast | `loc.l8.fail` | `pd:fail` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Source reference
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L6 Source reference".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/article/{id}/edit?ref=:sourceId`
|
|
||||||
|
|
||||||
### Source reference (group "L6 Source reference", 11 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Side by side | Show source reference | `loc.l6.split` | `src:split` |
|
|
||||||
| 2 | Reference hidden | Hide source reference; button returns | `loc.l6.closed` | `src:closed` |
|
|
||||||
| 3 | Reopened | Show source reference again; scroll positions kept | `loc.l6.reopen` | `src:reopen` |
|
|
||||||
| 4 | Change reference | Menu; tracked source stays English | `loc.l6.changeRef` | `src:changeRef` |
|
|
||||||
| 5 | Narrow desktop (1024 px) | True 1024 px frame | `loc.l6.narrow` | `src:narrow` |
|
|
||||||
| 6 | Long content | Both panes scroll independently | `loc.l6.long` | `src:long` |
|
|
||||||
| 7 | Arabic beside English | dir=rtl on the Arabic title and body only | `loc.l6.rtl` | `src:rtl` |
|
|
||||||
| 8 | Mobile: editing | Segment | `loc.l6.mobileEdit` | `src:mobileEdit` |
|
|
||||||
| 9 | Mobile: source | Segment | `loc.l6.mobileSrc` | `src:mobileSrc` |
|
|
||||||
| 10 | Mobile: closed | Show source reference button | `loc.l6.mobileClosed` | `src:mobileClosed` |
|
|
||||||
| 11 | Reference unavailable | Deleted or unreadable | `loc.l6.unavail` | `src:unavail` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Localization: source reference and review (release 2)
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L6 Source reference (release 2) (release 2)
|
||||||
|
Route: /article/{id}/edit?ref=:sourceId
|
||||||
|
API: proposed: GET /v1/article/{id} (read-only fetch of the reference)
|
||||||
|
|
||||||
|
Release 2. The temporary reference (?ref=, dashed outline, "Temporary reference") and the tracked review source (solid "Tracked source" badge in the review banner) are separate: changing the reference never changes the tracked source. Show source reference is in the editor header (desktop, icon-only at 1024) and the overflow sheet (mobile). Each pane scrolls independently. dir and lang go on the title and body of the editing pane and of the reference, never on the app shell.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l6.split | Side by side | Show source reference | LocaleFrame{s:{v:"src",x:"split"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l6.closed | Reference hidden | Hide source reference; button returns | LocaleFrame{s:{v:"src",x:"closed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.reopen | Reopened | Scroll positions kept | LocaleFrame{s:{v:"src",x:"reopen"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.changeRef | Change reference | Menu; tracked source stays English | LocaleFrame{s:{v:"src",x:"changeRef"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.narrow | Narrow desktop (1024) | True 1024 frame | LocaleFrame{s:{v:"src",x:"narrow"}} | 1024, 390 | Journal |
|
||||||
|
| loc.l6.long | Long content | Both panes scroll independently | LocaleFrame{s:{v:"src",x:"long"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.rtl | Arabic editing, English reference | dir=rtl on the Arabic title and body only | LocaleFrame{s:{v:"src",x:"rtl"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.arRef | French editing, Arabic reference | dir=rtl on the reference pane only | LocaleFrame{s:{v:"src",x:"arRef"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileEdit | Mobile: editing | Segment | LocaleFrame{s:{v:"src",x:"mobileEdit"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileSrc | Mobile: source | Segment | LocaleFrame{s:{v:"src",x:"mobileSrc"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileClosed | Mobile: closed | Show source reference in the bottom bar or sheet | LocaleFrame{s:{v:"src",x:"mobileClosed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.unavail | Reference unavailable | Deleted or unreadable | LocaleFrame{s:{v:"src",x:"unavail"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L9 Freshness and review (release 2) (release 2)
|
||||||
|
Route: editor banner · list badge
|
||||||
|
API: proposed: POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } (internal fields; never shown)
|
||||||
|
|
||||||
|
Release 2. Publication (solid badge) and freshness (outlined badge with refresh icon) are separate. Labels: No version, Draft, Needs review, Reviewed. A copied draft starts as Needs review. The dialog shows "Changed fields: Title, Body", the source version with its date and the translation version with its date. No revision numbers appear in the UI.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l9.warn | Source updated | English changed after review | LocaleFrame{s:{v:"fr",x:"warn"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.pub | Published, needs review | Still online | LocaleFrame{s:{v:"fr",x:"pub"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.review | Mark reviewed | Confirm with changed fields and dates | LocaleFrame{s:{v:"fr",x:"review"}} | 1440, 390 | Journal |
|
||||||
|
| loc.review.changed | Source changed again | During the review | LocaleFrame{s:{v:"fr",x:"changed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.changedTr | Translation changed | Saved by someone else while confirming | LocaleFrame{s:{v:"fr",x:"changedTr"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.replace | Choose tracked source | Cycle prevented | LocaleFrame{s:{v:"fr",x:"replace"}} | 1440, 390 | Journal |
|
||||||
|
| loc.src.inaccessible | Source unavailable (access) | History kept | LocaleFrame{s:{v:"fr",x:"inaccessible"}} | 1440, 390 | Journal |
|
||||||
|
| loc.src.deleted | Source deleted | Choose a new source | LocaleFrame{s:{v:"fr",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.done | Reviewed | Toast | LocaleFrame{s:{v:"fr",x:"done"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.fail | Review failed | Toast | LocaleFrame{s:{v:"fr",x:"fail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.frReview | Published and needs review in the list | French tab, Needs review: 2 versions | LocaleFrame{s:{v:"li",x:"frReview"}} | 1440, 1024, 390 | Journal |
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Freshness and review
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L9 Freshness and review".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `editor banner · list badge`
|
|
||||||
|
|
||||||
### Freshness and review (group "L9 Freshness and review", 10 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Source updated | English changed after review | `loc.l9.warn` | `fr:warn` |
|
|
||||||
| 2 | Published, needs review | | `loc.l9.pub` | `fr:pub` |
|
|
||||||
| 3 | Mark reviewed | Confirm with revisions | `loc.l9.review` | `fr:review` |
|
|
||||||
| 4 | Source changed again | Revision 9 while reviewing 8 | `loc.l9.changed` | `fr:changed` |
|
|
||||||
| 5 | Translation changed | Saved by someone else while confirming | `loc.l9.changedTr` | `fr:changedTr` |
|
|
||||||
| 6 | Choose tracked source | Cycle prevented | `loc.l9.replace` | `fr:replace` |
|
|
||||||
| 7 | Source unavailable (access) | History kept | `loc.l9.inaccessible` | `fr:inaccessible` |
|
|
||||||
| 8 | Source deleted | Replace | `loc.l9.gone` | `fr:gone` |
|
|
||||||
| 9 | Reviewed | Toast | `loc.l9.done` | `fr:done` |
|
|
||||||
| 10 | Review failed | Toast | `loc.l9.fail` | `fr:fail` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# First-time setup for existing articles
|
|
||||||
|
|
||||||
Visual reference: `RenewCMS Screens Localization.dc.html`, group "L2 First-time setup for existing articles".
|
|
||||||
|
|
||||||
Read [localization.md](../localization.md) first. Its integration decisions supersede conflicting board annotations. These states describe target behavior, not shipped behavior.
|
|
||||||
|
|
||||||
Route: `/settings/sites/:key/languages (banner) · /settings/languages/setup`
|
|
||||||
|
|
||||||
### First-time setup for existing articles (group "L2 First-time setup for existing articles", 5 states)
|
|
||||||
|
|
||||||
| # | State | Trigger | Stable ID | Frame selector |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | `loc.l2.ready` | `su:ready` |
|
|
||||||
| 2 | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | `loc.l2.empty` | `su:empty` |
|
|
||||||
| 3 | Applying | POST in flight | `loc.l2.applying` | `su:applying` |
|
|
||||||
| 4 | Done | 16 articles labelled | `loc.l2.done` | `su:done` |
|
|
||||||
| 5 | Failed | Nothing changed; choices kept | `loc.l2.fail` | `su:fail` |
|
|
||||||
|
|
||||||
Exact rendered copy and geometry are in `source/LocaleFrame.dc.html` for the selector above. Use Nuxt UI mappings in `components.md`; the frame markup is a visual reference, not production component code.
|
|
||||||
|
|
||||||
The standard captures are 1440×900 and 390×844 in light and dark. The narrow reference state also uses 1024×768. The visual harness must support these sources and sizes before this screen can pass its gate. No visual exceptions or new clock-sensitive skips are approved.
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Localization: site languages, public URL and setup
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L1 Site languages
|
||||||
|
Route: /settings/sites/:key/languages
|
||||||
|
API: proposed: GET/PUT /v1/site/{key}/languages
|
||||||
|
|
||||||
|
Navigation: Settings → Sites → [site] → Languages only. The site name is in the heading and breadcrumb. Site formatting locale, default content language, article language and social locale are four separate values. At least one language stays enabled and the default must be enabled: to retire the default, first Make default on another language, then Disable. Disable and Remove never delete or unpublish content. Disabled languages show a Disabled tag and a Re-enable action; their versions stay readable and editable per permission. Drafts can be published only after Re-enable ("French is disabled on this site. Re-enable it to publish."). The language filter lists them under Disabled. Social locale per language has three states: Derived (from the language code, e.g. pt-BR gives pt_BR), Set (an owner typed it, e.g. fr_FR) and Not set (og:locale left out). Only owners edit it.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l1.single | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | LocaleFrame{s:{v:"sl",x:"single"}} | 1440, 390 | Archive |
|
||||||
|
| loc.l1.multi | Multilingual | Journal: four enabled, two disabled (Spanish, Italian) | LocaleFrame{s:{v:"sl",x:"multi"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l1.loading | Loading | Request in flight | LocaleFrame{s:{v:"sl",x:"loading"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.error | Load failed | Network or server error | LocaleFrame{s:{v:"sl",x:"error"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.add | Add a language | Add language | LocaleFrame{s:{v:"sl",x:"add"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.saveFail | Add failed | Server error, selection kept | LocaleFrame{s:{v:"sl",x:"saveFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.adddup | Duplicate language | Already enabled | LocaleFrame{s:{v:"sl",x:"adddup"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.broader | Only a broader tag supported | Add Portuguese (Portugal): substitution explained, confirmation required | LocaleFrame{s:{v:"sl",x:"broader"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.default | Make default | Row menu | LocaleFrame{s:{v:"sl",x:"default"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.defaultFail | Set default failed | Server error, nothing changed | LocaleFrame{s:{v:"sl",x:"defaultFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.disable | Disable with content | Row menu → Disable German | LocaleFrame{s:{v:"sl",x:"disable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.stale | Languages changed | Another owner changed them: "Languages changed. Reload." | LocaleFrame{s:{v:"sl",x:"stale"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.disableDefault | Disable default blocked | Disable English | LocaleFrame{s:{v:"sl",x:"disableDefault"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.disableLast | Disable last blocked | Only one enabled | LocaleFrame{s:{v:"sl",x:"disableLast"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenable | Re-enable | Spanish row → Re-enable | LocaleFrame{s:{v:"sl",x:"reenable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenableDone | Re-enabled | Toast after success | LocaleFrame{s:{v:"sl",x:"reenableDone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenableFail | Re-enable failed | Dialog error, still disabled | LocaleFrame{s:{v:"sl",x:"reenableFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.remove | Remove (no content) | Italian (0 versions) | LocaleFrame{s:{v:"sl",x:"remove"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.removeFail | Remove failed | Dialog error, still listed | LocaleFrame{s:{v:"sl",x:"removeFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.removeBlocked | Remove blocked | Spanish has versions | LocaleFrame{s:{v:"sl",x:"removeBlocked"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.readonly | Read-only view | Non-owner: list and social locale readable; no controls | LocaleFrame{s:{v:"sl",x:"readonly"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.saved | Saved | After Add language | LocaleFrame{s:{v:"sl",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.social | Social locale: three states | Atelier: Derived en_GB and pt_BR, Set fr_FR, Not set | LocaleFrame{s:{v:"sl",x:"social"}} | 1440, 1024, 390 | Atelier |
|
||||||
|
| loc.l1.socialEdit | Social locale: edit | Edit on French (Set: fr_FR) | LocaleFrame{s:{v:"sl",x:"socialEdit"}} | 1440, 390 | Atelier |
|
||||||
|
| loc.l1.socialInvalid | Social locale: invalid | fr-FR typed with a hyphen | LocaleFrame{s:{v:"sl",x:"socialInvalid"}} | 1440, 390 | Atelier |
|
||||||
|
|
||||||
|
## L1b Public URL pattern (owner setting)
|
||||||
|
Route: /settings/sites/:key/public-url
|
||||||
|
API: proposed: GET/PUT /v1/site/{key} { public_url_pattern }
|
||||||
|
|
||||||
|
Owner-only. One pattern per site, for example https://example.com/{lang}/{slug}. Supported placeholders: {lang} (language code) and {slug} (the article address in that language). {slug} is required: without it every article would share one address. The preview uses a real article of the site. When unset, article panels show "Public URL not configured" and link here. No URL is invented.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l1b.set | Set | Pattern saved, preview shown | LocaleFrame{s:{v:"pu",x:"set"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.editing | Editing | Live preview follows the field | LocaleFrame{s:{v:"pu",x:"editing"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.unknown | Unknown placeholder | {language} typed | LocaleFrame{s:{v:"pu",x:"unknown"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.scheme | Bad scheme | ftp:// typed | LocaleFrame{s:{v:"pu",x:"scheme"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.encoded | Not encoded | A space in the path | LocaleFrame{s:{v:"pu",x:"encoded"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.noSlug | Missing {slug} | Pattern without {slug} | LocaleFrame{s:{v:"pu",x:"noSlug"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.saved | Saved | Toast | LocaleFrame{s:{v:"pu",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.unset | Unset | No pattern; opened from "Public URL not configured" | LocaleFrame{s:{v:"pu",x:"unset"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.viewOnly | Read-only view | Non-owner: pattern readable, no controls | LocaleFrame{s:{v:"pu",x:"viewOnly"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L2 First-time setup for existing articles
|
||||||
|
Route: /settings/sites/:key/languages/setup
|
||||||
|
API: proposed: GET /v1/language/setup, POST /v1/language/setup { sites:[{ key, lang }] } (lang is the full tag, e.g. pt-BR)
|
||||||
|
|
||||||
|
Opened from the Languages banner of any site; :key is the site it was opened from, and the page lists every site that still has unlabelled articles. Setup assigns a language per site and applies in one transaction. It never translates, links or moves articles. Each site is seeded from its full Site.Locale when a matching language entry exists: pt-BR becomes Portuguese (Brazil). A site with no locale has no seed and the owner must choose. Nothing applies until the owner confirms every row, including a site with no articles. Retrying is safe.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l2.ready | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | LocaleFrame{s:{v:"su",x:"ready"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.noLocale | No-locale site | Notes only: the owner must choose | LocaleFrame{s:{v:"su",x:"noLocale"}} | 1440, 390 | Notes |
|
||||||
|
| loc.l2.empty | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | LocaleFrame{s:{v:"su",x:"empty"}} | 1440, 390 | Gallery |
|
||||||
|
| loc.l2.applying | Applying | Request in flight | LocaleFrame{s:{v:"su",x:"applying"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.inProgress | Setup already running | Another owner started it; starting again is safe | LocaleFrame{s:{v:"su",x:"inProgress"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.done | Done | 16 articles labelled | LocaleFrame{s:{v:"su",x:"done"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.fail | Failed | Nothing changed; choices kept; retry is safe | LocaleFrame{s:{v:"su",x:"fail"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Localization: existing surfaces
|
||||||
|
Board: RenewCMS Screens Localization. Frame: LocaleFrame. Data: locale-fixtures.js (single fixture source), tables in locale-spec.js. UI copy says "article", never "story". No revision numbers, IDs or HTTP status names in user-facing text.
|
||||||
|
|
||||||
|
## L12 Existing surfaces
|
||||||
|
Route: Home · Activity · Media · editor details
|
||||||
|
API: existing; language added where it applies
|
||||||
|
|
||||||
|
Single-language sites show none of this. Home counts articles, not versions.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l12.home | Home: recent articles | Default-language title; code only when not default | LocaleFrame{s:{v:"sf",x:"home"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.homeFr | Home: reader of French only | Titles in French with code FR | LocaleFrame{s:{v:"sf",x:"homeFr"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.activity | Activity | Events name the language | LocaleFrame{s:{v:"sf",x:"activity"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.media | Media usage | Shared image counted once, versions listed | LocaleFrame{s:{v:"sf",x:"media"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.menu | Article menu labels | Publish and Delete name the language | LocaleFrame{s:{v:"sf",x:"menu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.apiCopy | API URL copy | Copies the version URL with ?lang= | LocaleFrame{s:{v:"sf",x:"apiCopy"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.seoPreview | SEO preview | Localized address and canonical | LocaleFrame{s:{v:"sf",x:"seoPreview"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.siteFallback | Site text fallback | Used only when its language matches the article | LocaleFrame{s:{v:"sf",x:"siteFallback"}} | 1440, 390 | Journal |
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<x-dc>
|
<x-dc>
|
||||||
<dc-import name="LocalizationShell" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" src-pane="{{ srcPane }}" ed-banner="{{ edBanner }}" hint-size="100%,100%"></dc-import>
|
<dc-import name="LocalizationShell" s="{{ shell }}" mobile="{{ mob }}" slot-body="{{ main }}" slot-overlays="{{ ov }}" src-pane="{{ srcPane }}" ed-banner="{{ edBanner }}" ed-body-el="{{ edBodyEl }}" ed-header="{{ edHeader }}" ed-mob-bar="{{ edMobBar }}" hint-size="100%,100%"></dc-import>
|
||||||
</x-dc>
|
</x-dc>
|
||||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1440,"height":900},"s":{"editor":null,"tsType":"object"},"mobile":{"editor":"boolean","default":false,"tsType":"boolean"}}">
|
||||||
class Component extends DCLogic {
|
class Component extends DCLogic {
|
||||||
@@ -45,10 +45,11 @@ class Component extends DCLogic {
|
|||||||
D('padding:20px 24px;flex-shrink:0;flex-wrap:wrap;display:flex;gap:8px;' + (m ? 'flex-direction:column-reverse' : 'justify-content:flex-end'), ...[].concat(foot))))
|
D('padding:20px 24px;flex-shrink:0;flex-wrap:wrap;display:flex;gap:8px;' + (m ? 'flex-direction:column-reverse' : 'justify-content:flex-end'), ...[].concat(foot))))
|
||||||
const Toast = (tone, ic, t, d) => D('position:absolute;z-index:80;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:12px;display:flex;gap:10px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ui-' + tone + ');' + TN[tone].split(';')[0], Ic(ic)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + HL, t), d && S(TO, d)))
|
const Toast = (tone, ic, t, d) => D('position:absolute;z-index:80;' + (m ? 'left:16px;right:16px;bottom:16px' : 'right:24px;bottom:24px;width:356px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);padding:12px;display:flex;gap:10px', S('width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:var(--ui-' + tone + ');' + TN[tone].split(';')[0], Ic(ic)), D('display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + HL, t), d && S(TO, d)))
|
||||||
const Sk = n => D('display:flex;flex-direction:column;gap:10px', ...Array.from({ length: n }, (_, i) => D('height:' + (m ? 56 : 44) + 'px;background:var(--ui-bg-elevated);opacity:' + (1 - i * .12))))
|
const Sk = n => D('display:flex;flex-direction:column;gap:10px', ...Array.from({ length: n }, (_, i) => D('height:' + (m ? 56 : 44) + 'px;background:var(--ui-bg-elevated);opacity:' + (1 - i * .12))))
|
||||||
const Page = (title, sub, act, ...c) => D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:16px;padding:' + (m ? '16px 16px 88px' : '24px') + ';position:relative', D('display:flex;gap:12px;align-items:flex-start;' + (m ? 'flex-direction:column' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, title), sub && S(TO + ';text-wrap:pretty', sub)), act), ...c)
|
const Page = (title, sub, act, ...c) => D('flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:16px;padding:' + (m ? '16px 16px 88px' : '24px') + ';position:relative', D('display:flex;gap:12px;align-items:flex-start;' + (m ? 'flex-direction:column' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, title), sub && S(TO + ';text-wrap:pretty', sub)), act), ...c)
|
||||||
const Card = (...c) => D('background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', ...c)
|
const Card = (...c) => D('flex-shrink:0;background:var(--ui-bg);border:1px solid var(--ui-border);padding:16px;display:flex;flex-direction:column;gap:12px', ...c)
|
||||||
const ROW = 'display:flex;align-items:center;gap:12px;border-top:1px solid var(--ui-border);padding:10px 16px;min-height:' + (m ? 56 : 48) + 'px'
|
const ROW = 'display:flex;align-items:center;gap:12px;border-top:1px solid var(--ui-border);padding:10px 16px;min-height:' + (m ? 56 : 48) + 'px'
|
||||||
const Av = i => S('width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', i)
|
const Av = i => S('width:26px;height:26px;flex-shrink:0;background:var(--rc-sand);color:var(--rc-on-sand);display:flex;align-items:center;justify-content:center;font:600 11px \'IBM Plex Sans\',sans-serif', i)
|
||||||
|
let srcPane = null, edBanner = null, edBodyEl = null, edHeader = null, edMobBar = null
|
||||||
let main = null, shell = { kind: 'slot', display: 'Marta Alves', role: s.role }, ov = []
|
let main = null, shell = { kind: 'slot', display: 'Marta Alves', role: s.role }, ov = []
|
||||||
// ---- data
|
// ---- data
|
||||||
const FX = window.LOCFX, S1 = FX.STORIES[0], S5 = FX.STORIES[4]
|
const FX = window.LOCFX, S1 = FX.STORIES[0], S5 = FX.STORIES[4]
|
||||||
@@ -56,7 +57,7 @@ class Component extends DCLogic {
|
|||||||
const EN = LV('en', 'English', 'EN'), FR = LV('fr', 'French', 'FR'), AR = LV('ar', 'Arabic', 'AR'), DE = LV('de', 'German', 'DE')
|
const EN = LV('en', 'English', 'EN'), FR = LV('fr', 'French', 'FR'), AR = LV('ar', 'Arabic', 'AR'), DE = LV('de', 'German', 'DE')
|
||||||
const CAPS = { full: { e: 1, p: 1, d: 1, c: 1, rv: 1 }, ro: {}, p2: { e: 1, d: 1, c: 1, rv: 1 }, p3: { p: 1 }, p6: { e: 1, p: 1, d: 1, c: 1, rv: 1, part: 1 } }
|
const CAPS = { full: { e: 1, p: 1, d: 1, c: 1, rv: 1 }, ro: {}, p2: { e: 1, d: 1, c: 1, rv: 1 }, p3: { p: 1 }, p6: { e: 1, p: 1, d: 1, c: 1, rv: 1, part: 1 } }
|
||||||
// ---- language menu (menu button + menu; rows are single menu items, no nested controls)
|
// ---- language menu (menu button + menu; rows are single menu items, no nested controls)
|
||||||
const LangBtn = (cur, o = {}) => A('span', { role: 'button', 'aria-haspopup': 'menu', 'aria-expanded': !!o.open, 'aria-label': 'Language: ' + cur + '. Switch version' }, 'display:inline-flex;align-items:center;gap:8px;height:' + (m ? 44 : 32) + 'px;padding:0 10px;border:1px solid ' + (o.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;flex-shrink:0;max-width:100%;width:max-content', Ic('languages'), S('min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', cur), o.code && S(mono + ';flex-shrink:0;' + MU, o.code), Ic('chevrons-up-down', 14))
|
const LangBtn = (cur, o = {}) => A('span', { role: 'button', 'aria-haspopup': 'menu', 'aria-expanded': !!o.open, 'aria-label': 'Language: ' + cur + '. Switch version' }, 'display:inline-flex;align-items:center;gap:8px;height:' + (m ? 44 : 32) + 'px;padding:0 10px;border:1px solid ' + (o.open ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);color:var(--ui-text-highlighted);font-weight:500;flex-shrink:0;max-width:260px;width:max-content', Ic('languages'), S('white-space:nowrap', cur), o.code && S(mono + ';flex-shrink:0;' + MU, o.code), Ic('chevrons-up-down', 14))
|
||||||
const Tag = (l, ic) => S('display:inline-flex;align-items:center;gap:4px;font-size:12px;' + MU, ic && Ic(ic, 12), l)
|
const Tag = (l, ic) => S('display:inline-flex;align-items:center;gap:4px;font-size:12px;' + MU, ic && Ic(ic, 12), l)
|
||||||
const LRow = r => { const cur = r.cur, add = r.add
|
const LRow = r => { const cur = r.cur, add = r.add
|
||||||
return A('div', { role: 'menuitem', 'aria-current': cur || undefined, tabIndex: -1 }, 'display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:6px 12px;' + (cur ? 'background:var(--rc-sand);color:var(--rc-on-sand)' : add ? 'color:var(--ui-text-toned)' : ''), S('width:16px', cur && Ic('check')), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (cur ? 600 : 500), r.n), S(mono + (cur ? '' : ';' + MU), r.c)),
|
return A('div', { role: 'menuitem', 'aria-current': cur || undefined, tabIndex: -1 }, 'display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:6px 12px;' + (cur ? 'background:var(--rc-sand);color:var(--rc-on-sand)' : add ? 'color:var(--ui-text-toned)' : ''), S('width:16px', cur && Ic('check')), D('flex:1;min-width:0;display:flex;flex-direction:column', S('overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:' + (cur ? 600 : 500), r.n), S(mono + (cur ? '' : ';' + MU), r.c)),
|
||||||
@@ -64,7 +65,7 @@ class Component extends DCLogic {
|
|||||||
const LangPop = (rows, o = {}) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;max-height:70%;border-top:1px solid var(--ui-border)' : 'left:' + (o.left ?? 292) + 'px;top:' + (o.top ?? 150) + 'px;width:400px;max-height:' + (o.max ?? 420) + 'px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
|
const LangPop = (rows, o = {}) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;max-height:70%;border-top:1px solid var(--ui-border)' : 'left:' + (o.left ?? 292) + 'px;top:' + (o.top ?? 150) + 'px;width:400px;max-height:' + (o.max ?? 420) + 'px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
|
||||||
m && D('display:flex;justify-content:center;padding:8px 0 0;flex-shrink:0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
m && D('display:flex;justify-content:center;padding:8px 0 0;flex-shrink:0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
D('padding:10px 12px 6px;display:flex;flex-direction:column;gap:6px;flex-shrink:0', S('font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Versions you can open'), o.find && Fd('', 'Find a language', { val: false })),
|
D('padding:10px 12px 6px;display:flex;flex-direction:column;gap:6px;flex-shrink:0', S('font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Versions you can open'), o.find && Fd('', 'Find a language', { val: false })),
|
||||||
A('div', { role: 'menu', 'aria-label': 'Versions of this story' }, 'display:flex;flex-direction:column;overflow-y:auto;min-height:0', ...rows.map(LRow)),
|
A('div', { role: 'menu', 'aria-label': 'Versions of this article' }, 'display:flex;flex-direction:column;overflow-y:auto;min-height:0', ...rows.map(LRow)),
|
||||||
D('padding:10px 12px;border-top:1px solid var(--ui-border);display:flex;gap:8px;flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', Ic('info', 14), 'Switching opens that version. The interface language stays the same.'))
|
D('padding:10px 12px;border-top:1px solid var(--ui-border);display:flex;gap:8px;flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', Ic('info', 14), 'Switching opens that version. The interface language stays the same.'))
|
||||||
const MENUS = {
|
const MENUS = {
|
||||||
all: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1 }],
|
all: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1 }],
|
||||||
@@ -72,270 +73,412 @@ class Component extends DCLogic {
|
|||||||
many: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'English (United Kingdom)', c: 'EN-GB', st: 'draft' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Italian', c: 'IT', st: 'draft' }, { n: 'Japanese', c: 'JA', st: 'pub' }, { n: 'Portuguese (Brazil)', c: 'PT-BR', add: 1 }, { n: 'Spanish', c: 'ES', add: 1 }, { n: 'Chinese (Simplified)', c: 'ZH-HANS', add: 1 }, { n: 'Arabic', c: 'AR', add: 1 }, { n: 'Dutch', c: 'NL', add: 1 }, { n: 'Polish', c: 'PL', add: 1 }],
|
many: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'English (United Kingdom)', c: 'EN-GB', st: 'draft' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Italian', c: 'IT', st: 'draft' }, { n: 'Japanese', c: 'JA', st: 'pub' }, { n: 'Portuguese (Brazil)', c: 'PT-BR', add: 1 }, { n: 'Spanish', c: 'ES', add: 1 }, { n: 'Chinese (Simplified)', c: 'ZH-HANS', add: 1 }, { n: 'Arabic', c: 'AR', add: 1 }, { n: 'Dutch', c: 'NL', add: 1 }, { n: 'Polish', c: 'PL', add: 1 }],
|
||||||
ro: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
ro: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||||
p4: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1, tag: 'Read only', tagIc: 'lock' }],
|
p4: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', st: 'draft', fr: 1, tag: 'Read only', tagIc: 'lock' }],
|
||||||
p5: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }]
|
p5: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||||
|
noAdd: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }],
|
||||||
|
withAdd: [{ n: 'English', c: 'EN', st: 'pub' }, { n: 'French', c: 'FR', st: 'draft', fr: 1, cur: 1 }, { n: 'German', c: 'DE', st: 'pub' }, { n: 'Arabic', c: 'AR', add: 1 }]
|
||||||
}
|
}
|
||||||
// ---- editor pieces
|
// ---- editor pieces
|
||||||
const ToolRow = ro => D('display:flex;gap:2px;padding:6px;border:1px solid var(--ui-border);background:var(--ui-bg);overflow:hidden;flex-shrink:0;' + TO, A('div', { role: 'toolbar', 'aria-label': 'Formatting' }, 'display:flex;gap:2px', ...['bold', 'italic', 'underline', 'heading-2', 'list', 'list-ordered', 'quote', 'link', 'image'].map(n => A('span', { role: 'button', 'aria-label': n }, 'width:' + (m ? 44 : 28) + 'px;height:' + (m ? 44 : 28) + 'px;display:flex;align-items:center;justify-content:center;flex-shrink:0', Ic(n)))))
|
const nr = !!s.narrow && !m
|
||||||
const Body = (L, o = {}) => A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-multiline': 'true', 'aria-readonly': o.ro || undefined, 'aria-label': (o.label || 'Body') + ' (' + L.n + ')', tabIndex: 0 }, 'position:relative;display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--ui-border);background:' + (o.ro ? 'var(--ui-bg-muted)' : 'var(--ui-bg)') + ';' + ((m && !o.pane) ? 'flex:none;' : 'flex:1;min-height:0;overflow:auto;') + 'font:400 ' + (L.dir === 'rtl' ? '17px/30px' : '15px/26px') + ' \'Source Serif 4\',serif;color:var(--ui-text)', ...(o.long ? L.p.concat(L.p, L.p) : L.p).map(t => S('text-wrap:pretty', t)))
|
const J = FX.SITES.journal, LNm = FX.LANG_NAMES
|
||||||
const TitleField = (L, o) => {
|
const Body = (L, o = {}) => A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-multiline': 'true', 'aria-readonly': o.ro || undefined, 'aria-label': (o.label || 'Body') + ' (' + L.n + ')', tabIndex: 0 }, 'display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--ui-border);background:' + (o.ro ? 'var(--ui-bg-muted)' : 'var(--ui-bg)') + ';flex:1;min-height:0;overflow:auto;font:400 ' + (L.dir === 'rtl' ? '17px/30px' : '15px/26px') + ' \'Source Serif 4\',serif;color:var(--ui-text)', ...(o.long ? L.p.concat(L.p, L.p) : L.p).map(t => S('text-wrap:pretty', t)))
|
||||||
const cap = o.cap, v = o.titleVal ?? L.t
|
const LONGT = 'Comment nous chiffrons une rénovation de salle de bain, poste par poste, avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes, expliqués avant la commande du premier carreau et de la première vanne'
|
||||||
if (!cap.e) return D('display:flex;flex-direction:column;gap:2px', S('font-size:12px;' + MU, 'Title (' + L.n + ')'), A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-readonly': 'true', 'aria-label': 'Title (' + L.n + ')', tabIndex: 0 }, 'font:600 18px/26px \'IBM Plex Sans\',sans-serif;' + HL, v))
|
const TitleIn = (L, o) => {
|
||||||
const st = o.titleState, err = st === 'invalid' || st === 'failed'
|
const st = o.tst || 'pristine', cap = o.cap, lab = 'Title (' + L.n + ')'
|
||||||
return D('display:flex;flex-direction:column;gap:4px', S('font-size:12px;' + MU, 'Title (' + L.n + ')'),
|
if (!cap.e) return D('display:flex;flex-direction:column;gap:2px', S('font-size:12px;' + MU, lab), D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 36) + 'px', A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-readonly': 'true', 'aria-label': lab, tabIndex: 0 }, 'flex:1;min-width:0;font:600 ' + (m ? 18 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;' + HL, L.t), Ic('lock', 14)))
|
||||||
D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 36) + 'px;padding:2px 10px;border:1px solid ' + (err ? 'var(--ui-error)' : st === 'focus' || st === 'dirty' ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);' + (st === 'focus' ? 'outline:2px solid var(--ui-primary);outline-offset:1px' : ''),
|
const val = { invalid: '', short: 'Sa', long: LONGT.slice(0, 203), edit: L.t + ' en 2026', unsaved: L.t + ' en 2026' }[st] ?? (o.titleVal ?? L.t)
|
||||||
A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-label': 'Title (' + L.n + ')', 'aria-invalid': err || undefined, tabIndex: 0 }, 'flex:1;min-width:0;font:600 18px/26px \'IBM Plex Sans\',sans-serif;' + (st === 'invalid' ? MU : HL) + ';text-wrap:pretty', st === 'invalid' ? 'Title' : v), st === 'saving' ? Ic('loader-circle', 14) : Ic('pencil', 14)),
|
const err = ['invalid', 'short', 'long', 'failed'].includes(st), on = ['focus', 'edit'].includes(st), cnt = ['focus', 'edit', 'short', 'long'].includes(st)
|
||||||
st === 'invalid' ? S('display:flex;gap:6px;color:var(--ui-error);font-size:12px', Ic('circle-x', 14), 'Enter a title.') : st === 'failed' ? D('display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--ui-error);font-size:12px', Ic('circle-x', 14), 'Couldn\'t save the title. Your text is kept.', Btn('Try again', 'secondary', { sm: true })) : st === 'saving' ? S(MU + ';font-size:12px', 'Saving title…') : st === 'dirty' || st === 'focus' ? S(MU + ';font-size:12px', 'Changing the title doesn\'t change the address. Edit the address in Search and sharing.') : null)
|
const msg = { invalid: 'Enter a title', short: 'Use at least 3 characters', long: 'Use 200 characters or fewer' }[st]
|
||||||
|
return D('display:flex;flex-direction:column;gap:4px', S('font-size:12px;' + MU, lab),
|
||||||
|
D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 44 : 40) + 'px;padding:2px 10px;border:1px solid ' + (err ? 'var(--ui-error)' : on ? 'var(--ui-primary)' : 'var(--ui-border-accented)') + ';background:var(--ui-bg);' + (st === 'focus' ? 'outline:2px solid var(--ui-primary);outline-offset:1px' : ''),
|
||||||
|
A('div', { dir: L.dir, lang: L.lang, role: 'textbox', 'aria-label': lab, 'aria-invalid': err || undefined, 'aria-describedby': 'title-help', tabIndex: 0 }, 'flex:1;min-width:0;font:600 ' + (m ? 18 : 20) + 'px/28px \'IBM Plex Sans\',sans-serif;' + (st === 'invalid' ? MU : HL) + ';text-wrap:pretty;' + (m ? '' : 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'), st === 'invalid' ? 'Title' : val),
|
||||||
|
cnt && S(mono + ';flex-shrink:0;color:' + (err ? 'var(--ui-error)' : 'var(--ui-text-muted)'), val.length + ' / 200'), st === 'saving' ? Ic('loader-circle', 14) : st === 'failed' ? Ic('circle-x', 14) : Ic('pencil', 14)),
|
||||||
|
A('div', { id: 'title-help', role: err ? 'alert' : undefined }, 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12px;line-height:18px;' + (err ? 'color:var(--ui-error)' : MU),
|
||||||
|
err && Ic('circle-x', 14), msg || (st === 'failed' ? 'Couldn\'t save the title. Your text is kept.' : st === 'saving' ? 'Saving title…' : (on || st === 'unsaved') ? 'Changing the title doesn\'t change the address.' : null), st === 'failed' && Btn('Try again', 'secondary', { sm: true })))
|
||||||
}
|
}
|
||||||
const PubAct = (L, o) => ({ l: o.pub === 'pub' ? 'Unpublish ' + L.n : (o.dirty ? 'Save and publish ' : 'Publish ') + L.n, dis: !!o.saving || !!o.saveBlock })
|
const Acts = (L, o) => {
|
||||||
const Life = (L, o) => (o.pub === 'pub' ? 'This version is online. Saving updates the live page right away.' : 'Saving keeps this version as a draft. It isn\'t public until you publish it.') + (o.cap.p && o.pub !== 'pub' ? ' Save and publish saves first, then publishes.' : '')
|
const cap = o.cap, on = o.pub === 'pub', d = !!o.dirty || !!o.saveFail, a = { prim: null, sec: [], un: null }
|
||||||
const SaveLbl = o => o.saving ? 'Saving…' : o.saveFail ? 'Try saving again' : 'Save'
|
if (o.pubFail) a.prim = { l: 'Retry publish', ic: 'rotate-cw' }
|
||||||
const SaveDis = o => (!o.dirty && !o.saving && !o.saveFail) || !!o.saveBlock
|
else if (o.saving) a.prim = { l: 'Saving…', busy: 1, dis: 1 }
|
||||||
const Edit = (L, o) => {
|
else if (cap.e && d && cap.p && !on && !o.saveFail) a.prim = { l: 'Save and publish ' + L.n, dis: !!o.saveBlock }
|
||||||
const cap = o.cap, sr = !!o.src, pa = PubAct(L, o)
|
else if (cap.e && d) a.prim = { l: o.saveFail ? 'Try saving again' : 'Save', dis: !!o.saveBlock }
|
||||||
const state = o.saveFail ? Bd('Save failed', 'error', 'circle-x') : o.dirty ? Bd('Unsaved changes', 'neutral', 'circle-dot') : null
|
else if (cap.p && !on && !o.pubDis) a.prim = { l: 'Publish ' + L.n }
|
||||||
const btns = []
|
const plain = a.prim && (a.prim.busy || a.prim.l === 'Save' || a.prim.l === 'Try saving again')
|
||||||
btns.push(Btn(sr ? 'Hide source reference' : 'Show source reference', 'secondary', { ic: 'columns-2' }))
|
if (cap.e && !plain) a.sec.push({ l: 'Save', dis: !d })
|
||||||
if (cap.e) btns.push(Btn(SaveLbl(o), 'secondary', { busy: o.saving, dis: SaveDis(o) }))
|
if (cap.p && on && !o.saving) a.un = { l: 'Unpublish ' + L.n }
|
||||||
if (cap.p) btns.push(Btn(pa.l, 'primary', { dis: pa.dis || o.pubDis }))
|
if (o.pubDis) a.prim = { l: 'Publish ' + L.n, dis: 1 }
|
||||||
if (cap.d || cap.c) btns.push(Btn('', 'ghost', { ic: 'ellipsis', al: 'More actions' }))
|
return a
|
||||||
return D('display:flex;flex-direction:column;gap:10px;flex-shrink:0', D('display:flex;align-items:center;gap:8px;' + MU, Ic('arrow-left', 14), S('font-size:12px', 'Articles')),
|
}
|
||||||
TitleField(L, o),
|
const OvItems = (L, o, k = {}) => {
|
||||||
D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', LangBtn(L.n, { code: L.c, open: o.open }), Pub(o.pub || 'draft'), o.fresh && Fr(o.fresh), state, !cap.e && Bd('Read only', 'neutral', 'lock'), ...(m ? [] : [D('flex:1'), ...btns])),
|
const cap = o.cap, a = Acts(L, o), it = []
|
||||||
m && D('display:flex;gap:8px', ...btns.slice(0, 1)),
|
if (m && !k.skipRef) it.push({ l: o.src ? 'Hide source reference' : 'Show source reference', ic: 'columns-2' })
|
||||||
cap.e && o.saveFail && !o.titleState && Alert('error', 'circle-x', 'Couldn\'t save ' + L.n, 'Your changes are kept here. Nothing was published. Try saving again.'),
|
if (a.un && (m || nr)) it.push({ l: a.un.l, ic: 'eye-off' })
|
||||||
cap.e && o.dirty && !o.saveFail && !o.titleState && S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', Life(L, o)),
|
if (cap.e) it.push({ l: 'Edit slug', ic: 'link', why: 'Editing the title never changes the slug.' })
|
||||||
!cap.e && Alert('neutral', 'lock', cap.p ? 'You can publish this version but not edit it' : 'You can read this version', cap.p ? 'Fields are read-only. Publish and Unpublish stay available. The source reference is still available.' : 'You can\'t edit it. The source reference and versions you can read stay available.'),
|
if (cap.e) it.push(cap.part ? { l: 'Move article', ic: 'arrow-right-left', dis: 1, why: 'You can\'t move this article.' } : { l: 'Move article', ic: 'arrow-right-left' })
|
||||||
cap.e && !cap.p && Alert('neutral', 'info', 'You can edit this version but not publish it', (o.pub === 'pub' ? 'This version is online, so saving updates the live page right away.' : 'Saving keeps it as a draft.') + ' Publishing and unpublishing need the publish permission.'))
|
if (cap.d) it.push({ l: 'Delete ' + L.n + ' version', ic: 'trash-2', danger: 1 })
|
||||||
|
if (cap.d) it.push(cap.part ? { l: 'Delete article', ic: 'trash-2', dis: 1, why: 'You can\'t delete this article.' } : { l: 'Delete article', ic: 'trash-2', danger: 1 })
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
const OvMenu = items => D('position:absolute;z-index:45;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'right:24px;top:' + (nr ? 150 : 140) + 'px;width:320px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column',
|
||||||
|
m && D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
|
A('div', { role: 'menu', 'aria-label': 'More actions' }, 'display:flex;flex-direction:column;padding:4px 0', ...items.map(i => A('div', { role: 'menuitem', 'aria-disabled': i.dis || undefined, tabIndex: -1 }, 'flex-shrink:0;display:flex;align-items:center;gap:10px;min-height:' + (m ? 52 : 40) + 'px;padding:8px 12px;' + (i.dis ? 'opacity:.6;' : '') + (i.danger ? 'color:var(--ui-error)' : HL), Ic(i.ic, 16), D('display:flex;flex-direction:column;gap:2px', S('font-weight:500', i.l), i.why && S(MU + ';font-size:12px;font-weight:400;line-height:16px', i.why))))))
|
||||||
|
const Hdr = (L, o) => {
|
||||||
|
const cap = o.cap, a = Acts(L, o), items = OvItems(L, o, { skipRef: m && !a.prim && !a.sec.length && !a.un })
|
||||||
|
const refL = o.src ? 'Hide source reference' : 'Show source reference'
|
||||||
|
const pb = x2 => x2 && Btn(x2.l, 'primary', { busy: x2.busy, dis: x2.dis, ic: x2.ic })
|
||||||
|
const status = o.saving ? Bd('Saving…', 'neutral', 'loader-circle') : o.saveFail ? Bd('Save failed', 'error', 'circle-x') : o.pubFail || o.saved ? Bd('Saved', 'success', 'check') : (o.dirty || o.tst === 'unsaved') ? Bd('Unsaved changes', 'neutral', 'circle-dot') : null
|
||||||
|
const fresh = o.fresh && (nr ? A('span', { 'aria-label': o.fresh, title: o.fresh }, 'display:inline-flex;align-items:center;justify-content:center;width:24px;height:20px;flex-shrink:0;border:1px solid currentColor;background:var(--ui-bg);color:var(--ui-warning)', Ic('refresh-cw', 12)) : Fr(o.fresh))
|
||||||
|
const left = D('flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;flex-wrap:wrap', LangBtn(L.n, { code: L.c, open: !!o.open }), Pub(o.pub), status, fresh, !cap.e && Bd('Read only', 'neutral', 'lock'))
|
||||||
|
const right = !m && D('display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto', Btn(nr ? '' : refL, 'secondary', { ic: 'columns-2', al: refL }), a.un && !nr && Btn(a.un.l), ...a.sec.map(x2 => Btn(x2.l, 'secondary', { dis: x2.dis })), pb(a.prim), items.length ? Btn('', 'secondary', { ic: 'ellipsis', al: 'More actions' }) : null)
|
||||||
|
return D('flex:1 1 100%;min-width:0;display:flex;flex-direction:column;gap:10px', D('display:flex;align-items:flex-start;gap:8px', !m && D('height:60px;display:flex;align-items:flex-end;' + MU, Ic('arrow-left', 16)), D('flex:1;min-width:0', TitleIn(L, o))), D('display:flex;align-items:center;gap:12px;flex-wrap:wrap', left, right))
|
||||||
|
}
|
||||||
|
const MBar = (L, o) => {
|
||||||
|
if (!m) return null
|
||||||
|
const a = Acts(L, o), items = OvItems(L, o, { skipRef: !a.prim && !a.sec.length && !a.un }), bs = []
|
||||||
|
if (a.prim) bs.push(D('flex:1 1 0;min-width:0;display:flex', Btn(a.prim.l, 'primary', { w: '100%', busy: a.prim.busy, dis: a.prim.dis, ic: a.prim.ic })))
|
||||||
|
a.sec.forEach(x2 => bs.push(Btn(x2.l, 'secondary', { dis: x2.dis })))
|
||||||
|
if (!a.prim && !a.sec.length) bs.push(D('flex:1 1 0;display:flex', Btn(a.un ? a.un.l : (o.src ? 'Hide source reference' : 'Show source reference'), 'secondary', { w: '100%', ic: a.un ? 'eye-off' : 'columns-2' })))
|
||||||
|
if (items.length) bs.push(Btn('', 'secondary', { ic: 'ellipsis', al: 'More actions' }))
|
||||||
|
return D('flex-shrink:0;background:var(--ui-bg);border-top:1px solid var(--ui-border);padding:12px 16px;display:flex;gap:8px;align-items:center', ...bs)
|
||||||
}
|
}
|
||||||
const MBar = (L, o) => { if (!m) return null; const cap = o.cap, pa = PubAct(L, o), bs = []; if (cap.e) bs.push(Btn(SaveLbl(o), 'secondary', { busy: o.saving, dis: SaveDis(o) })); if (cap.p) bs.push(D('flex:1 1 170px;display:flex', Btn(pa.l, 'primary', { w: '100%', dis: pa.dis }))); if (cap.d || cap.c) bs.push(Btn('', 'ghost', { ic: 'ellipsis', al: 'More actions' })); return bs.length ? D('position:absolute;left:0;right:0;bottom:0;z-index:30;background:var(--ui-bg);border-top:1px solid var(--ui-border);padding:12px 16px;display:flex;gap:8px;flex-wrap:wrap', ...bs) : null }
|
|
||||||
const Seg = (on, L, S0) => A('div', { role: 'radiogroup', 'aria-label': 'Show' }, 'display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...['Source (' + S0.n + ')', 'Edit (' + L.n + ')'].map((t, i) => A('span', { role: 'radio', 'aria-checked': (on ? i === 0 : i === 1) }, 'flex:1;height:44px;display:flex;align-items:center;justify-content:center;font-weight:500;background:' + ((on ? i === 0 : i === 1) ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + ((on ? i === 0 : i === 1) ? 'var(--ui-bg)' : 'var(--ui-text-toned)'), t)))
|
const Seg = (on, L, S0) => A('div', { role: 'radiogroup', 'aria-label': 'Show' }, 'display:flex;border:1px solid var(--ui-border-accented);flex-shrink:0', ...['Source (' + S0.n + ')', 'Edit (' + L.n + ')'].map((t, i) => A('span', { role: 'radio', 'aria-checked': (on ? i === 0 : i === 1) }, 'flex:1;height:44px;display:flex;align-items:center;justify-content:center;font-weight:500;background:' + ((on ? i === 0 : i === 1) ? 'var(--ui-primary)' : 'var(--ui-bg)') + ';color:' + ((on ? i === 0 : i === 1) ? 'var(--ui-bg)' : 'var(--ui-text-toned)'), t)))
|
||||||
|
const TempRef = S('white-space:nowrap;display:inline-flex;align-items:center;gap:4px;height:20px;padding:0 6px;font-size:12px;font-weight:500;border:1px dashed var(--ui-border-accented);color:var(--ui-text-toned);background:var(--ui-bg)', Ic('book-open', 12), 'Temporary reference')
|
||||||
|
const Tracked = S0n => Bd('Tracked source: ' + S0n, 'info', 'link')
|
||||||
const Src = (S0, o = {}) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px',
|
const Src = (S0, o = {}) => D('flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px',
|
||||||
D('display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--ui-bg-elevated);flex-wrap:wrap', Ic('book-open', 14), S('font-weight:600;' + HL, 'Reference: ' + S0.n), S(mono + ';' + MU, S0.c), Pub('pub'), S('margin-left:auto;display:inline-flex;gap:4px;' + MU + ';font-size:12px', Ic('lock', 14), 'Read only'), Btn('Change reference', 'secondary', { sm: true })),
|
D('flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:8px 12px;border:1px dashed var(--ui-border-accented);background:var(--ui-bg)', D('display:flex;align-items:center;gap:8px;flex-wrap:wrap', TempRef, S('font-weight:600;' + HL, S0.n), S(mono + ';' + MU, S0.c), Pub('pub')), D('display:flex;align-items:center;gap:8px', S('white-space:nowrap;display:inline-flex;gap:4px;' + MU + ';font-size:12px', Ic('lock', 14), 'Read only'), D('flex:1'), Btn('Change reference', 'secondary', { sm: true }))),
|
||||||
S(MU + ';font-size:12px;line-height:18px', 'Reference only. The tracked source for review is English, revision 8. Changing the reference doesn\'t change it.'),
|
S('flex-shrink:0;' + MU + ';font-size:12px;line-height:18px', 'For comparing only. Review is tracked against English. Changing the reference doesn\'t change the tracked source.'),
|
||||||
o.gone ? Alert('warning', 'triangle-alert', 'This reference isn\'t available', 'It was deleted or you can no longer read it. Your French version isn\'t affected.', Btn('Choose another reference', 'secondary', { sm: true })) : [
|
o.gone ? Alert('warning', 'triangle-alert', 'This reference isn\'t available', 'It was deleted or you can no longer read it. Your version isn\'t affected.', Btn('Choose another reference', 'secondary', { sm: true })) : [
|
||||||
o.chg && Alert('neutral', 'info', 'Changed fields since French was reviewed (revision 7 → 8)', 'Title, Body (2 paragraphs). Fields are listed, not compared line by line.'),
|
|
||||||
A('div', { dir: S0.dir, lang: S0.lang, role: 'heading', 'aria-level': '2', tabIndex: 0 }, 'font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, S0.t),
|
A('div', { dir: S0.dir, lang: S0.lang, role: 'heading', 'aria-level': '2', tabIndex: 0 }, 'font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, S0.t),
|
||||||
Body(S0, { ro: true, label: 'Reference', long: o.long, pane: true })])
|
Body(S0, { ro: true, label: 'Reference', long: o.long })])
|
||||||
const EdPage = (L, o, extra) => D('flex:1;min-width:0;min-height:0;overflow:' + (m ? 'auto' : 'hidden') + ';display:flex;flex-direction:column;gap:12px;padding:' + (m ? '16px 16px 128px' : '24px') + ';position:relative', Edit(L, o), ...[].concat(extra || []), (o.cap.e && !(m && o.srcMob)) ? ToolRow() : null, (m && o.srcMob) ? null : Body(L, { ro: !o.cap.e, long: o.long }), (!m && o.seoNote) ? Alert('neutral', 'info', 'Search and sharing: description is empty', 'No fallback: the English site default isn\'t used for French. Add a French description or the tag is left out.') : null)
|
|
||||||
const Editor = (L, o, extra) => {
|
|
||||||
const S0 = o.S0 || EN
|
|
||||||
if (!o.src) return D('flex:1;min-height:0;display:flex;position:relative', EdPage(L, o, extra), MBar(L, o))
|
|
||||||
if (m) { const on = !!o.srcMob; return D('flex:1;min-height:0;position:relative;display:flex;flex-direction:column', D('flex:1;min-height:0;display:flex;flex-direction:column;gap:12px;padding:16px 16px 128px', Seg(on, L, S0), on ? Src(S0, o) : D('flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:12px', Edit(L, { ...o, src: 1 }), o.cap.e && ToolRow(), Body(L, { ro: !o.cap.e, long: o.long }))), MBar(L, o)) }
|
|
||||||
return D('flex:1;min-height:0;display:flex', D('width:' + (o.narrow ? '42%' : '50%') + ';flex-shrink:0;min-width:0;border-right:1px solid var(--ui-border);overflow:hidden;padding:24px;display:flex;flex-direction:column;background:var(--ui-bg-muted)', Src(S0, o)), EdPage(L, o, extra))
|
|
||||||
}
|
|
||||||
// ---- site shells
|
// ---- site shells
|
||||||
const setS = { nav: 'settings', crumbs: ['Settings', 'Sites', 'Journal', 'Languages'], mTitle: 'Journal · Languages', mLead: 'arrow-left' }
|
const setS = { nav: 'settings', crumbs: ['Settings', 'Sites', 'Journal', 'Languages'], mTitle: 'Journal · Languages', mLead: 'arrow-left' }
|
||||||
const edS = { nav: 'articles', crumbs: ['Articles', 'Editor'], mTitle: 'Editor', mLead: 'arrow-left' }
|
const edS = { nav: 'articles', crumbs: ['Articles', 'Editor'], mTitle: 'Editor', mLead: 'arrow-left' }
|
||||||
const liS = { nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
|
const liS = { nav: 'articles', crumbs: ['Articles'], mTitle: 'Articles' }
|
||||||
// ---- site languages
|
// ---- site languages
|
||||||
const LNG = FX.LANGS.map(l => { const c = FX.count(l.code, FX.STORIES); return [l.name, l.code, l.def ? 'Default' : l.enabled ? '' : 'Disabled', c[0], c[1], l.social] })
|
const SOC = (site, code) => { const q = (FX.SOCIAL[site] || {})[code] || ['none']; return q[0] === 'derived' ? 'Derived: ' + q[1] : q[0] === 'set' ? 'Set: ' + q[1] : 'Not set' }
|
||||||
const LRows = ro => LNG.map(r => D(ROW + ';opacity:' + (r[2] === 'Disabled' ? .8 : 1), D('flex:1;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + HL + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', r[0]), S(mono + ';' + MU, r[1] + ' · social locale ' + (r[5] === 'Not set' ? 'not set' : r[5]))), r[2] === 'Default' ? Bd('Default', 'info', 'star') : r[2] === 'Disabled' ? Bd('Disabled', 'neutral', 'eye-off') : null, !m && S(MU + ';width:190px', r[3] + (r[3] === 1 ? ' version · ' : ' versions · ') + r[4] + ' published'), !ro && r[2] === 'Disabled' ? Btn('Re-enable', 'secondary', { sm: true }) : null, !ro && Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Actions for ' + r[0] })))
|
const SiteRows = (siteKey, ro) => { const si = FX.SITES[siteKey], all = si.langs.map(k => [k, 1]).concat(si.disabled.map(k => [k, 0])); return all.map(([k, en]) => { const c = FX.count(k, si.stories), def = k === si.def, soc = SOC(siteKey, k)
|
||||||
|
return D(ROW + ';flex-wrap:wrap;opacity:' + (en ? 1 : .85), D('flex:1 1 160px;min-width:0;display:flex;flex-direction:column', S('font-weight:500;' + HL + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap', LNm[k] || k), S(mono + ';' + MU, k)),
|
||||||
|
def ? Bd('Default', 'info', 'star') : !en ? Bd('Disabled', 'neutral', 'eye-off') : null, !m && S(MU + ';width:170px', FX.pl(c[0], 'version') + ' · ' + c[1] + ' published'),
|
||||||
|
D('display:flex;align-items:center;gap:6px;' + (m ? 'flex:1 1 100%' : 'width:270px;white-space:nowrap'), S('font-size:12px;white-space:nowrap;' + MU, 'Social locale'), S(mono + ';' + (soc === 'Not set' ? MU : HL), soc), !ro && Btn('Edit', 'ghost', { sm: true, al: 'Edit social locale for ' + (LNm[k] || k) })),
|
||||||
|
!ro && !en ? Btn('Re-enable', 'secondary', { sm: true }) : null, !ro && Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Actions for ' + (LNm[k] || k) })) }) }
|
||||||
const slPage = () => {
|
const slPage = () => {
|
||||||
const ro = x === 'readonly', single = x === 'single', site = single ? 'Archive' : 'Journal', T = 'Languages', SUB = 'Content languages for ' + site + '. Each article version is written in one language.'
|
const ro = x === 'readonly', single = x === 'single', skey = single ? 'archive' : x === 'social' || x === 'socialEdit' || x === 'socialInvalid' ? 'atelier' : 'journal', site = FX.SITES[skey].name, T = 'Languages', SUB = 'Content languages for ' + site + '. Each article version is written in one language.'
|
||||||
if (x === 'loading') return Page(T, SUB, null, Sk(4))
|
if (x === 'loading') return Page(T, SUB, null, Sk(4))
|
||||||
if (x === 'error') return Page(T, SUB, null, Alert('error', 'circle-x', 'Couldn\'t load languages', 'Check your connection and try again.', Btn('Try again', 'secondary', { sm: true })))
|
if (x === 'error') return Page(T, SUB, null, Alert('error', 'circle-x', 'Couldn\'t load languages', 'Check your connection and try again.', Btn('Try again', 'secondary', { sm: true })))
|
||||||
|
const banner = x === 'disableDefault' ? Alert('warning', 'triangle-alert', 'English is the default', 'Choose another default first, then disable English.') : x === 'disableLast' ? Alert('warning', 'triangle-alert', 'At least one language must stay enabled', 'Journal needs a language for new articles.') : x === 'defaultFail' ? Alert('error', 'circle-x', 'Couldn\'t change the default language', 'Nothing changed. English is still the default. Try again.', Btn('Try again', 'secondary', { sm: true })) : x === 'removeFail' ? Alert('error', 'circle-x', 'Couldn\'t remove Italian', 'Nothing changed. Italian is still listed. Try again.', Btn('Try again', 'secondary', { sm: true })) : x === 'stale' ? Alert('error', 'circle-x', 'Languages changed. Reload.', 'Another owner changed the languages for Journal while this page was open. Reload to see the current list. Your change wasn\'t applied.', Btn('Reload', 'secondary', { sm: true })) : null
|
||||||
return Page(T, SUB, !ro && Btn('Add language', 'primary', { ic: 'plus' }),
|
return Page(T, SUB, !ro && Btn('Add language', 'primary', { ic: 'plus' }),
|
||||||
ro && Alert('neutral', 'lock', 'Only owners can change languages', 'You can see which languages are enabled and how many versions each has. Ask an owner to add, disable or change a language.'),
|
ro && Alert('neutral', 'lock', 'Only owners can change languages', 'You can see which languages are enabled, their social locale and how many versions each has. Ask an owner to change them.'), banner,
|
||||||
x === 'disableDefault' && Alert('warning', 'triangle-alert', 'English is the default', 'Choose another default first, then disable English.'),
|
single ? Card(S('font-weight:600;' + HL, 'Archive uses one language'), S(TO, 'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.'), D('display:flex;gap:10px;align-items:center', Bd('Default', 'info', 'star'), S('font-weight:500;' + HL, 'Portuguese (Brazil)'), S(mono + ';' + MU, 'pt-BR'), S(MU + ';font-size:12px', 'Social locale'), S(mono + ';' + HL, SOC('archive', 'pt-BR') === 'Not set' ? 'Derived: pt_BR' : '')))
|
||||||
x === 'disableLast' && Alert('warning', 'triangle-alert', 'At least one language must stay enabled', 'Journal needs a language for new articles.'),
|
: D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', 'Language'), ...SiteRows(skey, ro)),
|
||||||
single ? Card(S('font-weight:600;' + HL, 'Archive uses one language'), S(TO, 'New articles are written in Portuguese (Brazil). Add a language to write translations and publish them separately.'), D('display:flex;gap:10px;align-items:center', Bd('Default', 'info', 'star'), S('font-weight:500;' + HL, 'Portuguese (Brazil)'), S(mono + ';' + MU, 'pt-BR'))) :
|
single && Alert('info', 'info', 'Regional language kept: pt-BR', 'Setup kept the full site locale, so the default content language is Portuguese (Brazil), not plain Portuguese. The site locale still formats dates and numbers. Social locale: Derived: pt_BR, taken from the language code.'),
|
||||||
D('background:var(--ui-bg);border:1px solid var(--ui-border)', D('padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', 'Language'), ...LRows(ro)),
|
!single && !ro && S(MU + ';font-size:12px;text-wrap:pretty', 'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it. Social locale: Derived comes from the language code, Set is chosen by an owner, Not set leaves og:locale out.'))
|
||||||
single && Alert('info', 'info', 'Regional language kept: pt-BR', 'Setup kept the full site locale, so the default content language is Portuguese (Brazil), not plain Portuguese. The site locale still formats dates and numbers. Social metadata locale: pt_BR, taken from the language tag.'),
|
|
||||||
!single && !ro && S(MU + ';font-size:12px;text-wrap:pretty', 'Disabling a language keeps its versions readable and editable, and published ones stay online. Drafts can be published again after you re-enable it.'))
|
|
||||||
}
|
}
|
||||||
const slOv = () => {
|
const slOv = () => {
|
||||||
const addBody = (err) => [err && Alert('error', 'circle-x', 'Couldn\'t save languages', 'Nothing changed. Your selection is kept. Try again.'), Fd('Language', 'Portuguese (Brazil)', { sel: true, focus: !err }), Fd('Language code', 'pt-BR', { mono: true, ro: true, lock: true, help: 'Used in the API and in page language metadata.' }), Fd('Social metadata locale', 'pt_BR', { mono: true, ro: true, lock: true, help: 'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.' })]
|
const addBody = (err) => [err && Alert('error', 'circle-x', 'Couldn\'t save languages', 'Nothing changed. Your selection is kept. Try again.'), Fd('Language', 'Portuguese (Brazil)', { sel: true, focus: !err }), Fd('Language code', 'pt-BR', { mono: true, ro: true, lock: true, help: 'Used in the API and in page language metadata.' }), Fd('Social locale', 'Derived: pt_BR', { mono: true, ro: true, lock: true, help: 'Taken from the language code. For a code without a region, such as fr, an owner sets one or leaves it empty and og:locale is left out.' })]
|
||||||
if (x === 'add') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.' }, addBody(), [Btn('Cancel'), Btn('Add language', 'primary')])
|
const soc = (val, err, name, code) => Dlg({ title: 'Social locale for ' + name, desc: 'Used for og:locale on ' + name + ' pages. Leave it empty to omit the tag.' }, [Fd('Social locale', val, { mono: true, focus: !err, err, help: err ? null : 'Format: language_REGION, for example ' + code + '. Letters only, one underscore.' }), code === 'pt_BR' && Alert('info', 'info', 'Derived from the language code', 'pt-BR gives pt_BR. Setting a value replaces the derived one.')], [Btn('Cancel'), Btn('Save social locale', 'primary', { dis: !!err })])
|
||||||
if (x === 'saveFail') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, addBody(true), [Btn('Cancel'), Btn('Try again', 'primary')])
|
const dlgs = {
|
||||||
if (x === 'adddup') return Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'French', { sel: true, err: 'French is already enabled for Journal.' })], [Btn('Cancel'), Btn('Add language', 'primary', { dis: true })])
|
add: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal. Regional variants such as English (United Kingdom) are separate entries.' }, addBody(), [Btn('Cancel'), Btn('Add language', 'primary')]),
|
||||||
if (x === 'default') return Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language, and it is the first choice for each story\'s title in the list.' }, [Alert('info', 'info', 'No content changes', 'Existing versions keep their language, status and links. Public requests that name no language will resolve French where a published French version exists, and return 404 where one doesn\'t. Requests that name a language aren\'t affected.')], [Btn('Cancel'), Btn('Make French default', 'primary')])
|
saveFail: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, addBody(true), [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||||
if (x === 'disable') return Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions: 1 draft and 3 published.' }, [Alert('warning', 'triangle-alert', 'Nothing is deleted or unpublished', 'The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can\'t be published until you re-enable German. Nobody can create new German versions.')], [Btn('Cancel'), Btn('Disable German', 'primary')])
|
adddup: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'French', { sel: true, err: 'French is already enabled for Journal.' })], [Btn('Cancel'), Btn('Add language', 'primary', { dis: true })]),
|
||||||
if (x === 'reenable') return Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.' }, [], [Btn('Cancel'), Btn('Re-enable Spanish', 'primary')])
|
broader: () => Dlg({ title: 'Add a language', desc: 'Choose a language for Journal.' }, [Fd('Language', 'Portuguese (Portugal)', { sel: true }), Alert('warning', 'triangle-alert', 'Portuguese (Portugal) isn\'t available', 'Only the broader language Portuguese (pt) is supported. Pages and the API will say pt, not pt-PT. Articles written in this language can\'t be told apart from other Portuguese.'), D('display:flex;gap:10px;align-items:center;min-height:' + (m ? 44 : 24) + 'px', S('width:16px;height:16px;border:1px solid var(--ui-primary);background:var(--ui-primary);color:var(--ui-bg);display:flex;align-items:center;justify-content:center', Ic('check', 12)), S('font-weight:500;' + HL, 'Add Portuguese (pt) instead'))], [Btn('Cancel'), Btn('Add Portuguese', 'primary')]),
|
||||||
if (x === 'remove') return Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian. You can add it again later.' }, [], [Btn('Cancel'), Btn('Remove Italian', 'danger')])
|
default: () => Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language, and it is the first choice for each article\'s title in the list.' }, [Alert('info', 'info', 'No content changes', 'Existing versions keep their language, status and links. Public requests that name no language will resolve French where a published French version exists, and return not found where one doesn\'t. Requests that name a language aren\'t affected.')], [Btn('Cancel'), Btn('Make French default', 'primary')]),
|
||||||
if (x === 'removeBlocked') return Dlg({ title: 'Spanish can\'t be removed', desc: 'Spanish is used by 1 version. Disable the language to keep it, or delete it first.' }, [], [Btn('Close'), Btn('Disable Spanish', 'primary')])
|
defaultFail: () => Dlg({ title: 'Make French the default language?', desc: 'New articles start in the default language.' }, [Alert('error', 'circle-x', 'Couldn\'t change the default language', 'Nothing changed. Try again.')], [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||||
|
disable: () => Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions: 1 draft and 3 published.' }, [Alert('warning', 'triangle-alert', 'Nothing is deleted or unpublished', 'The 3 published German versions stay online. All 4 versions stay readable and editable. The draft can\'t be published until you re-enable German. Nobody can create new German versions.')], [Btn('Cancel'), Btn('Disable German', 'primary')]),
|
||||||
|
stale: () => Dlg({ title: 'Disable German?', desc: 'German is used by 4 versions.' }, [Alert('error', 'circle-x', 'Languages changed. Reload.', 'Your change wasn\'t applied.')], [Btn('Cancel'), Btn('Reload', 'primary')]),
|
||||||
|
reenable: () => Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again. The 1 existing Spanish version stays as it is. Nothing else changes.' }, [], [Btn('Cancel'), Btn('Re-enable Spanish', 'primary')]),
|
||||||
|
reenableFail: () => Dlg({ title: 'Re-enable Spanish?', desc: 'Writers can create Spanish versions again.' }, [Alert('error', 'circle-x', 'Couldn\'t re-enable Spanish', 'Nothing changed. Spanish is still disabled. Try again.')], [Btn('Cancel'), Btn('Try again', 'primary')]),
|
||||||
|
remove: () => Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian. You can add it again later.' }, [], [Btn('Cancel'), Btn('Remove Italian', 'danger')]),
|
||||||
|
removeFail: () => Dlg({ title: 'Remove Italian?', desc: 'No article uses Italian.' }, [Alert('error', 'circle-x', 'Couldn\'t remove Italian', 'Nothing changed. Italian is still listed. Try again.')], [Btn('Cancel'), Btn('Try again', 'danger')]),
|
||||||
|
removeBlocked: () => Dlg({ title: 'Spanish can\'t be removed', desc: 'Spanish is used by 1 version. Disable the language to keep it, or delete it first.' }, [], [Btn('Close'), Btn('Disable Spanish', 'primary')]),
|
||||||
|
socialEdit: () => soc('fr_FR', null, 'French', 'fr_FR'),
|
||||||
|
socialInvalid: () => soc('fr-FR', 'Use letters and one underscore, for example fr_FR.', 'French', 'fr_FR')
|
||||||
|
}
|
||||||
if (x === 'saved') return [Toast('success', 'circle-check', 'French added', 'Writers can now create French versions.')]
|
if (x === 'saved') return [Toast('success', 'circle-check', 'French added', 'Writers can now create French versions.')]
|
||||||
return null
|
if (x === 'reenableDone') return [Toast('success', 'circle-check', 'Spanish re-enabled', 'Writers can create Spanish versions again. Drafts can be published.')]
|
||||||
|
return dlgs[x] ? dlgs[x]() : null
|
||||||
|
}
|
||||||
|
// ---- public URL pattern
|
||||||
|
const puPage = () => {
|
||||||
|
const bad = { unknown: ['https://journal.example.com/{language}/{slug}', 'Unknown placeholder {language}. Use {lang} and {slug}.'], scheme: ['ftp://journal.example.com/{lang}/{slug}', 'Start with https:// or http://.'], encoded: ['https://journal.example.com/my pages/{lang}/{slug}', 'Remove spaces and characters that need encoding. Use letters, digits, - and _.'], noSlug: ['https://journal.example.com/{lang}', 'Add {slug} so each article has its own address.'] }[x]
|
||||||
|
const ro = x === 'viewOnly', unset = x === 'unset', val = bad ? bad[0] : unset ? '' : x === 'editing' ? 'https://journal.example.com/{lang}/articles/{slug}' : 'https://journal.example.com/{lang}/{slug}'
|
||||||
|
const prev = bad ? null : unset ? null : x === 'editing' ? 'https://journal.example.com/fr/articles/comment-nous-chiffrons-une-renovation' : 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation'
|
||||||
|
return Page('Public URL', 'Where Journal articles appear on your website. Used for canonical and alternate links.', null,
|
||||||
|
ro && Alert('neutral', 'lock', 'Only owners can change this', 'You can read the pattern. Ask an owner to change it.'),
|
||||||
|
unset && Alert('info', 'info', 'Public URL not configured', 'Page, canonical and alternate addresses stay hidden until you add a pattern. Links by article ID still work.'),
|
||||||
|
Card(Fd('Public URL pattern', val || 'https://example.com/{lang}/{slug}', { mono: true, val: !!val, ro, focus: !!bad || x === 'editing', err: bad && bad[1], help: bad ? null : 'Placeholders: {lang} is the language code, such as fr or pt-BR. {slug} is the article address in that language.' }),
|
||||||
|
prev && D('display:flex;flex-direction:column;gap:4px', S('font-size:12px;' + MU, 'Preview with "Comment nous chiffrons une rénovation de salle de bain" in French'), S(mono + ';' + HL + ';overflow-wrap:anywhere', prev)),
|
||||||
|
!ro && D('display:flex;gap:8px', Btn('Save', 'primary', { dis: !!bad || unset }), Btn('Cancel'))))
|
||||||
}
|
}
|
||||||
// ---- setup
|
// ---- setup
|
||||||
const SUV = FX.SETUP, SUN = SUV.reduce((a, r) => a + r.n, 0)
|
const SUV = FX.SETUP, SUN = SUV.reduce((a, r) => a + r.n, 0)
|
||||||
const suVal = (r, chosen) => r.name ? r.name + ' · ' + r.tag : chosen ? 'English · en' : 'Choose a language'
|
const suVal = (r, chosen) => r.name ? r.name + ' · ' + r.tag : chosen ? 'English · en' : 'Choose a language'
|
||||||
const suBody = () => {
|
const suBody = () => {
|
||||||
if (x === 'empty') return Page('Languages', 'Content languages for Gallery.', null, Card(S('font-weight:600;' + HL, 'Confirm the default language'), S(TO, 'Gallery has no articles yet. New articles will be written in this language.'), Fd('Default content language', 'English (United Kingdom) · en-GB', { sel: true, help: 'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.' }), D('display:flex', Btn('Save', 'primary'))))
|
if (x === 'empty') return Page('Languages', 'Content languages for Gallery.', null, Card(S('font-weight:600;' + HL, 'Confirm the default language'), S(TO, 'Gallery has no articles yet. New articles will be written in this language.'), Fd('Default content language', 'English (United Kingdom) · en-GB', { sel: true, help: 'Seeded from site locale en-GB. The full locale is kept. Confirm or change it.' }), D('display:flex', Btn('Save', 'primary'))))
|
||||||
const done = x === 'done', busy = x === 'applying', fail = x === 'fail', chosen = x !== 'ready'
|
const done = x === 'done', busy = x === 'applying', fail = x === 'fail', prog = x === 'inProgress', nol = x === 'noLocale', chosen = x !== 'ready' && !nol, rowsSrc = nol ? SUV.filter(r => !r.name) : SUV, N = rowsSrc.reduce((a, r) => a + r.n, 0)
|
||||||
return Page('Set article languages', 'Existing articles don\'t have a language yet. Confirm or change the language for each site.', null,
|
return Page('Set article languages', 'Existing articles don\'t have a language yet. Confirm or change the language for each site.', null,
|
||||||
done ? Alert('success', 'circle-check', 'Languages assigned', SUN + ' articles now have a language. Nothing was translated, and no articles were grouped.', Btn('Open Articles', 'secondary', { sm: true })) :
|
done ? Alert('success', 'circle-check', 'Languages assigned', SUN + ' articles now have a language. Nothing was translated, and no articles were grouped.', Btn('Open Articles', 'secondary', { sm: true })) :
|
||||||
fail ? Alert('error', 'circle-x', 'Nothing was changed', 'The update failed, so no article has a language yet. Your choices are kept. Try again; running it twice is safe.', Btn('Try again', 'secondary', { sm: true })) :
|
fail ? Alert('error', 'circle-x', 'Nothing was changed', 'The update failed, so no article has a language yet. Your choices are kept. Try again. Running it twice is safe.', Btn('Try again', 'secondary', { sm: true })) :
|
||||||
|
prog ? Alert('info', 'loader-circle', 'Setup is already running', 'Another owner started it. Languages appear here when it finishes. Articles created meanwhile get each site\'s default language. Starting it again is safe: sites that already have a language are skipped.', Btn('Check again', 'secondary', { sm: true })) :
|
||||||
busy ? Alert('info', 'loader-circle', 'Assigning languages', 'Articles created while this runs get the site\'s default language.') :
|
busy ? Alert('info', 'loader-circle', 'Assigning languages', 'Articles created while this runs get the site\'s default language.') :
|
||||||
Alert('info', 'info', 'This labels articles. It doesn\'t translate them.', 'Each existing article becomes the only version of its own story. Each site\'s language is seeded from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.'),
|
nol ? Alert('error', 'circle-x', 'Choose a language for Notes', 'Notes has no site locale, so there is nothing to start from. Choose the language its articles are written in.') :
|
||||||
D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...SUV.map(r => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:140px;display:flex;flex-direction:column', S('font-weight:600;' + HL, r.site), S(MU + ';font-size:12px', r.n + ' articles · ' + r.note)), D('width:' + (m ? '100%' : '300px'), Fd('', suVal(r, chosen), { sel: !done, val: !!(r.name || chosen), help: done ? null : r.name ? 'Seeded from site locale ' + r.locale + '. Confirm or change.' : chosen ? null : 'Required before you can apply.' }))))),
|
Alert('info', 'info', 'This labels articles. It doesn\'t translate them.', 'Each existing article becomes the only version of its own article group. Each site\'s language starts from its locale with the region kept: pt-BR stays Portuguese (Brazil). Nothing is applied until you confirm.'),
|
||||||
!done && D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Btn(busy ? 'Assigning…' : 'Confirm and assign to ' + SUN + ' articles', 'primary', { busy, dis: busy || x === 'ready' }), S(MU + ';font-size:12px', 'Counts are articles in ' + SUV.length + ' sites.')))
|
D('background:var(--ui-bg);border:1px solid var(--ui-border)', ...rowsSrc.map(r => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:140px;display:flex;flex-direction:column', S('font-weight:600;' + HL, r.site), S(MU + ';font-size:12px', r.n + ' articles · ' + r.note)), D('width:' + (m ? '100%' : '300px'), Fd('', suVal(r, chosen), { sel: !done && !prog, val: !!(r.name || chosen), err: nol && !r.name ? 'Required before you can apply.' : null, ro: prog, help: done || nol ? null : r.name ? 'Seeded from site locale ' + r.locale + '. Confirm or change.' : chosen ? null : 'Required before you can apply.' }))))),
|
||||||
|
!done && D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Btn(busy ? 'Assigning…' : 'Confirm and assign to ' + N + ' articles', 'primary', { busy, dis: busy || prog || x === 'ready' || nol }), S(MU + ';font-size:12px', 'Counts are articles in ' + rowsSrc.length + (rowsSrc.length === 1 ? ' site.' : ' sites.'))))
|
||||||
}
|
}
|
||||||
// ---- list
|
// ---- article list
|
||||||
const FXS = FX.STORIES, LN = { en: 'English', fr: 'French', de: 'German', ar: 'Arabic', es: 'Spanish' }
|
const LN = LNm
|
||||||
const upMap = v => Object.fromEntries(Object.entries(v).map(([k, q]) => [k.toUpperCase(), q]))
|
const Pager = (V) => D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;' + (m ? 'justify-content:space-between' : ''), S(MU + ';font-size:12px;' + (m ? 'flex:1 1 100%' : 'flex:1'), 'Showing ' + V.from + '–' + V.to + ' of ' + V.unit),
|
||||||
const Cov = (map, o = {}) => D('display:flex;gap:6px;flex-wrap:wrap', ...(o.langs || FX.ENABLED.map(k => k.toUpperCase()).concat(map.ES ? ['ES'] : [])).filter(k => !o.hideMissing || map[k]).map(k => { const q = map[k], st = q && q[0], nr = q && q[1]; return A('span', { 'aria-label': k + ': ' + (st === 'pub' ? 'published' : st === 'draft' ? 'draft' : 'no version') + (nr ? ', needs review' : '') + (k === 'ES' ? ', language disabled' : '') }, 'display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;' + (st ? 'border:1px solid var(--ui-border-accented);' + HL : 'border:1px dashed var(--ui-border-accented);' + MU), k, st === 'pub' ? Ic('circle-check', 12) : st === 'draft' ? Ic('pencil', 12) : Ic('minus', 12), nr && Ic('refresh-cw', 12)) }))
|
D('display:flex;align-items:center;gap:8px;' + MU + ';font-size:12px', 'Rows', D('', Fd('', String(V.size), { sel: true }))),
|
||||||
const Pager = (total, unitName, pages, size = 5) => D('display:flex;align-items:center;gap:12px;flex-wrap:wrap;' + (m ? 'justify-content:space-between' : ''), S(MU + ';font-size:12px;' + (m ? 'flex:1 1 100%' : 'flex:1'), 'Showing 1–' + Math.min(size, total) + ' of ' + total + ' ' + unitName),
|
D('display:flex;align-items:center;gap:6px', Btn('', 'secondary', { ic: 'chevron-left', al: 'Previous page', dis: V.page < 2, sm: !m }), S(HL + ';font-weight:500;padding:0 6px', 'Page ' + V.page + ' of ' + V.pages), Btn('', 'secondary', { ic: 'chevron-right', al: 'Next page', dis: V.page >= V.pages, sm: !m })))
|
||||||
D('display:flex;align-items:center;gap:8px;' + MU + ';font-size:12px', 'Rows', D('', Fd('', String(size), { sel: true }))),
|
const Chip = l => D('white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:6px;height:' + (m ? 36 : 24) + 'px;padding:0 8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);' + HL + ';font-size:12px', l, A('span', { role: 'button', 'aria-label': 'Remove filter ' + l }, 'display:inline-flex', Ic('x', 14)))
|
||||||
D('display:flex;align-items:center;gap:6px', Btn('', 'secondary', { ic: 'chevron-left', al: 'Previous page', dis: true, sm: !m }), S(HL + ';font-weight:500;padding:0 6px', m ? 'Page 1 of ' + pages : '1 of ' + pages), Btn('', 'secondary', { ic: 'chevron-right', al: 'Next page', dis: pages < 2, sm: !m })))
|
const ChipC = (c, k) => A('span', { 'aria-label': (LN[c.k] || c.k) + ': ' + (c.st === 'pub' ? 'published' : c.st === 'draft' ? 'draft' : 'no version') + (c.nr ? ', needs review' : '') + (c.off ? ', language disabled' : '') }, 'white-space:nowrap;display:inline-flex;align-items:center;gap:4px;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;' + (c.st ? 'border:1px solid var(--ui-border-accented);' + HL : 'border:1px dashed var(--ui-border-accented);' + MU) + (c.off ? ';opacity:.75' : ''), c.k.toUpperCase(), c.st === 'pub' ? Ic('circle-check', 12) : c.st === 'draft' ? Ic('pencil', 12) : Ic('plus', 12), c.nr && Ic('refresh-cw', 12))
|
||||||
const Chip = l => D('display:inline-flex;align-items:center;gap:6px;height:' + (m ? 36 : 24) + 'px;padding:0 8px;border:1px solid var(--ui-border-accented);background:var(--ui-bg);' + HL + ';font-size:12px', l, A('span', { role: 'button', 'aria-label': 'Remove filter ' + l }, 'display:inline-flex', Ic('x', 14)))
|
const Cov = (cs) => { const Lc = m || nr ? 4 : 5, vis = cs.slice(0, Lc), more = cs.slice(Lc); return D('display:flex;gap:6px;flex-wrap:' + (m ? 'wrap' : 'nowrap'), ...vis.map(ChipC), more.length ? A('span', { role: 'button', 'aria-label': more.length + ' more languages: ' + more.map(c => LN[c.k] || c.k).join(', ') }, 'display:inline-flex;align-items:center;height:22px;padding:0 6px;font:500 12px \'IBM Plex Mono\',monospace;background:var(--ui-bg-elevated);' + HL, '+' + more.length) : null) }
|
||||||
const AT = [{ t: 'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants', v: { EN: ['pub', 0], 'EN-GB': ['draft', 0], FR: ['draft', 1], DE: ['pub', 0], 'PT-BR': ['draft', 0], 'ZH-HANS': ['pub', 0], JA: ['pub', 0], AR: ['draft', 0] }, by: 'Marta Alves', upd: '2 h ago' }, { t: 'Heat pumps for stone farmhouses', v: { EN: ['pub', 0], 'EN-GB': ['pub', 0], FR: ['draft', 0], DE: ['pub', 0], JA: ['draft', 0] }, by: 'Luis Ferrer', upd: 'Yesterday' }, { t: 'Sourcing reclaimed oak beams', v: { EN: ['draft', 0], 'EN-GB': ['draft', 0] }, by: 'Camille Roche', upd: '14 Oct' }]
|
const covW = m ? '' : (nr ? 'width:330px;' : 'width:430px;')
|
||||||
|
const RowMenuBtn = () => Btn('', 'ghost', { ic: 'ellipsis', sm: true, al: 'Row actions' })
|
||||||
const liPage = () => {
|
const liPage = () => {
|
||||||
const press = x === 'emptyMissing', atel = x === 'long', lim = x === 'limited', fail = x === 'listFail', none = x === 'noResults', load = x === 'frLoading', draftF = x === 'filters'
|
const C = { page2: { page: 2 }, fr: { lang: 'fr' }, frMissing: { lang: 'fr', tab: 'missing' }, emptyMissing: { site: 'press', lang: 'fr', tab: 'missing' }, noResults: { q: 'zinc' }, frLoading: { lang: 'fr', load: 1 }, listFail: { fail: 1 }, limited: { lim: 1 }, filters: { lang: 'fr', status: 'draft' }, tabsScroll: { lang: 'fr', tab: 'missing', shift: 1 }, long: { site: 'atelier', status: 'draft', seo: 1 }, author: { author: 'Marta Alves' }, review: { tab: 'review' }, frReview: { lang: 'fr', tab: 'review' }, langFilter: { menu: 1 }, rowMenu: { row: 1 }, ...(x === 'home' ? {} : {}) }[x] || {}
|
||||||
const missView = x === 'frMissing' || x === 'tabsScroll' || press, frView = x === 'fr' || load || draftF || missView, active = draftF || atel
|
const site = FX.SITES[C.site || 'journal'], lim = !!C.lim
|
||||||
const DS = press ? FX.PRESS : FXS, isD = q => q && q[0] === 'draft'
|
const V = FX.view({ site, lang: C.lang, tab: C.tab, status: C.status, author: C.author, seo: C.seo, q: C.q, page: C.page, read: lim ? ['en'] : null, create: lim ? [] : null, scope: lim ? (s0 => ['Camille Roche', 'Luis Ferrer'].includes(s0.by)) : null })
|
||||||
const frV = DS.filter(s => s.v.fr && (!draftF || isD(s.v.fr))), frM = DS.filter(s => !s.v.fr && (!draftF || Object.values(s.v).some(isD)))
|
const tabs = (C.load || C.fail) ? V.tabs.map(q => [q[0], '–', q[2]]) : V.tabs, tabOn = Math.max(0, V.tabs.findIndex(q => q[2] === V.tab))
|
||||||
let tabs
|
const act = [C.status && 'Status: Draft', C.author && 'Author: ' + C.author, C.seo && 'SEO: Needs attention'].filter(Boolean)
|
||||||
if (frView) tabs = [['All', frV.length], ['Published', frV.filter(s => s.v.fr[0] === 'pub').length], ['Drafts', frV.filter(s => isD(s.v.fr)).length], ['Needs review', frV.filter(s => s.v.fr[1]).length], ['Not in French', frM.length]]
|
const chips = act.length ? ['Site: ' + site.name].concat(C.lang ? ['Language: ' + LN[C.lang]] : [], act) : []
|
||||||
else if (atel) tabs = [['All stories', 3], ['Needs translation', 3], ['Needs review', 1]]
|
const nF = chips.length
|
||||||
else if (none) tabs = [['All stories', 0], ['Needs translation', 0], ['Needs review', 0]]
|
const filt = () => D('display:flex;gap:8px;flex-wrap:wrap;align-items:center', D('flex:1;min-width:' + (m ? '0' : '200px'), Fd('', C.q ? C.q : C.lang ? 'Search ' + LN[C.lang] + ' titles' : 'Search titles in every language', { val: !!C.q })),
|
||||||
else if (lim) tabs = [['All stories', 3], ['Needs translation', 3], ['Needs review', 0]]
|
m ? Btn('Filters' + (nF ? ' · ' + nF : ''), 'secondary', { ic: 'sliders-horizontal', al: nF ? 'Filters, ' + nF + ' active' : 'Filters' }) : [Fd('', site.name, { sel: true }), Fd('', C.lang ? LN[C.lang] : 'All languages', { sel: true }), Fd('', C.status ? 'Draft' : 'Any status', { sel: true }), Fd('', C.author || 'Any author', { sel: true, focus: x === 'author' }), Fd('', C.seo ? 'Needs attention' : 'Any SEO state', { sel: true })].map(e => D('flex:0 0 auto;width:' + (nr ? 128 : 150) + 'px', e)))
|
||||||
else tabs = [['All stories', DS.length], ['Needs translation', DS.filter(s => FX.ENABLED.some(k => !s.v[k])).length], ['Needs review', DS.filter(s => Object.values(s.v).some(q => q[1])).length]]
|
const chipRow = chips.length ? D('display:flex;gap:6px;flex-wrap:wrap;align-items:center', ...chips.map(Chip), Btn('Clear all', 'ghost', { sm: !m })) : null
|
||||||
if (load || fail) tabs = tabs.map(q => [q[0], '–'])
|
const shift = C.shift ? -96 : 0
|
||||||
const tabOn = missView ? 4 : 0, site = press ? 'Press' : atel ? 'Atelier' : 'Journal'
|
|
||||||
const filt = () => D('display:flex;gap:8px;flex-wrap:wrap;align-items:center', D('flex:1;min-width:' + (m ? '0' : '220px'), Fd('', none ? 'zinc' : frView ? 'Search French titles' : 'Search titles in every language', { val: none })),
|
|
||||||
m ? Btn('Filters' + (active ? ' · 3' : ''), 'secondary', { ic: 'sliders-horizontal', al: active ? 'Filters, 3 active' : 'Filters' }) : [Fd('', site, { sel: true }), Fd('', frView ? 'French' : 'All languages', { sel: true }), Fd('', active ? 'Draft' : 'Any status', { sel: true }), Fd('', 'Any author', { sel: true }), Fd('', atel ? 'SEO: Needs attention' : 'Any SEO state', { sel: true })])
|
|
||||||
const chips = active && D('display:flex;gap:6px;flex-wrap:wrap', ...(draftF ? [Chip('Site: Journal'), Chip('Language: French'), Chip('Status: Draft')] : [Chip('Site: Atelier'), Chip('Status: Draft'), Chip('SEO: Needs attention')]), Btn('Clear all', 'ghost', { sm: true }))
|
|
||||||
const shift = x === 'tabsScroll' ? -96 : 0
|
|
||||||
const tabRow = D('position:relative;border-bottom:1px solid var(--ui-border);display:flex;align-items:center;gap:4px', m && Btn('', 'ghost', { ic: 'chevron-left', sm: true, al: 'Scroll tabs left', dis: !shift }),
|
const tabRow = D('position:relative;border-bottom:1px solid var(--ui-border);display:flex;align-items:center;gap:4px', m && Btn('', 'ghost', { ic: 'chevron-left', sm: true, al: 'Scroll tabs left', dis: !shift }),
|
||||||
A('div', { role: 'tablist', 'aria-label': 'Article views' }, 'flex:1;min-width:0;overflow:hidden;display:flex;gap:' + (m ? 16 : 24) + 'px;position:relative', D('display:flex;gap:' + (m ? 16 : 24) + 'px;flex-shrink:0;transform:translateX(' + (m ? shift : 0) + 'px)', ...tabs.map((q, i) => A('div', { role: 'tab', 'aria-selected': i === tabOn }, 'padding:' + (m ? '12px 0' : '8px 0') + ';white-space:nowrap;font-weight:' + (i === tabOn ? 600 : 500) + ';color:' + (i === tabOn ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (i === tabOn ? 'var(--ui-primary)' : 'transparent'), q[0] + ' ', S(MU + ';font-weight:400', q[1])))), m && D('position:absolute;right:0;top:0;bottom:0;width:28px;background:linear-gradient(90deg,transparent,var(--ui-bg))'), m && shift && D('position:absolute;left:0;top:0;bottom:0;width:28px;background:linear-gradient(270deg,transparent,var(--ui-bg))')), m && Btn('', 'ghost', { ic: 'chevron-right', sm: true, al: 'Scroll tabs right' }))
|
A('div', { role: 'tablist', 'aria-label': 'Article views' }, 'flex:1;min-width:0;overflow:hidden;display:flex;position:relative', D('display:flex;gap:' + (m ? 16 : 24) + 'px;flex-shrink:0;transform:translateX(' + (m ? shift : 0) + 'px)', ...tabs.map((q, i) => A('div', { role: 'tab', 'aria-selected': i === tabOn }, 'padding:' + (m ? '12px 0' : '8px 0') + ';white-space:nowrap;font-weight:' + (i === tabOn ? 600 : 500) + ';color:' + (i === tabOn ? 'var(--ui-text-highlighted)' : 'var(--ui-text-toned)') + ';border-bottom:2px solid ' + (i === tabOn ? 'var(--ui-primary)' : 'transparent'), q[0] + ' ', S(MU + ';font-weight:400', q[1])))), m && D('position:absolute;right:0;top:0;bottom:0;width:28px;background:linear-gradient(90deg,transparent,var(--ui-bg))'), m && shift && D('position:absolute;left:0;top:0;bottom:0;width:28px;background:linear-gradient(270deg,transparent,var(--ui-bg))')), m && Btn('', 'ghost', { ic: 'chevron-right', sm: true, al: 'Scroll tabs right' }))
|
||||||
const Row = (title, sub, right, lg) => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: lg }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title), sub && S(MU + ';font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', sub)), right)
|
const card = 'background:var(--ui-bg);border:1px solid var(--ui-border)', hd = !m && D('display:flex;gap:12px;padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', S('flex:1', 'Article'), S(covW + 'flex-shrink:0', 'Languages'), S('width:110px', 'Author'), S('width:80px', 'Updated'), S('width:28px'))
|
||||||
const Story = (title, sub, map, o, by, upd) => D(ROW + ';' + (m ? 'flex-direction:column;align-items:stretch;gap:6px' : ''), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title), sub && S(MU + ';font-size:12px', sub)), D((m ? '' : 'width:230px;') + 'flex-shrink:0', Cov(map, o)), m ? S(MU + ';font-size:12px', by + ' · ' + upd) : S(MU + ';width:120px', by), !m && S(MU + ';width:90px', upd))
|
const creat = lim ? [] : site.langs
|
||||||
const card = 'background:var(--ui-bg);border:1px solid var(--ui-border)', head = !m && D('display:flex;gap:12px;padding:8px 16px;background:var(--ui-bg-elevated);' + MU + ';font-weight:500', S('flex:1', 'Story'), S('width:230px', 'Languages'), S('width:120px', 'Author'), S('width:90px', 'Updated'))
|
const StoryRow = (r) => { const s0 = r.s, cs = FX.chips(s0, site, creat), t = FX.tt(s0, r.rep)
|
||||||
let body, total = 0, unit = '', pages = 1
|
return D(ROW + ';' + (m ? 'flex-wrap:wrap;gap:6px 12px' : ''), D('flex:1 1 ' + (m ? '70%' : '0') + ';min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: r.rep }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', t), r.rep !== site.def && S(MU + ';font-size:12px', 'Shown in ' + (LN[r.rep] || r.rep) + (site.disabled.includes(r.rep) ? ' · language disabled' : ''))), m && D('order:2;flex:1 1 100%', Cov(cs)), !m && D(covW + 'flex-shrink:0', Cov(cs)), m ? S('order:3;' + MU + ';font-size:12px;flex:1', s0.by + ' · ' + s0.upd) : [S(MU + ';width:110px', s0.by), S(MU + ';width:80px', s0.upd)], D(m ? 'order:1' : '', RowMenuBtn())) }
|
||||||
if (fail) body = Alert('error', 'circle-x', 'Couldn\'t load articles', 'Your filters and page are kept. Try again.', Btn('Try again', 'secondary', { sm: true }))
|
const VerRow = (r) => { const q = r.s.v[C.lang]; return D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1 1 ' + (m ? '70%' : '0') + ';min-width:0;display:flex;flex-direction:column;gap:2px', A('span', { lang: C.lang }, HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', FX.tt(r.s, C.lang)), m && S(MU + ';font-size:12px', r.s.by + ' · ' + r.s.upd)), D('display:flex;gap:6px', Pub(q[0]), q[1] ? Fr('Needs review') : null), !m && [S(MU + ';width:110px;margin-left:12px', r.s.by), S(MU + ';width:80px', r.s.upd)], RowMenuBtn()) }
|
||||||
else if (load) body = Sk(5)
|
const MissRow = (r) => D(ROW + ';' + (m ? 'flex-wrap:wrap' : ''), D('flex:1 1 0;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap', FX.tt(r.s, r.rep)), S(MU + ';font-size:12px', 'Opens ' + LN[r.rep] + ' · ' + (r.s.v[r.rep][0] === 'pub' ? 'published' : 'draft') + (site.disabled.includes(r.rep) ? ' · language disabled' : ''))), Btn(m ? '' : 'Add ' + LN[C.lang] + ' translation', 'secondary', { sm: true, ic: 'plus', al: 'Add ' + LN[C.lang] + ' translation to ' + FX.tt(r.s, r.rep) }), RowMenuBtn())
|
||||||
else if (press) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('circle-check', 28), S('font-weight:600;' + HL, 'Every story you can see has a French version'), S(TO + ';max-width:360px', 'Stories without a French version will appear here.'))
|
let body
|
||||||
else if (none) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('search', 28), S('font-weight:600;' + HL, 'No stories match "zinc"'), S(TO, 'Search covers titles in every language you can read.'), Btn('Clear search', 'secondary', { sm: true }))
|
if (C.fail) body = Alert('error', 'circle-x', 'Couldn\'t load articles', 'Your filters and page are kept. Try again.', Btn('Try again', 'secondary', { sm: true }))
|
||||||
else if (missView) { const rep = s => ['en', 'fr', 'de', 'ar', 'es'].find(k => s.v[k]); body = D(card, ...frM.map(s => { const k = rep(s), q = s.v[k]; return Row(s.title, 'Opens ' + LN[k] + ' · ' + (q[0] === 'pub' ? 'published' : 'draft') + (k === 'es' ? ' · language disabled' : ''), Btn(m ? '' : 'Add French translation', 'secondary', { sm: true, ic: 'plus', al: 'Add French translation to ' + s.title })) })); total = frM.length; unit = 'stories with no French version you can read' }
|
else if (C.load) body = Sk(5)
|
||||||
else if (frView) { body = D(card, ...frV.map(s => Row(s.tr.fr, m ? s.by + ' · ' + s.upd : null, D('display:flex;gap:4px;flex-wrap:wrap;justify-content:flex-end', Pub(s.v.fr[0]), s.v.fr[1] ? Fr('Needs review') : null, !m && S(MU + ';width:120px;margin-left:12px', s.by), !m && S(MU + ';width:80px', s.upd)), 'fr'))); total = frV.length; unit = 'French versions' }
|
else if (!V.total && C.lang && V.tab === 'missing') body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('circle-check', 28), S('font-weight:600;' + HL, 'Every article you can see has a ' + LN[C.lang] + ' version'), S(TO + ';max-width:360px', 'Articles without a ' + LN[C.lang] + ' version will appear here.'))
|
||||||
else if (atel) { body = D(card, head, ...AT.map(r => Story(r.t, null, r.v, { langs: ['EN', 'EN-GB', 'FR', 'DE', 'PT-BR', 'ZH-HANS', 'JA', 'AR'] }, r.by, r.upd))); total = 3; unit = 'stories' }
|
else if (!V.total) body = D('padding:48px 16px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;' + card, Ic('search', 28), S('font-weight:600;' + HL, 'No articles match "' + (C.q || '') + '"'), S(TO, 'Search covers titles in every language you can read.'), Btn('Clear search', 'secondary', { sm: true }))
|
||||||
else { const rows = lim ? [FXS[1], FXS[2], FXS[3]] : FXS.slice(0, 5); body = D(card, head, ...rows.map(s => Story(s.title, s.v.es && !s.v.en ? 'Spanish only · language disabled on Journal' : null, lim ? { EN: s.v.en } : upMap(s.v), { hideMissing: lim }, s.by, s.upd))); total = lim ? 3 : FXS.length; unit = lim ? 'stories you can read' : 'stories'; pages = lim ? 1 : Math.ceil(total / 5) }
|
else body = D(card, C.lang && V.tab === 'missing' ? null : (C.lang ? null : hd), ...V.rows.map(C.lang ? (V.tab === 'missing' ? MissRow : VerRow) : StoryRow))
|
||||||
return D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:' + (m ? 12 : 16) + 'px;padding:' + (m ? '16px 16px 16px' : '24px') + ';position:relative', D('display:flex;align-items:center;gap:12px', S('flex:1;font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, 'Articles'), Btn(m ? '' : 'New article', 'primary', { ic: 'plus', al: 'New article' })), lim && Alert('neutral', 'lock', 'Showing only what you can read', 'Languages, counts and missing-translation results reflect versions you have permission to see.'), filt(), chips, tabRow, body, total ? Pager(total, unit, pages) : null)
|
const unit = C.fail || C.load ? '' : V.unit
|
||||||
|
return D('flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;gap:' + (m ? 12 : 16) + 'px;padding:' + (m ? '16px' : '24px') + ';position:relative', D('display:flex;align-items:baseline;gap:12px', S('font:600 20px/28px \'IBM Plex Sans\',sans-serif;' + HL, 'Articles'), A('span', { 'aria-live': 'polite' }, 'flex:1;' + MU + ';font-weight:500', unit), Btn(m ? '' : 'New article', 'primary', { ic: 'plus', al: 'New article' })),
|
||||||
|
lim && Alert('neutral', 'lock', 'Showing only what you can read', 'Languages, counts and missing-translation results reflect the versions you have permission to see. You can\'t create translations on this site.'), filt(), chipRow, tabRow, body, (V.total && !C.fail && !C.load) ? Pager(V) : null)
|
||||||
}
|
}
|
||||||
const sheet = () => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'center') + ';justify-content:center', A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Filters' }, 'width:100%;max-width:' + (m ? '100%' : '420px') + ';max-height:92%;background:var(--ui-bg);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
const sheet = (o) => D('position:absolute;inset:0;z-index:50;background:var(--rc-overlay);display:flex;align-items:' + (m ? 'flex-end' : 'center') + ';justify-content:center', A('div', { role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Filters' }, 'width:100%;max-width:' + (m ? '100%' : '420px') + ';max-height:92%;background:var(--ui-bg);display:flex;flex-direction:column', D('display:flex;justify-content:center;padding:8px 0 0', S('width:36px;height:4px;background:var(--ui-border-accented)')),
|
||||||
D('padding:16px 16px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, 'Filters'), Btn('', 'ghost', { ic: 'x', al: 'Close filters' })),
|
D('padding:16px 16px 0;display:flex;align-items:center', S('flex:1;font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, 'Filters'), Btn('', 'ghost', { ic: 'x', al: 'Close filters' })),
|
||||||
D('padding:16px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', Fd('Site', 'Journal', { sel: true }), Fd('Language', 'French', { sel: true }), Fd('Status', 'Draft', { sel: true }), Fd('Author', 'Any author', { sel: true }), Fd('SEO', 'Any SEO state', { sel: true })),
|
D('padding:16px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;min-height:0', Fd('Site', 'Journal', { sel: true }), Fd('Language', o.lang || 'All languages', { sel: true }), Fd('Status', o.status || 'Any status', { sel: true }), Fd('Author', o.author || 'Any author', { sel: true, focus: !!o.author }), Fd('SEO', 'Any SEO state', { sel: true })),
|
||||||
D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', Btn('Clear all', 'secondary'), D('flex:1;display:flex', Btn('Show 2 results', 'primary', { w: '100%' })))))
|
D('padding:12px 16px;border-top:1px solid var(--ui-border);display:flex;gap:8px', Btn('Clear all', 'secondary'), D('flex:1;display:flex', Btn('Show ' + o.n + ' results', 'primary', { w: '100%' })))))
|
||||||
|
const listOv = () => {
|
||||||
|
const V1 = FX.view({ site: J, lang: 'fr', status: 'draft' }), V2 = FX.view({ site: J, author: 'Marta Alves' })
|
||||||
|
if (x === 'filters' && m) return [sheet({ lang: 'French', status: 'Draft', n: V1.total })]
|
||||||
|
if (x === 'author' && m) return [sheet({ author: 'Marta Alves', n: V2.total })]
|
||||||
|
if (x === 'author') return [D('position:absolute;z-index:40;left:' + (nr ? 500 : 1018) + 'px;top:170px;width:240px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)', A('div', { role: 'listbox', 'aria-label': 'Author' }, 'display:flex;flex-direction:column;padding:4px 0', ...[['Any author', J.stories.length]].concat(FX.AUTHORS).map((a, i) => A('div', { role: 'option', 'aria-selected': a[0] === 'Marta Alves' }, 'display:flex;align-items:center;gap:8px;min-height:36px;padding:6px 12px;' + (a[0] === 'Marta Alves' ? 'background:var(--rc-sand);color:var(--rc-on-sand);font-weight:600' : HL), S('width:16px', a[0] === 'Marta Alves' && Ic('check')), S('flex:1', a[0]), S(mono + ';' + (a[0] === 'Marta Alves' ? '' : MU), a[1])))))]
|
||||||
|
const langRows = (grp) => (grp ? J.disabled : J.langs).map(k => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + (grp ? MU : HL), S('width:16px'), S('flex:1', LN[k]), S(mono + ';' + MU, FX.count(k, J.stories)[0] + ' versions')))
|
||||||
|
if (x === 'langFilter') return [D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'left:' + (nr ? 330 : 792) + 'px;top:170px;width:280px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column;max-height:70%', A('div', { role: 'listbox', 'aria-label': 'Language' }, 'display:flex;flex-direction:column;overflow-y:auto;padding:4px 0', D('padding:4px 12px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Enabled'), D('display:flex;align-items:center;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + HL, S('width:16px'), 'All languages'), ...langRows(0), D('padding:8px 12px 4px;border-top:1px solid var(--ui-border);font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;' + MU, 'Disabled'), ...langRows(1), D('padding:6px 12px 8px;font-size:12px;' + MU, 'Disabled languages keep their versions. They can\'t get new ones.')))]
|
||||||
|
if (x === 'rowMenu') return [OvMenu([{ l: 'Open English', ic: 'file-pen' }, { l: 'Open another language…', ic: 'languages' }, { l: 'Add translation…', ic: 'plus' }, { l: 'Unpublish English', ic: 'eye-off' }, { l: 'Copy API URL', ic: 'copy' }, { l: 'Delete English version', ic: 'trash-2', danger: 1 }])]
|
||||||
|
return []
|
||||||
|
}
|
||||||
// ---- new article
|
// ---- new article
|
||||||
const naOv = () => {
|
const naOv = () => {
|
||||||
const multi = x !== 'single'
|
const multi = x !== 'single'
|
||||||
return Dlg({ title: 'New article', desc: x === 'translation' ? 'Create a translation of a story you can read.' : 'Give the article a title. You can write the rest in the editor.' }, [
|
return Dlg({ title: 'New article', desc: x === 'translation' ? 'Create a translation of an article you can read.' : 'Give the article a title. You can write the rest in the editor.' }, [
|
||||||
x === 'translation' ? [Fd('Story', 'Roof inspection checklist', { sel: true, help: 'No French version yet. Copies from English · published' }), Fd('Language', 'French', { sel: true }), Fd('Title', 'Liste de contrôle pour l\'inspection de la toiture', { focus: true, lang: 'fr' })] :
|
x === 'translation' ? [Fd('Article', 'Roof inspection checklist', { sel: true, help: 'No French version yet. Copies from English · published' }), Fd('Language', 'French', { sel: true }), Fd('Title', 'Liste de contrôle pour l\'inspection de la toiture', { focus: true, lang: 'fr' })] :
|
||||||
[Fd('Title', x === 'sitechange' ? 'Spring newsletter' : 'Title', { val: x === 'sitechange', focus: x !== 'sitechange', help: '0 / 200' }), Fd('Site', x === 'sitechange' ? 'Notes' : 'Journal', { sel: true }),
|
[Fd('Title', x === 'sitechange' ? 'Spring newsletter' : 'Title', { val: x === 'sitechange', focus: x !== 'sitechange', help: '0 / 200' }), Fd('Site', x === 'sitechange' ? 'Notes' : x === 'single' ? 'Archive' : 'Journal', { sel: true }),
|
||||||
multi && Fd('Language', 'English', { sel: true, help: x === 'sitechange' ? 'Notes only offers English, so the language was reset from French.' : 'Default for Journal.' }),
|
multi && Fd('Language', 'English', { sel: true, help: x === 'sitechange' ? 'Notes only offers English, so the language was reset from French.' : 'Default for Journal.' }),
|
||||||
multi && D('display:flex;flex-direction:column;gap:8px', Radio('New story', 'Starts a story in this language.', true), Radio('Translation of an existing story', 'Uses the same rules as Add translation.', false))]
|
multi && D('display:flex;flex-direction:column;gap:8px', Radio('New article', 'Starts an article in this language.', true), Radio('Translation of an existing article', 'Uses the same rules as Add translation.', false))]
|
||||||
], [Btn('Cancel'), Btn(x === 'translation' ? 'Create French draft' : 'Create article', 'primary')])
|
], [Btn('Cancel'), Btn(x === 'translation' ? 'Create French draft' : 'Create article', 'primary')])
|
||||||
}
|
}
|
||||||
// ---- add translation
|
// ---- add translation
|
||||||
const FRT = S1.tr.fr, FRT5 = 'Liste de contrôle pour l\'inspection de la toiture'
|
const FRT = S1.tr.fr, FRT5 = 'Liste de contrôle pour l\'inspection de la toiture'
|
||||||
const LS = st => LV('en', 'English', 'EN', st)
|
const LS = st => LV('en', 'English', 'EN', st)
|
||||||
const atOv = () => {
|
const atOv = () => {
|
||||||
const empty = x === 'empty', long = x === 'long', dupCase = x === 'dup' || x === 'dupHidden', st = dupCase ? S1 : S5, ft = dupCase ? FRT : FRT5, rev = st.v.en[2]
|
const empty = x === 'empty', long = x === 'long', dupCase = x === 'dup' || x === 'dupHidden', st = dupCase ? S1 : S5, ft = dupCase ? FRT : FRT5
|
||||||
const f = [Fd('Language', 'French', { sel: true }), Fd('Source version', 'English · published · revision ' + rev, { sel: true, help: 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.' }), Fd('Title in French', long ? ft + ' avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes' : ft, { focus: x === 'form', lang: 'fr', help: 'Copying text is a starting point: it stays in English until you translate it.' }),
|
const dis = ['gone', 'unreadable', 'langOff', 'dup', 'dupHidden', 'saving'].includes(x)
|
||||||
|
const f = [Fd('Language', 'French', { sel: true, ro: x === 'langOff', err: x === 'langOff' ? 'French was disabled on this site.' : null }), Fd('Source version', x === 'unreadable' ? 'Choose a version' : 'English · published', { sel: true, val: x !== 'unreadable' && x !== 'gone', help: 'You choose which version to copy from. It is also the source tracked for review. Access is checked again when you create the draft.' }), Fd('Title in French', long ? ft + ' avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes' : ft, { focus: x === 'form', lang: 'fr', help: 'Copying text is a starting point: it stays in English until you translate it.' }),
|
||||||
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;' + HL, 'Starting content'), Radio('Start empty', 'Only the title. Freshness shows No source until you pick a tracked source.', empty), Radio('Copy from English', 'Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren\'t duplicated. The version starts as Needs review.', !empty))]
|
D('display:flex;flex-direction:column;gap:8px', S('font-weight:500;' + HL, 'Starting content'), Radio('Start empty', 'Only the title. Freshness shows No source until you pick a tracked source.', empty), Radio('Copy from English', 'Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren\'t duplicated. The version starts as Needs review.', !empty))]
|
||||||
if (long) f.push(Alert('neutral', 'info', 'Authors', 'Marta Alves, Camille Roche, Luis Ferrer, Ana Pereira, Jonas Weber and 4 more will be copied. You can change them in the editor.'), Alert('neutral', 'info', 'Images', 'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.'))
|
if (long) f.push(Alert('neutral', 'info', 'Authors', 'Marta Alves, Camille Roche, Luis Ferrer, Ana Pereira, Jonas Weber and 4 more will be copied. You can change them in the editor.'), Alert('neutral', 'info', 'Images', 'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.'))
|
||||||
if (x === 'dup') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'French already exists for this story', 'Open it to continue. Your entries are kept here.', Btn('Open French version', 'secondary', { sm: true })), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
const T = 'Add a translation'
|
||||||
if (x === 'dupHidden') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'French can\'t be created for this story', 'Try a different language, or ask an owner. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
if (x === 'dup') return Dlg({ title: T }, [Alert('error', 'circle-x', 'French already exists for this article', 'Open it to continue. Your entries are kept here.', Btn('Open French version', 'secondary', { sm: true })), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||||
if (x === 'fail') return Dlg({ title: 'Add a translation' }, [Alert('error', 'circle-x', 'Couldn\'t create the translation', 'Your entries are kept. Try again.'), ...f], [Btn('Cancel'), Btn('Try again', 'primary')])
|
if (x === 'dupHidden') return Dlg({ title: T }, [Alert('error', 'circle-x', 'French can\'t be created for this article', 'Try a different language, or ask an owner. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||||
if (x === 'gone') return Dlg({ title: 'Add a translation' }, [Alert('warning', 'triangle-alert', 'The source isn\'t available', 'It was deleted or you can no longer read it. Choose another source you can read, or start empty.'), Fd('Source version', 'Choose a version', { sel: true, val: false }), Fd('Title in French', ft)], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
if (x === 'fail') return Dlg({ title: T }, [Alert('error', 'circle-x', 'Couldn\'t create the translation', 'Your entries are kept. Try again.'), ...f], [Btn('Cancel'), Btn('Try again', 'primary')])
|
||||||
if (x === 'saving') return Dlg({ title: 'Add a translation' }, f, [Btn('Cancel', 'secondary', { dis: true }), Btn('Creating…', 'primary', { busy: true })])
|
if (x === 'gone') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'The source was deleted', 'Choose another source you can read, or start empty. Your title is kept.'), ...f.slice(0, 1), f[1], f[2]], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||||
return Dlg({ title: 'Add a translation', desc: 'Creates a French draft of "' + st.title + '". It isn\'t published.', top: long ? 24 : 60 }, f, [Btn('Cancel'), Btn('Create French draft', 'primary')])
|
if (x === 'unreadable') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'The source isn\'t available to you', 'You can no longer read the version you chose. Choose another source you can read, or start empty. Your title is kept.'), ...f.slice(0, 1), f[1], f[2]], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||||
|
if (x === 'langOff') return Dlg({ title: T }, [Alert('warning', 'triangle-alert', 'French was disabled', 'French was disabled on this site while you were filling this in. Ask an owner to re-enable it, or choose another language. Your entries are kept.'), ...f], [Btn('Cancel'), Btn('Create French draft', 'primary', { dis: true })])
|
||||||
|
if (x === 'saving') return Dlg({ title: T }, f, [Btn('Cancel', 'secondary', { dis: true }), Btn('Creating…', 'primary', { busy: true })])
|
||||||
|
return Dlg({ title: T, desc: 'Creates a French draft of "' + st.title + '". It isn\'t published.', top: long ? 24 : 60 }, f, [Btn('Cancel'), Btn('Create French draft', 'primary')])
|
||||||
}
|
}
|
||||||
// ---- publish / delete
|
// ---- publish / delete
|
||||||
const pdOv = () => ({
|
const pdOv = () => ({
|
||||||
publish: () => Dlg({ title: 'Publish French?', desc: 'Only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Publish French', 'primary')]),
|
publish: () => Dlg({ title: 'Publish French?', desc: 'Only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Publish French', 'primary')]),
|
||||||
|
publishDisabled: () => [],
|
||||||
unpublish: () => Dlg({ title: 'Unpublish French?', desc: 'The French version goes back to draft and stops being public. English stays online.' }, [], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
|
unpublish: () => Dlg({ title: 'Unpublish French?', desc: 'The French version goes back to draft and stops being public. English stays online.' }, [], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
|
||||||
|
unpublishDirty: () => Dlg({ title: 'Unpublish French?', desc: 'The saved French version goes offline and becomes a draft. English stays online.' }, [Alert('neutral', 'info', 'Your edits stay in the editor', 'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.')], [Btn('Cancel'), Btn('Unpublish French', 'primary')]),
|
||||||
delete: () => Dlg({ title: 'Delete the French version?', desc: '"' + FRT + '" will be deleted. Other versions stay.' }, [], [Btn('Cancel'), Btn('Delete French version', 'danger')]),
|
delete: () => Dlg({ title: 'Delete the French version?', desc: '"' + FRT + '" will be deleted. Other versions stay.' }, [], [Btn('Cancel'), Btn('Delete French version', 'danger')]),
|
||||||
deleteSource: () => Dlg({ title: 'Delete the English version?', desc: 'English is the tracked source for versions you can read.' }, [Alert('warning', 'triangle-alert', 'Review tracking will pause', 'Versions that track English stay as they are. Their review history is kept, and they ask you to choose a new source.')], [Btn('Cancel'), Btn('Delete English version', 'danger')]),
|
deleteSource: () => Dlg({ title: 'Delete the English version?', desc: 'English is the tracked source for versions you can read.' }, [Alert('warning', 'triangle-alert', 'Review tracking will pause', 'Versions that track English stay as they are. Their review history is kept, and they ask you to choose a new source.')], [Btn('Cancel'), Btn('Delete English version', 'danger')]),
|
||||||
deleteLast: () => Dlg({ title: 'Delete the only version?', desc: 'English is the only version of this story. Deleting it deletes the story.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
|
deleteStoryOk: () => Dlg({ title: 'Delete English, French, German and Arabic?', desc: 'This deletes the article and every version you can read. It can\'t be undone.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
|
||||||
|
deleteLast: () => Dlg({ title: 'Delete the only version?', desc: 'English is the only version of this article. Deleting it deletes the article.' }, [], [Btn('Cancel'), Btn('Delete article', 'danger')]),
|
||||||
saveAndPublish: () => Dlg({ title: 'Save and publish French?', desc: 'Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Save and publish French', 'primary')]),
|
saveAndPublish: () => Dlg({ title: 'Save and publish French?', desc: 'Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren\'t affected.' }, [], [Btn('Cancel'), Btn('Save and publish French', 'primary')]),
|
||||||
saveFailPublish: () => [Toast('error', 'circle-x', 'Couldn\'t save French', 'Nothing was published. Your changes are kept. Try again.')],
|
saveFailPublish: () => [Toast('error', 'circle-x', 'Couldn\'t save French', 'Nothing was published. Your changes are kept. Try again.')],
|
||||||
deleteStory: () => Dlg({ title: 'You can\'t delete this story', desc: 'Deleting a story needs delete permission on every version. You can still delete the version you are editing.' }, [], [Btn('Close'), Btn('Delete French version', 'danger')]),
|
deleteStory: () => Dlg({ title: 'You can\'t delete this article', desc: 'Deleting an article needs delete permission on every version. You can still delete the version you are editing.' }, [], [Btn('Close'), Btn('Delete French version', 'danger')]),
|
||||||
fail: () => [Toast('error', 'circle-x', 'Couldn\'t publish French', 'Nothing changed. Try again.')],
|
fail: () => [Toast('error', 'circle-x', 'Couldn\'t publish French', 'Nothing changed. Try again.')],
|
||||||
done: () => [Toast('success', 'circle-check', 'French published', 'Other versions weren\'t changed.')]
|
done: () => [Toast('success', 'circle-check', 'French published', 'Other versions weren\'t changed.')]
|
||||||
}[x]?.())
|
}[x]?.())
|
||||||
// ---- freshness
|
// ---- freshness and review (release 2)
|
||||||
const frOv = () => ({
|
const frOv = () => ({
|
||||||
review: () => Dlg({ title: 'Mark French as reviewed?', desc: 'This records that French revision 5 matches English revision 8 (14 Oct 2026). It doesn\'t change publication.' }, [Alert('neutral', 'info', 'Changed fields', 'Title, Body. Authors and the cover image don\'t count.')], [Btn('Cancel'), Btn('Mark as reviewed', 'primary')]),
|
review: () => Dlg({ title: 'Mark French as reviewed?', desc: 'This records that the French version matches the English source. It doesn\'t change publication.' }, [Alert('neutral', 'info', 'Changed fields: Title, Body', 'Authors and the cover image don\'t count.'), D('display:grid;grid-template-columns:auto 1fr;gap:6px 16px;font-size:13px', S(MU, 'Source'), S(HL, 'English version saved 14 Oct 2026, 09:12'), S(MU, 'Translation'), S(HL, 'French version saved 15 Oct 2026, 16:40'))], [Btn('Cancel'), Btn('Mark as reviewed', 'primary')]),
|
||||||
changed: () => Dlg({ title: 'English changed again', desc: 'English is now at revision 9. You were reviewing revision 8.' }, [Alert('warning', 'triangle-alert', 'Review not recorded', 'Check the latest changes first. Nothing was marked.')], [Btn('Cancel'), Btn('Show source reference', 'secondary'), Btn('Review revision 9', 'primary')]),
|
changed: () => Dlg({ title: 'Review not recorded' }, [Alert('warning', 'triangle-alert', 'The English source changed while you were reviewing. Review again.', 'English was saved again at 14 Oct 2026, 11:20. Check the latest changes first. Nothing was marked.')], [Btn('Cancel'), Btn('Show source reference', 'secondary'), Btn('Review again', 'primary')]),
|
||||||
changedTr: () => Dlg({ title: 'French changed while you were reviewing', desc: 'Camille Roche saved French revision 6 after you opened this.' }, [Alert('warning', 'triangle-alert', 'Review not recorded', 'This changed since you opened it. Review again.')], [Btn('Cancel'), Btn('Reload French', 'primary')]),
|
changedTr: () => Dlg({ title: 'Review not recorded' }, [Alert('warning', 'triangle-alert', 'French was changed by someone else while you were reviewing. Review again.', 'Someone saved the French version at 15 Oct 2026, 16:52. Nothing was marked.')], [Btn('Cancel'), Btn('Reload French', 'primary')]),
|
||||||
replace: () => Dlg({ title: 'Choose a tracked source', desc: 'Pick a version of this story you can read. French will show Needs review until you review it against the new source.' }, [Radio('English', 'Published · revision 8', false), Radio('German', 'Published · revision 3', true), Alert('error', 'circle-x', 'German can\'t be the source', 'It tracks French as its source. A source can\'t track back to this version.')], [Btn('Cancel'), Btn('Use German as source', 'primary', { dis: true })]),
|
replace: () => Dlg({ title: 'Choose a tracked source', desc: 'Pick a version of this article you can read. French will show Needs review until you review it against the new source.' }, [Radio('English', 'Published', false), Radio('German', 'Published', true), Alert('error', 'circle-x', 'German can\'t be the source', 'German already tracks French as its source. A source can\'t track back to this version.')], [Btn('Cancel'), Btn('Use German as source', 'primary', { dis: true })]),
|
||||||
done: () => [Toast('success', 'circle-check', 'French marked as reviewed', 'Reviewed against English revision 8.')],
|
done: () => [Toast('success', 'circle-check', 'French marked as reviewed', 'Reviewed against the English version saved 14 Oct 2026.')],
|
||||||
fail: () => [Toast('error', 'circle-x', 'Couldn\'t mark as reviewed', 'Nothing was recorded. Try again.')]
|
fail: () => [Toast('error', 'circle-x', 'Couldn\'t mark as reviewed', 'Nothing was recorded. Try again.')]
|
||||||
}[x]?.())
|
}[x]?.())
|
||||||
// ---- permissions / conflicts
|
// ---- conflicts, moves, permissions
|
||||||
const pmOv = () => ({
|
const CfOpt = (t, d, b, k = 'secondary', top) => D((top ? '' : 'border-top:1px solid var(--ui-border);padding-top:14px;') + 'display:flex;' + (m ? 'flex-direction:column;gap:8px' : 'align-items:center;gap:16px'), D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S('font-weight:600;' + HL, t), S(MU + ';font-size:13px;line-height:19px;text-wrap:pretty', d)), D((m ? '' : 'width:200px;flex-shrink:0;') + 'display:flex', Btn(b, k, { w: '100%' })))
|
||||||
session: () => Dlg({ title: 'Your session expired', desc: 'Log in to continue. Your title, body and search fields for this French version are kept in this browser and restored after you log in, with a notice.' }, [], [Btn('Log in', 'primary')]),
|
const Cf = (o) => Dlg({ title: o.title || 'Someone else saved this version', desc: o.desc || 'Someone else saved this version at ' + (o.t || '14:32') + '. Your changes aren\'t saved. They stay here until you choose.', w: 560, top: 60 }, [o.alert, CfOpt('Keep my changes', 'Nothing is sent. Your changes stay in the editor.', 'Keep my changes', 'primary', 1), ...(o.rc ? [CfOpt('Reload theirs', 'This discards your unsaved changes.', 'Reload and discard my changes')] : [CfOpt('Download my version', 'Saves your title and body as a text file. Nothing is sent.', 'Download my version'), CfOpt('Reload theirs', 'Shows their version. You confirm first.', 'Reload theirs'), CfOpt('Save over theirs', 'Replaces the title, body and search fields of this version only. Other languages are untouched.', 'Save over theirs')])], [])
|
||||||
conflict: () => Dlg({ title: 'Someone else saved this version', desc: 'Camille Roche saved French revision 6 at 14:32. Your changes aren\'t saved. They stay here until you discard them.', w: 520 }, [Alert('neutral', 'info', 'Save over theirs', 'Replaces the title, body and search fields of the French version only. Other languages aren\'t changed. If someone saves again first, you\'ll be asked again.')], [Btn('Keep my changes'), Btn('Download my version', 'secondary', { ic: 'download' }), Btn('Reload theirs…'), Btn('Save over theirs', 'danger')]),
|
const VList = [['English', 'Published'], ['French', 'Draft'], ['German', 'Published'], ['Arabic', 'Draft · Read only']]
|
||||||
move: () => Dlg({ title: 'Move the story to Notes?', desc: 'This moves every version together.' }, [Fd('Site', 'Notes', { sel: true }), Alert('warning', 'triangle-alert', 'Notes doesn\'t enable every language in this story', 'Enable the missing languages on Notes first. Disabled-language versions count too. Nothing is split.')], [Btn('Cancel'), Btn('Move story', 'primary', { dis: true })]),
|
const moveNote = (hidden) => D('display:flex;flex-direction:column;gap:6px', S('font-weight:500;' + HL, 'Moves all language versions of this article.'), ...VList.filter(r => !(hidden && r[0] === 'Arabic')).map(r => S(TO, r[0] + ' · ' + r[1])), hidden && S(MU + ';font-size:12px', 'Versions you can\'t read aren\'t listed or counted.'))
|
||||||
moveSlug: () => Dlg({ title: 'Move the story to Archive?', desc: 'All versions move together.' }, [Fd('Site', 'Archive', { sel: true }), Alert('error', 'circle-x', 'A French address is already used on Archive', 'Change the French address before moving.', Btn('Edit French address', 'secondary', { sm: true }))], [Btn('Cancel'), Btn('Move story', 'primary', { dis: true })]),
|
const mvPanel = (hidden) => D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0;border-top:1px solid var(--ui-border)' : 'right:24px;top:215px;width:340px;border:1px solid var(--ui-border)') + ';background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:16px;display:flex;flex-direction:column;gap:12px', S('font-weight:600;' + HL, 'Site'), D('display:flex;flex-direction:column;gap:0;border:1px solid var(--ui-border)', ...['Journal', 'Atelier', 'Archive', 'Notes'].map(k => D('display:flex;align-items:center;gap:8px;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + (k === 'Journal' ? 'background:var(--rc-sand);color:var(--rc-on-sand);font-weight:600' : HL), S('width:16px', k === 'Journal' && Ic('check')), k))), moveNote(hidden), D('display:flex;gap:8px', Btn('Cancel'), D('flex:1;display:flex', Btn('Move article…', 'primary', { w: '100%' }))))
|
||||||
movePerm: () => Dlg({ title: 'You can\'t move this story' }, [Alert('error', 'circle-x', 'Moving needs edit permission on every version', 'Ask an owner to move it. Nothing was changed.')], [Btn('Close')])
|
const mvDlg = (kind) => {
|
||||||
}[x]?.())
|
const base = { desc: 'This moves every version together. Nothing is split.' }
|
||||||
|
return {
|
||||||
|
moveConfirm: () => Dlg({ title: 'Move to Atelier?', ...base }, [moveNote(false), Alert('neutral', 'info', 'Addresses and links', 'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.')], [Btn('Cancel'), Btn('Move article', 'primary')]),
|
||||||
|
moveBusy: () => Dlg({ title: 'Moving to Atelier…', desc: 'All 4 versions move together. Keep this page open.' }, [Alert('info', 'loader-circle', 'Moving', 'This takes a few seconds.')], [Btn('Cancel', 'secondary', { dis: true }), Btn('Moving…', 'primary', { busy: true })]),
|
||||||
|
move: () => Dlg({ title: 'Move to Notes?', ...base }, [Alert('warning', 'triangle-alert', 'Notes doesn\'t enable every language in this article', 'Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved.')], [Btn('Cancel'), Btn('Move article', 'primary', { dis: true })]),
|
||||||
|
moveSlug: () => Dlg({ title: 'Move to Atelier?', ...base }, [Alert('error', 'circle-x', 'An address is already used on Atelier', 'The French address "comment-nous-chiffrons-une-renovation" is already used by another French article on Atelier. Change it before moving. Nothing was moved.', Btn('Edit slug', 'secondary', { sm: true }))], [Btn('Cancel'), Btn('Move article', 'primary', { dis: true })]),
|
||||||
|
movePerm: () => Dlg({ title: 'You can\'t move this article' }, [Alert('error', 'circle-x', 'Moving needs edit permission on every version', 'Ask an owner to move it. Nothing was changed.')], [Btn('Close')]),
|
||||||
|
moveDone: () => [Toast('success', 'circle-check', 'Moved to Atelier', 'All 4 versions moved together.')]
|
||||||
|
}[kind]
|
||||||
|
}
|
||||||
|
const pmOv = () => {
|
||||||
|
const mv = mvDlg(x)
|
||||||
|
if (mv) return mv()
|
||||||
|
if (x === 'moveSelect' || x === 'moveSelectHidden') return [mvPanel(x === 'moveSelectHidden')]
|
||||||
|
const dl = { session: () => Dlg({ title: 'Your session expired', desc: 'Log in to continue. Your title, body and search fields for this French version are kept in this browser and restored after you log in, with a notice.' }, [], [Btn('Log in', 'primary')]),
|
||||||
|
conflict: () => Cf({}),
|
||||||
|
conflictRestored: () => Cf({ alert: Alert('info', 'info', 'Restored unsaved changes from 14:18', 'Your session ended. These changes were kept in this browser.') }),
|
||||||
|
reloadConfirm: () => Cf({ title: 'Reload theirs?', desc: 'This discards your unsaved changes.', rc: 1 }),
|
||||||
|
conflict2: () => Cf({ t: '14:41', alert: Alert('error', 'circle-x', 'Couldn\'t replace theirs', 'Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here.') }),
|
||||||
|
download: () => [Cf({}), Toast('success', 'circle-check', 'Download started', 'Your French title and body are saved as a text file. Your changes are still in the editor.')],
|
||||||
|
restored: () => [] }
|
||||||
|
return dl[x]?.()
|
||||||
|
}
|
||||||
|
// ---- existing surfaces
|
||||||
|
const sfPage = () => {
|
||||||
|
const mk2 = (t, d, ...c) => Card(S('font-weight:600;' + HL, t), d && S(TO, d), ...c)
|
||||||
|
const RowS = (a, b, c) => D(ROW + ';padding-left:0;padding-right:0', D('flex:1;min-width:0;display:flex;flex-direction:column;gap:2px', S(HL + ';font-weight:500', a), b && S(MU + ';font-size:12px', b)), c)
|
||||||
|
if (x === 'home' || x === 'homeFr') { const fr = x === 'homeFr', V = FX.view({ site: J, read: fr ? ['fr'] : null }); return Page('Home', fr ? 'You can read French versions only.' : 'Welcome back, Marta.', null, mk2('Recently updated', null, ...V.rows.slice(0, 5).map(r => RowS(FX.tt(r.s, r.rep), r.s.by + ' · ' + r.s.upd, r.rep !== J.def ? S(mono + ';border:1px solid var(--ui-border-accented);padding:0 6px;' + HL, r.rep.toUpperCase()) : null))), S(MU + ';font-size:12px', 'Each article appears once, with its default-language title. A language code shows only when the title isn\'t in the default language.')) }
|
||||||
|
if (x === 'activity') return Page('Activity', 'Recent changes in Journal and Archive.', null, mk2('Today and this week', null, ...[['Camille Roche', 'Published French version of "Guide de l\'enduit à la chaux"', '2 h ago'], ['Luis Ferrer', 'Published French version of "Liste d\'hiver pour les fenêtres en bois"', 'Yesterday'], ['Marta Alves', 'Published German version of "So kalkulieren wir eine Badsanierung"', '3 days ago'], ['Luis Ferrer', 'Created German version of "Checkliste für die Dachprüfung"', '14 Oct'], ['Camille Roche', 'Published "Guia de cal e areia"', '14 Oct']].map(r => RowS(r[1], r[0] + ' · ' + r[2]))), S(MU + ';font-size:12px', 'The last event is on Archive, which uses one language, so it names none.'))
|
||||||
|
if (x === 'media') return Page('Media', 'Image details', null, mk2('bathroom-plan.jpg', null, D('height:160px;border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;' + MU + ';font:12px \'IBM Plex Mono\',monospace', 'image preview'), S('font-weight:500;' + HL, 'Used in 2 articles · 4 versions · 1 file'), S(MU + ';font-size:12px', 'A shared image is one file. It is counted once per article, and the versions that use it are listed.'), RowS('How we price a bathroom renovation', 'English, French, German'), RowS('A guide to lime plaster', 'English')))
|
||||||
|
if (x === 'menu') return Page('Article menu', 'The same menu names the language on multilingual sites.', null, D('display:flex;gap:16px;flex-wrap:wrap', ...[['Journal (several languages)', ['Publish French', 'Delete French version']], ['Archive (one language)', ['Publish', 'Delete']]].map(g => D('flex:1 1 220px;display:flex;flex-direction:column;gap:6px', S('font-size:12px;' + MU, g[0]), D('border:1px solid var(--ui-border);background:var(--ui-bg);box-shadow:var(--rc-shadow-pop);padding:4px 0', ...g[1].map((l, i) => D('display:flex;align-items:center;gap:10px;min-height:' + (m ? 48 : 36) + 'px;padding:6px 12px;' + (i ? 'color:var(--ui-error)' : HL), Ic(i ? 'trash-2' : 'globe'), S('font-weight:500', l))))))))
|
||||||
|
if (x === 'apiCopy') return Page('Details', 'French version', null, mk2('Public API URL', 'Each language version has its own address. The URL carries the language.', D('display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--ui-bg-elevated);' + mono, S('flex:1', '/v1/article/14?lang=fr'), Ic('copy', 14))))
|
||||||
|
if (x === 'seoPreview') return Page('Search preview', 'French version', null, mk2('How it can appear in search', null, D('display:flex;flex-direction:column;gap:2px;padding:12px;background:var(--ui-bg-elevated)', S(mono + ';' + MU, 'journal.example.com › fr › comment-nous-chiffrons-une-renovation'), S('font:600 16px/24px \'IBM Plex Sans\',sans-serif;' + HL, FRT + ' | Journal'), S(TO, 'La plupart des devis échouent parce qu\'ils cachent les trois mêmes coûts.')), S(MU + ';font-size:12px', 'Shows the localized address and canonical. Description: Fallback: French excerpt.')))
|
||||||
|
if (x === 'siteFallback') return Page('Search and sharing', 'Site-level text is used as a fallback only in its own language.', null, ...[['French article', 'Description', 'Fallback: French site description', 'Used: "Rénovation et conseils pour la maison" (site text in French)', 1], ['Arabic article', 'Description', 'No fallback', 'Empty. English site default not used.', 0]].map(r => Card(S('font-weight:600;' + HL, r[0]), Fd(r[1], r[4] ? 'Empty' : 'Empty', { val: false, help: r[2] + '. ' + r[3] }), r[4] ? Bd('Uses French site text', 'info', 'languages') : Bd('Tag left out', 'neutral', 'minus'))))
|
||||||
|
return null
|
||||||
|
}
|
||||||
// ---- compose
|
// ---- compose
|
||||||
const EDV = { v: 'ed' }
|
const apiToast = x === 'apiCopy' ? Toast('success', 'circle-check', 'API URL copied', '/v1/article/14?lang=fr') : null
|
||||||
if (v === 'sl') { Object.assign(shell, setS, x === 'single' ? { crumbs: ['Settings', 'Sites', 'Archive', 'Languages'], mTitle: 'Archive · Languages' } : {}); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', slPage()); ov = [].concat(slOv() || []) }
|
if (v === 'sl') { Object.assign(shell, setS, x === 'single' ? { crumbs: ['Settings', 'Sites', 'Archive', 'Languages'], mTitle: 'Archive · Languages' } : x === 'social' || x === 'socialEdit' || x === 'socialInvalid' ? { crumbs: ['Settings', 'Sites', 'Atelier', 'Languages'], mTitle: 'Atelier · Languages' } : {}); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', slPage()); ov = [].concat(slOv() || []) }
|
||||||
else if (v === 'su') { Object.assign(shell, setS, { crumbs: ['Settings', 'Languages'] }); main = D('flex:1;min-height:0;display:flex;flex-direction:column', suBody()) }
|
else if (v === 'pu') { Object.assign(shell, setS, { crumbs: ['Settings', 'Sites', 'Journal', 'Public URL'], mTitle: 'Journal · Public URL' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', puPage()); if (x === 'saved') ov = [Toast('success', 'circle-check', 'Public URL saved', 'Journal pages now use this pattern.')] }
|
||||||
else if (v === 'li') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); if (x === 'filters' && m) ov = [sheet()] }
|
else if (v === 'su') { Object.assign(shell, setS, { crumbs: ['Settings', 'Sites', 'Journal', 'Languages', 'Setup'], mTitle: 'Set article languages' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column', suBody()) }
|
||||||
|
else if (v === 'li') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = listOv() }
|
||||||
else if (v === 'na') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = [naOv()] }
|
else if (v === 'na') { Object.assign(shell, liS); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', liPage()); ov = [naOv()] }
|
||||||
|
else if (v === 'sf') { Object.assign(shell, { nav: x === 'home' || x === 'homeFr' ? 'home' : x === 'activity' ? 'activity' : x === 'media' ? 'media' : 'articles', crumbs: [x === 'home' || x === 'homeFr' ? 'Home' : x === 'activity' ? 'Activity' : x === 'media' ? 'Media' : 'Articles'], mTitle: 'Journal' }); main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', sfPage()); if (apiToast) ov = [apiToast] }
|
||||||
else if (v === 'seo' || v === 'api') {
|
else if (v === 'seo' || v === 'api') {
|
||||||
Object.assign(shell, edS)
|
Object.assign(shell, edS)
|
||||||
let mainEl
|
let mainEl
|
||||||
if (v === 'seo') {
|
if (v === 'seo') {
|
||||||
const unset = x === 'unset'
|
const unset = x === 'unset', slugD = x === 'editSlug'
|
||||||
const rows = [['Address (slug)', 'comment-nous-chiffrons-une-renovation', 'Unique within Journal and French. Changing the title never changes it. Edit address changes it on purpose. Published: "Links to the old address will stop working. RenewCMS does not create redirects."'], ['Page title', FRT + ' | Journal', 'Empty uses the French title. Fallback: French title.'], ['Description', 'Empty', 'No fallback: the English site default isn\'t used for French, and the tag is left out.'], ['Image alternative text', 'Un plan de salle de bain avec les mesures', 'Written per language.']]
|
const rows = [['Page title', FRT + ' | Journal', 'Fallback: French title', 1], ['Description', 'Empty', 'No fallback. Empty. English site default not used. The tag is left out.', 0], ['Image alternative text', 'Un plan de salle de bain avec les mesures', 'Written per language.', 1]]
|
||||||
mainEl = Page('Search and sharing · French', 'Applies only to the French version. Page language: fr.', null, unset ? Alert('info', 'info', 'Public URL not configured', 'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.', Btn('Open site settings', 'secondary', { sm: true })) : Card(D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('Canonical', 'neutral', 'link'), S(mono + ';' + HL, 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation')), S(MU + ';font-size:12px', 'Built from the site\'s public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.')), !unset && Card(S('font-weight:600;' + HL, 'Alternate languages'), S(TO, 'Published versions in enabled languages only.'), D('display:flex;gap:10px;align-items:center', S(mono, 'EN'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/en/how-we-price…'), Bd('Published', 'success', 'circle-check')), S(MU + ';font-size:12px', 'Drafts and the disabled Spanish language are left out.')), !unset && Card(S('font-weight:600;' + HL, 'Social metadata locale'), D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('og:locale', 'neutral', 'share-2'), x === 'noSocial' ? S(MU, 'Not set') : S(mono + ';' + HL, 'fr_FR')), S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', x === 'noSocial' ? 'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.' : 'Set by an owner for French in Journal languages. Without it the tag would be left out: a region is never guessed from the language.')), ...rows.map(r => Fd(r[0], r[1], { help: r[2], val: r[1] !== 'Empty', mono: r[0].startsWith('Address') })))
|
mainEl = Page('Search and sharing · French', 'Applies only to the French version. Page language: fr.', null,
|
||||||
|
unset ? Alert('info', 'info', 'Public URL not configured', 'Add a public URL pattern for Journal to show page and canonical addresses. API links by article ID still work.', Btn('Open Public URL settings', 'secondary', { sm: true })) : Card(D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('Canonical', 'neutral', 'link'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'https://journal.example.com/fr/comment-nous-chiffrons-une-renovation')), S(MU + ';font-size:12px', 'Built from the Public URL pattern https://journal.example.com/{lang}/{slug}. Each language points to itself unless you set a canonical override.')),
|
||||||
|
!unset && Card(S('font-weight:600;' + HL, 'Alternate languages'), S(TO, 'Published versions in enabled languages only.'), D('display:flex;gap:10px;align-items:center', S(mono, 'EN'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/en/how-we-price-a-bathroom-renovation'), Bd('Published', 'success', 'circle-check')), D('display:flex;gap:10px;align-items:center', S(mono, 'DE'), S('flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + TO, 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung'), Bd('Published', 'success', 'circle-check')), S(MU + ';font-size:12px', 'Drafts and the disabled Spanish language are left out.')),
|
||||||
|
Card(S('font-weight:600;' + HL, 'Social locale'), D('display:flex;gap:8px;align-items:center;flex-wrap:wrap', Bd('og:locale', 'neutral', 'share-2'), x === 'noSocial' ? S(MU, 'Social locale not set') : S(mono + ';' + HL, 'Set: fr_FR')), S(MU + ';font-size:12px;line-height:18px;text-wrap:pretty', x === 'noSocial' ? 'French has no region in its code and no social locale is set for it, so og:locale and og:locale:alternate are left out. An owner can set one in Journal languages.' : 'Set by an owner for French in Journal languages. A region is never guessed from the language.')),
|
||||||
|
Card(Fd('Address (slug)', 'comment-nous-chiffrons-une-renovation', { mono: true, ro: true, lock: true, help: 'Unique within Journal and French. Editing the title never changes the address.' }), D('display:flex;flex-direction:column;gap:6px;align-items:flex-start', Btn('Edit slug', 'secondary', { ic: 'pencil' }), S(MU + ';font-size:12px;line-height:18px', 'Editing the title never changes the slug. Only Edit slug does.'), S(HL + ';font-size:12px;line-height:18px', 'Links to the old address will stop working. RenewCMS does not create redirects.'))),
|
||||||
|
...rows.map(r => Fd(r[0], r[1], { help: r[2], val: r[3] === 1 })))
|
||||||
|
if (slugD) ov = [Dlg({ title: 'Edit slug for the French version', desc: 'The address changes for French only.' }, [Fd('Address (slug)', 'comment-nous-calculons-une-renovation', { mono: true, focus: true }), Alert('warning', 'triangle-alert', 'This version is published', 'Links to the old address will stop working. RenewCMS does not create redirects.'), S(MU + ';font-size:12px', 'Editing the title never changes the slug.')], [Btn('Cancel'), Btn('Change address', 'primary')])]
|
||||||
} else mainEl = Page('Integration · Languages', 'Proposed. Not available in the current API.', null, Alert('warning', 'triangle-alert', 'Proposed capability', 'These requests don\'t exist yet. Existing article-ID requests are unchanged.'),
|
} else mainEl = Page('Integration · Languages', 'Proposed. Not available in the current API.', null, Alert('warning', 'triangle-alert', 'Proposed capability', 'These requests don\'t exist yet. Existing article-ID requests are unchanged.'),
|
||||||
Card(S('font-weight:600;' + HL, 'Exact lookup: site + language + slug'), S(mono + ';' + HL, 'GET /v1/public/site/journal/lang/fr/article/{slug}'), S(TO, 'Matches the published French version with that slug, or returns 404 article_not_found. It never falls back, because a slug belongs to one language.'), S(mono + ';' + MU, '{ "story_id": "st_01", "lang": "fr", "slug": "…", "alternates": [{ "lang": "en", "slug": "…" }] }')),
|
Card(S('font-weight:600;' + HL, '1. Exact lookup: site + language + slug'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'GET /v1/public/site/{key}/lang/{lang}/article/{slug}'), S(TO, 'Matches the published version with that slug in that language, or returns article_not_found. It never falls back, because a slug belongs to one language.'), S(mono + ';' + MU + ';overflow-wrap:anywhere', '{ "story_id": "st_01", "lang": "fr", "slug": "…", "alternates": [{ "lang": "en", "slug": "…" }] }')),
|
||||||
Card(S('font-weight:600;' + HL, 'Resolve a story: story_id + language'), S(mono + ';' + HL, 'GET /v1/public/site/journal/story/{story_id}?lang=fr&fallback=default'), S(TO, 'Use the story_id from any response or from alternates[]. French published: 200, "lang": "fr". French missing or unpublished and fallback=default: 200 with the published default, "lang": "en", "requested_lang": "fr", "fallback": true. Default also missing or unpublished: 404 language_not_available. Without fallback=default a missing French is 404 language_not_available. No other language is tried, and drafts are never returned.')),
|
Card(S('font-weight:600;' + HL, '2. Resolve by article group, with fallback'), S(mono + ';' + HL + ';overflow-wrap:anywhere', 'GET /v1/public/site/{key}/story/{story_id}?lang=fr&fallback=default'), S(TO, 'Use story_id from any response or from alternates[]. French published: lang "fr". French missing or unpublished with fallback=default: the published default, with "lang": "en", "requested_lang": "fr", "fallback": true. Without fallback=default, a missing French is language_not_available. No other language is tried, and drafts are never returned.')),
|
||||||
Card(S('font-weight:600;' + HL, 'Existing, unchanged'), S(mono + ';' + HL, 'GET /v1/article/{id}'), S(TO, 'Article-ID endpoints and links keep working. Each id is one language version.')))
|
Card(S('font-weight:600;' + HL, '3. Errors'), S(mono + ';' + HL, 'article_not_found · language_not_available · language_disabled'), S(TO, 'article_not_found: no published version has that slug in that language. language_not_available: the requested language and the allowed fallback are both unavailable. language_disabled: publishing in a disabled language.')),
|
||||||
|
Card(S('font-weight:600;' + HL, 'Existing, unchanged'), S(mono + ';' + HL, 'GET /v1/article/{id}'), S(mono + ';' + HL, 'PATCH /v1/article/{id}/publish'), S(mono + ';' + HL, 'Editor: /article/{id}/edit'), S(TO, 'Each article ID is one language version. Article-ID links keep working.')))
|
||||||
main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', mainEl)
|
main = D('flex:1;min-height:0;display:flex;flex-direction:column;position:relative', mainEl)
|
||||||
} else {
|
} else {
|
||||||
Object.assign(shell, edS)
|
Object.assign(shell, edS)
|
||||||
const rtl = x === 'rtl', en = x === 'navFail'
|
const rtl = x === 'rtl', en = x === 'navFail' || (v === 'ed' && ['cleanOnline', 'onlineDirty', 'unpublishDirty'].includes(x))
|
||||||
let L = rtl ? AR : en ? EN : FR
|
let L = rtl ? AR : FR
|
||||||
const o = { cap: CAPS.full, pub: en ? 'pub' : 'draft', seoNote: false }
|
const o = { cap: CAPS.full, pub: 'draft' }
|
||||||
let extra = null
|
const bn = []
|
||||||
const popLeft = m ? 0 : 292
|
let ovl = []
|
||||||
const pop = (rows, q = {}) => ov.push(LangPop(rows, { left: popLeft, top: 188, ...q }))
|
const pop = (rows, q = {}) => ovl.push(LangPop(rows, { left: 248, top: 176, ...q }))
|
||||||
|
const lifeOnline = 'This version is online. Saving updates the live page right away.'
|
||||||
if (v === 'ed') {
|
if (v === 'ed') {
|
||||||
o.seoNote = !m && !['menu', 'many', 'two'].includes(x)
|
|
||||||
if (x === 'menu') { o.open = 1; pop(MENUS.all) } else if (x === 'two') { o.open = 1; pop(MENUS.two) } else if (x === 'many') { o.open = 1; pop(MENUS.many, { find: 1, max: 520 }) }
|
if (x === 'menu') { o.open = 1; pop(MENUS.all) } else if (x === 'two') { o.open = 1; pop(MENUS.two) } else if (x === 'many') { o.open = 1; pop(MENUS.many, { find: 1, max: 520 }) }
|
||||||
else if (x === 'ro') o.cap = CAPS.ro
|
else if (x === 'dirty') { o.dirty = 1; o.cap = CAPS.p2 }
|
||||||
else if (x === 'roMenu') { o.cap = CAPS.ro; o.open = 1; pop(MENUS.ro) }
|
|
||||||
else if (x === 'p2') o.cap = CAPS.p2
|
|
||||||
else if (x === 'p3') o.cap = CAPS.p3
|
|
||||||
else if (x === 'p4') { o.open = 1; pop(MENUS.p4) }
|
|
||||||
else if (x === 'p5') { o.open = 1; pop(MENUS.p5) }
|
|
||||||
else if (x === 'p6') { o.cap = CAPS.p6; ov.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'right:24px;top:188px;width:300px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'More actions' }, 'display:flex;flex-direction:column', A('div', { role: 'menuitem' }, 'padding:10px 12px;min-height:' + (m ? 48 : 36) + 'px;display:flex;align-items:center;color:var(--ui-error)', 'Delete French version'), A('div', { role: 'menuitem', 'aria-disabled': 'true' }, 'padding:10px 12px;display:flex;flex-direction:column;gap:2px;opacity:.6', 'Delete story', S(MU + ';font-size:12px', 'You can\'t delete this story.')), A('div', { role: 'menuitem', 'aria-disabled': 'true' }, 'padding:10px 12px;display:flex;flex-direction:column;gap:2px;opacity:.6', 'Move story', S(MU + ';font-size:12px', 'Needs edit permission on every version.'))))) }
|
|
||||||
else if (x === 'unsaved') { o.dirty = 1; ov.push(Dlg({ title: 'Leave without saving?', desc: 'Your changes to the French version aren\'t saved.' }, [], [Btn('Keep editing'), Btn('Discard and open English', 'danger'), Btn('Save and open English', 'primary')])) }
|
|
||||||
else if (x === 'saving') { o.dirty = 1; o.saving = 1 }
|
|
||||||
else if (x === 'saved') ov.push(Toast('success', 'circle-check', 'French saved', 'English wasn\'t changed.'))
|
|
||||||
else if (x === 'dirtyPublish') o.dirty = 1
|
else if (x === 'dirtyPublish') o.dirty = 1
|
||||||
else if (x === 'saveFail') { o.dirty = 1; o.saveFail = 1 }
|
else if (x === 'cleanOnline') { o.pub = 'pub'; bn.push(Alert('neutral', 'info', lifeOnline)) }
|
||||||
else if (x === 'onlineDirty') { o.dirty = 1; o.pub = 'pub' }
|
else if (x === 'onlineDirty') { o.dirty = 1; o.pub = 'pub'; bn.push(Alert('neutral', 'info', lifeOnline)) }
|
||||||
else if (['invalid', 'titleSaving', 'failed', 'focus', 'dirty'].includes(x)) { o.titleState = { titleSaving: 'saving' }[x] || x; o.dirty = x !== 'focus'; if (x === 'invalid') { o.titleVal = ''; o.saveBlock = 1 } if (x === 'titleSaving') o.saving = 1; if (x === 'failed') o.saveFail = 1 }
|
else if (x === 'saveFail') { o.dirty = 1; o.saveFail = 1; bn.push(Alert('error', 'circle-x', 'Couldn\'t save French', 'Your changes are kept here. Nothing was published. Try saving again.')) }
|
||||||
|
else if (x === 'pubFail') { o.pubFail = 1; bn.push(Alert('error', 'circle-x', 'Saved as a draft. Publishing failed. Try publishing again.', 'Your edits are saved. Only the publishing step failed.', Btn('Retry publish', 'secondary', { sm: true }))) }
|
||||||
|
else if (x === 'unsaved') { o.dirty = 1; ovl.push(Dlg({ title: 'Leave without saving?', desc: 'Your changes to the French version aren\'t saved.' }, [], [Btn('Keep editing'), Btn('Discard and open English', 'danger'), Btn('Save and open English', 'primary')])) }
|
||||||
|
else if (x === 'saving') { o.dirty = 1; o.saving = 1 }
|
||||||
|
else if (x === 'saved') { o.saved = 1; ovl.push(Toast('success', 'circle-check', 'French saved', 'English wasn\'t changed.')) }
|
||||||
|
else if (x === 'ovf') { o.dirty = 1; ovl.push(OvMenu(OvItems(L, { ...o }))) }
|
||||||
|
else if (['focus', 'titleEdit', 'titleUnsaved', 'invalid', 'titleShort', 'titleLong', 'titleSaving', 'failed'].includes(x)) { o.tst = { titleEdit: 'edit', titleUnsaved: 'unsaved', titleShort: 'short', titleLong: 'long', titleSaving: 'saving' }[x] || x; o.dirty = !['focus', 'saving', 'failed'].includes(o.tst) || o.tst === 'failed'; if (o.tst === 'failed') o.saveFail = 1; if (['invalid', 'short', 'long'].includes(o.tst)) o.saveBlock = 1; if (o.tst === 'saving') { o.saving = 1; o.dirty = 1 } }
|
||||||
else if (x === 'loading') o.loading = 1
|
else if (x === 'loading') o.loading = 1
|
||||||
else if (x === 'gone') { main = Page('This version isn\'t available', 'It may have been deleted, or you may not have access.', null, Btn('Back to Articles', 'secondary')) }
|
else if (x === 'gone') { main = Page('This version isn\'t available', 'It may have been deleted, or you may not have access.', null, Btn('Back to Articles', 'secondary')) }
|
||||||
else if (x === 'navFail') ov.push(Toast('error', 'circle-x', 'Couldn\'t open the French version', 'You\'re still editing English. Try again.'))
|
else if (x === 'navFail') ovl.push(Toast('error', 'circle-x', 'Couldn\'t open the French version', 'You\'re still editing English. Try again.'))
|
||||||
} else if (v === 'src') {
|
}
|
||||||
o.src = x !== 'closed' && x !== 'mobileClosed'; o.narrow = x === 'narrow'; o.long = x === 'long'; o.srcMob = x === 'mobileSrc' || x === 'unavail' && m; o.gone = x === 'unavail'
|
else if (v === 'pm') {
|
||||||
if (x === 'reopen') ov.push(Toast('info', 'info', 'Reference: English', 'Restored. Your place in both panes is kept.'))
|
if (x === 'restored') { o.dirty = 1; bn.push(Alert('info', 'info', 'Restored unsaved changes from 14:18', 'You logged in again. Your French title and body were kept in this browser.')) }
|
||||||
if (x === 'changeRef') ov.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'left:200px;top:210px;width:340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'Reference version' }, 'display:flex;flex-direction:column', LRow({ n: 'English', c: 'EN', st: 'pub', cur: 1 }), LRow({ n: 'German', c: 'DE', st: 'pub' })), D('padding:10px 12px;border-top:1px solid var(--ui-border);' + MU + ';font-size:12px;line-height:18px', 'Only changes what you compare against. The tracked review source stays English. Use Change tracked source in the review banner to change that.')))
|
else if (x === 'conflict' || x === 'conflict2' || x === 'download' || x === 'conflictRestored' || x === 'reloadConfirm') o.dirty = 1
|
||||||
if (x === 'changeRef') { o.src = true }
|
else if (x.startsWith('move') && x !== 'moveSelect' && x !== 'moveSelectHidden') { o.cap = { ...CAPS.full } }
|
||||||
|
}
|
||||||
|
else if (v === 'src') {
|
||||||
|
o.src = x !== 'closed' && x !== 'mobileClosed'; o.narrow = x === 'narrow'; o.long = x === 'long'; o.srcMob = x === 'mobileSrc' || (x === 'unavail' && m); o.gone = x === 'unavail'
|
||||||
|
if (x === 'rtl') { o.S0 = EN }
|
||||||
|
if (x === 'arRef') o.S0 = AR
|
||||||
|
if (x === 'reopen') ovl.push(Toast('info', 'info', 'Reference: English', 'Restored. Your place in both panes is kept.'))
|
||||||
|
if (x === 'changeRef') ovl.push(D('position:absolute;z-index:40;' + (m ? 'left:0;right:0;bottom:0' : 'left:260px;top:220px;width:340px') + ';background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);display:flex;flex-direction:column', A('div', { role: 'menu', 'aria-label': 'Reference version' }, 'display:flex;flex-direction:column', LRow({ n: 'English', c: 'EN', st: 'pub', cur: 1 }), LRow({ n: 'German', c: 'DE', st: 'pub' })), D('padding:10px 12px;border-top:1px solid var(--ui-border);' + MU + ';font-size:12px;line-height:18px', 'This only changes what you compare against. The tracked source for review stays English. Use Change tracked source in the review banner to change that.')))
|
||||||
} else if (v === 'fr') {
|
} else if (v === 'fr') {
|
||||||
o.fresh = 'Needs review'
|
o.fresh = 'Needs review'
|
||||||
const buttons = [Btn('Show source reference', 'secondary', { sm: true }), CAPS.full.rv && Btn('Mark as reviewed', 'primary', { sm: true }), Btn('Change tracked source', 'ghost', { sm: true })].filter(Boolean)
|
const buttons = [Btn('Show source reference', 'secondary', { sm: true }), Btn('Mark as reviewed', 'primary', { sm: true }), Btn('Change tracked source', 'ghost', { sm: true })]
|
||||||
const ban = (tone, ic, t, d, act) => Alert(tone, ic, t, d, act)
|
if (x === 'gone') { o.fresh = 'No source'; bn.push(Alert('neutral', 'info', 'Source deleted', 'The tracked English version was deleted. Review history is kept. Choose a new source to track changes again.', Btn('Choose a new source', 'secondary', { sm: true }))) }
|
||||||
if (x === 'gone') { o.fresh = 'No source'; extra = ban('neutral', 'info', 'Source deleted', 'The tracked English version was deleted. Review history is kept. Choose a new source to track changes again.', Btn('Choose a new source', 'secondary', { sm: true })) }
|
else if (x === 'inaccessible') { o.fresh = 'Source unavailable'; bn.push(Alert('warning', 'lock', 'The source isn\'t available to you right now', 'It may be restricted. Review history is kept. Mark as reviewed is off until you can read it again.', Btn('Choose a new source', 'ghost', { sm: true }))) }
|
||||||
else if (x === 'inaccessible') { o.fresh = 'Source unavailable'; extra = ban('warning', 'lock', 'The source isn\'t available to you right now', 'It may be restricted. Review history is kept. Mark as reviewed is off until you can read it again.', Btn('Choose a new source', 'ghost', { sm: true })) }
|
else { if (x === 'pub') o.pub = 'pub'; bn.push(D('display:flex;flex-direction:column;gap:8px', D('display:flex;gap:8px;align-items:center', Tracked('English')), Alert('warning', 'triangle-alert', 'English changed after French was reviewed', 'Changed fields: Title, Body. English was saved 14 Oct 2026, 09:12. French was last reviewed 12 Oct 2026.' + (x === 'pub' ? ' French is still published. Reviewing doesn\'t change publication.' : ''), buttons))) }
|
||||||
else { if (x === 'pub') o.pub = 'pub'; extra = ban('warning', 'triangle-alert', 'English changed after French was reviewed', 'French was reviewed against English revision 7 (12 Oct 2026). English is at revision 8 (14 Oct 2026). Changed fields: Title, Body.' + (x === 'pub' ? ' French is still published. Reviewing doesn\'t change publication.' : ''), buttons) }
|
ovl = ovl.concat(frOv() || [])
|
||||||
ov = ov.concat(frOv() || [])
|
} else if (v === 'pd') { o.pub = ['unpublish', 'unpublishDirty', 'done', 'deleteSource', 'deleteLast', 'deleteStoryOk', 'deleteStory'].includes(x) ? 'pub' : 'draft'; if (['saveAndPublish', 'saveFailPublish', 'unpublishDirty'].includes(x)) o.dirty = 1; if (x === 'saveFailPublish') o.saveFail = 1; if (x === 'publishDisabled') { o.pubDis = 1; bn.push(Alert('warning', 'triangle-alert', 'French is disabled on this site. Re-enable it to publish.', 'You can still edit and save this version. Ask an owner to re-enable French.')) } ovl = ovl.concat(pdOv() || []) }
|
||||||
} else if (v === 'pd') { o.pub = x === 'unpublish' || x === 'done' ? 'pub' : 'draft'; if (x === 'saveAndPublish' || x === 'saveFailPublish') o.dirty = 1; if (x === 'saveFailPublish') o.saveFail = 1; ov = ov.concat(pdOv() || []) }
|
else if (v === 'at') { o.pub = 'pub'; L = LS(x === 'dup' || x === 'dupHidden' ? S1 : S5); ovl = ovl.concat(atOv()) }
|
||||||
else if (v === 'at') { o.pub = 'pub'; o.cap = CAPS.full; L = LS(x === 'dup' || x === 'dupHidden' ? S1 : S5); ov = ov.concat(atOv()) }
|
if (v === 'pm') ovl = ovl.concat(pmOv() || [])
|
||||||
else if (v === 'pm') { if (x === 'conflict') { o.dirty = 1 } ; ov = ov.concat(pmOv() || []) }
|
if (v === 'perm') {
|
||||||
if (!main) main = o.loading ? D('flex:1;padding:24px;display:flex;flex-direction:column;gap:14px', Sk(6)) : Editor(L, o, extra)
|
const cp = { ro: CAPS.ro, roMenu: CAPS.ro, p2: CAPS.p2, p3: CAPS.p3, p4: CAPS.full, p5: CAPS.full, p6: CAPS.p6, noAdd: CAPS.full, withAdd: CAPS.full }[x] || CAPS.full
|
||||||
|
o.cap = cp
|
||||||
|
if (x === 'noAdd' || x === 'withAdd') L = LV('fr', 'French', 'FR', FX.STORIES[1])
|
||||||
|
if (x === 'ro' || x === 'roMenu') bn.push(Alert('neutral', 'lock', 'You can read this version. You can\'t edit it.', 'The source reference and the versions you can read stay available.'))
|
||||||
|
if (x === 'p2') bn.push(Alert('neutral', 'info', 'Publishing needs the publish permission', 'Saving keeps it as a draft.'))
|
||||||
|
if (x === 'p3') bn.push(Alert('neutral', 'lock', 'You can publish this version but not edit it', 'Fields are read-only. Publish stays available.'))
|
||||||
|
if (x === 'roMenu') { o.open = 1; pop(MENUS.ro) }
|
||||||
|
if (x === 'p4') { o.open = 1; pop(MENUS.p4) }
|
||||||
|
if (x === 'p5') { o.open = 1; pop(MENUS.p5) }
|
||||||
|
if (x === 'noAdd') { o.open = 1; pop(MENUS.noAdd) }
|
||||||
|
if (x === 'withAdd') { o.open = 1; pop(MENUS.withAdd) }
|
||||||
|
if (x === 'p6') ovl.push(OvMenu(OvItems(L, o)))
|
||||||
|
}
|
||||||
|
ov = ovl
|
||||||
|
if (!main) {
|
||||||
|
const cap = o.cap, S0 = o.S0 || EN
|
||||||
|
if (en) { L = EN }
|
||||||
|
if (m && o.src) bn.push(Seg(!!o.srcMob, L, S0))
|
||||||
|
if (o.src && (!m || o.srcMob)) srcPane = D((m ? 'flex:1 0 100%;padding:16px' : 'width:' + (o.narrow || nr ? '42%' : '50%') + ';padding:24px') + ';flex-shrink:0;min-width:0;border-right:1px solid var(--ui-border);overflow:hidden;display:flex;flex-direction:column;background:var(--ui-bg-muted)', Src(S0, o))
|
||||||
|
if (bn.length) edBanner = D('flex-shrink:0;display:flex;flex-direction:column;gap:8px;padding:12px ' + (m ? 16 : 24) + 'px;border-bottom:1px solid var(--ui-border)', ...bn)
|
||||||
|
const ps = o.long ? L.p.concat(L.p, L.p) : L.p
|
||||||
|
edBodyEl = A('div', { dir: L.dir, lang: L.lang, 'aria-readonly': !cap.e || undefined }, !cap.e ? 'color:var(--ui-text-toned)' : '', ...ps.map(q => A('p', { dir: L.dir }, 'margin:0 0 20px;font-size:' + (m ? 17 : 18) + 'px;line-height:1.66', q)))
|
||||||
|
const sv = o.saving ? 'saving' : o.saveFail ? 'error' : o.dirty ? 'dirty' : 'clean'
|
||||||
|
edHeader = Hdr(L, o); edMobBar = MBar(L, o)
|
||||||
|
Object.assign(shell, { kind: 'editor', crumbs: ['Articles', L.t], mTitle: 'Edit article', mLead: 'arrow-left', ...(nr ? { w: 1024, h: 768 } : {}), ed: { readonly: !cap.e, status: o.pub === 'pub' ? 'online' : 'draft', save: sv, title: L.t, loading: !!o.loading, len: 3200, seoSummary: v === 'ed' ? '1 suggestion' : 'Ready', lang: { name: L.n, code: L.c, open: !!o.open, ro: !cap.e, fresh: o.fresh || '' } } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { srcPane: null, edBanner: null, shell, mob: m, main, ov: ov.length ? D('position:absolute;inset:0;pointer-events:none', ...ov.map(e => D('pointer-events:none', e))) : null }
|
if (nr && shell.kind !== 'editor') Object.assign(shell, { w: 1024, h: 768 })
|
||||||
|
return { srcPane, edBanner, edBodyEl, edHeader, edMobBar, shell, mob: m, main, ov: ov.length ? D('position:absolute;inset:0;pointer-events:none', ...ov.map(e => D('pointer-events:none', e))) : null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">404</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">This article doesn't exist</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px">It may have been deleted, or the link is wrong.</span><span data-act="nav-articles" style="align-self:flex-start;height:{{ btnH }};padding:0 12px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">All articles</span></div>
|
<div style="padding:{{ pad }};display:flex;flex-direction:column;gap:14px;max-width:560px"><span style="font:500 13px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">404</span><span style="font:600 24px/32px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">This article doesn't exist</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px">It may have been deleted, or the link is wrong.</span><span data-act="nav-articles" style="align-self:flex-start;height:{{ btnH }};padding:0 12px;display:flex;align-items:center;background:var(--ui-primary);color:var(--ui-bg);font-weight:500">All articles</span></div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
<sc-if value="{{ edFound }}">
|
<sc-if value="{{ edFound }}">
|
||||||
<div style="flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:{{ edHeadPad }};display:flex;flex-wrap:wrap;align-items:center;gap:{{ edHeadGap }}">
|
<div style="flex-shrink:0;background:var(--ui-bg);border-bottom:1px solid var(--ui-border);padding:{{ edHeadPad }};display:flex;flex-wrap:wrap;align-items:center;gap:{{ edHeadGap }}">{{ edHeader }}<sc-if value="{{ edDefHead }}">
|
||||||
<sc-if value="{{ desk }}"><span data-act="back" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat"></span></span></sc-if>
|
<sc-if value="{{ desk }}"><span data-act="back" style="width:32px;height:32px;display:flex;align-items:center;justify-content:center;color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/arrow-left.svg') center / contain no-repeat"></span></span></sc-if>
|
||||||
<div style="flex:1 1 {{ edTitleBasis }};min-width:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
<div style="flex:1 1 {{ edTitleBasis }};min-width:0;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
<sc-if value="{{ s.ed.loading }}"><span style="width:320px;max-width:70%;height:20px;background:var(--ui-bg-accented)"></span><span style="width:60px;height:22px;background:var(--ui-bg-accented)"></span></sc-if>
|
<sc-if value="{{ s.ed.loading }}"><span style="width:320px;max-width:70%;height:20px;background:var(--ui-bg-accented)"></span><span style="width:60px;height:22px;background:var(--ui-bg-accented)"></span></sc-if>
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ delText }}</span>
|
<span data-act="overflow" role="button" aria-label="Delete article" style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;white-space:nowrap;flex-shrink:0;font-weight:500;border:1px solid var(--ui-error);color:var(--ui-error);cursor:pointer"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/trash-2.svg') center / contain no-repeat"></span>{{ delText }}</span>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</div>
|
</sc-if></div>
|
||||||
{{ edBanner }}<sc-if value="{{ showToolbar }}">
|
{{ edBanner }}<sc-if value="{{ showToolbar }}">
|
||||||
<div role="toolbar" aria-label="Formatting" style="flex-shrink:0;padding:6px {{ tbPadX }};display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);position:relative;overflow:{{ tbOverflow }};background:var(--ui-bg)">
|
<div role="toolbar" aria-label="Formatting" style="flex-shrink:0;padding:6px {{ tbPadX }};display:flex;align-items:center;gap:2px;border-bottom:1px solid var(--ui-border);position:relative;overflow:{{ tbOverflow }};background:var(--ui-bg)">
|
||||||
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0;transform:translateX({{ tbShift }})"><sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="20"><sc-if value="{{ t.sep }}"><span style="width:1px;height:20px;background:var(--ui-border-accented);margin:0 6px;flex-shrink:0"></span></sc-if><sc-if value="{{ t.btn }}"><span data-act="tb-{{ t.icon }}" style="height:{{ tbBtn }};min-width:{{ tbBtn }};padding:0 6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:4px;position:relative;color:{{ t.fg }};opacity:{{ t.op }};outline:{{ t.ring }};outline-offset:1px;background:{{ t.bg }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat"></span>{{ t.text }}<sc-if value="{{ t.tip }}"><span role="tooltip" style="position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;z-index:30;white-space:nowrap;padding:4px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:400 12px/16px 'IBM Plex Sans',sans-serif">{{ t.tip }}</span></sc-if></span></sc-if></sc-for></div><sc-if value="{{ tbFadeL }}"><span style="position:absolute;left:0;top:0;bottom:0;width:24px;background:linear-gradient(to right,var(--ui-bg),transparent);pointer-events:none"></span></sc-if><sc-if value="{{ tbFadeR }}"><span style="position:absolute;right:0;top:0;bottom:0;width:32px;background:linear-gradient(to left,var(--ui-bg),transparent);pointer-events:none"></span></sc-if>
|
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0;transform:translateX({{ tbShift }})"><sc-for list="{{ toolbar }}" as="t" hint-placeholder-count="20"><sc-if value="{{ t.sep }}"><span style="width:1px;height:20px;background:var(--ui-border-accented);margin:0 6px;flex-shrink:0"></span></sc-if><sc-if value="{{ t.btn }}"><span data-act="tb-{{ t.icon }}" style="height:{{ tbBtn }};min-width:{{ tbBtn }};padding:0 6px;flex-shrink:0;display:flex;align-items:center;justify-content:center;gap:4px;position:relative;color:{{ t.fg }};opacity:{{ t.op }};outline:{{ t.ring }};outline-offset:1px;background:{{ t.bg }}"><span aria-hidden="true" style="display:inline-block;width:16px;height:16px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ t.icon }}.svg') center / contain no-repeat"></span>{{ t.text }}<sc-if value="{{ t.tip }}"><span role="tooltip" style="position:absolute;top:100%;left:50%;transform:translateX(-50%);margin-top:6px;z-index:30;white-space:nowrap;padding:4px 8px;background:var(--ui-bg-inverted);color:var(--ui-text-inverted);font:400 12px/16px 'IBM Plex Sans',sans-serif">{{ t.tip }}</span></sc-if></span></sc-if></sc-for></div><sc-if value="{{ tbFadeL }}"><span style="position:absolute;left:0;top:0;bottom:0;width:24px;background:linear-gradient(to right,var(--ui-bg),transparent);pointer-events:none"></span></sc-if><sc-if value="{{ tbFadeR }}"><span style="position:absolute;right:0;top:0;bottom:0;width:32px;background:linear-gradient(to left,var(--ui-bg),transparent);pointer-events:none"></span></sc-if>
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
<div style="font-size:{{ h2F }};line-height:1.3;font-weight:600;margin:32px 0 12px">What goes into the price</div>
|
<div style="font-size:{{ h2F }};line-height:1.3;font-weight:600;margin:32px 0 12px">What goes into the price</div>
|
||||||
<ul style="margin:0 0 20px;padding-left:28px;list-style:square;font-size:{{ pF }};line-height:1.66"><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>
|
<ul style="margin:0 0 20px;padding-left:28px;list-style:square;font-size:{{ pF }};line-height:1.66"><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>
|
||||||
<sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if>
|
<sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if>
|
||||||
<p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We don't add a margin on materials you choose yourself.<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p></sc-if><sc-if value="{{ edBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">{{ edBody }}<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p><sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if></sc-if>
|
<p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">We don't add a margin on materials you choose yourself.<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p></sc-if>{{ edBodyEl }}<sc-if value="{{ edBody }}"><p style="margin:0 0 20px;font-size:{{ pF }};line-height:1.66">{{ edBody }}<sc-if value="{{ caret }}"><span style="display:inline-block;width:2px;height:1.1em;background:var(--ui-text-highlighted);vertical-align:-3px;margin-left:1px"></span></sc-if></p><sc-if value="{{ s.ed.image }}"><div style="margin:28px 0;aspect-ratio:16/9;background:var(--ui-bg-elevated);border:1px dashed var(--ui-border-accented);display:flex;align-items:center;justify-content:center;color:var(--ui-text-muted);font:400 13px 'IBM Plex Sans',sans-serif">/static/uploadedImages/3f9c…e1.jpg</div></sc-if></sc-if>
|
||||||
</div>
|
</div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,12 +200,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</div>
|
</div>
|
||||||
<sc-if value="{{ mob }}"><sc-if value="{{ edReady }}">
|
<sc-if value="{{ mob }}">{{ edMobBar }}<sc-if value="{{ edDefMob }}"><sc-if value="{{ edReady }}">
|
||||||
<div style="flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:8px">
|
<div style="flex-shrink:0;padding:12px 16px;border-top:1px solid var(--ui-border);background:var(--ui-bg);display:flex;flex-direction:column;gap:8px">
|
||||||
<sc-if value="{{ counter.msg }}"><span style="color:{{ counter.fg }};font-size:13px;line-height:19px">{{ counter.msg }}</span></sc-if>
|
<sc-if value="{{ counter.msg }}"><span style="color:{{ counter.fg }};font-size:13px;line-height:19px">{{ counter.msg }}</span></sc-if>
|
||||||
<div style="display:flex;gap:8px"><span data-act="details" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat"></span></span><span data-act="save" style="flex:1;height:44px;padding:0 12px;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:15px;opacity:{{ saveOp }}">Save</span><span data-act="publish" style="flex:1 0 auto;padding:0 16px;white-space:nowrap;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500;font-size:15px;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}">{{ pub.t }}</span></div>
|
<div style="display:flex;gap:8px"><span data-act="details" style="width:44px;height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-toned)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;width:18px;height:18px;background-color:currentColor;-webkit-mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/panel-right.svg') center / contain no-repeat"></span></span><span data-act="save" style="flex:1;height:44px;padding:0 12px;white-space:nowrap;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:15px;opacity:{{ saveOp }}">Save</span><span data-act="publish" style="flex:1 0 auto;padding:0 16px;white-space:nowrap;height:44px;display:flex;align-items:center;justify-content:center;gap:6px;font-weight:500;font-size:15px;background:{{ pub.bg }};color:{{ pub.fg }};border:1px solid {{ pub.bd }};opacity:{{ pub.op }}">{{ pub.t }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</sc-if></sc-if>
|
</sc-if></sc-if></sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
|
|
||||||
@@ -552,9 +552,9 @@ class Component extends DCLogic {
|
|||||||
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
langSeg: [['en','English'],['fr','Français']].map(([k,tt])=>{const on=(s.lang||'en')===k;return {act:'lang-'+k,code:k,t:tt,on,op:on?1:0}}), themeSeg: [['system','monitor','System'],['light','sun','Light'],['dark','moon','Dark']].map(([k,icon,tt])=>{const on=(s.mode||'light')===k;return {act:'theme-'+k,icon,t:tt,on,op:on?1:0}}), menuOpen: !!s.userMenu, deskMenu: !m && !!s.userMenu, mobMenu: m && !!s.userMenu, pfItemBg: 'var(--ui-bg-elevated)',
|
||||||
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
chipBg: s.userMenu || s.nav === 'profile' ? 'var(--rc-sand)' : 'transparent', chipFg: s.userMenu || s.nav === 'profile' ? 'var(--rc-on-sand)' : 'var(--ui-text-highlighted)',
|
||||||
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
isSettings: k === 'settings', isProfile: k === 'profile', pfLoading: k === 'profile' && !!pf.loading, pfReady: k === 'profile' && !pf.loading,
|
||||||
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', sampleBody: !e.body, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
upBtnDir: m ? 'column' : 'row', upBtnAlign: m ? 'stretch' : 'flex-start', cardPad: '16px', cardPadX: m ? '16px' : '20px', inH: m ? '44px' : '40px', pfCols: m ? 'minmax(0,1fr)' : 'minmax(0,1fr) minmax(0,1.4fr)', pwDir: m ? 'column' : 'row', pwAlign: m ? 'stretch' : 'flex-end', edTitle, edBody: e.body || '', edBodyEl: this.props.edBodyEl || null, sampleBody: !e.body && !this.props.edBodyEl, tabs, rows, save, counter, pub, mFields, up, toasts, endpoints, toolbar,
|
||||||
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
|
contentShow: CT.length > 0, contentRows, contentColl: m && !s.contentOpen, contentCount: String(CT.length), contentMore: (!m || s.contentOpen) && CT.length > ctVis.length ? 'Show all ' + CT.length : '', desk: !m, mob: m, notBoot: !s.boot,
|
||||||
fw: m ? '390px' : '1440px', fh: m ? '844px' : '900px', dir: m ? 'column' : 'row',
|
fw: m ? '390px' : (s.w ? s.w + 'px' : '1440px'), fh: m ? '844px' : (s.h ? s.h + 'px' : '900px'), dir: m ? 'column' : 'row',
|
||||||
mLead: s.mLead || 'menu',
|
mLead: s.mLead || 'menu',
|
||||||
navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
navDrawer: m && !!s.drawer, integSearch: k === 'integration' && !!s.search, isSlot: !!this.props.slotBody, slotBody: this.props.slotBody || null, slotOverlays: this.props.slotOverlays || null, isBoot: !!s.boot, isHome: k === 'home' && !s.boot, isList: k === 'articles', isEditor: k === 'editor', isInteg: k === 'integration', isKeys: k === 'apikeys', isError: k === 'error', isUsers: k === 'users',
|
||||||
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
usRows, usError: !!US.error, usOnly: !!US.only && !US.error, usTable: !m && !US.error, usCards: m && !US.error,
|
||||||
@@ -581,7 +581,7 @@ class Component extends DCLogic {
|
|||||||
emptyBg: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-bg)', emptyFg: L.empty && L.empty.primary ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', emptyBd: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
|
emptyBg: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-bg)', emptyFg: L.empty && L.empty.primary ? 'var(--ui-bg)' : 'var(--ui-text-highlighted)', emptyBd: L.empty && L.empty.primary ? 'var(--ui-primary)' : 'var(--ui-border-accented)',
|
||||||
edFound: k === 'editor' && !e.notfound, edReady, edPending: edReady && !!e.pending, edOnline: edReady && !e.pending && online, edDraft: edReady && !e.pending && !online,
|
edFound: k === 'editor' && !e.notfound, edReady, edPending: edReady && !!e.pending, edOnline: edReady && !e.pending && online, edDraft: edReady && !e.pending && !online,
|
||||||
edHeadPad: m ? '12px 16px' : '14px 24px', edHeadGap: m ? '8px 12px' : '12px', edTitleBasis: m ? '100%' : '200px',
|
edHeadPad: m ? '12px 16px' : '14px 24px', edHeadGap: m ? '8px 12px' : '12px', edTitleBasis: m ? '100%' : '200px',
|
||||||
showToolbar: k === 'editor' && !e.notfound && !e.preview,
|
showToolbar: k === 'editor' && !e.notfound && !e.preview && !e.readonly, edDefHead: !this.props.edHeader, edDefMob: !this.props.edMobBar, edHeader: this.props.edHeader || null, edMobBar: this.props.edMobBar || null,
|
||||||
tbPadX: m ? '8px' : '24px', tbBtn: m ? '40px' : '28px',
|
tbPadX: m ? '8px' : '24px', tbBtn: m ? '40px' : '28px',
|
||||||
edBg: e.preview ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', edPad: m ? '24px 16px' : '40px 48px', edMargin: e.preview ? '0 auto' : '0',
|
edBg: e.preview ? 'var(--ui-bg-muted)' : 'var(--ui-bg)', edPad: m ? '24px 16px' : '40px 48px', edMargin: e.preview ? '0 auto' : '0',
|
||||||
h1F: m ? '28px' : '32px', h2F: m ? '23px' : '26px', pF: m ? '17px' : '18px', caret: !e.preview,
|
h1F: m ? '28px' : '32px', h2F: m ? '23px' : '26px', pF: m ? '17px' : '18px', caret: !e.preview,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
||||||
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
||||||
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: article localization</h1>
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: article localization</h1>
|
||||||
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">One story, several language versions. Proposed feature: every API change is labelled as proposed. 1440 px at 50%, 390 px at 75%. The theme switch shows every state in dark mode. Imported localization reference. Product and API corrections are in docs/design/localization.md. Rendered parity and prototype flows remain unverified.</p>
|
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">One story, several language versions. Proposed feature: every API change is labelled as proposed. 1440 px at 50%, 1024 px at 50% for layout-dependent states, 390 px at 75%. The theme switch shows every state in dark mode. Each state shows its ID and fixture. Groups L6 and L9 are release 2. Repository code was unavailable, so existing behaviour comes from the design project only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
||||||
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
||||||
@@ -33,15 +33,16 @@
|
|||||||
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
||||||
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
||||||
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
||||||
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span><span style="color:var(--ui-text-muted)">{{ g.count }}{{ g.rel }}</span></div>
|
||||||
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
||||||
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<div style="display:flex;flex-direction:column;gap:12px">
|
||||||
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span><span style="font:400 12px/18px 'IBM Plex Mono',monospace;color:var(--ui-text-muted)">{{ st.id }} · frame {{ st.sel }} · fixture {{ st.fx }}</span></div>
|
||||||
<div style="display:flex;gap:24px;align-items:flex-start">
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
||||||
<div style="width:{{ st.dw }};height:{{ st.dh }};overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:{{ st.fw }};height:{{ st.fh }};zoom:{{ st.z }}"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
<div style="width:{{ st.dw }};height:{{ st.dh }};overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:{{ st.fw }};height:{{ st.fh }};zoom:{{ st.z }}"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ false }}" hint-size="1440px,900px"></dc-import></div></div>
|
||||||
|
<sc-if value="{{ st.tri }}"><div style="width:512px;height:384px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:1024px;height:768px;zoom:0.5"><dc-import name="LocaleFrame" s="{{ st.s2 }}" mobile="{{ false }}" hint-size="1024px,768px"></dc-import></div></div></sc-if>
|
||||||
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0"><div style="width:390px;height:844px;zoom:0.75"><dc-import name="LocaleFrame" s="{{ st.s }}" mobile="{{ true }}" hint-size="390px,844px"></dc-import></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,13 +77,28 @@ class Component extends DCLogic {
|
|||||||
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
groups() { return window.LOC.groups.map(g => ({ ...g, label: g.id + ' ' + g.title, states: g.states.map(a => ({ name: a[0], trigger: a[1], dw: a[4]==='narrow'?'717px':'720px', dh: a[4]==='narrow'?'538px':'450px', fw: a[4]==='narrow'?'1024px':'1440px', fh: a[4]==='narrow'?'768px':'900px', z: a[4]==='narrow'?0.7:0.5, s: { v: a[2], x: a[3] }, nar: a[4] === 'narrow' })) })) }
|
groups() { return window.LOC.groups.map(g => ({ ...g, count: g.states.length + ' states · ', rel: g.release === 2 ? 'Release 2' : 'Release 1', label: g.id + ' ' + g.title, states: g.states.map(a => { const nar = a[4] === 'narrow', tri = a[4] === 'tri'; return { name: a[0], trigger: a[1], id: a[5], fx: a[6], sel: 'LocaleFrame{v:' + a[2] + ',x:' + (a[3] || "''") + '}', tri, dw: nar ? '717px' : '720px', dh: nar ? '538px' : '450px', fw: nar ? '1024px' : '1440px', fh: nar ? '768px' : '900px', z: nar ? 0.7 : 0.5, s: { v: a[2], x: a[3], narrow: nar }, s2: { v: a[2], x: a[3], narrow: true } } }) })) }
|
||||||
|
ratio(a, b2) { const h = c => { c = c.replace('#', ''); return [0, 2, 4].map(i => parseInt(c.slice(i, i + 2), 16)) }, L = rgb => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) }; return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2]) }, x = L(h(a)), y = L(h(b2)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) }
|
||||||
|
mix(fg, bg, p) { const h = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16)), f = h(fg), g = h(bg); return '#' + f.map((v, i) => Math.round(v * p + g[i] * (1 - p)).toString(16).padStart(2, '0')).join('') }
|
||||||
|
computed() {
|
||||||
|
const FX = window.LOCFX, LOC = window.LOC, J = FX.SITES.journal
|
||||||
|
const jv = FX.view({ site: J }), p2 = FX.view({ site: J, page: 2 }), fr = FX.view({ site: J, lang: 'fr' }), frm = FX.view({ site: J, lang: 'fr', tab: 'missing' }), rv = FX.view({ site: J, tab: 'review' }), au = FX.view({ site: J, author: 'Marta Alves' })
|
||||||
|
const cnt = k => FX.count(k, J.stories), ver = J.stories.reduce((a, s) => a + Object.keys(s.v).length, 0), eq = (a, b2) => a === b2 ? 'Matches' : 'MISMATCH'
|
||||||
|
const checks = [['Articles', 8, J.stories.length], ['Versions', 17, ver], ['Page size', 5, jv.size], ['Page 1 rows', 5, jv.rows.length], ['Page 2 rows', 3, p2.rows.length], ['Pages', 2, jv.pages], ['en versions / published', '7 / 6', cnt('en').join(' / ')], ['fr versions / published', '4 / 2', cnt('fr').join(' / ')], ['fr needing review', 2, fr.tabs[3][1]], ['de versions / published', '4 / 3', cnt('de').join(' / ')], ['ar versions', 1, cnt('ar')[0]], ['es versions', 1, cnt('es')[0]], ['it versions', 0, cnt('it')[0]], ['Articles without French', 4, frm.total], ['Needs translation tab', 7, jv.tabs[1][1]], ['Needs review tab', 2, rv.total], ['Author Marta Alves', 3, au.total], ['Setup articles (Journal + Notes + Archive)', 16, FX.SETUP.reduce((a, r) => a + r.n, 0)], ['Press: not in French', 0, FX.view({ site: FX.SITES.press, lang: 'fr', tab: 'missing' }).total]]
|
||||||
|
const checkRows = checks.map(c => [c[0], String(c[1]), String(c[2]), eq(String(c[1]), String(c[2]))])
|
||||||
|
const byFx = {}; LOC.groups.forEach(g => g.states.forEach(a => { (byFx[a[6]] = byFx[a[6]] || []).push(a[5]) }))
|
||||||
|
const fxRows = Object.entries(byFx).map(([k, v]) => [k, FX.FIXTURE_NOTES[k.split(',')[0].replace(' (limited scope)', '').trim()] || '', v.length + ' states', v.length > 12 ? v.slice(0, 8).join(', ') + ' and ' + (v.length - 8) + ' more (every state not listed under another fixture)' : v.join(', ')])
|
||||||
|
const cl = ['--ui-success', '--rc-success-soft'], ct = (m2) => { const t = m2 === 'dark' ? this.dark : this.light; const pr = [['Published badge', t['--ui-success'], t['--rc-success-soft'], 4.5], ['Info badge (Default, Tracked source)', t['--ui-info'], t['--rc-info-soft'], 4.5], ['Warning badge', t['--ui-warning'], t['--rc-warning-soft'], 4.5], ['Error badge', t['--ui-error'], t['--rc-error-soft'], 4.5], ['Neutral badge (Draft, Read only)', t['--ui-text-toned'], t['--ui-bg-elevated'], 4.5], ['Freshness badge (outlined)', t['--ui-warning'], t['--ui-bg'], 4.5], ['Selected row and menu item', t['--rc-on-sand'], t['--rc-sand'], 4.5], ['Coverage chip', t['--ui-text-highlighted'], t['--ui-bg'], 4.5], ['Dashed missing chip text', t['--ui-text-muted'], t['--ui-bg'], 4.5], ['Disabled-language chip (75%)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .75), t['--ui-bg'], 4.5], ['Primary button', t['--ui-bg'], t['--ui-primary'], 4.5], ['Disabled menu item (60%, exempt)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .6), t['--ui-bg'], 4.5], ['Disabled button (40%, exempt)', this.mix(t['--ui-text-highlighted'], t['--ui-bg'], .4), t['--ui-bg'], 4.5], ['Dashed and input borders (non-text)', t['--ui-border-accented'], t['--ui-bg'], 3]]; return pr.map(r => [r[0], this.ratio(r[1], r[2]).toFixed(2), r[3]]) }
|
||||||
|
const L = ct('light'), D = ct('dark'), conRows = L.map((r, i) => [r[0], r[1], D[i][1], r[2] + ':1', (+r[1] >= r[2] && +D[i][1] >= r[2]) ? 'Pass' : (/exempt/.test(r[0]) ? 'Exempt (disabled)' : 'FAIL')])
|
||||||
|
const cntRows = LOC.groups.map(g => [g.id + ' ' + g.title, String(g.states.length), String(g.states.filter(a => a[4] === 'tri').length), g.release === 2 ? 'Release 2' : 'Release 1']).concat([['Total', String(LOC.groups.reduce((a, g) => a + g.states.length, 0)), String(LOC.groups.reduce((a, g) => a + g.states.filter(a2 => a2[4] === 'tri').length, 0)), '']])
|
||||||
|
return [{ title: 'Fixture index', cols: ['Fixture', 'Contents', 'Used by', 'State IDs'], w: '1.2fr 2fr .8fr 3fr', rows: fxRows }, { title: 'Fixture totals check (computed from locale-fixtures.js now)', cols: ['Figure', 'Decided', 'Computed', 'Result'], w: '2fr 1fr 1fr 1fr', rows: checkRows }, { title: 'State counts per group', cols: ['Group', 'States', 'Also at 1024', 'Release'], w: '3fr 1fr 1fr 1fr', rows: cntRows }, { title: 'Contrast of badges, chips and variants (computed from the tokens)', cols: ['Element', 'Light', 'Dark', 'Needs', 'Result'], w: '3fr 1fr 1fr 1fr 1.4fr', rows: conRows }]
|
||||||
|
}
|
||||||
renderVals() {
|
renderVals() {
|
||||||
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
||||||
const on = this.state.mode === k
|
const on = this.state.mode === k
|
||||||
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
||||||
})
|
})
|
||||||
return { rootRef: this.rootRef, modes, groups: this.groups(), tables: window.LOC.tables }
|
return { rootRef: this.rootRef, modes, groups: this.groups(), tables: window.LOC.tables.concat(this.computed()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Shared localization fixtures. One source for the Localization board (LocaleFrame), the Prototype (LocaleLive) and the Handoff.
|
||||||
|
// Dataset "Journal": 8 stories, 17 versions. Every count shown anywhere is derived from STORIES or LANGS.
|
||||||
|
// Separate fixtures (marked): PRESS (all stories translated, for the nothing-missing state), JOURNAL_PRE_FR (prototype start: French not enabled yet).
|
||||||
|
(function () {
|
||||||
|
const P = {
|
||||||
|
en: ['Most quotes fail because they hide the same three costs. Here is how we itemise them before the first tile is ordered.', 'Start with the room, not the catalogue. Measure the walls, note where the pipes run and photograph the existing fixtures.', 'Next, separate what is fixed from what is chosen. Plumbing and waterproofing are fixed. Tiles and taps are chosen.', 'Price the fixed items first and ask for a written range. A range with a stated assumption is more useful than a single figure.', 'Allow ten percent for what the walls reveal. Old bathrooms almost always hide one surprise behind the tiles.', 'Check the floor falls toward the drain before any membrane goes down. Correcting a fall after tiling means lifting everything.', 'Choose the tiles after the layout is drawn. A tile that needs many cuts costs more in labour than it saves in material.', 'Ask the tiler to confirm the adhesive and grout for the wet area in writing, and keep the data sheets with the quote.', 'Agree who protects the hallway floor and who removes the old bath. These small jobs are the ones that start disputes.', 'Finally, agree the order of work with the tiler and the plumber before anything is ordered.'],
|
||||||
|
fr: ['La plupart des devis échouent parce qu\'ils cachent les trois mêmes coûts. Voici comment nous les détaillons avant de commander le premier carreau.', 'Commencez par la pièce, pas par le catalogue. Mesurez les murs, repérez le passage des canalisations et photographiez les équipements existants.', 'Séparez ensuite ce qui est fixe de ce qui est choisi. La plomberie et l\'étanchéité sont fixes. Les carreaux et la robinetterie sont choisis.', 'Chiffrez d\'abord les postes fixes et demandez une fourchette écrite.'],
|
||||||
|
de: ['Die meisten Angebote scheitern, weil sie dieselben drei Kosten verschweigen. So listen wir sie auf, bevor die erste Fliese bestellt wird.', 'Beginnen Sie mit dem Raum, nicht mit dem Katalog. Messen Sie die Wände und halten Sie fest, wo die Rohre verlaufen.'],
|
||||||
|
ar: ['تفشل معظم عروض الأسعار لأنها تُخفي التكاليف الثلاثة نفسها. إليك كيف نفصّلها قبل طلب أول بلاطة.', 'ابدأ بالغرفة لا بالكتالوج. قِس الجدران، وحدّد مسار الأنابيب، وصوّر التركيبات الحالية.', 'افصل بعد ذلك ما هو ثابت عمّا هو مختار. السباكة والعزل ثابتان، أما البلاط والصنابير فمختارة.']
|
||||||
|
}
|
||||||
|
const LANG_NAMES = { en: 'English', fr: 'French', de: 'German', ar: 'Arabic', es: 'Spanish', it: 'Italian', 'pt-BR': 'Portuguese (Brazil)', 'en-GB': 'English (United Kingdom)' }
|
||||||
|
const DIR = { ar: 'rtl' }
|
||||||
|
// v: lang -> [status, needsReview, revision]
|
||||||
|
const mk = (id, title, v, tr, by, upd) => ({ id, title, v, tr: tr || {}, by, upd })
|
||||||
|
const STORIES = [
|
||||||
|
mk('s1', 'How we price a bathroom renovation', { en: ['pub', 0, 8], fr: ['draft', 1, 5], de: ['pub', 0, 3], ar: ['draft', 1, 2] }, { fr: 'Comment nous chiffrons une rénovation de salle de bain', de: 'So kalkulieren wir eine Badsanierung', ar: 'كيف نسعّر تجديد الحمّام' }, 'Marta Alves', '2 h ago'),
|
||||||
|
mk('s2', 'A guide to lime plaster', { en: ['pub', 0, 4], fr: ['pub', 0, 4], de: ['pub', 0, 2] }, { fr: 'Guide de l\'enduit à la chaux', de: 'Ein Leitfaden zu Kalkputz' }, 'Camille Roche', 'Yesterday'),
|
||||||
|
mk('s3', 'Winter checklist for timber windows', { en: ['pub', 0, 6], fr: ['pub', 1, 3] }, { fr: 'Liste d\'hiver pour les fenêtres en bois' }, 'Luis Ferrer', '3 days ago'),
|
||||||
|
mk('s4', 'Spring newsletter', { en: ['draft', 0, 1] }, {}, 'Marta Alves', '3 days ago'),
|
||||||
|
mk('s5', 'Roof inspection checklist', { en: ['pub', 0, 3], de: ['draft', 0, 1] }, { de: 'Checkliste für die Dachprüfung' }, 'Luis Ferrer', '14 Oct'),
|
||||||
|
mk('s6', 'Why we reuse original floorboards', { en: ['pub', 0, 2], fr: ['draft', 0, 1] }, { fr: 'Pourquoi nous réutilisons les lames de parquet d\'origine' }, 'Camille Roche', '13 Oct'),
|
||||||
|
mk('s7', 'Our first passive-house retrofit', { en: ['pub', 0, 5], de: ['pub', 0, 2] }, { de: 'Unsere erste Passivhaus-Sanierung' }, 'Marta Alves', '12 Oct'),
|
||||||
|
mk('s8', 'Cómo elegir un contratista', { es: ['pub', 0, 2] }, {}, 'Luis Ferrer', '10 Oct')
|
||||||
|
]
|
||||||
|
const ENABLED = ['en', 'fr', 'de', 'ar']
|
||||||
|
const LANGS = [
|
||||||
|
{ name: 'English', code: 'en', def: true, enabled: true, social: 'Not set' },
|
||||||
|
{ name: 'French', code: 'fr', enabled: true, social: 'fr_FR' },
|
||||||
|
{ name: 'German', code: 'de', enabled: true, social: 'Not set' },
|
||||||
|
{ name: 'Arabic', code: 'ar', enabled: true, social: 'Not set' },
|
||||||
|
{ name: 'Spanish', code: 'es', enabled: false, social: 'Not set' },
|
||||||
|
{ name: 'Italian', code: 'it', enabled: false, social: 'Not set' }
|
||||||
|
]
|
||||||
|
const count = (code, stories) => { let n = 0, pub = 0; stories.forEach(s => { const x = s.v[code]; if (x) { n++; if (x[0] === 'pub') pub++ } }); return [n, pub] }
|
||||||
|
const PRESS = [
|
||||||
|
mk('p1', 'Opening hours over the holidays', { en: ['pub', 0, 2], fr: ['pub', 0, 2] }, { fr: 'Horaires pendant les fêtes' }, 'Marta Alves', 'Yesterday'),
|
||||||
|
mk('p2', 'Meet the new site manager', { en: ['pub', 0, 3], fr: ['draft', 1, 1] }, { fr: 'Rencontrez la nouvelle cheffe de chantier' }, 'Luis Ferrer', '3 days ago'),
|
||||||
|
mk('p3', 'Annual report', { en: ['draft', 0, 1], fr: ['pub', 0, 1] }, { fr: 'Rapport annuel' }, 'Marta Alves', '14 Oct')
|
||||||
|
]
|
||||||
|
// Prototype start: French is not enabled, so no French versions exist yet.
|
||||||
|
const preFr = () => STORIES.map(s => { const v = { ...s.v }; delete v.fr; const tr = { ...s.tr }; delete tr.fr; return { ...s, v, tr } })
|
||||||
|
// Setup fixture (before languages exist): articles per site, site locale as stored, the language entry it maps to.
|
||||||
|
const SETUP = [
|
||||||
|
{ site: 'Journal', n: 8, locale: 'en', tag: 'en', name: 'English', note: 'Site locale en' },
|
||||||
|
{ site: 'Notes', n: 3, locale: null, tag: null, name: null, note: 'No site locale' },
|
||||||
|
{ site: 'Archive', n: 5, locale: 'pt-BR', tag: 'pt-BR', name: 'Portuguese (Brazil)', note: 'Site locale pt-BR' }
|
||||||
|
]
|
||||||
|
|
||||||
|
STORIES[3].seo = 1; STORIES[4].seo = 1
|
||||||
|
Object.assign(LANG_NAMES, { 'zh-Hans': 'Chinese (Simplified)', ja: 'Japanese' })
|
||||||
|
const ATELIER = [
|
||||||
|
mk('a1', 'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants', { en: ['pub', 0, 4], 'en-GB': ['draft', 0, 1], fr: ['draft', 1, 2], de: ['pub', 0, 2], 'pt-BR': ['draft', 0, 1], 'zh-Hans': ['pub', 0, 2], ja: ['pub', 0, 2], ar: ['draft', 0, 1] }, {}, 'Marta Alves', '2 h ago'),
|
||||||
|
mk('a2', 'Heat pumps for stone farmhouses', { en: ['pub', 0, 2], 'en-GB': ['pub', 0, 1], fr: ['draft', 0, 1], de: ['pub', 0, 1], ja: ['draft', 0, 1] }, {}, 'Luis Ferrer', 'Yesterday'),
|
||||||
|
mk('a3', 'Sourcing reclaimed oak beams', { en: ['draft', 0, 1], 'en-GB': ['draft', 0, 1] }, {}, 'Camille Roche', '14 Oct')
|
||||||
|
]
|
||||||
|
ATELIER[0].seo = 1; ATELIER[2].seo = 1
|
||||||
|
const ARCHIVE = [['Como restaurar azulejos antigos', 'pub', 'Marta Alves', '3 days ago'], ['Guia de cal e areia', 'pub', 'Camille Roche', '14 Oct'], ['Pintura para fachadas húmidas', 'draft', 'Luis Ferrer', '13 Oct'], ['Orçamento de uma reabilitação', 'pub', 'Marta Alves', '12 Oct'], ['Telhados de telha cerâmica', 'pub', 'Camille Roche', '10 Oct']].map((r, i) => mk('r' + (i + 1), r[0], { 'pt-BR': [r[1], 0, 1] }, {}, r[2], r[3]))
|
||||||
|
const NOTES = [['Site visit notes', 'Luis Ferrer', '3 days ago'], ['Supplier call summary', 'Marta Alves', '14 Oct'], ['Tile samples list', 'Camille Roche', '10 Oct']].map((r, i) => mk('n' + (i + 1), r[0], {}, {}, r[1], r[2]))
|
||||||
|
const SITES = {
|
||||||
|
journal: { name: 'Journal', stories: STORIES, langs: ['en', 'fr', 'de', 'ar'], disabled: ['es', 'it'], def: 'en', size: 5, locale: 'en' },
|
||||||
|
press: { name: 'Press', stories: PRESS, langs: ['en', 'fr'], disabled: [], def: 'en', size: 5, locale: 'en' },
|
||||||
|
atelier: { name: 'Atelier', stories: ATELIER, langs: ['en', 'en-GB', 'fr', 'de', 'pt-BR', 'zh-Hans', 'ja', 'ar'], disabled: [], def: 'en', size: 5, locale: 'en' },
|
||||||
|
archive: { name: 'Archive', stories: ARCHIVE, langs: ['pt-BR'], disabled: [], def: 'pt-BR', size: 5, locale: 'pt-BR' },
|
||||||
|
notes: { name: 'Notes', stories: NOTES, langs: [], disabled: [], def: null, size: 5, locale: null },
|
||||||
|
gallery: { name: 'Gallery', stories: [], langs: ['en-GB'], disabled: [], def: 'en-GB', size: 5, locale: 'en-GB' }
|
||||||
|
}
|
||||||
|
const pl = (n, w) => n + ' ' + w + (n === 1 ? '' : 's')
|
||||||
|
const tt = (s, k) => s.tr[k] || s.title
|
||||||
|
const repKey = (s, def) => s.v[def] ? def : Object.keys(s.v).sort()[0]
|
||||||
|
const view = o => {
|
||||||
|
const site = o.site, en = site.langs, lang = o.lang || null, create = o.create || en, st = o.status, au = o.author, q = (o.q || '').toLowerCase(), seo = o.seo, size = o.size || site.size
|
||||||
|
const ss = site.stories.map(s => { const v = {}; Object.keys(s.v).forEach(k => { if (!o.read || o.read.includes(k)) v[k] = s.v[k] }); return { ...s, v } }).filter(s => Object.keys(s.v).length && (!o.scope || o.scope(s)))
|
||||||
|
const pass = (s, vs, ks) => (!st || vs.some(x => x[0] === st)) && (!au || s.by === au) && (!seo || s.seo) && (!q || ks.some(k => tt(s, k).toLowerCase().includes(q)))
|
||||||
|
const all = s => pass(s, Object.values(s.v), Object.keys(s.v))
|
||||||
|
let tabs, rowsBy, LN = lang ? LANG_NAMES[lang] : ''
|
||||||
|
if (lang) {
|
||||||
|
const inL = ss.filter(s => s.v[lang] && pass(s, [s.v[lang]], [lang])), miss = ss.filter(s => !s.v[lang] && all(s))
|
||||||
|
rowsBy = { all: inL, pub: inL.filter(s => s.v[lang][0] === 'pub'), draft: inL.filter(s => s.v[lang][0] === 'draft'), review: inL.filter(s => s.v[lang][1]), missing: miss }
|
||||||
|
tabs = [['All', 'all'], ['Published', 'pub'], ['Drafts', 'draft'], ['Needs review', 'review'], ['Not in ' + LN, 'missing']]
|
||||||
|
} else {
|
||||||
|
const b = ss.filter(all)
|
||||||
|
rowsBy = { all: b, missing: b.filter(s => create.some(k => en.includes(k) && !s.v[k])), review: b.filter(s => Object.values(s.v).some(x => x[1])) }
|
||||||
|
tabs = [['All articles', 'all'], ['Needs translation', 'missing'], ['Needs review', 'review']]
|
||||||
|
}
|
||||||
|
const tab = o.tab || 'all', rows = rowsBy[tab], total = rows.length, pages = Math.max(1, Math.ceil(total / size)), page = Math.min(o.page || 1, pages), from = total ? (page - 1) * size + 1 : 0, to = Math.min(total, page * size)
|
||||||
|
const unit = lang ? ({ all: pl(total, LN + ' version'), pub: 'published ' + LN + ' version' + (total === 1 ? '' : 's'), draft: pl(total, LN + ' draft'), review: pl(total, LN + ' version') + ' ' + (total === 1 ? 'needs' : 'need') + ' review', missing: pl(total, 'article') + ' not in ' + LN })[tab] : ({ all: pl(total, 'article'), missing: pl(total, 'article') + ' ' + (total === 1 ? 'needs' : 'need') + ' translation', review: pl(total, 'article') + ' ' + (total === 1 ? 'needs' : 'need') + ' review' })[tab]
|
||||||
|
return { tabs: tabs.map(t => [t[0], rowsBy[t[1]].length, t[1]]), tab, total, pages, page, size, from, to, unit, rows: rows.slice((page - 1) * size, page * size).map(s => ({ s, rep: repKey(s, site.def) })) }
|
||||||
|
}
|
||||||
|
const chips = (s, site, create) => {
|
||||||
|
const en = site.langs, out = [], has = k => !!s.v[k]
|
||||||
|
const order = [site.def].concat(en.filter(k => k !== site.def))
|
||||||
|
order.forEach(k => { if (has(k)) out.push({ k, st: s.v[k][0], nr: s.v[k][1] }) })
|
||||||
|
Object.keys(s.v).filter(k => !en.includes(k)).forEach(k => out.push({ k, st: s.v[k][0], nr: s.v[k][1], off: 1 }))
|
||||||
|
order.forEach(k => { if (!has(k) && (create || en).includes(k)) out.push({ k, st: null }) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const SOCIAL = { journal: { en: ['none'], fr: ['set', 'fr_FR'], de: ['none'], ar: ['none'], es: ['none'], it: ['none'] }, atelier: { en: ['none'], 'en-GB': ['derived', 'en_GB'], fr: ['set', 'fr_FR'], de: ['none'], 'pt-BR': ['derived', 'pt_BR'], 'zh-Hans': ['none'], ja: ['none'], ar: ['none'] } }
|
||||||
|
const AUTHORS = Object.entries(STORIES.reduce((a, s) => (a[s.by] = (a[s.by] || 0) + 1, a), {}))
|
||||||
|
const FIXTURE_NOTES = { Journal: '8 articles, 17 versions, page size 5; en (default), fr, de, ar enabled; es, it disabled', Press: '3 articles, en + fr, all translated', Atelier: '3 articles, 8 languages, long labels', Archive: '5 articles, one language (pt-BR kept)', Notes: '3 articles, no site locale', Gallery: 'No articles, site locale en-GB' }
|
||||||
|
window.LOCFX = { P, STORIES, PRESS, ATELIER, ARCHIVE, NOTES, SITES, ENABLED, LANGS, LANG_NAMES, DIR, SETUP, preFr, count, view, chips, pl, tt, repKey, SOCIAL, AUTHORS, FIXTURE_NOTES, TOTALS: { stories: 8, versions: 17 } }
|
||||||
|
})()
|
||||||
+1975
-271
File diff suppressed because it is too large.
Load diff
+338
-2
@@ -188,6 +188,342 @@ States and copy in screens/account.md. Board: RenewCMS Screens Account.dc.html (
|
|||||||
| 409 last_admin | "You can't delete your account yet" (non-dismissable dialog) |
|
| 409 last_admin | "You can't delete your account yet" (non-dismissable dialog) |
|
||||||
| 409 email_unavailable | Landing only: "We couldn't change your email address" (same for in use and rejected) |
|
| 409 email_unavailable | Landing only: "We couldn't change your email address" (same for in use and rejected) |
|
||||||
|
|
||||||
## Localization target state inventory
|
## Localization state IDs (groups L1-L12 on the Localization board; source of truth: locale-spec.js)
|
||||||
|
Every state has a stable ID, a frame selector and a fixture. Legacy IDs from the first pass keep their names and are the canonical ID of the state they map to; the loc.lN.xxx alias is listed below.
|
||||||
|
|
||||||
The twelve `screens/localization-*.md` files enumerate 109 target states with stable `loc.*` IDs and board selectors. See [localization.md](localization.md) for precedence, lifecycle and verification limitations. A source inventory is not a passing visual result. Each SPA slice must add mocks using its actual board fixture, then pass the existing visual gate. Narrow desktop support is additional to the standard desktop/mobile captures.
|
Total: 169 states (was 109).
|
||||||
|
|
||||||
|
## L1 Site languages
|
||||||
|
Route: /settings/sites/:key/languages
|
||||||
|
API: proposed: GET/PUT /v1/site/{key}/languages
|
||||||
|
|
||||||
|
Navigation: Settings → Sites → [site] → Languages only. The site name is in the heading and breadcrumb. Site formatting locale, default content language, article language and social locale are four separate values. At least one language stays enabled and the default must be enabled: to retire the default, first Make default on another language, then Disable. Disable and Remove never delete or unpublish content. Disabled languages show a Disabled tag and a Re-enable action; their versions stay readable and editable per permission. Drafts can be published only after Re-enable ("French is disabled on this site. Re-enable it to publish."). The language filter lists them under Disabled. Social locale per language has three states: Derived (from the language code, e.g. pt-BR gives pt_BR), Set (an owner typed it, e.g. fr_FR) and Not set (og:locale left out). Only owners edit it.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l1.single | Single language | Owner opens Languages on Archive (one language, pt-BR kept) | LocaleFrame{s:{v:"sl",x:"single"}} | 1440, 390 | Archive |
|
||||||
|
| loc.l1.multi | Multilingual | Journal: four enabled, two disabled (Spanish, Italian) | LocaleFrame{s:{v:"sl",x:"multi"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l1.loading | Loading | Request in flight | LocaleFrame{s:{v:"sl",x:"loading"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.error | Load failed | Network or server error | LocaleFrame{s:{v:"sl",x:"error"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.add | Add a language | Add language | LocaleFrame{s:{v:"sl",x:"add"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.saveFail | Add failed | Server error, selection kept | LocaleFrame{s:{v:"sl",x:"saveFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.adddup | Duplicate language | Already enabled | LocaleFrame{s:{v:"sl",x:"adddup"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.broader | Only a broader tag supported | Add Portuguese (Portugal): substitution explained, confirmation required | LocaleFrame{s:{v:"sl",x:"broader"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.default | Make default | Row menu | LocaleFrame{s:{v:"sl",x:"default"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.defaultFail | Set default failed | Server error, nothing changed | LocaleFrame{s:{v:"sl",x:"defaultFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.disable | Disable with content | Row menu → Disable German | LocaleFrame{s:{v:"sl",x:"disable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.stale | Languages changed | Another owner changed them: "Languages changed. Reload." | LocaleFrame{s:{v:"sl",x:"stale"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.disableDefault | Disable default blocked | Disable English | LocaleFrame{s:{v:"sl",x:"disableDefault"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.disableLast | Disable last blocked | Only one enabled | LocaleFrame{s:{v:"sl",x:"disableLast"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenable | Re-enable | Spanish row → Re-enable | LocaleFrame{s:{v:"sl",x:"reenable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenableDone | Re-enabled | Toast after success | LocaleFrame{s:{v:"sl",x:"reenableDone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.reenableFail | Re-enable failed | Dialog error, still disabled | LocaleFrame{s:{v:"sl",x:"reenableFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.remove | Remove (no content) | Italian (0 versions) | LocaleFrame{s:{v:"sl",x:"remove"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.removeFail | Remove failed | Dialog error, still listed | LocaleFrame{s:{v:"sl",x:"removeFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.removeBlocked | Remove blocked | Spanish has versions | LocaleFrame{s:{v:"sl",x:"removeBlocked"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.readonly | Read-only view | Non-owner: list and social locale readable; no controls | LocaleFrame{s:{v:"sl",x:"readonly"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.saved | Saved | After Add language | LocaleFrame{s:{v:"sl",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1.social | Social locale: three states | Atelier: Derived en_GB and pt_BR, Set fr_FR, Not set | LocaleFrame{s:{v:"sl",x:"social"}} | 1440, 1024, 390 | Atelier |
|
||||||
|
| loc.l1.socialEdit | Social locale: edit | Edit on French (Set: fr_FR) | LocaleFrame{s:{v:"sl",x:"socialEdit"}} | 1440, 390 | Atelier |
|
||||||
|
| loc.l1.socialInvalid | Social locale: invalid | fr-FR typed with a hyphen | LocaleFrame{s:{v:"sl",x:"socialInvalid"}} | 1440, 390 | Atelier |
|
||||||
|
|
||||||
|
## L1b Public URL pattern (owner setting)
|
||||||
|
Route: /settings/sites/:key/public-url
|
||||||
|
API: proposed: GET/PUT /v1/site/{key} { public_url_pattern }
|
||||||
|
|
||||||
|
Owner-only. One pattern per site, for example https://example.com/{lang}/{slug}. Supported placeholders: {lang} (language code) and {slug} (the article address in that language). {slug} is required: without it every article would share one address. The preview uses a real article of the site. When unset, article panels show "Public URL not configured" and link here. No URL is invented.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l1b.set | Set | Pattern saved, preview shown | LocaleFrame{s:{v:"pu",x:"set"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.editing | Editing | Live preview follows the field | LocaleFrame{s:{v:"pu",x:"editing"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.unknown | Unknown placeholder | {language} typed | LocaleFrame{s:{v:"pu",x:"unknown"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.scheme | Bad scheme | ftp:// typed | LocaleFrame{s:{v:"pu",x:"scheme"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.encoded | Not encoded | A space in the path | LocaleFrame{s:{v:"pu",x:"encoded"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.noSlug | Missing {slug} | Pattern without {slug} | LocaleFrame{s:{v:"pu",x:"noSlug"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.saved | Saved | Toast | LocaleFrame{s:{v:"pu",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.unset | Unset | No pattern; opened from "Public URL not configured" | LocaleFrame{s:{v:"pu",x:"unset"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l1b.viewOnly | Read-only view | Non-owner: pattern readable, no controls | LocaleFrame{s:{v:"pu",x:"viewOnly"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L2 First-time setup for existing articles
|
||||||
|
Route: /settings/sites/:key/languages/setup
|
||||||
|
API: proposed: GET /v1/language/setup, POST /v1/language/setup { sites:[{ key, lang }] } (lang is the full tag, e.g. pt-BR)
|
||||||
|
|
||||||
|
Opened from the Languages banner of any site; :key is the site it was opened from, and the page lists every site that still has unlabelled articles. Setup assigns a language per site and applies in one transaction. It never translates, links or moves articles. Each site is seeded from its full Site.Locale when a matching language entry exists: pt-BR becomes Portuguese (Brazil). A site with no locale has no seed and the owner must choose. Nothing applies until the owner confirms every row, including a site with no articles. Retrying is safe.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l2.ready | Ready to assign | Journal (en), Notes (no locale, must choose), Archive (pt-BR kept) | LocaleFrame{s:{v:"su",x:"ready"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.noLocale | No-locale site | Notes only: the owner must choose | LocaleFrame{s:{v:"su",x:"noLocale"}} | 1440, 390 | Notes |
|
||||||
|
| loc.l2.empty | Site with no articles | Gallery has no articles; en-GB kept, confirm and Save | LocaleFrame{s:{v:"su",x:"empty"}} | 1440, 390 | Gallery |
|
||||||
|
| loc.l2.applying | Applying | Request in flight | LocaleFrame{s:{v:"su",x:"applying"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.inProgress | Setup already running | Another owner started it; starting again is safe | LocaleFrame{s:{v:"su",x:"inProgress"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.done | Done | 16 articles labelled | LocaleFrame{s:{v:"su",x:"done"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
| loc.l2.fail | Failed | Nothing changed; choices kept; retry is safe | LocaleFrame{s:{v:"su",x:"fail"}} | 1440, 390 | Journal, Notes, Archive |
|
||||||
|
|
||||||
|
## L3 New article
|
||||||
|
Route: /articles (New article modal)
|
||||||
|
API: existing POST /v1/article/ + proposed lang, story_id
|
||||||
|
|
||||||
|
Single-language sites show no language field. Multilingual sites show Language and a New article / Translation of an existing article choice. The translation path uses the same endpoint and rules as Add translation. The picker lists only articles the user can read; the chosen version is the source.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l3.single | Single language | Title and site only | LocaleFrame{s:{v:"na",x:"single"}} | 1440, 390 | Archive |
|
||||||
|
| loc.l3.multi | Multilingual: new article | Language defaults to the site default | LocaleFrame{s:{v:"na",x:"multi"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l3.translation | Translation of an article | Article picker + language | LocaleFrame{s:{v:"na",x:"translation"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l3.sitechange | Site changed | Selected language not offered by the new site | LocaleFrame{s:{v:"na",x:"sitechange"}} | 1440, 390 | Journal, Notes |
|
||||||
|
|
||||||
|
## L4 Add a translation
|
||||||
|
Route: /article/{id}/edit?dialog=add-translation&lang=fr
|
||||||
|
API: proposed: POST /v1/article/{id}/translations
|
||||||
|
|
||||||
|
Always creates a draft. Source: the user picks which readable version to copy from (default: the default-language version if readable, else the first readable). The source version is captured in the same transaction as the copy. Copied text stays in the source language until edited; the version starts as Needs review. Start empty: no tracked source, so freshness shows No source. The primary action stays in the sticky footer on mobile; the body scrolls.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l4.form | Copy from source | Editor → language menu → Add translation | LocaleFrame{s:{v:"at",x:"form"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.empty | Start empty | Title only | LocaleFrame{s:{v:"at",x:"empty"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.saving | Creating | Submit in flight | LocaleFrame{s:{v:"at",x:"saving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.dup.readable | Exists, readable | Conflict; Open offered | LocaleFrame{s:{v:"at",x:"dup"}} | 1440, 390 | Journal |
|
||||||
|
| loc.dup.hidden | Exists, not readable | Neutral error, no Open | LocaleFrame{s:{v:"at",x:"dupHidden"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.fail | Failed | Server error, entries kept | LocaleFrame{s:{v:"at",x:"fail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.gone | Source deleted | Deleted while the dialog was open | LocaleFrame{s:{v:"at",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.unreadable | Source no longer readable | Access lost between opening and submitting | LocaleFrame{s:{v:"at",x:"unreadable"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.langOff | Language disabled mid-flow | An owner disabled French while the dialog was open | LocaleFrame{s:{v:"at",x:"langOff"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l4.long | Long dialog | Long title, many authors and images; body scrolls, footer stays | LocaleFrame{s:{v:"at",x:"long"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L5 Editor: header, actions and title
|
||||||
|
Route: /article/{id}/edit (one id per version)
|
||||||
|
API: proposed: GET /v1/article/{id} → translations[], capabilities[]
|
||||||
|
|
||||||
|
Header priority and the action matrix are in the tables below. One filled primary button at most: Save and publish when allowed, dirty and offline; otherwise Save when dirty; otherwise Publish when clean and offline. Save stays separate and secondary. Desktop collapse order as width shrinks (1440 → 1024): reference button loses its label, Unpublish moves to the overflow menu, the freshness badge becomes icon-only. Mobile: title, language, publication and dirty badges stay at the top; Save, primary and an overflow button sit in a fixed bottom bar; reference, Unpublish, Edit slug, Move and Delete go to the overflow sheet. The language control is a menu button with a menu (not a listbox).
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l5.default | Clean draft | Open a draft you can publish; title pristine | LocaleFrame{s:{v:"ed",x:"default"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.dirty | Dirty draft | Can edit, can't publish: Save is primary | LocaleFrame{s:{v:"ed",x:"dirty"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.dirtyPublish | Dirty draft, publish allowed | Save and publish French is primary; Save secondary | LocaleFrame{s:{v:"ed",x:"dirtyPublish"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.cleanOnline | Clean online | Unpublish only; no primary | LocaleFrame{s:{v:"ed",x:"cleanOnline"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.onlineDirty | Dirty online | Save updates the live page; Unpublish stays | LocaleFrame{s:{v:"ed",x:"onlineDirty"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.saving | Saving | Save in flight | LocaleFrame{s:{v:"ed",x:"saving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.saved | Saved | Toast | LocaleFrame{s:{v:"ed",x:"saved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.saveFail | Save failed | Stays unsaved; Try saving again; nothing published | LocaleFrame{s:{v:"ed",x:"saveFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.pubFail | Publish failed after save | Saved as a draft; Retry publish; edits shown as saved | LocaleFrame{s:{v:"ed",x:"pubFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5.ovf | Overflow menu | More actions (sheet on mobile) | LocaleFrame{s:{v:"ed",x:"ovf"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.menu | Menu: four languages | Open menu | LocaleFrame{s:{v:"ed",x:"menu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.two | Menu: two languages | Two-language site | LocaleFrame{s:{v:"ed",x:"two"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.many | Menu: many languages | Scrolls inside the viewport, with filter | LocaleFrame{s:{v:"ed",x:"many"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.unsaved | Switch with unsaved changes | Choose English while dirty | LocaleFrame{s:{v:"ed",x:"unsaved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.loading | Loading version | Slow switch | LocaleFrame{s:{v:"ed",x:"loading"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.navFail | Switch failed | Stays on current version | LocaleFrame{s:{v:"ed",x:"navFail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.gone | Version unavailable | Deleted or no access (neutral) | LocaleFrame{s:{v:"ed",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.focus | Title: focus | Field focused, not changed | LocaleFrame{s:{v:"ed",x:"focus"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleEdit | Title: editing | Typing; counter shown | LocaleFrame{s:{v:"ed",x:"titleEdit"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleUnsaved | Title: unsaved | Changed, focus left | LocaleFrame{s:{v:"ed",x:"titleUnsaved"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.invalid | Title: invalid | Empty: "Enter a title" | LocaleFrame{s:{v:"ed",x:"invalid"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleShort | Title: too short | Under 3 characters | LocaleFrame{s:{v:"ed",x:"titleShort"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleLong | Title: too long | Over 200 characters | LocaleFrame{s:{v:"ed",x:"titleLong"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.titleSaving | Title: saving | Save in flight | LocaleFrame{s:{v:"ed",x:"titleSaving"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5.failed | Title: save failed | Text kept, still unsaved, Try again | LocaleFrame{s:{v:"ed",x:"failed"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L5b Permission states (per version)
|
||||||
|
Route: /article/{id}/edit
|
||||||
|
API: capabilities[] on GET /v1/article/{id}; every PATCH/DELETE rechecked on the server
|
||||||
|
|
||||||
|
Every action renders from its own capability (read, edit, publish, delete, create translation, review). See the capability matrix. Missing-translation rows appear only for users who can create; unreadable siblings are absent with no placeholder and no count. The formatting toolbar is hidden whenever edit is missing.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.perm.readonly | P1 Read-only version | Banner, read-only fields, no Save, Publish, Delete, toolbar; reference stays | LocaleFrame{s:{v:"perm",x:"ro"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l5b.roMenu | P1 menu | No Add translation row | LocaleFrame{s:{v:"perm",x:"roMenu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.editNoPublish | P2 Edit, no publish | Save only; hint about the publish permission | LocaleFrame{s:{v:"perm",x:"p2"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.perm.publishNoEdit | P3 Publish, no edit | Publish only; fields read-only | LocaleFrame{s:{v:"perm",x:"p3"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.perm.siblingReadOnly | P4 Sibling readable, not editable | Arabic row tagged Read only | LocaleFrame{s:{v:"perm",x:"p4"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.siblingHidden | P5 Sibling not readable | German absent, no count, no placeholder | LocaleFrame{s:{v:"perm",x:"p5"}} | 1440, 390 | Journal |
|
||||||
|
| loc.perm.createNotAll | P6 Can create, not modify all | Delete article and Move disabled with the reason; Delete French version available | LocaleFrame{s:{v:"perm",x:"p6"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5b.noAdd | Menu without Add translation | No create permission; Arabic is missing but offers nothing | LocaleFrame{s:{v:"perm",x:"noAdd"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l5b.withAdd | Menu with Add translation | Same article, user may create | LocaleFrame{s:{v:"perm",x:"withAdd"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L6 Source reference (release 2) (release 2)
|
||||||
|
Route: /article/{id}/edit?ref=:sourceId
|
||||||
|
API: proposed: GET /v1/article/{id} (read-only fetch of the reference)
|
||||||
|
|
||||||
|
Release 2. The temporary reference (?ref=, dashed outline, "Temporary reference") and the tracked review source (solid "Tracked source" badge in the review banner) are separate: changing the reference never changes the tracked source. Show source reference is in the editor header (desktop, icon-only at 1024) and the overflow sheet (mobile). Each pane scrolls independently. dir and lang go on the title and body of the editing pane and of the reference, never on the app shell.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l6.split | Side by side | Show source reference | LocaleFrame{s:{v:"src",x:"split"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l6.closed | Reference hidden | Hide source reference; button returns | LocaleFrame{s:{v:"src",x:"closed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.reopen | Reopened | Scroll positions kept | LocaleFrame{s:{v:"src",x:"reopen"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.changeRef | Change reference | Menu; tracked source stays English | LocaleFrame{s:{v:"src",x:"changeRef"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.narrow | Narrow desktop (1024) | True 1024 frame | LocaleFrame{s:{v:"src",x:"narrow"}} | 1024, 390 | Journal |
|
||||||
|
| loc.l6.long | Long content | Both panes scroll independently | LocaleFrame{s:{v:"src",x:"long"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.rtl | Arabic editing, English reference | dir=rtl on the Arabic title and body only | LocaleFrame{s:{v:"src",x:"rtl"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.arRef | French editing, Arabic reference | dir=rtl on the reference pane only | LocaleFrame{s:{v:"src",x:"arRef"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileEdit | Mobile: editing | Segment | LocaleFrame{s:{v:"src",x:"mobileEdit"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileSrc | Mobile: source | Segment | LocaleFrame{s:{v:"src",x:"mobileSrc"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.mobileClosed | Mobile: closed | Show source reference in the bottom bar or sheet | LocaleFrame{s:{v:"src",x:"mobileClosed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l6.unavail | Reference unavailable | Deleted or unreadable | LocaleFrame{s:{v:"src",x:"unavail"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L7 Article list and coverage
|
||||||
|
Route: /articles?site&lang&tab=all|missing|review&q&status&author&seo&page&size
|
||||||
|
API: proposed: lang, tab=all|missing|review on GET /v1/article/ (same names as the URL)
|
||||||
|
|
||||||
|
Journal: 8 articles, 17 versions, page size 5. The list header shows the unit: "8 articles" (all languages, one row per article with its translations), "4 French versions" (language selected), "4 articles not in French" (missing tab), "7 articles need translation", "2 articles need review". Coverage chips: default language first, then other existing versions, then disabled-language versions, then dashed missing chips (only for languages the user can create). Chips collapse after 5 at 1440 and 4 at 1024 and 390, leaving a "+N" chip. Row actions are the same on every width: Open, Open another language, Add translation, Publish or Unpublish, Copy API URL, Delete version; every row has the row menu button. All state is in the URL.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l7.all | All languages, page 1 | 5 of 8 articles | LocaleFrame{s:{v:"li",x:"all"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.page2 | All languages, page 2 (last) | 3 of 8 articles | LocaleFrame{s:{v:"li",x:"page2"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.fr | French selected | 4 French versions | LocaleFrame{s:{v:"li",x:"fr"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.frMissing | Not in French | 4 articles not in French | LocaleFrame{s:{v:"li",x:"frMissing"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.emptyMissing | Nothing missing | Press: all articles translated; no summary, no pager | LocaleFrame{s:{v:"li",x:"emptyMissing"}} | 1440, 1024, 390 | Press |
|
||||||
|
| loc.l7.noResults | No results | Search "zinc"; every count 0 | LocaleFrame{s:{v:"li",x:"noResults"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.frLoading | Loading | Counts shown as – | LocaleFrame{s:{v:"li",x:"frLoading"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.listFail | Failed | Filters kept | LocaleFrame{s:{v:"li",x:"listFail"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.limited | Limited access | Reads English only, can't create: no dashed chips | LocaleFrame{s:{v:"li",x:"limited"}} | 1440, 1024, 390 | Journal (limited scope) |
|
||||||
|
| loc.l7.review | Needs review tab | 2 articles need review | LocaleFrame{s:{v:"li",x:"review"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.author | Author filter | Marta Alves: 3 articles, chips and Clear all | LocaleFrame{s:{v:"li",x:"author"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.langFilter | Language filter | Enabled and Disabled groups | LocaleFrame{s:{v:"li",x:"langFilter"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.rowMenu | Row menu | Same actions on desktop and mobile | LocaleFrame{s:{v:"li",x:"rowMenu"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.filters | Mobile filter sheet | Journal, French, Draft: 2 results | LocaleFrame{s:{v:"li",x:"filters"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l7.tabsScroll | Tabs scrolled | Not in French reachable; fade and chevrons | LocaleFrame{s:{v:"li",x:"tabsScroll"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l7.long | Eight languages, long labels, filters | Atelier: chips collapse to +3, +4, +4; Draft and SEO filters | LocaleFrame{s:{v:"li",x:"long"}} | 1440, 1024, 390 | Atelier |
|
||||||
|
|
||||||
|
## L8 Publishing and deletion
|
||||||
|
Route: same as the editor
|
||||||
|
API: existing PATCH /v1/article/{id}/publish and /unpublish and DELETE per article id
|
||||||
|
|
||||||
|
Every action names its language. No bulk publish. With unsaved edits the primary reads Save and publish and saves first; if the save fails nothing is published and the edits stay unsaved; if the save works and publishing fails the edits show as saved. Unpublish never saves: the saved version goes offline and local edits stay in the editor. Saving an online version updates the live page immediately, and the editor says so. Deletion confirmations name only readable versions.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l8.publish | Publish French | Confirm, English untouched | LocaleFrame{s:{v:"pd",x:"publish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.lang.publishDisabled | Publishing blocked: language disabled | French disabled on the site | LocaleFrame{s:{v:"pd",x:"publishDisabled"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.saveAndPublish | Save and publish French | Unsaved edits: saves first, then publishes | LocaleFrame{s:{v:"pd",x:"saveAndPublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.saveFailPublish | Save failed, nothing published | Toast; edits kept and retryable | LocaleFrame{s:{v:"pd",x:"saveFailPublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.unpublish | Unpublish French | Confirm | LocaleFrame{s:{v:"pd",x:"unpublish"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.unpublishDirty | Unpublish with unsaved edits | Saved version goes offline; edits kept; nothing saved | LocaleFrame{s:{v:"pd",x:"unpublishDirty"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.delete | Delete French version | Confirm | LocaleFrame{s:{v:"pd",x:"delete"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteSource | Delete a source version | Warns about review tracking | LocaleFrame{s:{v:"pd",x:"deleteSource"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteLast | Delete the only version | Deletes the article | LocaleFrame{s:{v:"pd",x:"deleteLast"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteStoryOk | Delete article (all readable versions) | Names readable versions only | LocaleFrame{s:{v:"pd",x:"deleteStoryOk"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.deleteStory | Delete article blocked | Neutral, no version named | LocaleFrame{s:{v:"pd",x:"deleteStory"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.done | Published | Toast | LocaleFrame{s:{v:"pd",x:"done"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l8.fail | Publish failed | Toast | LocaleFrame{s:{v:"pd",x:"fail"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L9 Freshness and review (release 2) (release 2)
|
||||||
|
Route: editor banner · list badge
|
||||||
|
API: proposed: POST /v1/article/{id}/review { source_article_id, source_revision, translation_revision } (internal fields; never shown)
|
||||||
|
|
||||||
|
Release 2. Publication (solid badge) and freshness (outlined badge with refresh icon) are separate. Labels: No version, Draft, Needs review, Reviewed. A copied draft starts as Needs review. The dialog shows "Changed fields: Title, Body", the source version with its date and the translation version with its date. No revision numbers appear in the UI.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l9.warn | Source updated | English changed after review | LocaleFrame{s:{v:"fr",x:"warn"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.pub | Published, needs review | Still online | LocaleFrame{s:{v:"fr",x:"pub"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.review | Mark reviewed | Confirm with changed fields and dates | LocaleFrame{s:{v:"fr",x:"review"}} | 1440, 390 | Journal |
|
||||||
|
| loc.review.changed | Source changed again | During the review | LocaleFrame{s:{v:"fr",x:"changed"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.changedTr | Translation changed | Saved by someone else while confirming | LocaleFrame{s:{v:"fr",x:"changedTr"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.replace | Choose tracked source | Cycle prevented | LocaleFrame{s:{v:"fr",x:"replace"}} | 1440, 390 | Journal |
|
||||||
|
| loc.src.inaccessible | Source unavailable (access) | History kept | LocaleFrame{s:{v:"fr",x:"inaccessible"}} | 1440, 390 | Journal |
|
||||||
|
| loc.src.deleted | Source deleted | Choose a new source | LocaleFrame{s:{v:"fr",x:"gone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.done | Reviewed | Toast | LocaleFrame{s:{v:"fr",x:"done"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.fail | Review failed | Toast | LocaleFrame{s:{v:"fr",x:"fail"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l9.frReview | Published and needs review in the list | French tab, Needs review: 2 versions | LocaleFrame{s:{v:"li",x:"frReview"}} | 1440, 1024, 390 | Journal |
|
||||||
|
|
||||||
|
## L10 Metadata and public API
|
||||||
|
Route: Search and sharing panel · /integration
|
||||||
|
API: existing slug, SEO fields; proposed alternates[], lang; exact lookup by site + lang + slug; resolution by story_id
|
||||||
|
|
||||||
|
Slug is unique per (site, lang). Exact lookup is by site + language + slug and never falls back. Language resolution and fallback use the stable story_id. Website URLs come from the site's Public URL pattern; without it the panel says "Public URL not configured" and links to the setting. Canonical is the localized page itself unless an override is set. SEO labels: "Fallback: French title", "No fallback", "Empty. English site default not used."
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.seo.noFallback | Search and sharing: French | Localized slug, canonical, alternates, fallback labels, Edit slug | LocaleFrame{s:{v:"seo",x:""}} | 1440, 390 | Journal |
|
||||||
|
| loc.slug.published | Edit slug | Published version: warning about old links | LocaleFrame{s:{v:"seo",x:"editSlug"}} | 1440, 390 | Journal |
|
||||||
|
| loc.url.unset | Public URL not configured | No pattern, no fabricated URL | LocaleFrame{s:{v:"seo",x:"unset"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l10.noSocial | Social locale not set | fr has no region and none is set: og:locale omitted | LocaleFrame{s:{v:"seo",x:"noSocial"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l10.api | Integration: languages | Exact lookup, resolve by article group, errors | LocaleFrame{s:{v:"api",x:""}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L11 Conflicts and sessions
|
||||||
|
Route: editor
|
||||||
|
API: existing 401/403/404/409 patterns
|
||||||
|
|
||||||
|
The conflict dialog gives four choices. Keep my changes is the only filled button; the other three are secondary and equal. "Someone else saved this version at <time>". No revision numbers. On mobile the dialog is a bottom sheet with Keep my changes first, so it needs no scrolling at 390 px.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l11.session | Session expired | Draft kept locally | LocaleFrame{s:{v:"pm",x:"session"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.restored | Restored after login | Editor shows "Restored unsaved changes from 14:18" | LocaleFrame{s:{v:"pm",x:"restored"}} | 1440, 390 | Journal |
|
||||||
|
| loc.conflict | Concurrent update | Four choices, local work kept | LocaleFrame{s:{v:"pm",x:"conflict"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.conflictRestored | Concurrent update after session expiry | Restored changes notice above the choices | LocaleFrame{s:{v:"pm",x:"conflictRestored"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.reloadConfirm | Reload theirs: confirm | "This discards your unsaved changes." | LocaleFrame{s:{v:"pm",x:"reloadConfirm"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.conflict2 | Second conflict | Save over theirs failed again; nothing overwritten | LocaleFrame{s:{v:"pm",x:"conflict2"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11.download | Download started | Toast; changes still in the editor | LocaleFrame{s:{v:"pm",x:"download"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L11b Move article to another site
|
||||||
|
Route: editor → site select
|
||||||
|
API: proposed: POST /v1/story/{story_id}/move { site }
|
||||||
|
|
||||||
|
Entry point is the site select in the editor details. Articles move as a whole, only when the destination enables every language they contain (disabled ones included) and the user can edit all versions. The note lists readable versions only; versions the user can't read are not listed or counted. Moves are atomic and revalidate permissions and conflicts in the transaction.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l11b.moveSelect | Site select | Note lists all four versions, one tagged Read only | LocaleFrame{s:{v:"pm",x:"moveSelect"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveSelectHidden | Site select, hidden version | Arabic not readable: not listed or counted | LocaleFrame{s:{v:"pm",x:"moveSelectHidden"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveConfirm | Confirm | Move to Atelier? | LocaleFrame{s:{v:"pm",x:"moveConfirm"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveBusy | In progress | Dialog locked | LocaleFrame{s:{v:"pm",x:"moveBusy"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.moveDone | Moved | Toast | LocaleFrame{s:{v:"pm",x:"moveDone"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l11b.move | Language missing in destination | Notes enables English only | LocaleFrame{s:{v:"pm",x:"move"}} | 1440, 390 | Journal, Notes |
|
||||||
|
| loc.l11b.moveSlug | Address conflict | Names the conflicting readable version only | LocaleFrame{s:{v:"pm",x:"moveSlug"}} | 1440, 390 | Journal, Atelier |
|
||||||
|
| loc.l11b.movePerm | No permission | Neutral | LocaleFrame{s:{v:"pm",x:"movePerm"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
## L12 Existing surfaces
|
||||||
|
Route: Home · Activity · Media · editor details
|
||||||
|
API: existing; language added where it applies
|
||||||
|
|
||||||
|
Single-language sites show none of this. Home counts articles, not versions.
|
||||||
|
|
||||||
|
| ID | State | Trigger | Selector | Sizes | Fixture |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| loc.l12.home | Home: recent articles | Default-language title; code only when not default | LocaleFrame{s:{v:"sf",x:"home"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.homeFr | Home: reader of French only | Titles in French with code FR | LocaleFrame{s:{v:"sf",x:"homeFr"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.activity | Activity | Events name the language | LocaleFrame{s:{v:"sf",x:"activity"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.media | Media usage | Shared image counted once, versions listed | LocaleFrame{s:{v:"sf",x:"media"}} | 1440, 1024, 390 | Journal |
|
||||||
|
| loc.l12.menu | Article menu labels | Publish and Delete name the language | LocaleFrame{s:{v:"sf",x:"menu"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.apiCopy | API URL copy | Copies the version URL with ?lang= | LocaleFrame{s:{v:"sf",x:"apiCopy"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.seoPreview | SEO preview | Localized address and canonical | LocaleFrame{s:{v:"sf",x:"seoPreview"}} | 1440, 390 | Journal |
|
||||||
|
| loc.l12.siteFallback | Site text fallback | Used only when its language matches the article | LocaleFrame{s:{v:"sf",x:"siteFallback"}} | 1440, 390 | Journal |
|
||||||
|
|
||||||
|
### Legacy IDs kept
|
||||||
|
| Canonical ID | Alias |
|
||||||
|
|---|---|
|
||||||
|
| loc.lang.disableDefault | loc.l1.disableDefault |
|
||||||
|
| loc.lang.disableLast | loc.l1.disableLast |
|
||||||
|
| loc.dup.readable | loc.l4.dup |
|
||||||
|
| loc.dup.hidden | loc.l4.dupHidden |
|
||||||
|
| loc.perm.readonly | loc.l5b.ro |
|
||||||
|
| loc.perm.editNoPublish | loc.l5b.p2 |
|
||||||
|
| loc.perm.publishNoEdit | loc.l5b.p3 |
|
||||||
|
| loc.perm.siblingReadOnly | loc.l5b.p4 |
|
||||||
|
| loc.perm.siblingHidden | loc.l5b.p5 |
|
||||||
|
| loc.perm.createNotAll | loc.l5b.p6 |
|
||||||
|
| loc.lang.publishDisabled | loc.l8.publishDisabled |
|
||||||
|
| loc.review.changed | loc.l9.changed |
|
||||||
|
| loc.src.inaccessible | loc.l9.inaccessible |
|
||||||
|
| loc.src.deleted | loc.l9.gone |
|
||||||
|
| loc.seo.noFallback | loc.l10.default |
|
||||||
|
| loc.slug.published | loc.l10.editSlug |
|
||||||
|
| loc.url.unset | loc.l10.unset |
|
||||||
|
| loc.conflict | loc.l11.conflict |
|
||||||
|
|
||||||
|
### Changes against the previous 109
|
||||||
|
Added (70): L1:sl:broader, L1:sl:defaultFail, L1:sl:stale, L1:sl:reenableDone, L1:sl:reenableFail, L1:sl:removeFail, L1:sl:social, L1:sl:socialEdit, L1:sl:socialInvalid, L1b:pu:set, L1b:pu:editing, L1b:pu:unknown, L1b:pu:scheme, L1b:pu:encoded, L1b:pu:noSlug, L1b:pu:saved, L1b:pu:unset, L1b:pu:viewOnly, L2:su:noLocale, L2:su:inProgress, L4:at:unreadable, L4:at:langOff, L5:ed:cleanOnline, L5:ed:pubFail, L5:ed:ovf, L5:ed:titleEdit, L5:ed:titleUnsaved, L5:ed:titleShort, L5:ed:titleLong, L5b:perm:ro, L5b:perm:roMenu, L5b:perm:p2, L5b:perm:p3, L5b:perm:p4, L5b:perm:p5, L5b:perm:p6, L5b:perm:noAdd, L5b:perm:withAdd, L6:src:arRef, L7:li:page2, L7:li:review, L7:li:author, L7:li:langFilter, L7:li:rowMenu, L8:pd:publishDisabled, L8:pd:unpublishDirty, L8:pd:deleteStoryOk, L9:li:frReview, L10:seo:editSlug, L11:pm:restored, L11:pm:conflictRestored, L11:pm:reloadConfirm, L11:pm:conflict2, L11:pm:download, L11b:pm:moveSelect, L11b:pm:moveSelectHidden, L11b:pm:moveConfirm, L11b:pm:moveBusy, L11b:pm:moveDone, L11b:pm:move, L11b:pm:moveSlug, L11b:pm:movePerm, L12:sf:home, L12:sf:homeFr, L12:sf:activity, L12:sf:media, L12:sf:menu, L12:sf:apiCopy, L12:sf:seoPreview, L12:sf:siteFallback
|
||||||
|
|
||||||
|
Changed (rekeyed to a new group or frame, same meaning): L5b|ed|ro, L5b|ed|roMenu, L5b|ed|p2, L5b|ed|p3, L5b|ed|p4, L5b|ed|p5, L5b|ed|p6, L11|pm|move, L11|pm|moveSlug, L11|pm|movePerm
|
||||||
|
|
||||||
|
Removed without replacement: none.
|
||||||
|
|
||||||
|
Kept unchanged: 99.
|
||||||
Reference in new issue
Block a user