diff --git a/.gitignore b/.gitignore index 59ebf3f2..31fcd7ad 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,7 @@ vitest.config.*.timestamp* .claude/worktrees .nx/polygraph .nx/self-healing + +# Impeccable design skill — machine-local state (config.json is committed) +.impeccable/config.local.json +.impeccable/live/ diff --git a/apps/tracker/.impeccable/config.json b/apps/tracker/.impeccable/config.json new file mode 100644 index 00000000..7142df2d --- /dev/null +++ b/apps/tracker/.impeccable/config.json @@ -0,0 +1,3 @@ +{ + "buildPath": "comp" +} diff --git a/apps/tracker/PRODUCT.md b/apps/tracker/PRODUCT.md new file mode 100644 index 00000000..a159cd9f --- /dev/null +++ b/apps/tracker/PRODUCT.md @@ -0,0 +1,118 @@ +# Product + + + +## Platform + +web + +## Users + +Primary: an application developer, mid-feature, who has just written UI and needs to add, +find, or verify translation keys and get back to coding. They already know the codebase and +the key they are looking for. Interruption cost is the dominant concern. + +Secondary (real, must not be blocked): + +- Translators and reviewers working through `new` / `stale` entries a locale at a time, + judging wording rather than moving fast. +- The i18n owner, accountable for translation health across collections before a release — + hunting gaps, stale entries, and protected-term violations. + +## Product Purpose + +LingoTracker manages translation resources for apps using the Transloco library. The Tracker +web UI is its visual surface: browse a collection's folder tree, search across keys and +values, filter by locale and status, edit translations, and trigger auto-translation. + +Success is a developer resolving a translation question in the UI faster than they could by +hand-editing JSON — and a release going out with no `new` or `stale` entries left behind. + +## Positioning + +Translations live as plain JSON files in the user's own repository, next to the code, with +metadata (MD5 checksums, status) in sibling files. There is no hosted service and no +database: every change is a Git diff a teammate can review in a normal pull request. The +UI is a lens onto those files, not a system of record that owns them. + +## Operating Context + +- Launched locally via `npx lingo-tracker-app`; the API (default port 3030) serves the UI and + reads and writes the developer's working tree. +- The UI is one of three interfaces over the same core library, alongside a CLI (used + interactively and in CI/CD) and a REST API. Behavior must not contradict the CLI. +- Work is organized into **collections** — resources grouped by team, feature, or domain. + Some collections are **read-only** (e.g. component-library translations vendored under + `node_modules`). +- Routes: `/collections` (manager), `/browser/:collectionName` (the main working surface), + `/settings`. + +## Capabilities and Constraints + +Confirmed capabilities in the UI: + +- Folder-tree sidebar over dot-delimited resource keys (`apps.common.buttons.ok`). +- Search across keys and values; filter by locale and by status. +- Compact / expanded density modes; view preferences persisted per collection. +- Translation editor dialog with similar-translation suggestions as you type. +- One-click auto-translation (Google Translate), preserving ICU placeholders and Transloco + variables. +- Collection create/edit; read-only collections are protected across CLI, API, and UI. +- Settings page manages the protected-terms list: an alphabetical list with inline rename, + staged add/remove, and an explicit save, backed by a standalone JSON file. +- Comment and tag popovers; indexing overlay during long operations. + +Constraints: + +- **Local-first, no accounts.** Single machine, single user. No auth, no multi-user + presence, no remote sync state to design around. +- **Read-only collections must always be unmistakable** in the UI. +- **The Tracker UI is itself fully localized** with Transloco and dogfoods LingoTracker. + Every user-visible string goes through the resource workflow — no hardcoded copy. Shipped + UI locales: `en`, `de`, `es`, `fr-ca`, `ja`, `ru`. Layouts must survive long German and + Russian strings and Japanese line-breaking. +- Angular 20 standalone components, Angular Material, NgRx Signals, OnPush change detection. +- Terminology is domain vocabulary and is not up for renaming: collection, resource, key, + locale, base locale, bundle, glossary, protected term, and the status lifecycle + `new` → `translated` → `stale` → `verified`. + +## Brand Commitments + +- Name: **LingoTracker**. Tagline: "Effortlessly Track, Validate, and Manage Your + Translations." +- The **watercolor visual identity is binding**, not placeholder: warm parchment + backgrounds, coral/vermillion primary, deep sky-blue secondary, and the watercolor accent + palette in `src/styles/tokens.scss`. Nunito for all text; Grechen Fuemen reserved for the + LingoTracker brand title only. +- Logo assets: `src/assets/logo.png`, `src/assets/logo-dark.png`, + `src/assets/lingo-tracker-favicon-cropped.png`. +- Light and dark themes both ship. + +## Evidence on Hand + +- Real product docs: repo `README.md`, `ROADMAP.md`, `docs/` (auto-translation, glossary, + protected terms, validate, bundle type generation), and a docs site under `docs-site/`. +- Real translation data: the Tracker's own `src/assets/i18n/*.json` across six locales. +- The project is described as stable and "currently being validated in an enterprise + application." There are **no** named customers, testimonials, logos, benchmarks, pricing, + or usage numbers — future work must not invent any. + +## Product Principles + +1. **The files are the truth.** The UI never becomes a source of state the repository cannot + reproduce; every action ends as a reviewable Git diff. +2. **Don't break the developer's flow.** The primary user arrived with a key in mind and + wants to leave; the shortest path from landing to that key wins over browsing elegance. +3. **Status is the spine.** `new` / `translated` / `stale` / `verified` is how every user + decides what to touch next, and it must be legible at a glance at any density. +4. **Never let a destructive or locked state be a surprise.** Read-only collections, + protected terms, and deletions announce themselves before the fact. +5. **Practice what it preaches.** The UI is localized, ICU-correct, and layout-safe in every + locale it ships — it is its own reference implementation. + +## Accessibility & Inclusion + +No product-specific standard has been established yet — recorded as undecided. Existing code +shows deliberate `aria-labelledby` use on settings sections, so the incumbent baseline is +Angular Material semantics plus explicit landmark labeling; future work should not regress +below that. diff --git a/apps/tracker/src/app/collections/collections-manager.html b/apps/tracker/src/app/collections/collections-manager.html index b7353eb1..eb81b8d5 100644 --- a/apps/tracker/src/app/collections/collections-manager.html +++ b/apps/tracker/src/app/collections/collections-manager.html @@ -3,18 +3,53 @@

{{ TOKENS.COLLECTIONS.TITLE | transloco }}

+ @if (store.hasCollections()) { +

+ {{ TOKENS.COLLECTIONS.SUBTITLEX | transloco: { count: store.collectionEntries().length } }} +

+ } +
+ +
+ @if (showFilter()) { +
+ + + @if (isFiltering()) { + + } +
+ } + +
-
@@ -37,7 +72,7 @@

{{ TOKENS.COLLECTIONS.TITLE | transloco }}

{{ store.error() }}

+ +} + -@if (store.hasCollections() && !store.isLoading()) { +@if (cards().length > 0 && !store.isLoading()) {
- @for (item of store.collectionEntriesWithLocales(); track item.name; let i = $index) { -
- -
-
-
-
- folder -
-

{{ item.name }}

- @if (item.config.readOnly) { - - lock - {{ TOKENS.COLLECTIONS.CARD.READONLYBADGE | transloco }} - - } -
-
- - - - - - -
+
+ -
-

- subdirectory_arrow_right - {{ item.config.translationsFolder }} + +

+

{{ card.name }}

+

+ {{ card.translationsFolder }}

-
- -
- -
+ + @if (card.readOnly) { +

+ + {{ TOKENS.COLLECTIONS.CARD.READONLYTOOLTIP | transloco }} +

+ } + + + }
} diff --git a/apps/tracker/src/app/collections/collections-manager.scss b/apps/tracker/src/app/collections/collections-manager.scss index fa586508..a08652ed 100644 --- a/apps/tracker/src/app/collections/collections-manager.scss +++ b/apps/tracker/src/app/collections/collections-manager.scss @@ -9,14 +9,17 @@ padding: var(--spacing-8) var(--spacing-6); position: relative; min-height: 100%; -} + // Card surface sits one step above the page (which uses --color-background-subtle). + --card-surface: var(--color-background); + --card-surface-hover: color-mix(in srgb, var(--color-primary) 3%, var(--color-background)); +} // --------------------------------------------------------------------------- // Page header // --------------------------------------------------------------------------- .page-header { - margin-bottom: var(--spacing-8); + margin-bottom: var(--spacing-6); position: relative; z-index: 1; } @@ -33,18 +36,33 @@ display: flex; flex-direction: column; gap: var(--spacing-1); + min-width: 0; } .header-title { font-family: var(--font-family-body); color: var(--color-text-primary); - font-size: clamp(2.5rem, 6vw, 4rem); + font-size: clamp(1.75rem, 2.6vw, 2.25rem); font-weight: var(--font-weight-bold); - letter-spacing: -0.01em; - line-height: var(--line-height-display); + letter-spacing: -0.02em; + line-height: var(--line-height-tight); margin: 0; } +.header-count { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + margin: 0; +} + +.header-tools { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--spacing-3); +} + .header-rule { background: linear-gradient( 90deg, @@ -55,17 +73,84 @@ transparent 100% ); border-radius: var(--border-radius-full); - height: 3px; + height: 2px; margin-top: var(--spacing-4); width: 100%; - opacity: 0.6; + opacity: 0.7; +} + +// --------------------------------------------------------------------------- +// Filter field +// --------------------------------------------------------------------------- +.filter-field { + align-items: center; + background: var(--card-surface); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-lg); + display: flex; + gap: var(--spacing-2); + min-width: 0; + padding: 0 var(--spacing-1) 0 var(--spacing-3); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + width: 260px; + + &:focus-within { + border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 14%, transparent); + } +} + +.filter-icon { + color: var(--color-text-secondary); + flex: none; + font-size: 18px; + height: 18px; + width: 18px; +} + +.filter-input { + background: none; + border: 0; + color: var(--color-text-primary); + flex: 1 1 auto; + font-family: inherit; + font-size: var(--font-size-sm); + min-width: 0; + padding: var(--spacing-2) 0; + + &::placeholder { + color: var(--color-text-secondary); + } + + &:focus { + outline: none; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.filter-clear { + flex: none; + height: 32px !important; + line-height: 32px !important; + width: 32px !important; + + .mat-icon { + color: var(--color-text-secondary); + font-size: 18px; + height: 18px; + width: 18px; + } } .add-btn { - border-radius: var(--border-radius-xl) !important; + border-radius: var(--border-radius-lg) !important; + flex: none; font-weight: var(--font-weight-semibold) !important; - letter-spacing: 0.02em; - padding: var(--spacing-2) var(--spacing-6) !important; + letter-spacing: 0.01em; + padding: var(--spacing-2) var(--spacing-5) !important; transition: box-shadow var(--transition-normal), transform var(--transition-normal) !important; mat-icon, @@ -75,7 +160,7 @@ } &:hover:not([disabled]) { - box-shadow: 0 4px 20px color-mix(in srgb, var(--color-primary) 30%, transparent) !important; + box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--color-primary) 45%, transparent) !important; transform: translateY(-1px); } } @@ -229,268 +314,326 @@ .empty-message { color: var(--color-text-secondary); font-family: var(--font-family-body); - font-size: var(--font-size-2xl); + font-size: var(--font-size-xl); margin: 0; - max-width: 400px; + max-width: 42ch; text-align: center; } // --------------------------------------------------------------------------- -// Collections grid +// No matches state // --------------------------------------------------------------------------- -.collections-grid { - display: grid; - gap: var(--spacing-6); +.no-matches { + align-items: center; + display: flex; + flex-direction: column; + gap: var(--spacing-3); + justify-content: center; + min-height: 320px; position: relative; z-index: 1; +} - @media (max-width: 768px) { - grid-template-columns: 1fr; - } +.no-matches-icon { + color: var(--color-text-secondary); + font-size: 32px; + height: 32px; + opacity: 0.7; + width: 32px; +} - @media (min-width: 769px) and (max-width: 1200px) { - grid-template-columns: repeat(2, 1fr); - } +.no-matches-message { + color: var(--color-text-secondary); + font-size: var(--font-size-base); + margin: 0; + max-width: 42ch; + overflow-wrap: anywhere; + text-align: center; +} - @media (min-width: 1201px) { - grid-template-columns: repeat(3, 1fr); - } +// --------------------------------------------------------------------------- +// Collections grid +// --------------------------------------------------------------------------- +.collections-grid { + align-items: start; + display: grid; + gap: var(--spacing-5); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + position: relative; + z-index: 1; } // --------------------------------------------------------------------------- -// Collection card — watercolor style +// Collection card // --------------------------------------------------------------------------- .collection-card { - - animation: card-enter 0.4s cubic-bezier(0.22, 1, 0.36, 1) backwards; - animation-delay: calc(var(--card-index, 0) * 60ms); + animation: card-enter 0.45s cubic-bezier(0.16, 1, 0.3, 1) backwards; + animation-delay: calc(var(--card-index, 0) * 50ms); + background: var(--card-surface); + border: 1px solid var(--color-border); border-radius: var(--border-radius-xl); + box-shadow: var(--shadow-sm); cursor: pointer; - min-height: 160px; + display: flex; + flex-direction: column; outline-offset: 3px; + padding: var(--spacing-5); position: relative; - transition: transform var(--transition-normal), box-shadow var(--transition-normal); + transition: + transform var(--transition-normal), + box-shadow var(--transition-normal), + border-color var(--transition-normal), + background var(--transition-normal); &:hover { - box-shadow: var(--shadow-lg); - transform: translateY(-4px); - - .card-accent { - opacity: 1; - } + background: var(--card-surface-hover); + border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); + box-shadow: var(--shadow-md); + transform: translateY(-2px); .card-arrow { + color: var(--color-primary); opacity: 1; transform: translateX(0); } .card-icon-wrap { - background: var(--color-primary); - border-color: transparent; + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 45%, transparent); + } + } + + &--readonly .card-icon-wrap { + background: color-mix(in srgb, var(--color-warning) 16%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 35%, transparent); - mat-icon { - color: var(--color-text-inverse); - } + mat-icon { + color: color-mix(in srgb, var(--color-warning) 72%, var(--color-text-primary)); } } - &:focus-visible { - outline: 2px solid var(--color-primary); + &--readonly:hover .card-icon-wrap { + background: color-mix(in srgb, var(--color-warning) 26%, transparent); + border-color: color-mix(in srgb, var(--color-warning) 55%, transparent); } } @keyframes card-enter { from { opacity: 0; - transform: translateY(16px); - } -} - -.card-accent { - background: var(--color-primary); - border-radius: var(--border-radius-xl); - inset: 0; - opacity: 0; - padding: 2px; - position: absolute; - transition: opacity var(--transition-normal); - z-index: 0; - - // Inner mask to create border-only gradient effect - &::after { - background: var(--color-background-subtle); - border-radius: calc(var(--border-radius-xl) - 2px); - content: ''; - inset: 2px; - position: absolute; - } -} - -.card-inner { - backdrop-filter: blur(12px); - background: color-mix(in srgb, var(--color-background) 88%, transparent); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-xl); - display: flex; - flex-direction: column; - height: 100%; - padding: var(--spacing-5); - position: relative; - transition: border-color var(--transition-normal), background var(--transition-normal); - z-index: 1; - overflow: hidden; - - .collection-card:hover & { - border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border)); + transform: translateY(14px); } } .card-header { - align-items: flex-start; - display: flex; - gap: var(--spacing-1); - justify-content: space-between; - margin-bottom: var(--spacing-3); - position: relative; - z-index: 1; -} - -.card-title { - align-items: center; - display: flex; - flex: 1; + align-items: start; + display: grid; gap: var(--spacing-3); - min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; } .card-icon-wrap { align-items: center; background: color-mix(in srgb, var(--color-primary) 12%, transparent); - border: 1.5px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent); border-radius: var(--border-radius-lg); display: flex; flex-shrink: 0; - height: 40px; + height: 38px; justify-content: center; transition: background var(--transition-normal), border-color var(--transition-normal); - width: 40px; + width: 38px; mat-icon { color: var(--color-primary); - font-size: 20px; - height: 20px; - transition: color var(--transition-normal); - width: 20px; + font-size: 19px; + height: 19px; + width: 19px; } } +.card-heading { + min-width: 0; + padding-top: 1px; +} + .collection-name { color: var(--color-text-primary); - flex: 1; font-family: var(--font-family-body); - font-size: clamp(1.25rem, 2vw, 1.5rem); + font-size: var(--font-size-lg); font-weight: var(--font-weight-bold); letter-spacing: -0.01em; line-height: var(--line-height-tight); margin: 0; - word-break: break-word; - position: relative; - z-index: 1; + overflow-wrap: anywhere; +} + +// Two lines reserved whether the path needs them or not, so cards in a row stay flush. +.folder-path { + --path-line-height: 1.45; + + color: var(--color-text-secondary); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--path-line-height); + margin: var(--spacing-1) 0 0; + min-height: calc(2 * var(--path-line-height) * var(--font-size-xs)); + overflow: hidden; + overflow-wrap: anywhere; } .card-actions { flex-shrink: 0; - margin: calc(-1 * var(--spacing-1)) calc(-1 * var(--spacing-2)) 0 0; - position: relative; - z-index: 1; + margin: calc(-1 * var(--spacing-2)) calc(-1 * var(--spacing-2)) 0 0; button { - color: var(--color-text-tertiary) !important; + color: var(--color-text-secondary) !important; transition: color var(--transition-fast) !important; &:hover { - color: var(--color-text-secondary) !important; + color: var(--color-text-primary) !important; } } } -.card-content { +.readonly-note { + align-items: center; + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + border-radius: var(--border-radius-md); + color: color-mix(in srgb, var(--color-warning) 40%, var(--color-text-primary)); display: flex; - flex: 1; - flex-direction: column; - gap: var(--spacing-3); - position: relative; - z-index: 1; + font-size: var(--font-size-xs); + gap: var(--spacing-2); + line-height: var(--line-height-snug); + margin: var(--spacing-4) 0 0; + padding: var(--spacing-2) var(--spacing-3); } -.folder-path { +.readonly-note-icon { + flex: none; + font-size: 14px; + height: 14px; + width: 14px; +} + +.card-footer { align-items: center; - color: var(--color-text-tertiary); + border-top: 1px solid var(--color-border-subtle); display: flex; - font-family: var(--font-family-mono); - font-size: var(--font-size-sm); + gap: var(--spacing-3); + justify-content: space-between; + margin-top: var(--spacing-4); + padding-top: var(--spacing-3); +} + +.locale-chips { + display: flex; + flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--spacing-1); + list-style: none; margin: 0; - padding-top: var(--spacing-2); - word-break: break-all; - - .path-icon { - color: var(--color-text-tertiary); - flex-shrink: 0; - font-size: 14px; - height: 14px; - opacity: 0.6; - width: 14px; + min-width: 0; + padding: 0; +} + +.locale-chip { + align-items: baseline; + background: var(--color-background-muted); + border: 1px solid transparent; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + display: inline-flex; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + gap: var(--spacing-1); + line-height: 1.5; + padding: 1px var(--spacing-2); + + &--base { + background: color-mix(in srgb, var(--color-secondary) 16%, transparent); + border-color: color-mix(in srgb, var(--color-secondary) 35%, transparent); + color: color-mix(in srgb, var(--color-secondary) 45%, var(--color-text-primary)); + } + + &--more { + background: none; + border-color: var(--color-border-strong); + border-style: dashed; + cursor: help; } } -.card-locales { - margin-top: auto; - position: relative; - z-index: 1; +.locale-chip-tag { + font-family: var(--font-family-body); + font-size: 0.625rem; + font-weight: var(--font-weight-medium); + letter-spacing: 0.04em; + opacity: 0.85; + text-transform: uppercase; } .card-arrow { - align-items: center; - bottom: var(--spacing-4); - display: flex; - opacity: 0; - position: absolute; - right: var(--spacing-4); - transform: translateX(-8px); - transition: opacity var(--transition-normal), transform var(--transition-normal); - z-index: 2; + color: var(--color-text-secondary); + flex: none; + font-size: 18px; + height: 18px; + opacity: 0.75; + transform: translateX(-4px); + transition: + color var(--transition-normal), + opacity var(--transition-normal), + transform var(--transition-normal); + width: 18px; +} - mat-icon { - color: var(--color-primary); - font-size: 18px; - height: 18px; - width: 18px; +// --------------------------------------------------------------------------- +// Narrow screens +// --------------------------------------------------------------------------- +@media (max-width: 600px) { + :host { + padding: var(--spacing-6) var(--spacing-4); + } + + .header-content { + align-items: stretch; + flex-direction: column; + } + + .header-tools { + flex-wrap: nowrap; + } + + .filter-field { + width: auto; + flex: 1 1 auto; + } + + .collection-card { + padding: var(--spacing-4); } } // --------------------------------------------------------------------------- // Dark theme adjustments // --------------------------------------------------------------------------- -:host-context([data-theme='dark']) { - .card-inner { - background: color-mix(in srgb, var(--color-background) 70%, transparent); - } +@mixin collections-dark { + --card-surface: color-mix(in srgb, var(--color-text-primary) 5%, var(--color-background-subtle)); + --card-surface-hover: color-mix(in srgb, var(--color-primary) 8%, var(--color-background-subtle)); +} - .card-accent::after { - background: var(--color-background); - } +:host-context([data-theme='dark']) { + @include collections-dark; } @media (prefers-color-scheme: dark) { :host-context(:root:not([data-theme='light']):not([data-theme='dark'])) { - .card-inner { - background: color-mix(in srgb, var(--color-background) 70%, transparent); - } - - .card-accent::after { - background: var(--color-background); - } + @include collections-dark; } } @@ -499,28 +642,16 @@ // --------------------------------------------------------------------------- @media (prefers-reduced-motion: reduce) { .ring { animation: none; } - .collection-card { animation: none; } -} -// --------------------------------------------------------------------------- -// Read-only badge -// --------------------------------------------------------------------------- -.readonly-badge { - display: inline-flex; - align-items: center; - gap: 4px; - margin-left: var(--spacing-2); - padding: 2px 8px; - border-radius: var(--border-radius-sm, 4px); - background-color: color-mix(in srgb, var(--color-warn, #b26a00) 12%, transparent); - color: var(--color-warn, #b26a00); - font-size: var(--font-size-xs, 0.75rem); - font-weight: var(--font-weight-medium); - white-space: nowrap; + .collection-card { + animation: none; + + &:hover { + transform: none; + } + } - .readonly-badge-icon { - font-size: 14px; - width: 14px; - height: 14px; + .card-arrow { + transform: none; } } diff --git a/apps/tracker/src/app/collections/collections-manager.spec.ts b/apps/tracker/src/app/collections/collections-manager.spec.ts new file mode 100644 index 00000000..3a3bc038 --- /dev/null +++ b/apps/tracker/src/app/collections/collections-manager.spec.ts @@ -0,0 +1,125 @@ +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { LingoTrackerConfigDto } from '@simoncodes-ca/data-transfer'; +import { CollectionsManager } from './collections-manager'; +import { CollectionsStore } from './store/collections.store'; +import { CollectionsApiService } from './services/collections-api.service'; +import { getTranslocoTestingModule } from '../../testing/transloco-testing.module'; + +const config: LingoTrackerConfigDto = { + exportFolder: 'dist/export', + importFolder: 'dist/import', + baseLocale: 'en', + locales: ['en', 'fr-ca'], + collections: { + zulu: { translationsFolder: 'sample/zulu', locales: ['en', 'fr-ca'] }, + alpha: { + translationsFolder: 'sample/alpha', + locales: ['fr-ca', 'es', 'en', 'de', 'ja', 'ru'], + }, + Bravo: { translationsFolder: 'vendor/bravo', locales: ['de', 'ja'], baseLocale: 'de', readOnly: true }, + }, +}; + +const api = { + getConfig: vi.fn(), + updateConfig: vi.fn(), + createCollection: vi.fn(), + updateCollection: vi.fn(), + deleteCollection: vi.fn(), +}; + +describe('CollectionsManager', () => { + let fixture: ComponentFixture; + let component: CollectionsManager; + let store: InstanceType; + + beforeEach(async () => { + vi.resetAllMocks(); + api.getConfig.mockReturnValue(of(config)); + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [CollectionsManager, NoopAnimationsModule, getTranslocoTestingModule()], + providers: [ + CollectionsStore, + { provide: CollectionsApiService, useValue: api }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: Router, useValue: { navigate: vi.fn() } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CollectionsManager); + component = fixture.componentInstance; + store = TestBed.inject(CollectionsStore); + store.loadCollections(); + fixture.detectChanges(); + }); + + it('sorts collections by name, ignoring case', () => { + expect(component.cards().map((c) => c.name)).toEqual(['alpha', 'Bravo', 'zulu']); + }); + + it('lists the base locale first, whether it comes from the collection or the global config', () => { + const alpha = component.cards().find((c) => c.name === 'alpha'); + const bravo = component.cards().find((c) => c.name === 'Bravo'); + + expect(alpha?.baseLocale).toBe('en'); + expect(alpha?.visibleLocales[0]).toBe('en'); + expect(bravo?.baseLocale).toBe('de'); + expect(bravo?.visibleLocales[0]).toBe('de'); + }); + + it('caps locale chips at four, overflow chip included, so the chip row never wraps', () => { + const alpha = component.cards().find((c) => c.name === 'alpha'); + + expect(alpha?.visibleLocales).toEqual(['en', 'fr-ca', 'es']); + expect(alpha?.overflowLocales).toEqual(['de', 'ja', 'ru']); + }); + + it('shows every locale when they all fit', () => { + const zulu = component.cards().find((c) => c.name === 'zulu'); + + expect(zulu?.visibleLocales).toEqual(['en', 'fr-ca']); + expect(zulu?.overflowLocales).toEqual([]); + }); + + it('marks read-only collections', () => { + expect(component.cards().find((c) => c.name === 'Bravo')?.readOnly).toBe(true); + expect(component.cards().find((c) => c.name === 'zulu')?.readOnly).toBe(false); + }); + + it('filters on name and on translations folder', () => { + component.filter.set('BRAV'); + expect(component.cards().map((c) => c.name)).toEqual(['Bravo']); + + component.filter.set('sample/'); + expect(component.cards().map((c) => c.name)).toEqual(['alpha', 'zulu']); + }); + + it('reports no matches only while a filter excludes everything', () => { + expect(component.hasNoMatches()).toBe(false); + + component.filter.set('nothing-matches-this'); + expect(component.hasNoMatches()).toBe(true); + + component.clearFilter(); + expect(component.filter()).toBe(''); + expect(component.hasNoMatches()).toBe(false); + }); + + it('hides the filter until the list outgrows a handful of collections', () => { + expect(component.showFilter()).toBe(false); + }); + + it('navigates to the browser with the collection name encoded', () => { + const router = TestBed.inject(Router); + component.navigateToBrowser('a b'); + + expect(router.navigate).toHaveBeenCalledWith(['/browser', 'a%20b']); + }); +}); diff --git a/apps/tracker/src/app/collections/collections-manager.ts b/apps/tracker/src/app/collections/collections-manager.ts index 295b4a23..f57e0d07 100644 --- a/apps/tracker/src/app/collections/collections-manager.ts +++ b/apps/tracker/src/app/collections/collections-manager.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, computed, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatButtonModule } from '@angular/material/button'; @@ -10,17 +10,36 @@ import { Router } from '@angular/router'; import { TranslocoModule, TranslocoService } from '@jsverse/transloco'; import { CollectionsStore } from './store/collections.store'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; -import { TagList } from '../shared/tag-list/tag-list.component'; import { NotificationService } from '../shared/notification'; +/** + * Total locale chips a card shows, overflow chip included. Capped so every card keeps a + * single chip row and rows of cards stay flush with each other. + */ +const MAX_LOCALE_CHIPS = 4; + +/** Below this many collections the name filter is more clutter than help. */ +const FILTER_THRESHOLD = 6; + +/** A collection prepared for display: chips resolved, overflow already split off. */ +export interface CollectionCardView { + readonly name: string; + readonly translationsFolder: string; + readonly readOnly: boolean; + readonly baseLocale: string | undefined; + readonly visibleLocales: readonly string[]; + readonly overflowLocales: readonly string[]; +} + /** * Collections Manager component for viewing and managing translation collections. * * Features: - * - Displays all collections in a responsive grid + * - Displays all collections in a responsive grid, sorted by name + * - Filters by name or translations folder once the list grows past a handful * - Create, edit, and delete collections * - Navigate to translation browser for each collection - * - Loading and empty states + * - Loading, empty, and no-matches states * - Success/error notifications via snackbar */ @Component({ @@ -36,7 +55,6 @@ import { NotificationService } from '../shared/notification'; MatMenuModule, MatTooltipModule, TranslocoModule, - TagList, ], templateUrl: './collections-manager.html', styleUrl: './collections-manager.scss', @@ -51,6 +69,57 @@ export class CollectionsManager { readonly TOKENS = TRACKER_TOKENS; + /** Current text typed into the name/folder filter. */ + readonly filter = signal(''); + + /** Collections sorted by name and prepared for the card template. */ + readonly cards = computed(() => { + const query = this.filter().trim().toLowerCase(); + + return this.store + .collectionEntriesWithLocales() + .filter( + (item) => + query.length === 0 || + item.name.toLowerCase().includes(query) || + item.config.translationsFolder.toLowerCase().includes(query), + ) + .map((item) => this.#toCardView(item)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); + }); + + readonly showFilter = computed(() => this.store.collectionEntriesWithLocales().length > FILTER_THRESHOLD); + readonly isFiltering = computed(() => this.filter().trim().length > 0); + readonly hasNoMatches = computed(() => this.store.hasCollections() && this.cards().length === 0); + + /** + * Orders a collection's locales with the base locale first, then splits off the tail beyond + * MAX_LOCALE_CHIPS so the chip row never wraps and cards in a row share a height. + */ + #toCardView(item: { + name: string; + config: { translationsFolder: string; readOnly?: boolean }; + locales: string[] | undefined; + baseLocale: string | undefined; + }): CollectionCardView { + const locales = item.locales ?? []; + const base = item.baseLocale; + const ordered = base && locales.includes(base) ? [base, ...locales.filter((l) => l !== base)] : [...locales]; + + return { + name: item.name, + translationsFolder: item.config.translationsFolder, + readOnly: item.config.readOnly === true, + baseLocale: base, + visibleLocales: ordered.length > MAX_LOCALE_CHIPS ? ordered.slice(0, MAX_LOCALE_CHIPS - 1) : ordered, + overflowLocales: ordered.length > MAX_LOCALE_CHIPS ? ordered.slice(MAX_LOCALE_CHIPS - 1) : [], + }; + } + + clearFilter(): void { + this.filter.set(''); + } + /** * Opens the create collection dialog. */ diff --git a/apps/tracker/src/app/header/app-header.html b/apps/tracker/src/app/header/app-header.html index e795a35c..e55e16e0 100644 --- a/apps/tracker/src/app/header/app-header.html +++ b/apps/tracker/src/app/header/app-header.html @@ -39,30 +39,43 @@

LingoTracker

} - + - - - +
+ - - + + + + +
+ +
+
+ } @if (store.isLoading()) { @@ -8,51 +38,228 @@

{{ TOKENS.SETTINGS.TITLE | transloco }}

} @if (store.error(); as error) { - + } -
-

- {{ TOKENS.SETTINGS.PROTECTEDTERMSLABEL | transloco }} -

- - - - @for (term of protectedTermsList(); track term) { - - {{ term }} - - - } - - - {{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }} - +
+
+

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSLABEL | transloco }} +

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSCOUNTX | transloco : { count: termCount() } }} +
+ +
+ info +

{{ TOKENS.SETTINGS.PROTECTEDTERMSHINT | transloco }}

+
@if (protectedTermsFilePath(); as filePath) { -

- {{ TOKENS.SETTINGS.PROTECTEDTERMSFILELABEL | transloco : { file: filePath } }} +

+ description + + {{ TOKENS.SETTINGS.PROTECTEDTERMSFILELABEL | transloco : { file: filePath } }}

} -
- -
+ + + @if (addError(); as duplicate) { + + } + + @if (isEmpty()) { +
+ shield +

{{ TOKENS.SETTINGS.PROTECTEDTERMSEMPTYTITLE | transloco }}

+

{{ TOKENS.SETTINGS.PROTECTEDTERMSEMPTYMESSAGE | transloco }}

+
+ } @else { +
+ @if (showFilter()) { +
+ search + + @if (isFiltering()) { + + } +
+ } + +
+ @if (hasNoMatches()) { +

+ {{ TOKENS.SETTINGS.PROTECTEDTERMSNOMATCHESX | transloco : { query: filter().trim() } }} +

+ } @else { +
    + @for (entry of visibleEntries(); track entry.id) { +
  • + @if (editingId() === entry.id) { +
    + +
    + + +
    +
    + @if (editError(); as duplicate) { + + } + } @else { +
    + {{ entry.value }} + + @switch (statusOf(entry)) { + @case ('removed') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGEREMOVING | transloco }} + + } + @case ('added') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGENEW | transloco }} + + } + @case ('edited') { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSBADGEEDITED | transloco }} + + } + } + + @if (statusOf(entry) === 'edited' && entry.original) { + + {{ TOKENS.SETTINGS.PROTECTEDTERMSWASX | transloco : { term: entry.original } }} + + } +
    + +
    + @if (entry.removed) { + + } @else { + + + } +
    + } +
  • + } +
+ } +
+
+ } +
-
\ No newline at end of file + diff --git a/apps/tracker/src/app/settings/settings.scss b/apps/tracker/src/app/settings/settings.scss index 57934ab6..bc013cba 100644 --- a/apps/tracker/src/app/settings/settings.scss +++ b/apps/tracker/src/app/settings/settings.scss @@ -1,54 +1,711 @@ +:host { + display: block; + height: 100%; +} + .settings-page { display: flex; flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; + gap: var(--spacing-5); + // The app has no global border-box reset, so the padding below would otherwise + // be added on top of the full height and push the column past the viewport. + box-sizing: border-box; + height: 100%; + padding: var(--spacing-6) var(--spacing-6) var(--spacing-6); max-width: 720px; margin: 0 auto; + overflow: hidden; } .settings-header { display: flex; align-items: center; + justify-content: space-between; + gap: var(--spacing-4); + flex: none; + flex-wrap: wrap; } .settings-title { margin: 0; + color: var(--color-text-primary); } .settings-progress { - margin-bottom: 1rem; + flex: none; + margin: 0; } .settings-error { - padding: 0.75rem 1rem; - border: 1px solid var(--mat-snack-bar-button-color, #f44336); - border-radius: 4px; - color: #f44336; - background: rgba(244, 67, 54, 0.08); + flex: none; + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid color-mix(in srgb, var(--color-error) 35%, transparent); + border-radius: var(--border-radius-lg); + color: color-mix(in srgb, var(--color-error) 65%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-error) 10%, transparent); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); } -.settings-section { +.settings-error-icon { + flex: none; + font-size: 20px; + width: 20px; + height: 20px; +} + +// ── Protected terms ────────────────────────────────────────────────────────── + +.terms { display: flex; flex-direction: column; - gap: 0.5rem; + flex: 1 1 auto; + min-height: 0; + + // An opaque twin of the panel fill, so the scroll shadows below have something + // solid to fade into. color-mix with `transparent` cannot be used for that. + --terms-surface: color-mix(in srgb, var(--color-background) 88%, var(--color-background-subtle)); + --terms-scroll-shadow: rgb(44 36 28 / 0.18); + + // Material's default disabled fill is a heavy grey slab — the loudest thing on + // the page while doing nothing. Quiet it down to a token tint. + --mdc-filled-button-disabled-container-color: color-mix(in srgb, var(--color-border) 45%, transparent); + --mdc-filled-button-disabled-label-text-color: var(--color-text-tertiary); +} + +.terms-heading { + display: flex; + align-items: baseline; + gap: var(--spacing-3); + flex-wrap: wrap; } -.settings-section-title { +.terms-title { margin: 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-snug); + color: var(--color-text-primary); } -.settings-field { - width: 100%; +.terms-count { + padding: 2px var(--spacing-2); + border: 1px solid color-mix(in srgb, var(--color-secondary) 30%, transparent); + border-radius: var(--border-radius-full); + background: color-mix(in srgb, var(--color-secondary) 12%, transparent); + color: var(--color-secondary-active); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.settings-actions { +// Information callout. One flat panel, no accent rule down the side: the tint +// and the icon already say what kind of block this is. +.terms-note { display: flex; - justify-content: flex-end; + align-items: flex-start; + gap: var(--spacing-2); + margin: var(--spacing-3) 0 0; + padding: var(--spacing-3) var(--spacing-4); + border: 1px solid color-mix(in srgb, var(--color-info) 28%, transparent); + border-radius: var(--border-radius-lg); + background: color-mix(in srgb, var(--color-info) 10%, transparent); } -.settings-protected-terms-file { - margin: 0 0 8px; - font-size: 12px; - opacity: 0.7; + +.terms-note-icon { + flex: none; + // Nudged onto the first line's optical centre rather than its box top. + margin-top: 1px; + font-size: 18px; + width: 18px; + height: 18px; + color: color-mix(in srgb, var(--color-info) 75%, var(--color-text-primary)); +} + +.terms-note-text { + margin: 0; + // Secondary text on a tinted surface takes its cast from that hue, not grey. + color: color-mix(in srgb, var(--color-info) 20%, var(--color-text-primary)); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +.terms-file { + display: flex; + align-items: flex-start; + gap: var(--spacing-1); + margin: var(--spacing-2) 0 0; + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); word-break: break-all; } + +.terms-file span { + min-width: 0; +} + +.terms-file-icon { + flex: none; + margin-top: 1px; + font-size: 14px; + width: 14px; + height: 14px; +} + +// ── Add row ────────────────────────────────────────────────────────────────── + +.terms-add { + display: flex; + align-items: flex-start; + gap: var(--spacing-2); + flex: none; + margin-top: var(--spacing-5); +} + +.terms-add-field { + flex: 1 1 auto; + min-width: 0; +} + +.terms-add-icon, +.terms-filter-icon { + margin-inline-end: var(--spacing-2); + color: var(--color-text-tertiary); +} + +.terms-add-button { + flex: none; + height: 56px; + min-width: 88px; + border-radius: var(--border-radius-lg); +} + +.terms-inline-error { + display: flex; + align-items: center; + gap: var(--spacing-1); + margin: var(--spacing-2) 0 0; + color: color-mix(in srgb, var(--color-error) 65%, var(--color-text-primary)); + font-size: var(--font-size-xs); + line-height: var(--line-height-snug); +} + +.terms-inline-error-icon { + flex: none; + font-size: 16px; + width: 16px; + height: 16px; +} + +.terms-panel { + display: flex; + flex-direction: column; + flex: 0 1 auto; + min-height: 0; + margin-top: var(--spacing-5); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-xl); + background: var(--terms-surface); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.terms-scroll { + flex: 1 1 auto; + min-height: 120px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-color: var(--color-border-strong) transparent; + scrollbar-width: thin; + + // Classic scroll shadows: the two `local` gradients ride the content and hide + // the fixed ones at the extremes, so a shadow shows only where more rows are. + background: + linear-gradient(var(--terms-surface) 30%, transparent) top / 100% 20px no-repeat local, + linear-gradient(transparent, var(--terms-surface) 70%) bottom / 100% 20px no-repeat local, + radial-gradient(farthest-side at 50% 0, var(--terms-scroll-shadow), transparent) top / 100% 8px no-repeat scroll, + radial-gradient(farthest-side at 50% 100%, var(--terms-scroll-shadow), transparent) bottom / 100% 8px no-repeat scroll; +} + +[data-theme='dark'] .terms { + --terms-scroll-shadow: rgb(0 0 0 / 0.55); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']):not([data-theme='dark']) .terms { + --terms-scroll-shadow: rgb(0 0 0 / 0.55); + } +} + +.terms-filter { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: none; + padding: var(--spacing-1) var(--spacing-2) var(--spacing-1) var(--spacing-4); + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-background-muted) 55%, transparent); +} + +.terms-filter-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; + color: var(--color-text-tertiary); +} + +.terms-filter-input { + flex: 1 1 auto; + min-width: 0; + padding: var(--spacing-2) 0; + border: 0; + background: none; + color: var(--color-text-primary); + font-family: inherit; + font-size: var(--font-size-sm); + + &::placeholder { + color: var(--color-text-secondary); + } + + &:focus { + outline: none; + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.terms-filter:focus-within { + background: color-mix(in srgb, var(--color-secondary) 8%, transparent); +} + +.terms-filter-clear { + flex: none; + --mdc-icon-button-state-layer-size: 32px; + + .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +.terms-no-matches { + margin: 0; + padding: var(--spacing-8) var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + text-align: center; +} + +// ── List ───────────────────────────────────────────────────────────────────── + +.terms-list { + list-style: none; + margin: 0; + padding: 0; +} + +.term { + display: flex; + align-items: center; + gap: var(--spacing-3); + min-height: 40px; + padding: 0 var(--spacing-2) 0 var(--spacing-4); + border-top: 1px solid var(--color-border-subtle); + transition: background var(--transition-fast); + + &:first-child { + border-top: 0; + } + + &:hover, + &:focus-within { + background: color-mix(in srgb, var(--color-primary) 5%, transparent); + } +} + +.term-body { + display: flex; + align-items: baseline; + gap: var(--spacing-2); + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; +} + +.term-value { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + word-break: break-word; + transition: color var(--transition-fast); +} + +.term-was { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + text-decoration: line-through; + word-break: break-word; +} + +.term-badge { + flex: none; + padding: 1px var(--spacing-2); + border-radius: var(--border-radius-full); + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.02em; + line-height: 1.5; + text-transform: uppercase; +} + +.term-badge--added { + background: color-mix(in srgb, var(--color-success) 18%, transparent); + color: color-mix(in srgb, var(--color-success) 42%, var(--color-text-primary)); +} + +.term-badge--edited { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: color-mix(in srgb, var(--color-warning) 42%, var(--color-text-primary)); +} + +.term-badge--removed { + background: color-mix(in srgb, var(--color-error) 18%, transparent); + color: color-mix(in srgb, var(--color-error) 42%, var(--color-text-primary)); +} + +.term-actions { + display: flex; + align-items: center; + gap: 2px; + flex: none; + margin-inline-start: auto; +} + +// Row controls stay out of the way until the row is reached, but never hide from +// keyboard or touch — a pointer-less visitor gets no hover to reveal them. +.term-action { + --mdc-icon-button-state-layer-size: 34px; + // Material's 48px touch target overflows a 45px row and makes the scroller + // report 2px of phantom overflow. Drop it here and restore a real one below, + // on the pointers that actually need it. + --mat-icon-button-touch-target-display: none; + color: var(--color-text-secondary); + transition: color var(--transition-fast); + + .term:hover &, + .term:focus-within &, + &:focus-visible { + color: var(--color-text-primary); + } + + .mat-icon { + font-size: 17px; + width: 17px; + height: 17px; + } +} + +// Coarse pointers get back a 44px target, and rows tall enough to hold it. +@media (pointer: coarse) { + .term { + min-height: 48px; + } + + .term-action { + --mdc-icon-button-state-layer-size: 44px; + } +} + +.term-action--remove:hover, +.term:hover .term-action--remove:hover { + color: color-mix(in srgb, var(--color-error) 70%, var(--color-text-primary)); +} + +.term-action--confirm, +.term:hover .term-action--confirm { + color: color-mix(in srgb, var(--color-success) 55%, var(--color-text-primary)); +} + +.term-undo { + --mdc-text-button-label-text-color: color-mix(in srgb, var(--color-secondary) 70%, var(--color-text-primary)); + --mdc-text-button-container-height: 32px; + flex: none; + font-size: var(--font-size-xs); + + .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-inline-end: var(--spacing-1); + } +} + +// ── Pending row states ─────────────────────────────────────────────────────── + +.term[data-status='added'] { + background: color-mix(in srgb, var(--color-success) 12%, transparent); +} + +.term[data-status='edited'] { + background: color-mix(in srgb, var(--color-warning) 13%, transparent); +} + +.term[data-status='removed'] { + background: color-mix(in srgb, var(--color-error) 11%, transparent); + + .term-value { + color: var(--color-text-secondary); + text-decoration: line-through; + text-decoration-color: color-mix(in srgb, var(--color-error) 70%, transparent); + text-decoration-thickness: 1.5px; + } +} + +// ── Inline rename ──────────────────────────────────────────────────────────── + +.term--editing { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-1); + padding-block: var(--spacing-2); + padding-inline-end: var(--spacing-2); + background: color-mix(in srgb, var(--color-secondary) 9%, transparent); +} + +.term-edit { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.term-edit-input { + flex: 1 1 auto; + min-width: 0; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius-md); + background: var(--color-background); + color: var(--color-text-primary); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + + &:focus-visible { + outline: 2px solid var(--focus-ring-color); + outline-offset: 1px; + } + + &[aria-invalid='true'] { + border-color: var(--color-error); + } +} + +.term-edit-error { + margin: 0; + padding-inline-start: var(--spacing-1); +} + +// ── Empty states ───────────────────────────────────────────────────────────── + +.terms-empty { + flex: none; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-2); + margin-top: var(--spacing-5); + padding: var(--spacing-10) var(--spacing-6); + border: 1px dashed var(--color-border-strong); + border-radius: var(--border-radius-xl); + background: color-mix(in srgb, var(--color-background-subtle) 70%, transparent); + text-align: center; +} + +.terms-empty-icon { + font-size: 40px; + width: 40px; + height: 40px; + color: color-mix(in srgb, var(--color-primary) 45%, transparent); +} + +.terms-empty-title { + margin: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.terms-empty-message { + margin: 0; + max-width: 44ch; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: var(--line-height-normal); +} + +// ── Save bar ───────────────────────────────────────────────────────────────── +// Pinned in the page header: the list scrolls beneath it, these controls never do. + +.terms-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-2); + flex: none; + flex-wrap: wrap; + min-width: 0; +} + +.terms-bar-status { + display: flex; + align-items: center; + gap: var(--spacing-1); + margin: 0; + min-width: 0; + padding: var(--spacing-1) var(--spacing-3); + border: 1px solid transparent; + border-radius: var(--border-radius-full); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-variant-numeric: tabular-nums; + white-space: nowrap; + + // A flex child defaults to min-width: auto, which is what let the pill push + // the Save button off a 320px viewport instead of truncating. + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + transition: color var(--transition-normal), background var(--transition-normal), + border-color var(--transition-normal); +} + +// The one authored moment on this page: the pill arming itself the instant the +// list stops matching the file, so a pending change is impossible to walk past. +.terms-bar--dirty .terms-bar-status { + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: color-mix(in srgb, var(--color-warning) 42%, var(--color-text-primary)); + font-weight: var(--font-weight-semibold); + animation: status-arm 420ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes status-arm { + from { + border-color: transparent; + background: color-mix(in srgb, var(--color-warning) 34%, transparent); + box-shadow: 0 6px 18px -8px color-mix(in srgb, var(--color-warning) 70%, transparent); + transform: translateY(-3px); + } + to { + border-color: color-mix(in srgb, var(--color-warning) 45%, transparent); + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + box-shadow: 0 0 0 transparent; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .terms-bar--dirty .terms-bar-status { + animation: none; + } +} + +.terms-bar-icon { + flex: none; + font-size: 18px; + width: 18px; + height: 18px; +} + +.terms-bar-actions { + display: flex; + align-items: center; + gap: var(--spacing-2); + flex: none; + margin-inline-start: auto; + + button { + white-space: nowrap; + } +} + +// ── Short viewports ────────────────────────────────────────────────────────── +// Landscape phones and short desktop windows. The section title already says what +// this list is, so the explanatory hint and the file path give up their rows to +// keep the filter and a useful number of terms on screen. + +@media (max-height: 500px) { + .settings-page { + gap: var(--spacing-3); + padding-block: var(--spacing-4); + } + + .terms-note, + .terms-file { + display: none; + } + + .terms-add { + margin-top: var(--spacing-3); + } + + .terms-panel { + margin-top: var(--spacing-3); + } +} + +// ── Narrow screens ─────────────────────────────────────────────────────────── + +@media (max-width: 599px) { + .settings-page { + padding: var(--spacing-4) var(--spacing-4) var(--spacing-5); + } + + .terms-add-button { + min-width: 64px; + padding-inline: var(--spacing-3); + } + + .terms-file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terms-bar { + width: 100%; + } + + .terms-bar-actions { + margin-inline-start: auto; + } +} + +// ── Browser surfaces ───────────────────────────────────────────────────────── +// The parts nobody draws still belong to the palette. + +.terms { + ::selection { + background: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); + } +} + +.terms-add-field input, +.term-edit-input { + caret-color: var(--color-primary); +} + diff --git a/apps/tracker/src/app/settings/settings.spec.ts b/apps/tracker/src/app/settings/settings.spec.ts index 3a5aa77c..744ef86e 100644 --- a/apps/tracker/src/app/settings/settings.spec.ts +++ b/apps/tracker/src/app/settings/settings.spec.ts @@ -18,7 +18,7 @@ describe('Settings', () => { baseLocale: 'en', locales: ['en'], collections: {}, - protectedTerms: ['iPhone'], + protectedTerms: ['iPhone', 'Node.js'], protectedTermsFilePath: '/project/.lingo-tracker-protected-terms.json', }; @@ -29,6 +29,22 @@ describe('Settings', () => { updateGlobalConfig: updateGlobalConfigMock, }); + /** Creates the component against a store, and returns it after a first render. */ + const render = (config: LingoTrackerConfigDto | null, error: string | null = null) => { + TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(config, error) }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + return component; + }; + + const termValues = () => component.entries().map((entry) => entry.value); + const entryFor = (value: string) => { + const entry = component.entries().find((candidate) => candidate.value === value); + expect(entry).toBeDefined(); + return entry; + }; + beforeEach(async () => { vi.clearAllMocks(); await TestBed.configureTestingModule({ @@ -37,74 +53,326 @@ describe('Settings', () => { }).compileComponents(); }); - it('renders global protected terms seeded from the store config', async () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + it('renders global protected terms seeded from the store config', () => { + render(baseConfig); - expect(component.protectedTermsList()).toEqual(['iPhone']); + expect(termValues()).toEqual(['iPhone', 'Node.js']); + expect(fixture.nativeElement.textContent).toContain('iPhone'); + expect(fixture.nativeElement.querySelectorAll('.term')).toHaveLength(2); + }); - const text = fixture.nativeElement.textContent; - expect(text).toContain('iPhone'); + it('lists terms alphabetically regardless of the order the file stored them in', () => { + render({ ...baseConfig, protectedTerms: ['zulu', 'Alpha', 'mike'] }); + + expect(component.sortedEntries().map((entry) => entry.value)).toEqual(['Alpha', 'mike', 'zulu']); }); it('names the file the terms are stored in', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + render(baseConfig); expect(component.protectedTermsFilePath()).toBe('/project/.lingo-tracker-protected-terms.json'); - expect(fixture.nativeElement.querySelector('.settings-protected-terms-file')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.terms-file')).not.toBeNull(); }); it('omits the file line when the API reports no path', () => { - TestBed.overrideProvider(CollectionsStore, { - useValue: buildStore({ ...baseConfig, protectedTermsFilePath: undefined }), + render({ ...baseConfig, protectedTermsFilePath: undefined }); + + expect(fixture.nativeElement.querySelector('.terms-file')).toBeNull(); + }); + + it('shows the empty state when there are no terms', () => { + render({ ...baseConfig, protectedTerms: [] }); + + expect(component.isEmpty()).toBe(true); + expect(fixture.nativeElement.querySelector('.terms-empty')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.terms-list')).toBeNull(); + }); + + describe('adding', () => { + it('adds a trimmed term and clears the draft', () => { + render(baseConfig); + + component.onAddDraftChange(' C++ '); + component.addTerm(); + + expect(termValues()).toContain('C++'); + expect(component.addDraft()).toBe(''); + expect(component.changeCount()).toBe(1); }); - fixture = TestBed.createComponent(Settings); - fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('.settings-protected-terms-file')).toBeNull(); + it('refuses a duplicate and reports which term collided', () => { + render(baseConfig); + + component.onAddDraftChange('iPhone'); + component.addTerm(); + + expect(component.addError()).toBe('iPhone'); + expect(termValues()).toEqual(['iPhone', 'Node.js']); + }); + + it('clears the duplicate error as soon as the draft changes', () => { + render(baseConfig); + component.onAddDraftChange('iPhone'); + component.addTerm(); + + component.onAddDraftChange('iPhon'); + + expect(component.addError()).toBeNull(); + }); + + it('re-adding a term that is marked for removal takes the removal back', () => { + render(baseConfig); + const removed = entryFor('iPhone'); + if (!removed) return; + component.removeTerm(removed); + + component.onAddDraftChange('iPhone'); + component.addTerm(); + + expect(component.addError()).toBeNull(); + expect(entryFor('iPhone')?.removed).toBe(false); + expect(component.changeCount()).toBe(0); + }); }); - it('save invokes the store updateGlobalConfig with the current terms', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(baseConfig) }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('removing', () => { + it('keeps a saved term visible and marked until the change is saved', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.removeTerm(entry); + + expect(termValues()).toContain('iPhone'); + expect(component.statusOf(entryFor('iPhone') ?? entry)).toBe('removed'); + expect(component.termsToSave()).toEqual(['Node.js']); + expect(component.changeCount()).toBe(1); + }); - component.addProtectedTerm({ value: 'C++', chipInput: { clear: () => undefined } } as never); - component.save(); + it('restores a term marked for removal', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); - expect(updateGlobalConfigMock).toHaveBeenCalledWith({ protectedTerms: ['iPhone', 'C++'] }); + component.restoreTerm(entryFor('iPhone') ?? entry); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); + + it('drops a term added in this session outright rather than marking it', () => { + render(baseConfig); + component.onAddDraftChange('C++'); + component.addTerm(); + + const added = entryFor('C++'); + if (!added) return; + component.removeTerm(added); + + expect(termValues()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); }); - it('surfaces a failed save error to the user', () => { - TestBed.overrideProvider(CollectionsStore, { useValue: buildStore(null, 'update failed') }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('renaming', () => { + it('commits an edit and remembers the previous spelling', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('iPad'); + component.commitEdit(); + + expect(component.editingId()).toBeNull(); + expect(component.termsToSave()).toEqual(['iPad', 'Node.js']); + expect(entryFor('iPad')?.original).toBe('iPhone'); + expect(component.statusOf(entryFor('iPad') ?? entry)).toBe('edited'); + }); + + it('refuses an edit that collides with another term', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; - const text = fixture.nativeElement.textContent; - expect(text).toContain('update failed'); + component.beginEdit(entry); + component.onEditDraftChange('Node.js'); + component.commitEdit(); + + expect(component.editError()).toBe('Node.js'); + expect(component.editingId()).toBe(entry.id); + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + }); + + it('treats an emptied edit as a cancel', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange(' '); + component.commitEdit(); + + expect(component.editingId()).toBeNull(); + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + }); + + it('cancel leaves the term untouched', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('iPad'); + component.cancelEdit(); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); }); - it('does not clobber in-progress edits on a config refetch', () => { - const config = signal(baseConfig); - TestBed.overrideProvider(CollectionsStore, { - useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + describe('filtering', () => { + const manyTerms = { ...baseConfig, protectedTerms: ['a1', 'b2', 'c3', 'd4', 'e5', 'f6', 'g7', 'h8', 'iPhone'] }; + + it('offers no filter on a list short enough to scan', () => { + render(baseConfig); + + expect(component.showFilter()).toBe(false); + expect(fixture.nativeElement.querySelector('.terms-filter')).toBeNull(); }); - fixture = TestBed.createComponent(Settings); - component = fixture.componentInstance; - fixture.detectChanges(); - component.addProtectedTerm({ value: 'Node.js', chipInput: { clear: () => undefined } } as never); + it('offers a filter once the list is long enough to need one', () => { + render(manyTerms); + + expect(component.showFilter()).toBe(true); + expect(fixture.nativeElement.querySelector('.terms-filter')).not.toBeNull(); + }); + + it('drops a filter that would hide a term the user just added', () => { + render(manyTerms); + component.filter.set('a1'); + + component.onAddDraftChange('Zod'); + component.addTerm(); + + expect(component.filter()).toBe(''); + expect(component.visibleEntries().map((entry) => entry.value)).toContain('Zod'); + }); + + it('keeps a filter the newly added term still matches', () => { + render(manyTerms); + component.filter.set('zo'); + + component.onAddDraftChange('Zod'); + component.addTerm(); + + expect(component.filter()).toBe('zo'); + expect(component.visibleEntries().map((entry) => entry.value)).toEqual(['Zod']); + }); - // Simulate a successful save's config refetch arriving with the previous terms. - config.set({ ...baseConfig, protectedTerms: ['iPhone'] }); + it('drops a filter that would hide a term the user just renamed', () => { + render(manyTerms); + component.filter.set('iph'); + const entry = component.visibleEntries()[0]; + expect(entry).toBeDefined(); + if (!entry) return; + + component.beginEdit(entry); + component.onEditDraftChange('Zod'); + component.commitEdit(); + + expect(component.filter()).toBe(''); + expect(component.visibleEntries().map((entry) => entry.value)).toContain('Zod'); + }); - expect(component.protectedTermsList()).toEqual(['iPhone', 'Node.js']); + it('matches case-insensitively and reports when nothing matches', () => { + render(manyTerms); + + component.filter.set('iphone'); + expect(component.visibleEntries().map((entry) => entry.value)).toEqual(['iPhone']); + + component.filter.set('nothing here'); + expect(component.hasNoMatches()).toBe(true); + }); + }); + + describe('saving', () => { + it('sends the surviving terms in display order', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); + component.onAddDraftChange('C++'); + component.addTerm(); + + component.save(); + + expect(updateGlobalConfigMock).toHaveBeenCalledWith({ protectedTerms: ['C++', 'Node.js'] }); + }); + + it('does nothing when there is nothing to save', () => { + render(baseConfig); + + component.save(); + + expect(updateGlobalConfigMock).not.toHaveBeenCalled(); + }); + + it('revert all restores the saved list', () => { + render(baseConfig); + const entry = entryFor('iPhone'); + if (!entry) return; + component.removeTerm(entry); + component.onAddDraftChange('C++'); + component.addTerm(); + + component.revertAll(); + + expect(component.termsToSave()).toEqual(['iPhone', 'Node.js']); + expect(component.hasChanges()).toBe(false); + }); + + it('surfaces a failed save error to the user', () => { + render(null, 'update failed'); + + expect(fixture.nativeElement.textContent).toContain('update failed'); + }); + + it('does not clobber in-progress edits on a config refetch we did not ask for', () => { + const config = signal(baseConfig); + TestBed.overrideProvider(CollectionsStore, { + useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onAddDraftChange('C++'); + component.addTerm(); + config.set({ ...baseConfig, protectedTerms: ['iPhone', 'Node.js'] }); + fixture.detectChanges(); + + expect(component.termsToSave()).toEqual(['C++', 'iPhone', 'Node.js']); + }); + + it('adopts the refetched config as the new baseline after a save', () => { + const config = signal(baseConfig); + TestBed.overrideProvider(CollectionsStore, { + useValue: { config, error: signal(null), isLoading: signal(false), updateGlobalConfig: updateGlobalConfigMock }, + }); + fixture = TestBed.createComponent(Settings); + component = fixture.componentInstance; + fixture.detectChanges(); + + component.onAddDraftChange('C++'); + component.addTerm(); + component.save(); + config.set({ ...baseConfig, protectedTerms: ['C++', 'iPhone', 'Node.js'] }); + fixture.detectChanges(); + + expect(component.hasChanges()).toBe(false); + expect(component.termsToSave()).toEqual(['C++', 'iPhone', 'Node.js']); + }); }); }); diff --git a/apps/tracker/src/app/settings/settings.ts b/apps/tracker/src/app/settings/settings.ts index 25b4a5a9..e7fb2723 100644 --- a/apps/tracker/src/app/settings/settings.ts +++ b/apps/tracker/src/app/settings/settings.ts @@ -1,20 +1,50 @@ -import { Component, ChangeDetectionStrategy, inject, effect, signal, computed } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + type ElementRef, + inject, + effect, + signal, + computed, + untracked, + viewChildren, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatChipsModule, type MatChipInputEvent } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslocoModule } from '@jsverse/transloco'; import { normalizeProtectedTerms } from '@simoncodes-ca/domain'; -import { COMMA, ENTER } from '@angular/cdk/keycodes'; import { CollectionsStore } from '../collections/store/collections.store'; import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; +/** + * One row of the protected-terms editor. `original` is the value as last saved — + * absent for a term added in this session — so a row can describe itself as + * added, renamed or removed without diffing against the whole saved list. + */ +interface TermEntry { + readonly id: number; + readonly value: string; + readonly original?: string; + readonly removed: boolean; +} + +/** Above this many terms the list gets a filter field; below it, scanning is faster than typing. */ +const FILTER_THRESHOLD = 8; + +const compareTerms = (a: string, b: string): number => a.localeCompare(b, undefined, { sensitivity: 'base' }); + /** * Settings view. Currently exposes the global protected-terms list, written * through `PUT /api/config`. Structured so other global fields can be added * later without rework. Never exposes collections/locales/baseLocale editing. + * + * Edits are staged: adds, renames and removals are held as pending row state and + * only reach the API on Save, so every change is visible and reversible first. */ @Component({ selector: 'app-settings', @@ -26,8 +56,9 @@ import { TRACKER_TOKENS } from '../../i18n-types/tracker-resources'; MatButtonModule, MatIconModule, MatProgressBarModule, - MatChipsModule, MatFormFieldModule, + MatInputModule, + MatTooltipModule, ], templateUrl: './settings.html', styleUrl: './settings.scss', @@ -38,39 +69,240 @@ export class Settings { readonly TOKENS = TRACKER_TOKENS; - readonly termSeparatorKeyCodes = [ENTER, COMMA] as const; - readonly protectedTermsList = signal([]); + private readonly editInputs = viewChildren>('editInput'); + private readonly termRows = viewChildren>('termRow'); + + readonly entries = signal([]); + readonly addDraft = signal(''); + readonly filter = signal(''); + readonly editingId = signal(null); + readonly editDraft = signal(''); + /** Term that a failed add or rename collided with; cleared as soon as the input changes. */ + readonly addError = signal(null); + readonly editError = signal(null); + /** Path of the file the terms are stored in, surfaced read-only so the source of a diff is obvious. */ readonly protectedTermsFilePath = computed(() => this.store.config()?.protectedTermsFilePath); - /** True once the chip list has been seeded; prevents a later config refetch from clobbering edits. */ + + /** True once the list has been seeded; prevents a later config refetch from clobbering edits. */ readonly #seeded = signal(false); + /** Set while a save is in flight so the next config arrival is treated as the new baseline. */ + readonly #awaitingSave = signal(false); + #nextId = 0; + /** Row to reveal once it has rendered, so an added term is never added off-screen. */ + readonly #scrollToId = signal(null); + + /** Display order is alphabetical: the list is a set, and a sorted file keeps its Git diffs minimal. */ + readonly sortedEntries = computed(() => [...this.entries()].sort((a, b) => compareTerms(a.value, b.value))); + + readonly visibleEntries = computed(() => { + const query = this.filter().trim().toLowerCase(); + if (!query) return this.sortedEntries(); + return this.sortedEntries().filter( + (entry) => entry.value.toLowerCase().includes(query) || (entry.original?.toLowerCase().includes(query) ?? false), + ); + }); + + /** The terms a save would write: everything not marked for removal, in display order. */ + readonly termsToSave = computed(() => + normalizeProtectedTerms( + this.sortedEntries() + .filter((entry) => !entry.removed) + .map((entry) => entry.value), + ), + ); + + readonly termCount = computed(() => this.termsToSave().length); + + readonly changeCount = computed( + () => + this.entries().filter((entry) => entry.removed || entry.original === undefined || entry.original !== entry.value) + .length, + ); + + readonly hasChanges = computed(() => this.changeCount() > 0); + readonly isEmpty = computed(() => this.entries().length === 0); + readonly showFilter = computed(() => this.entries().length > FILTER_THRESHOLD); + readonly isFiltering = computed(() => this.filter().trim().length > 0); + readonly hasNoMatches = computed(() => !this.isEmpty() && this.isFiltering() && this.visibleEntries().length === 0); + readonly canAdd = computed(() => this.addDraft().trim().length > 0); constructor() { - // Seed the chip list from the store config as soon as it loads (App loads config on boot). - // Only seeds once, so a successful save that refetches config cannot overwrite in-progress edits. + // Seed from the store config as soon as it loads (App loads config on boot), and again + // after a save's refetch. A refetch that is not ours must not overwrite in-progress edits. + // The latches are read untracked so that arming one on save cannot itself trigger a + // reseed from the config still on screen — only a config arrival may reseed. + effect(() => { + const config = this.store.config(); + if (!config) return; + untracked(() => { + if (!this.#seeded() || this.#awaitingSave()) this.#seed(config.protectedTerms ?? []); + }); + }); + + // A failed save never refetches, so release the save latch on the error instead. + effect(() => { + if (!this.store.error()) return; + untracked(() => this.#awaitingSave.set(false)); + }); + effect(() => { - if (this.#seeded()) return; - const cfg = this.store.config(); - if (!cfg) return; - this.protectedTermsList.set(cfg.protectedTerms ?? []); - this.#seeded.set(true); + const input = this.editInputs()[0]; + if (this.editingId() === null || !input) return; + input.nativeElement.focus(); + input.nativeElement.select(); }); + + // The row may not be in the query yet when the id is set, so the target is + // held — not cleared — until it is found. Clearing early is what made an + // added term land silently below the fold of the scrolling list. + effect(() => { + const id = this.#scrollToId(); + const rows = this.termRows(); + if (id === null) return; + const row = rows.find((ref) => ref.nativeElement.getAttribute('data-term-id') === String(id)); + if (!row) return; + // Optional call: not every environment implements scrollIntoView (jsdom does not). + row.nativeElement.scrollIntoView?.({ block: 'nearest' }); + this.#scrollToId.set(null); + }); + } + + /** + * Brings the row for `value` into view. A filter that would hide it is dropped + * first: a term you just added or renamed must never vanish behind a filter. + */ + #revealTerm(id: number, value: string): void { + const query = this.filter().trim().toLowerCase(); + if (query && !value.toLowerCase().includes(query)) this.filter.set(''); + this.#scrollToId.set(id); + } + + #seed(terms: readonly string[]): void { + this.entries.set( + normalizeProtectedTerms([...terms]).map((value) => ({ + id: this.#nextId++, + value, + original: value, + removed: false, + })), + ); + this.#scrollToId.set(null); + this.editingId.set(null); + this.addError.set(null); + this.editError.set(null); + this.#seeded.set(true); + this.#awaitingSave.set(false); } - addProtectedTerm(event: MatChipInputEvent): void { - const [term] = normalizeProtectedTerms([event.value]); - if (term && !this.protectedTermsList().includes(term)) { - this.protectedTermsList.update((terms) => [...terms, term]); + /** The pending state of a row, used for its badge and tint. */ + statusOf(entry: TermEntry): 'removed' | 'added' | 'edited' | 'unchanged' { + if (entry.removed) return 'removed'; + if (entry.original === undefined) return 'added'; + return entry.original === entry.value ? 'unchanged' : 'edited'; + } + + addTerm(): void { + const [term] = normalizeProtectedTerms([this.addDraft()]); + if (!term) return; + + const existing = this.entries().find((entry) => entry.value === term); + if (existing) { + // A term the user is re-adding was only marked for removal — take that back rather than refuse. + if (existing.removed) { + this.restoreTerm(existing); + this.addDraft.set(''); + this.addError.set(null); + this.#revealTerm(existing.id, existing.value); + return; + } + this.addError.set(term); + this.#revealTerm(existing.id, existing.value); + return; + } + + const id = this.#nextId++; + this.entries.update((entries) => [...entries, { id, value: term, removed: false }]); + this.addDraft.set(''); + this.addError.set(null); + this.#revealTerm(id, term); + } + + onAddDraftChange(value: string): void { + this.addDraft.set(value); + this.addError.set(null); + } + + removeTerm(entry: TermEntry): void { + // A term added in this session has nothing to remove from the file — drop the row outright. + if (entry.original === undefined) { + this.entries.update((entries) => entries.filter((candidate) => candidate.id !== entry.id)); + return; + } + this.#patch(entry.id, { removed: true }); + } + + restoreTerm(entry: TermEntry): void { + this.#patch(entry.id, { removed: false }); + } + + beginEdit(entry: TermEntry): void { + this.editingId.set(entry.id); + this.editDraft.set(entry.value); + this.editError.set(null); + } + + onEditDraftChange(value: string): void { + this.editDraft.set(value); + this.editError.set(null); + } + + commitEdit(): void { + const id = this.editingId(); + if (id === null) return; + const entry = this.entries().find((candidate) => candidate.id === id); + if (!entry) return; + + const [term] = normalizeProtectedTerms([this.editDraft()]); + if (!term || term === entry.value) { + this.cancelEdit(); + return; + } + + if (this.entries().some((candidate) => candidate.id !== id && candidate.value === term && !candidate.removed)) { + this.editError.set(term); + return; } - event.chipInput?.clear(); + + this.#patch(id, { value: term }); + this.editingId.set(null); + this.editError.set(null); + this.#revealTerm(id, term); + } + + cancelEdit(): void { + this.editingId.set(null); + this.editError.set(null); } - removeProtectedTerm(term: string): void { - this.protectedTermsList.update((terms) => terms.filter((t) => t !== term)); + revertAll(): void { + this.#seed(this.store.config()?.protectedTerms ?? []); + this.filter.set(''); + } + + clearFilter(): void { + this.filter.set(''); } save(): void { + if (!this.hasChanges()) return; + this.cancelEdit(); + this.#awaitingSave.set(true); // Errors from a failed save surface via the store error signal (rendered in the template). - this.store.updateGlobalConfig({ protectedTerms: this.protectedTermsList() }); + this.store.updateGlobalConfig({ protectedTerms: this.termsToSave() }); + } + + #patch(id: number, patch: Partial>): void { + this.entries.update((entries) => entries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry))); } } diff --git a/apps/tracker/src/i18n/collections/card/resource_entries.json b/apps/tracker/src/i18n/collections/card/resource_entries.json index fe701155..b1e22c9b 100644 --- a/apps/tracker/src/i18n/collections/card/resource_entries.json +++ b/apps/tracker/src/i18n/collections/card/resource_entries.json @@ -29,24 +29,74 @@ "ja": "収集のためのその他のアクション", "de": "Weitere Aktionen zur Sammlung" }, - "readOnlyBadge": { - "source": "Read-only", - "comment": "Badge on a collection card indicating the collection is read-only.", - "tags": ["collections"], - "es": "Read-only", - "fr-ca": "Read-only", - "ru": "Read-only", - "ja": "Read-only", - "de": "Read-only" - }, "readOnlyTooltip": { "source": "This collection is read-only. Its resources can't be modified.", "comment": "Tooltip on the read-only badge on a collection card.", "tags": ["collections"], - "es": "This collection is read-only. Its resources can't be modified.", - "fr-ca": "This collection is read-only. Its resources can't be modified.", - "ru": "This collection is read-only. Its resources can't be modified.", - "ja": "This collection is read-only. Its resources can't be modified.", - "de": "This collection is read-only. Its resources can't be modified." + "es": "Esta colección es de solo lectura. Sus recursos no se pueden modificar.", + "fr-ca": "Cette collection est en lecture seule. Ses ressources ne peuvent pas être modifiées.", + "ru": "Эта коллекция доступна только для чтения. Её ресурсы нельзя изменить.", + "ja": "このコレクションは読み取り専用です。リソースを変更することはできません。", + "de": "Diese Sammlung ist schreibgeschützt. Ihre Ressourcen können nicht geändert werden." + }, + "baseLocaleLabel": { + "source": "base", + "comment": "Short marker on the locale chip that is the collection's base (source) locale", + "tags": ["collections"], + "es": "base", + "fr-ca": "base", + "ru": "база", + "ja": "基準", + "de": "Basis" + }, + "baseLocaleTooltipX": { + "source": "{locale} is the base locale every translation is compared against", + "comment": "Tooltip on the base-locale chip of a collection card", + "tags": ["collections"], + "es": "{locale} es el idioma base con el que se compara cada traducción", + "fr-ca": "{locale} est la langue de base à laquelle chaque traduction est comparée", + "ru": "{locale} — базовая локаль, с которой сравнивается каждый перевод", + "ja": "{locale} はすべての翻訳の比較元となるベースロケールです", + "de": "{locale} ist die Basissprache, mit der jede Übersetzung verglichen wird" + }, + "moreLocalesX": { + "source": "+{count}", + "comment": "Overflow chip showing how many further locales a collection has beyond those listed", + "tags": ["collections"], + "es": "+{count}", + "fr-ca": "+{count}", + "ru": "+{count}", + "ja": "+{count}", + "de": "+{count}" + }, + "moreLocalesTooltip": { + "source": "Also configured", + "comment": "Tooltip heading on the locale overflow chip, followed by the remaining locale codes", + "tags": ["collections"], + "es": "También configurados", + "fr-ca": "Également configurées", + "ru": "Также настроены", + "ja": "その他の設定済みロケール", + "de": "Ebenfalls konfiguriert" + }, + "openAriaLabelX": { + "source": "Open collection {name}", + "comment": "Accessible label for the clickable collection card", + "tags": ["collections"], + "es": "Abrir la colección {name}", + "fr-ca": "Ouvrir la collection {name}", + "ru": "Открыть коллекцию {name}", + "ja": "コレクション {name} を開く", + "de": "Sammlung {name} öffnen" + }, + "localesAriaLabel": { + "source": "Locales", + "comment": "Accessible label for the row of locale chips on a collection card", + "tags": ["collections"], + "es": "Idiomas", + "fr-ca": "Langues", + "ru": "Локали", + "ja": "ロケール", + "de": "Regionen" } } diff --git a/apps/tracker/src/i18n/collections/card/tracker_meta.json b/apps/tracker/src/i18n/collections/card/tracker_meta.json index 478ddc5f..ebfd9f8b 100644 --- a/apps/tracker/src/i18n/collections/card/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/card/tracker_meta.json @@ -11,7 +11,7 @@ "fr-ca": { "checksum": "29ab3c9f022d5d28e6d5ea87be010057", "baseChecksum": "2687006ffa30f69f72187cbb690d6cc0", - "status": "new" + "status": "translated" }, "ru": { "checksum": "fc542666f0b02b7b83ef1c93f694726c", @@ -41,7 +41,7 @@ "fr-ca": { "checksum": "66a9a5432f50dc163fec6cb64d2c88b3", "baseChecksum": "611307b7abf62c8f6ffc374701393f29", - "status": "new" + "status": "translated" }, "ru": { "checksum": "0f440420a40b59ebe5a8eae593b4bc31", @@ -89,64 +89,214 @@ "status": "translated" } }, - "readOnlyBadge": { + "readOnlyTooltip": { "en": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f" + "checksum": "ee180bd986e2dab87c25b037339bfd5c" }, "es": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "checksum": "9ab3e7da32a3797f94a03f07ad416964", + "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", + "status": "translated" }, "fr-ca": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "checksum": "449b54dbb579009b7563d01c9072bf71", + "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", + "status": "translated" }, "ru": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "checksum": "6dae80a1f2af0ac523f354e974162140", + "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", + "status": "translated" }, "ja": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "checksum": "fde6e356574e6b13978a8b8d0334b64c", + "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", + "status": "translated" }, "de": { - "checksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "baseChecksum": "d8bd6e60adcc01ce20f509ea6800de8f", - "status": "new" + "checksum": "212c3bf554cdac7de3579d2e2fe9f728", + "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", + "status": "translated" } }, - "readOnlyTooltip": { + "baseLocaleLabel": { "en": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c" + "checksum": "593616de15330c0fb2d55e55410bf994" }, "es": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", - "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "checksum": "593616de15330c0fb2d55e55410bf994", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" }, "fr-ca": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", - "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "checksum": "593616de15330c0fb2d55e55410bf994", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" }, "ru": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", - "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "checksum": "ea94cac2e669680d3816e6d3e3efbf48", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" }, "ja": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", - "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "checksum": "7f0ee9f183a641021fbcc0cb7720dafa", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" }, "de": { - "checksum": "ee180bd986e2dab87c25b037339bfd5c", - "baseChecksum": "ee180bd986e2dab87c25b037339bfd5c", - "status": "new" + "checksum": "9bc05d0fdf5b16984c37b23709d0e7b4", + "baseChecksum": "593616de15330c0fb2d55e55410bf994", + "status": "translated" + } + }, + "baseLocaleTooltipX": { + "en": { + "checksum": "aa50f117b9a413b85ee6b70fd4aab937" + }, + "es": { + "checksum": "d2e6cc6f6c5df4e3d078b350d801ea99", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "fr-ca": { + "checksum": "30e4bca1044b7a46e1b73554dcf20a1a", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "ru": { + "checksum": "bed874d8caf3cd092038db4dea3d7243", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "ja": { + "checksum": "6c4a1bf8221361adc29ffeaa7d167096", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + }, + "de": { + "checksum": "6092ee063d77ea1f277f5a9d6ed3ae59", + "baseChecksum": "aa50f117b9a413b85ee6b70fd4aab937", + "status": "translated" + } + }, + "moreLocalesX": { + "en": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b" + }, + "es": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "fr-ca": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "ru": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "ja": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + }, + "de": { + "checksum": "c0c07aab0babde9d94fe290cd09e993b", + "baseChecksum": "c0c07aab0babde9d94fe290cd09e993b", + "status": "translated" + } + }, + "moreLocalesTooltip": { + "en": { + "checksum": "0cb41eec7fc3fe0478756010f943782a" + }, + "es": { + "checksum": "f29f8e3bd39a757035ca0b7d037e1cbf", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "fr-ca": { + "checksum": "b2e8149e0175aabd4c1fd3bf9e053746", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "ru": { + "checksum": "1748322463dc903c3591ac104a5f8ce6", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "ja": { + "checksum": "e772efd6baa8c3fda0288fc0d2443fd6", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + }, + "de": { + "checksum": "516331ec9e6c72e1fbd2a5be1cf1327f", + "baseChecksum": "0cb41eec7fc3fe0478756010f943782a", + "status": "translated" + } + }, + "openAriaLabelX": { + "en": { + "checksum": "136ea657741ea6cf7762371a4527a123" + }, + "es": { + "checksum": "daaefd02d760a6ad78126d15ab74bd71", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "fr-ca": { + "checksum": "23ccdd0d1adffbbe18a2aa8143c1660b", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "ru": { + "checksum": "65d88bf86c657dd15f7da4c9471f5245", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "ja": { + "checksum": "8c972737c6851d6c4af0a9a7dc0f135f", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + }, + "de": { + "checksum": "2a56aae72caa193e6d1590274852407d", + "baseChecksum": "136ea657741ea6cf7762371a4527a123", + "status": "translated" + } + }, + "localesAriaLabel": { + "en": { + "checksum": "52ec7aab68a6cdc237d9db8133bc815f" + }, + "es": { + "checksum": "c88b33a3250b4e28f34e1ececba69206", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "fr-ca": { + "checksum": "1ea21417b4fc870a18d6a244983be72b", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ru": { + "checksum": "c98fc8570c98ecc0926915d287884bd2", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "ja": { + "checksum": "6740922e3a31d08d75cc90d75875e3ce", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" + }, + "de": { + "checksum": "e4594206ae15aba60a0884e4a5d1c220", + "baseChecksum": "52ec7aab68a6cdc237d9db8133bc815f", + "status": "translated" } } } diff --git a/apps/tracker/src/i18n/collections/filter/resource_entries.json b/apps/tracker/src/i18n/collections/filter/resource_entries.json new file mode 100644 index 00000000..aeecfa0b --- /dev/null +++ b/apps/tracker/src/i18n/collections/filter/resource_entries.json @@ -0,0 +1,32 @@ +{ + "placeholder": { + "source": "Filter collections", + "comment": "Placeholder for the input that narrows the collection list by name or folder", + "tags": ["collections"], + "es": "Filtrar colecciones", + "fr-ca": "Filtrer les collections", + "ru": "Фильтровать коллекции", + "ja": "コレクションをフィルタリング", + "de": "Sammlungen filtern" + }, + "clear": { + "source": "Clear filter", + "comment": "Accessible label and tooltip for the button that clears the collection filter", + "tags": ["collections"], + "es": "Borrar filtro", + "fr-ca": "Effacer le filtre", + "ru": "Очистить фильтр", + "ja": "フィルタをクリア", + "de": "Filter löschen" + }, + "noResultsX": { + "source": "No collections match \"{query}\".", + "comment": "Message shown when the collection filter excludes every collection", + "tags": ["collections"], + "es": "Ninguna colección coincide con «{query}».", + "fr-ca": "Aucune collection ne correspond à « {query} ».", + "ru": "Нет коллекций, соответствующих «{query}».", + "ja": "「{query}」に一致するコレクションはありません。", + "de": "Keine Sammlung stimmt mit „{query}“ überein." + } +} diff --git a/apps/tracker/src/i18n/collections/filter/tracker_meta.json b/apps/tracker/src/i18n/collections/filter/tracker_meta.json new file mode 100644 index 00000000..4baf4bca --- /dev/null +++ b/apps/tracker/src/i18n/collections/filter/tracker_meta.json @@ -0,0 +1,92 @@ +{ + "placeholder": { + "en": { + "checksum": "2dc6583e4f47458fc0ef102aab719d35" + }, + "es": { + "checksum": "7a77971a7da243014f0b3f1593abc171", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "fr-ca": { + "checksum": "2dd06fda5cbba2f62274026cb4e5377f", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "ru": { + "checksum": "9e1ca64cc0134fdc5c27e097e19d121e", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "ja": { + "checksum": "d1d4b7cd299310ae205952e59d08c7e5", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + }, + "de": { + "checksum": "7067809f2475ec77ba0971d201efdf77", + "baseChecksum": "2dc6583e4f47458fc0ef102aab719d35", + "status": "translated" + } + }, + "clear": { + "en": { + "checksum": "f53b6f6ba5a4c2c190041fe59d8926a0" + }, + "es": { + "checksum": "0334740e2efb300d578023c59f7a709c", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "fr-ca": { + "checksum": "e1e5c1cc57d60909661127ffcd4bf906", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "ru": { + "checksum": "550eb221497e0941e1a788cb01df7535", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "ja": { + "checksum": "4a29efe214d4afe10b3949dce437c666", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + }, + "de": { + "checksum": "981c2e7c4ec0ac90560d4115b472dd58", + "baseChecksum": "f53b6f6ba5a4c2c190041fe59d8926a0", + "status": "translated" + } + }, + "noResultsX": { + "en": { + "checksum": "7d537c46c249207a1fb0541dbcff6ec8" + }, + "es": { + "checksum": "c15a83af1adc1247f6222c0db028e310", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "fr-ca": { + "checksum": "698e154817ec999d8570d449f10d99d8", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "ru": { + "checksum": "d3ec995edc2046c0af8adba8b966f2bf", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "ja": { + "checksum": "14b8048c2d539979dce03a49c426e8bb", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + }, + "de": { + "checksum": "1b3bd7ce0bc2fa2ddb6f7453787b1f27", + "baseChecksum": "7d537c46c249207a1fb0541dbcff6ec8", + "status": "translated" + } + } +} diff --git a/apps/tracker/src/i18n/collections/resource_entries.json b/apps/tracker/src/i18n/collections/resource_entries.json index 1621ba8d..1588feef 100644 --- a/apps/tracker/src/i18n/collections/resource_entries.json +++ b/apps/tracker/src/i18n/collections/resource_entries.json @@ -16,5 +16,15 @@ "ru": "Добавить коллекцию", "ja": "コレクションを追加", "de": "Sammlung hinzufügen" + }, + "subtitleX": { + "source": "{ count, plural, =1 {1 collection} other {{count} collections} }", + "comment": "Pluralized count of collections shown under the Collections page title", + "tags": ["collections"], + "es": "{ count, plural, =1 {1 colección} other {{count} colecciones} }", + "fr-ca": "{ count, plural, =1 {1 collection} other {{count} collections} }", + "ru": "{ count, plural, one {{count} коллекция} few {{count} коллекции} many {{count} коллекций} other {{count} коллекции} }", + "de": "{ count, plural, =1 {1 Sammlung} other {{count} Sammlungen} }", + "ja": "{ count, plural, other {{count} 件のコレクション} }" } } diff --git a/apps/tracker/src/i18n/collections/tracker_meta.json b/apps/tracker/src/i18n/collections/tracker_meta.json index b3add7e3..493102d6 100644 --- a/apps/tracker/src/i18n/collections/tracker_meta.json +++ b/apps/tracker/src/i18n/collections/tracker_meta.json @@ -11,7 +11,7 @@ "fr-ca": { "checksum": "a9fc91939a389c7c73e7a3f3cbf411cd", "baseChecksum": "a9fc91939a389c7c73e7a3f3cbf411cd", - "status": "new" + "status": "translated" }, "ru": { "checksum": "306e41fce3bc9de4911363df9a80a5ba", @@ -41,7 +41,7 @@ "fr-ca": { "checksum": "8415e183eef3d657a530bbd6c1fb5ef0", "baseChecksum": "10ac958235fe83036f8e5244454f7c78", - "status": "new" + "status": "translated" }, "ru": { "checksum": "8e290a022048d2cf84f456d1170ce6aa", @@ -58,5 +58,35 @@ "baseChecksum": "10ac958235fe83036f8e5244454f7c78", "status": "translated" } + }, + "subtitleX": { + "en": { + "checksum": "8c4d7b9e54cbc1e6049c46ddbf30c213" + }, + "es": { + "checksum": "1234b8c5b91e16b2f35311e285ee776e", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "fr-ca": { + "checksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "ru": { + "checksum": "13854dbc38e315d4e5093ae77d3cafbc", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "de": { + "checksum": "456b16cfd00063d3a48eea67deea50fa", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + }, + "ja": { + "checksum": "2ee3e27eac4719379b664c5b482b8918", + "baseChecksum": "8c4d7b9e54cbc1e6049c46ddbf30c213", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/settings/resource_entries.json b/apps/tracker/src/i18n/settings/resource_entries.json index ac50f091..ce4e52d9 100644 --- a/apps/tracker/src/i18n/settings/resource_entries.json +++ b/apps/tracker/src/i18n/settings/resource_entries.json @@ -20,14 +20,14 @@ "de": "Globale Schutzbestimmungen" }, "protectedTermsHint": { - "source": "Brand names or jargon kept verbatim and never translated across all collections. Press Enter or comma to add.", - "comment": "Hint text under the global protected terms field in settings", + "source": "Terms that must survive translation unchanged, in every collection. Exports flag them for translators; imports reject a translation that altered one.", + "comment": "Hint under the global protected terms heading in settings. Names where the list is enforced: export marks the terms for translators, import rejects an altered translation.", "tags": ["settings"], - "es": "Las marcas o la jerga se conservan tal cual y nunca se traducen en todas las colecciones. Pulse Intro o una coma para añadir.", - "fr-ca": "Les marques et le jargon sont conservés tels quels et ne sont jamais traduits dans toutes les collections. Appuyez sur Entrée ou sur la virgule pour ajouter.", - "ru": "Названия брендов или терминология сохраняются дословно и никогда не переводятся во все коллекции. Нажмите Enter или запятую для добавления.", - "ja": "ブランド名や専門用語は、すべてのコレクションでそのまま使用され、翻訳されることはありません。Enterキーまたはカンマを押して追加してください。", - "de": "Markennamen und Fachbegriffe werden unverändert übernommen und nicht in alle Kollektionen übersetzt. Drücken Sie die Eingabetaste oder ein Komma, um etwas hinzuzufügen." + "es": "Términos que deben conservarse sin cambios en todas las colecciones tras la traducción. Las exportaciones los marcan para los traductores; las importaciones rechazan las traducciones que los hayan modificado.", + "fr-ca": "Les termes qui doivent absolument demeurer inchangés lors de la traduction, dans chaque collection, sont signalés aux traducteurs lors des exportations ; les importations rejettent toute traduction qui en aurait modifié un.", + "ru": "Термины, которые должны оставаться неизменными при переводе в любой коллекции. При экспорте они помечаются для переводчиков; при импорте перевод с изменённым термином отклоняется.", + "ja": "翻訳後も変更せずに、すべてのコレクションに必ず含めなければならない用語。エクスポート時には翻訳者向けにこれらの用語がマークされ、インポート時にはこれらの用語が変更された翻訳は拒否される。", + "de": "Begriffe, die in jeder Sammlung unverändert übersetzt werden müssen. Exporte kennzeichnen diese Begriffe für Übersetzer; Importe lehnen Übersetzungen ab, die bestehende Begriffe verändert haben." }, "protectedTermsAddPlaceholder": { "source": "Add protected term…", @@ -68,5 +68,215 @@ "ru": "Хранится в {file}", "ja": "{file} に保存されています", "de": "Gespeichert in {file}" + }, + "protectedTermsCountX": { + "source": "{ count, plural, =0 {No terms} =1 {1 term} other {{count} terms} }", + "comment": "Count badge beside the protected terms section title", + "tags": ["settings"], + "es": "{ count, plural, =0 {Sin términos} =1 {1 término} other {{count} términos} }", + "fr-ca": "{ count, plural, =0 {Aucun terme} =1 {1 terme} other {{count} termes} }", + "de": "{ count, plural, =0 {Keine Begriffe} =1 {1 Begriff} other {{count} Begriffe} }", + "ja": "{ count, plural, =0 {用語なし} other {{count} 件の用語} }", + "ru": "{ count, plural, =0 {Нет терминов} one {{count} термин} few {{count} термина} many {{count} терминов} other {{count} термина} }" + }, + "protectedTermsAddButton": { + "source": "Add", + "comment": "Button that adds the typed protected term to the list", + "tags": ["settings"], + "es": "Agregar", + "fr-ca": "Ajouter", + "ru": "Добавлять", + "ja": "追加", + "de": "Hinzufügen" + }, + "protectedTermsDuplicateX": { + "source": "Already on the list: {term}", + "comment": "Inline error when the typed protected term already exists. {term} is the term.", + "tags": ["settings"], + "es": "Ya está en la lista: {term}", + "fr-ca": "Déjà sur la liste : {term}", + "ru": "Уже в списке: {term}", + "ja": "既にリストに登録済み:{term}", + "de": "Bereits auf der Liste: {term}" + }, + "protectedTermsFilterPlaceholder": { + "source": "Filter terms", + "comment": "Placeholder of the input that filters the protected terms list", + "tags": ["settings"], + "es": "Términos de filtro", + "fr-ca": "Termes de filtrage", + "ru": "Фильтр по критериям", + "ja": "フィルター条件", + "de": "Filterbegriffe" + }, + "protectedTermsEmptyTitle": { + "source": "No protected terms yet", + "comment": "Title of the empty state when no protected terms exist", + "tags": ["settings"], + "es": "Aún no hay términos protegidos", + "fr-ca": "Aucun terme protégé pour le moment", + "ru": "Пока нет защищенных условий.", + "ja": "保護された条項はまだありません", + "de": "Noch keine geschützten Bedingungen" + }, + "protectedTermsEmptyMessage": { + "source": "Add a brand name or piece of jargon and auto-translation will keep it verbatim in every locale.", + "comment": "Body of the empty state when no protected terms exist", + "tags": ["settings"], + "es": "Añade una marca o una expresión técnica y la traducción automática la mantendrá idéntica en todos los idiomas.", + "fr-ca": "Ajoutez une marque ou un terme technique, et la traduction automatique le restituera mot à mot dans toutes les langues.", + "ru": "Добавьте название бренда или профессиональный жаргон, и автоматический перевод сохранит его дословно во всех языковых версиях.", + "ja": "ブランド名や専門用語を追加すると、自動翻訳機能がどの地域でも原文をそのまま再現します。", + "de": "Fügen Sie einen Markennamen oder einen Fachbegriff hinzu, und die automatische Übersetzung behält ihn in jeder Region wortgetreu bei." + }, + "protectedTermsNoMatchesX": { + "source": "No terms match “{query}”", + "comment": "Message when the filter matches no protected terms. {query} is the filter text.", + "tags": ["settings"], + "es": "No se encontraron términos que coincidan con “{query}”", + "fr-ca": "Aucun terme ne correspond à « {query} »", + "ru": "Нет терминов, соответствующих \"{query}\"", + "ja": "「{query}」に一致する用語はありません。", + "de": "Es wurden keine passenden Begriffe für „{query}“ gefunden." + }, + "protectedTermsEditAriaX": { + "source": "Edit protected term {term}", + "comment": "Accessible label of the edit button on a protected term row. {term} is the term.", + "tags": ["settings"], + "es": "Editar término protegido {term}", + "fr-ca": "Modifier le terme protégé {term}", + "ru": "Редактировать защищенный термин {term}", + "ja": "保護された用語を編集 {term}", + "de": "Bearbeiten Sie den geschützten Begriff {term}" + }, + "protectedTermsRemoveAriaX": { + "source": "Remove protected term {term}", + "comment": "Accessible label of the remove button on a protected term row. {term} is the term.", + "tags": ["settings"], + "es": "Eliminar término protegido {term}", + "fr-ca": "Supprimer le terme protégé {term}", + "ru": "Удалить защищенный термин {term}", + "ja": "保護された用語を削除します {term}", + "de": "Entferne den geschützten Begriff {term}" + }, + "protectedTermsRestoreAriaX": { + "source": "Keep protected term {term}", + "comment": "Accessible label of the undo button on a protected term marked for removal. {term} is the term.", + "tags": ["settings"], + "es": "Mantener el término protegido {term}", + "fr-ca": "Conserver le terme protégé {term}", + "ru": "Сохраняйте защищенный термин {term}", + "ja": "保護された用語を保持します {term}", + "de": "Schutzbegriff beibehalten {term}" + }, + "protectedTermsRenameAriaX": { + "source": "Rename protected term {term}", + "comment": "Accessible label of the text field shown while renaming a protected term. {term} is the term.", + "tags": ["settings"], + "es": "Cambiar el nombre del término protegido {term}", + "fr-ca": "Renommer le terme protégé {term}", + "ru": "Переименовать защищенный термин {term}", + "ja": "保護された用語の名前を変更します {term}", + "de": "geschützten Begriff umbenennen {term}" + }, + "protectedTermsUndo": { + "source": "Undo", + "comment": "Button that restores a protected term marked for removal", + "tags": ["settings"], + "es": "Deshacer", + "fr-ca": "Défaire", + "ru": "Отменить", + "ja": "元に戻す", + "de": "Rückgängig machen" + }, + "protectedTermsBadgeRemoving": { + "source": "Removing", + "comment": "Badge on a protected term that will be deleted when changes are saved", + "tags": ["settings"], + "es": "Eliminando", + "fr-ca": "Suppression", + "ru": "Удаление", + "ja": "削除", + "de": "Entfernen" + }, + "protectedTermsBadgeNew": { + "source": "New", + "comment": "Badge on a protected term that has been added but not yet saved", + "tags": ["settings"], + "es": "Nuevo", + "fr-ca": "Nouveau", + "ru": "Новый", + "ja": "新しい", + "de": "Neu" + }, + "protectedTermsBadgeEdited": { + "source": "Edited", + "comment": "Badge on a protected term that has been renamed but not yet saved", + "tags": ["settings"], + "es": "Editado", + "fr-ca": "Édité", + "ru": "Отредактировано", + "ja": "編集済み", + "de": "Bearbeitet" + }, + "protectedTermsWasX": { + "source": "was {term}", + "comment": "Small line under a renamed protected term showing its previous spelling. {term} is the previous term.", + "tags": ["settings"], + "es": "era {term}", + "fr-ca": "était {term}", + "ru": "был {term}", + "ja": "{term} でした", + "de": "war {term}" + }, + "protectedTermsUnsavedChangesX": { + "source": "{ count, plural, =1 {1 unsaved change} other {{count} unsaved changes} }", + "comment": "Count of pending protected term changes shown in the save bar", + "tags": ["settings"], + "es": "{ count, plural, =1 {1 cambio sin guardar} other {{count} cambios sin guardar} }", + "fr-ca": "{ count, plural, =1 {1 modification non enregistrée} other {{count} modifications non enregistrées} }", + "de": "{ count, plural, =1 {1 nicht gespeicherte Änderung} other {{count} nicht gespeicherte Änderungen} }", + "ja": "{ count, plural, other {{count} 件の未保存の変更} }", + "ru": "{ count, plural, one {{count} несохранённое изменение} few {{count} несохранённых изменения} many {{count} несохранённых изменений} other {{count} несохранённых изменения} }" + }, + "protectedTermsRevertAll": { + "source": "Revert all", + "comment": "Button that discards every pending protected term change", + "tags": ["settings"], + "es": "Revertir todo", + "fr-ca": "Annuler tout", + "ru": "Отменить все", + "ja": "すべて元に戻す", + "de": "Alle zurücksetzen" + }, + "protectedTermsAllSaved": { + "source": "All changes saved", + "comment": "Save bar message shown when there are no pending protected term changes", + "tags": ["settings"], + "es": "Todos los cambios guardados", + "fr-ca": "Toutes les modifications ont été enregistrées.", + "ru": "Все изменения сохранены", + "ja": "すべての変更が保存されました", + "de": "Alle Änderungen gespeichert" + }, + "protectedTermsListAriaLabel": { + "source": "Protected terms", + "comment": "Accessible label of the protected terms list", + "tags": ["settings"], + "es": "Términos protegidos", + "fr-ca": "Conditions protégées", + "ru": "Защищенные условия", + "ja": "保護された用語", + "de": "Geschützte Begriffe" + }, + "protectedTermsAddAriaLabel": { + "source": "New protected term", + "comment": "Accessible label of the field used to type a new protected term", + "tags": ["settings"], + "es": "Nuevo término protegido", + "fr-ca": "Nouvelle clause protégée", + "ru": "Новый защищенный срок", + "ja": "新しい保護用語", + "de": "Neuer geschützter Begriff" } } diff --git a/apps/tracker/src/i18n/settings/tracker_meta.json b/apps/tracker/src/i18n/settings/tracker_meta.json index b6d0d446..3e335430 100644 --- a/apps/tracker/src/i18n/settings/tracker_meta.json +++ b/apps/tracker/src/i18n/settings/tracker_meta.json @@ -61,31 +61,31 @@ }, "protectedTermsHint": { "en": { - "checksum": "7b0f1cfbc415f1a64eaf35a4a5130a50" + "checksum": "3f5b59832ab9049570811b872cf06fc4" }, "es": { - "checksum": "e8ae57fe27deade658614c2c09d9f1dd", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "9259c3a862a39abfc888818fce8a5559", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "fr-ca": { - "checksum": "5ba3989fd1f31b74c5eed31e22c8f69a", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "6904863d656c2928c310d58874361718", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "ru": { - "checksum": "7b0cbdaa4fff83c47405d97127771c27", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "dfddb5ec05872576434860b867fb62a5", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "ja": { - "checksum": "ce9dbaf5023d184004dee184413a23bb", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "a5014608f7b336279db2dabd6496b2e7", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" }, "de": { - "checksum": "8084d14e9feaf288c2a91f7ebd1fd80c", - "baseChecksum": "7b0f1cfbc415f1a64eaf35a4a5130a50", + "checksum": "a243fcd6fcb012d338cdd43a90d06082", + "baseChecksum": "3f5b59832ab9049570811b872cf06fc4", "status": "translated" } }, @@ -208,5 +208,635 @@ "baseChecksum": "1c3e994701dcd061ab663085ba97e538", "status": "translated" } + }, + "protectedTermsCountX": { + "en": { + "checksum": "103d531d99cbbd653aaaf16027aa465a" + }, + "es": { + "checksum": "3c06262053d19f678c456218ef348a20", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "fr-ca": { + "checksum": "b4cb9608b2be5815c25efc09b17d4636", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "de": { + "checksum": "ae0ac2bc17916492fd2cdff0f9c85ba0", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "ja": { + "checksum": "92c1c28e02d335afa605c80d9aa5b144", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + }, + "ru": { + "checksum": "ff9ea924a428bcd8bc91fdb5de3d2528", + "baseChecksum": "103d531d99cbbd653aaaf16027aa465a", + "status": "translated" + } + }, + "protectedTermsAddButton": { + "en": { + "checksum": "ec211f7c20af43e742bf2570c3cb84f9" + }, + "es": { + "checksum": "4e7a04a766cfe5d932335d492d006872", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "fr-ca": { + "checksum": "18d925e4d5b1b569288d119420bbcb42", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "ru": { + "checksum": "9e81b1bf2b8240b4eed3a702faa1e2ec", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "ja": { + "checksum": "7dc3a57449726219ac9d989688b2b575", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + }, + "de": { + "checksum": "5f4110486d00f1ceafd93d33f82c55a8", + "baseChecksum": "ec211f7c20af43e742bf2570c3cb84f9", + "status": "translated" + } + }, + "protectedTermsDuplicateX": { + "en": { + "checksum": "b0a44661c5b4620b8efc3d9e2bb8a127" + }, + "es": { + "checksum": "bb71f5fe098a8ce292242b19feb100f3", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "fr-ca": { + "checksum": "e1c8aaecf24431b7bccb85b01ae44194", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "ru": { + "checksum": "28082050a806ab48c3dbbfe5d365483c", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "ja": { + "checksum": "8a1626df277c858b9714337a79fe4efd", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + }, + "de": { + "checksum": "17f97ed0bb18a8d4dff0597553dab478", + "baseChecksum": "b0a44661c5b4620b8efc3d9e2bb8a127", + "status": "translated" + } + }, + "protectedTermsFilterPlaceholder": { + "en": { + "checksum": "19509c13ac5ba205bf0524f194a3a19a" + }, + "es": { + "checksum": "ed571e31fad73ef9005eca9b6e246dc4", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "fr-ca": { + "checksum": "0cb31e5a5d706f338ad3f29b3124efb8", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "ru": { + "checksum": "6194f9e0784d85af2d9f6f55ab8610a1", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "ja": { + "checksum": "92d14aec500f44d2aa0dced0fed13411", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + }, + "de": { + "checksum": "c56ff48f071f70f608bc0aeceb468df0", + "baseChecksum": "19509c13ac5ba205bf0524f194a3a19a", + "status": "translated" + } + }, + "protectedTermsEmptyTitle": { + "en": { + "checksum": "fb5eddf274a4854bcdde3812d9febb1f" + }, + "es": { + "checksum": "8759be0ad356e93c7ebcc13446c6cdae", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "fr-ca": { + "checksum": "3c336bb4dba4d1cde3f90e570e44c00e", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "ru": { + "checksum": "b51a0762c73271de46c6245481a3aa26", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "ja": { + "checksum": "c1e84163322aed67ec3d5b708960bf79", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + }, + "de": { + "checksum": "3e716b68fdbc63ef690bbaeb560290ee", + "baseChecksum": "fb5eddf274a4854bcdde3812d9febb1f", + "status": "translated" + } + }, + "protectedTermsEmptyMessage": { + "en": { + "checksum": "2054727e7c07981cd5cc8b00e0796342" + }, + "es": { + "checksum": "4b8608f156a1eaaf06a65dc153d4bbfb", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "fr-ca": { + "checksum": "815879e91a4c331c6d39eefcf77a0504", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "ru": { + "checksum": "8df854d1a83f0cfccd20c98ee8569892", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "ja": { + "checksum": "c976c3be7077f149ce33edb8a44d3847", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + }, + "de": { + "checksum": "ad324dc85a37832a250683c094417cfa", + "baseChecksum": "2054727e7c07981cd5cc8b00e0796342", + "status": "translated" + } + }, + "protectedTermsNoMatchesX": { + "en": { + "checksum": "f8d65fdf243d39da9eb4373e321d31a3" + }, + "es": { + "checksum": "c26ce12b2f82c03dffd2779b720085b4", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "fr-ca": { + "checksum": "013ed834cd45fb019dcb9b9bf666e230", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "ru": { + "checksum": "ac3135e8742a33fd4d2c6798c5af757c", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "ja": { + "checksum": "0b41abe5f029cd9003e76fa539297341", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + }, + "de": { + "checksum": "a966c8c0ab5191966a859a36c1c68cd6", + "baseChecksum": "f8d65fdf243d39da9eb4373e321d31a3", + "status": "translated" + } + }, + "protectedTermsEditAriaX": { + "en": { + "checksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12" + }, + "es": { + "checksum": "3cba7311c10e25deed91fa1e0bac0f4b", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "fr-ca": { + "checksum": "0a83a13cd3d77eebdd65f77b813882e8", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "ru": { + "checksum": "0af635d12f42c25f7ddf2e5f1ef2b2f2", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "ja": { + "checksum": "af13a67f4913d762de27c4d80553a702", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + }, + "de": { + "checksum": "143124fd3ab37fde8a228351a3c6faf2", + "baseChecksum": "ab6a3fe5a6c0bf750d9fdca1b77d9f12", + "status": "translated" + } + }, + "protectedTermsRemoveAriaX": { + "en": { + "checksum": "5850d7de12e5338b64ff9bfb97b7c6d6" + }, + "es": { + "checksum": "a2f49020ff75536ffc41ef06df9dfb9b", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "fr-ca": { + "checksum": "798fd87aed9ea72bc42d87bfdc03b604", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "ru": { + "checksum": "36dd7fafc9d38ced373e4e91520fc9dd", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "ja": { + "checksum": "f3c75789aed134eb096821606629cc4d", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + }, + "de": { + "checksum": "ca3a7989e6ef9c4c0a6f9a0d17edb47d", + "baseChecksum": "5850d7de12e5338b64ff9bfb97b7c6d6", + "status": "translated" + } + }, + "protectedTermsRestoreAriaX": { + "en": { + "checksum": "5063a8939aed3a15e52f762f3691b3d3" + }, + "es": { + "checksum": "ac3a2d154e36a19adfc007db5cd229be", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "fr-ca": { + "checksum": "86eef7070c5a2c851e17992716c45e80", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "ru": { + "checksum": "8305e0b9a50ae18a0265456ecd8330f6", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "ja": { + "checksum": "ed0b76f69c21db23267623bb4196df75", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + }, + "de": { + "checksum": "1a2436f81b0b527e255274a93e31c0cf", + "baseChecksum": "5063a8939aed3a15e52f762f3691b3d3", + "status": "translated" + } + }, + "protectedTermsRenameAriaX": { + "en": { + "checksum": "c61e91e4b0f0d5536633a0baa32af4a7" + }, + "es": { + "checksum": "d233e171be7c077cf1cef799601e95f0", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "fr-ca": { + "checksum": "fa9b94b51e4cb73bfa0e9c9ae5cd6eda", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "ru": { + "checksum": "7ccfe1325fbbe9729ac1924738aaa0cf", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "ja": { + "checksum": "750290c239359bc07aa4102ab15325f2", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + }, + "de": { + "checksum": "d40721a8b889055a8b5d0b47208e7197", + "baseChecksum": "c61e91e4b0f0d5536633a0baa32af4a7", + "status": "translated" + } + }, + "protectedTermsUndo": { + "en": { + "checksum": "1cdc076b28f70afac5fcedadf99fa119" + }, + "es": { + "checksum": "b1e81ba81d0718201c2dd96b43366155", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "fr-ca": { + "checksum": "3ccb172ac335692c04c8eb9546bb487b", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "ru": { + "checksum": "ecdbdc8b4c68acdb3b84d57d23d884f9", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "ja": { + "checksum": "db693ad58922866eea5dbe3db55fe1ff", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + }, + "de": { + "checksum": "476e09b415eb690ae4e982070cd8e303", + "baseChecksum": "1cdc076b28f70afac5fcedadf99fa119", + "status": "translated" + } + }, + "protectedTermsBadgeRemoving": { + "en": { + "checksum": "e44b66d27df57c4a36aa02b0f04ba8a2" + }, + "es": { + "checksum": "e5bd69d491631430a225e9fd1250e233", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "fr-ca": { + "checksum": "d1e49fa8ab5698d3ad1a0fe4aeb14675", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "ru": { + "checksum": "dd62fe0c29f765b5f064f2fff1daf99f", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "ja": { + "checksum": "c6577c2e908d63fa62c44381b35e9a16", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + }, + "de": { + "checksum": "513d306cc83aa63d5a3801c0cac5f739", + "baseChecksum": "e44b66d27df57c4a36aa02b0f04ba8a2", + "status": "translated" + } + }, + "protectedTermsBadgeNew": { + "en": { + "checksum": "03c2e7e41ffc181a4e84080b4710e81e" + }, + "es": { + "checksum": "73fa04be34aef055a1c1706edd6daba9", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "fr-ca": { + "checksum": "9ea17b10f506b881fed906aab0a50f8d", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "ru": { + "checksum": "97ae6e0bca16b37dbdc5e324d0ecaf60", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "ja": { + "checksum": "ad4b0bd55882d4f7164630d4158d3c39", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + }, + "de": { + "checksum": "e85a2f1d6de3e3b0e71b1a2e19952659", + "baseChecksum": "03c2e7e41ffc181a4e84080b4710e81e", + "status": "translated" + } + }, + "protectedTermsBadgeEdited": { + "en": { + "checksum": "67ed6ddb7d890b23166c1bef771e9451" + }, + "es": { + "checksum": "575dc6f05a827ced18fac29778c50faf", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "fr-ca": { + "checksum": "3520b26fbb7c5d356a083b69c8db81c8", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "ru": { + "checksum": "56824532ff3b0dc93c4ea4a7929fc8ba", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "ja": { + "checksum": "34f36d4263422ad6096cbf4366fc2232", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + }, + "de": { + "checksum": "293530933fdaba02627e1b9b7f8cf4e6", + "baseChecksum": "67ed6ddb7d890b23166c1bef771e9451", + "status": "translated" + } + }, + "protectedTermsWasX": { + "en": { + "checksum": "0bf9c129d85ee90084a2cf2f6bad4f45" + }, + "es": { + "checksum": "58af3e2a685c21b8b99619a43d56b2ac", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "fr-ca": { + "checksum": "9e7b91f671a0917b206d23ca21bbe1ff", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "ru": { + "checksum": "37c4b90e4d47f9f731eee6061ef0026b", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "ja": { + "checksum": "43a1c12a0eba259d440033ebdcd97b62", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + }, + "de": { + "checksum": "8654f7986c9d3957f30862d1edcce16b", + "baseChecksum": "0bf9c129d85ee90084a2cf2f6bad4f45", + "status": "translated" + } + }, + "protectedTermsUnsavedChangesX": { + "en": { + "checksum": "1f02081aa13bcd00e32e274c50ba54dc" + }, + "es": { + "checksum": "b2c67ff6bc9a5d1615804c709a2413f1", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "fr-ca": { + "checksum": "12e05bdd3eb292629840b36214efae0c", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "de": { + "checksum": "fb529b436b98914836bdcb1dcc12b8b4", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "ja": { + "checksum": "5a3336f92d7f7a8acd4a934b249d884a", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + }, + "ru": { + "checksum": "c3f3d18e533f989b7609601b1c138009", + "baseChecksum": "1f02081aa13bcd00e32e274c50ba54dc", + "status": "translated" + } + }, + "protectedTermsRevertAll": { + "en": { + "checksum": "1d4f0db2c59dfe8150f1fc62522605d2" + }, + "es": { + "checksum": "1e35e6710190249416bfbb6947ff8f3c", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "fr-ca": { + "checksum": "475cc08097d9a1b948c3c2e7fcd7ed98", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "ru": { + "checksum": "401fbf31c252e96097d8dfdb6e0a9270", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "ja": { + "checksum": "b31a396d0f71bd6b10a2b617d4440ec6", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + }, + "de": { + "checksum": "6ac4668ac57d4c7ad2231c485dbc9863", + "baseChecksum": "1d4f0db2c59dfe8150f1fc62522605d2", + "status": "translated" + } + }, + "protectedTermsAllSaved": { + "en": { + "checksum": "2d7d860c5dcec1aecdb4a28c6247855b" + }, + "es": { + "checksum": "6343db6e5130179a80b5424a26bed6d6", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "fr-ca": { + "checksum": "d2134ed60da9e91ac1db46b7371bb85e", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "ru": { + "checksum": "4c49936fffd41286e57fc4e7bd246d19", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "ja": { + "checksum": "e66dc56d8d0b80ed807f67b3f52815d9", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + }, + "de": { + "checksum": "c544d24db6b1f8f7d8ea72ea3aed3b1c", + "baseChecksum": "2d7d860c5dcec1aecdb4a28c6247855b", + "status": "translated" + } + }, + "protectedTermsListAriaLabel": { + "en": { + "checksum": "7379385eb22635589c818baf74aafb16" + }, + "es": { + "checksum": "ef79bfcabae0a7fd47891498cb82dd50", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "fr-ca": { + "checksum": "a50915aa6a8c65095d8e1bb8590758ca", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "ru": { + "checksum": "fdc1cb883ff12828b862ea564c1ae69c", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "ja": { + "checksum": "8d5b245534b279f6cadd6973cdc1b7ca", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + }, + "de": { + "checksum": "2e5a9fe78011711360bba52d9aee8ef2", + "baseChecksum": "7379385eb22635589c818baf74aafb16", + "status": "translated" + } + }, + "protectedTermsAddAriaLabel": { + "en": { + "checksum": "3c2da865e46ae607bdb569d1563ff1a5" + }, + "es": { + "checksum": "290b36fa9f055ad5db18cc6e71ad36b2", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "fr-ca": { + "checksum": "2c706e85bbd6309bd2091c4c0f80ddb3", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "ru": { + "checksum": "96b27e2d8fe3f812b82ecd133d1ce68b", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "ja": { + "checksum": "239c52d001448c4c6dfc3b11fa381a56", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + }, + "de": { + "checksum": "06d0a24f4105369fa21550295d1f6dc6", + "baseChecksum": "3c2da865e46ae607bdb569d1563ff1a5", + "status": "translated" + } } } diff --git a/apps/tracker/src/i18n/theme/resource_entries.json b/apps/tracker/src/i18n/theme/resource_entries.json index 68e668dc..a88f9418 100644 --- a/apps/tracker/src/i18n/theme/resource_entries.json +++ b/apps/tracker/src/i18n/theme/resource_entries.json @@ -2,8 +2,8 @@ "light": { "source": "Light", "comment": "Theme switcher light mode label", - "es": "Claro", - "fr-ca": "Clair", + "es": "Luz", + "fr-ca": "Lumière", "ru": "Свет", "ja": "ライト", "de": "Licht" @@ -34,5 +34,15 @@ "ru": "Настройки темы", "ja": "テーマ設定", "de": "Designeinstellungen" + }, + "currentThemeX": { + "source": "Theme: {theme}", + "comment": "Accessible label and tooltip of the theme switcher in the app header. {theme} is the active mode name (Light, Dark or System).", + "tags": ["theme"], + "es": "Tema: {theme}", + "fr-ca": "Thème : {theme}", + "ru": "Тема: {theme}", + "ja": "テーマ: {theme}", + "de": "Thema: {theme}" } } diff --git a/apps/tracker/src/i18n/theme/tracker_meta.json b/apps/tracker/src/i18n/theme/tracker_meta.json index c1cd3076..91ce6001 100644 --- a/apps/tracker/src/i18n/theme/tracker_meta.json +++ b/apps/tracker/src/i18n/theme/tracker_meta.json @@ -4,14 +4,14 @@ "checksum": "9914a0ce04a7b7b6a8e39bec55064b82" }, "es": { - "checksum": "abfd24adfebef98d653b605aa62baf2f", + "checksum": "1eb38aec13a1924b06965b63b66600fd", "baseChecksum": "9914a0ce04a7b7b6a8e39bec55064b82", - "status": "new" + "status": "translated" }, "fr-ca": { - "checksum": "c7e206a03eb924b8cf6f15e8c70f1f07", + "checksum": "cbfd56254a1df9664607203854e8aaee", "baseChecksum": "9914a0ce04a7b7b6a8e39bec55064b82", - "status": "new" + "status": "translated" }, "ru": { "checksum": "64f29ae4503509124973fbba7b822d88", @@ -36,12 +36,12 @@ "es": { "checksum": "e453a886a3295deb0ede87645ede5646", "baseChecksum": "a18366b217ebf811ad1886e4f4f865b2", - "status": "new" + "status": "translated" }, "fr-ca": { "checksum": "19bdcdffdcb06cb2f0a62af523ec7fea", "baseChecksum": "a18366b217ebf811ad1886e4f4f865b2", - "status": "new" + "status": "translated" }, "ru": { "checksum": "1d20f24767fa50f6c6846bec0a3b6bad", @@ -66,12 +66,12 @@ "es": { "checksum": "b431d1485aa37ae09fa4bfa7883356ca", "baseChecksum": "a45da96d0bf6575970f2d27af22be28a", - "status": "new" + "status": "translated" }, "fr-ca": { "checksum": "b222ce1d25f19d7b8ca005440c794baf", "baseChecksum": "a45da96d0bf6575970f2d27af22be28a", - "status": "new" + "status": "translated" }, "ru": { "checksum": "8d8c85f7294619318a9064c198372b09", @@ -96,12 +96,12 @@ "es": { "checksum": "b01b8e5e951cb35388f09b403356584c", "baseChecksum": "55b8deb25fdc0f8b628027f7d1d21cbe", - "status": "new" + "status": "translated" }, "fr-ca": { "checksum": "30aa80ebeaa3348e9dcefee1ac3ae5fe", "baseChecksum": "55b8deb25fdc0f8b628027f7d1d21cbe", - "status": "new" + "status": "translated" }, "ru": { "checksum": "e11e346c34aa99447a5979976e69905e", @@ -118,5 +118,35 @@ "baseChecksum": "55b8deb25fdc0f8b628027f7d1d21cbe", "status": "translated" } + }, + "currentThemeX": { + "en": { + "checksum": "0d379e13951e9c90b44e497b28fd7824" + }, + "es": { + "checksum": "3597e5bf1512172fb8674b954d32b74e", + "baseChecksum": "0d379e13951e9c90b44e497b28fd7824", + "status": "translated" + }, + "fr-ca": { + "checksum": "6baa5acc3a0329075a8a5144229263ce", + "baseChecksum": "0d379e13951e9c90b44e497b28fd7824", + "status": "translated" + }, + "ru": { + "checksum": "292c8cf303941136c7071f9acc4b3829", + "baseChecksum": "0d379e13951e9c90b44e497b28fd7824", + "status": "translated" + }, + "ja": { + "checksum": "5fda43f97e4a7634ad49bd5eb32cb4b3", + "baseChecksum": "0d379e13951e9c90b44e497b28fd7824", + "status": "translated" + }, + "de": { + "checksum": "435bf78956d350d98636eb99d9a6ef6c", + "baseChecksum": "0d379e13951e9c90b44e497b28fd7824", + "status": "translated" + } } } diff --git a/apps/tracker/src/styles.scss b/apps/tracker/src/styles.scss index 627c97c5..2218256d 100644 --- a/apps/tracker/src/styles.scss +++ b/apps/tracker/src/styles.scss @@ -46,6 +46,19 @@ pre { outline-offset: 2px; } +// Browser-supplied surfaces theming: selection, form accents, scrollbars +::selection { + background-color: color-mix(in srgb, var(--color-primary) 28%, transparent); + color: var(--color-text-primary); +} + +:root { + accent-color: var(--color-primary); + caret-color: var(--color-primary); + scrollbar-color: var(--color-border-strong) transparent; + scrollbar-width: thin; +} + // Search term highlighting mark.search-highlight { background-color: var(--color-highlight); diff --git a/apps/tracker/src/testing/transloco-testing.module.ts b/apps/tracker/src/testing/transloco-testing.module.ts index e6aac1af..70ccd4a1 100644 --- a/apps/tracker/src/testing/transloco-testing.module.ts +++ b/apps/tracker/src/testing/transloco-testing.module.ts @@ -164,6 +164,16 @@ export function getTranslocoTestingModule(options: TranslocoTestingOptions = {}) 'collections.dialog.createButton': 'Create', 'collections.dialog.delete.title': 'Delete Collection', 'collections.dialog.delete.message': 'Are you sure you want to delete {{ name }}?', + 'header.goToCollections': 'Go to Collections', + 'header.backToCollections': 'Back to Collections', + 'locale.currentLanguage': 'Current language: {{ language }}', + 'locale.selectLanguage': 'Select language', + 'settings.title': 'Settings', + 'theme.light': 'Light', + 'theme.dark': 'Dark', + 'theme.system': 'System', + 'theme.settings': 'Theme settings', + 'theme.currentThemeX': 'Theme: {{ theme }}', }, }, translocoConfig: { diff --git a/nx.json b/nx.json index f2cda4de..b4c26846 100644 --- a/nx.json +++ b/nx.json @@ -116,5 +116,6 @@ "testTargetName": "vitest:test" } } - ] + ], + "analytics": false } diff --git a/package.json b/package.json index 5e7b293a..d54cd7cd 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,11 @@ "sigstore@<=4.1.0": "4.1.1", "@sigstore/core@<=3.2.0": "3.2.1", "@sigstore/verify@3.1.0": "3.1.1" - } + }, + "ignoredBuiltDependencies": [ + "@swc/core", + "nx" + ] }, "nx": {}, "dependencies": {