feat(portal-shell): i18n string sweep — mark UI strings + FR translations (#93)
## Summary
Continue ADR-0019 implementation. Mark every UI string surfaced by the shell with `i18n="@@id"` (templates) or `$localize` (TypeScript), and populate [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) with French translations. The production build now ships **two genuinely different bundles** under `dist/.../{en,fr}/`.
**43 trans-units** marked, grouped by feature:
| Surface | Strings |
|---|---|
| `app.html` | skip-link |
| `header.html` | wordmark, search label + placeholder, action button aria-labels, user-menu placeholder |
| `sidebar.ts` + `.html` | menu groups + items, aside / nav aria-labels, role badge, toggle button (Expand / Collapse aria + Collapse text) |
| `theme-switcher.ts` + `.html` | mode labels, menu aria, trigger aria (`Theme: <mode> (open menu)` with named placeholder) |
| `footer.html` | aria-labels, copyright (interpolation preserved) |
| `home.html` | welcome heading + intro + status widget labels |
| `app.routes.ts` | browser tab titles |
## Tooling
- Add `"@angular/localize"` to the `types` array in both [`tsconfig.app.json`](apps/portal-shell/tsconfig.app.json) and [`tsconfig.spec.json`](apps/portal-shell/tsconfig.spec.json) so TypeScript resolves the `$localize` global at compile time. Specs need it too — they evaluate the same component code paths.
- Extraction target (`nx run portal-shell:extract-i18n`) reports **44 messages** (43 unique IDs).
## Translation choices worth flagging
- **Wordmark**: "APF Portal" → "Portail APF". Same key used for the `/` browser tab title. The PWA manifest (`site.webmanifest`) stays "APF Portal" — manifest values are not bundled, they sit in the static assets and are language-neutral in v1.
- **System theme mode** → "Système".
- **"Anonymous"** role → "Anonyme"; **"Role:"** → **"Rôle :"** (French uses a non-breaking space before the colon — typographic convention, preserved in the XLIFF target).
- **Accessibility links in the footer stay bilingual.** Each carries its own `lang` attribute (`lang="en"` and `lang="fr"`). The dual-link pattern goes away in the upcoming route-fusion PR; until then it's the most honest stopgap.
- **Both accessibility routes share one title key** (`@@route.accessibility.title`). In the EN bundle, both display "Accessibility statement · APF Portal"; in the FR bundle, both display "Déclaration d'accessibilité · Portail APF". After route fusion only one route remains.
## Verification
- Production build: **129 kB gzip initial per locale** (vs 122 kB before). +7 kB absorbs the i18n marker metadata and the embedded translation data in the FR bundle. Well under the 300 KB budget.
- Spot-checked the FR bundle: no leftover English source text on any swept string, route title, or home page intro. The `Welcome to APF Portal` in the lazy `home` chunk shows "Bienvenue sur Portail APF" in FR.
- **36 / 36 specs unchanged.** They run in the source locale (`en`), so the English assertions still match. No spec edits needed.
- Lint clean.
## Out of scope (each its own follow-up PR)
- **Locale switcher in the footer** + `__Host-portal_locale` cookie + smart `/` redirect.
- **Collapse `/accessibility` + `/accessibilite`** into one localised route with locale-translated path segments.
- **CI gate** that fails the build on a missing translation. Once the sweep is reviewed, we add `nx build --localize` to `ci:check` and verify it rejects unsealed strings.
- **Accessibility page content localisation.** Its content is driven by a `copy()` service rather than i18n-marked templates — restructured during the route fusion.
## Test plan
- [x] `pnpm exec nx run-many -t lint test build --projects=portal-shell` — green (36 / 36 specs).
- [x] `pnpm exec nx run portal-shell:extract-i18n` — clean, 43 unique unit IDs.
- [x] Production build emits both locales; FR bundle contains "Tableau de bord", "Aller au contenu principal", etc.
- [ ] Manual: `pnpm exec nx serve portal-shell` shows the EN source. `pnpm exec nx build portal-shell --configuration=production && pnpm exec nx run portal-shell:serve-static` → open `http://localhost:4200/fr/` for FR, `/en/` for EN.
- [ ] Manual: every aria-label announced by a screen reader in FR build matches the French translation.
- [ ] Manual: browser tab title flips between bundles ("APF Portal" vs "Portail APF").
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
<a class="skip-link" href="#main-content" i18n="@@app.skipLink">Skip to main content</a>
|
||||||
<app-header />
|
<app-header />
|
||||||
<div class="shell-body">
|
<div class="shell-body">
|
||||||
<app-sidebar />
|
<app-sidebar />
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
|
|
||||||
|
// Tab titles per route — marked with `$localize` so each locale's
|
||||||
|
// bundle ships its own. The `/accessibility` and `/accessibilite`
|
||||||
|
// pair shares a title key; the upcoming route fusion (per ADR-0019)
|
||||||
|
// collapses them to a single localised route.
|
||||||
|
const homeTitle = $localize`:@@route.home.title:APF Portal`;
|
||||||
|
const accessibilityTitle = $localize`:@@route.accessibility.title:Accessibility statement · APF Portal`;
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
|
loadComponent: () => import('./pages/home/home').then((m) => m.Home),
|
||||||
title: 'APF Portal',
|
title: homeTitle,
|
||||||
},
|
},
|
||||||
// RGAA + ADR-0016: the accessibility statement must be reachable
|
// RGAA + ADR-0016: the accessibility statement must be reachable
|
||||||
// from both locales' canonical paths. Same component, content
|
// from both locales' canonical paths. Same component, content
|
||||||
@@ -15,13 +22,13 @@ export const appRoutes: Route[] = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
||||||
data: { lang: 'en' },
|
data: { lang: 'en' },
|
||||||
title: 'Accessibility statement · APF Portal',
|
title: accessibilityTitle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'accessibilite',
|
path: 'accessibilite',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
import('./pages/accessibility/accessibility').then((m) => m.AccessibilityStatement),
|
||||||
data: { lang: 'fr' },
|
data: { lang: 'fr' },
|
||||||
title: 'Déclaration d’accessibilité · APF Portal',
|
title: accessibilityTitle,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<footer
|
<footer
|
||||||
|
i18n-aria-label="@@footer.aria.label"
|
||||||
aria-label="Page footer"
|
aria-label="Page footer"
|
||||||
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
|
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
|
||||||
>
|
>
|
||||||
<p>© {{ year }} APF France handicap</p>
|
<p i18n="@@footer.copyright">© {{ year }} APF France handicap</p>
|
||||||
|
|
||||||
<nav aria-label="Legal">
|
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
||||||
<ul class="flex items-center gap-4">
|
<ul class="flex items-center gap-4">
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
routerLink="/accessibility"
|
routerLink="/accessibility"
|
||||||
|
lang="en"
|
||||||
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
||||||
>Accessibility</a
|
>Accessibility</a
|
||||||
>
|
>
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
routerLink="/accessibilite"
|
routerLink="/accessibilite"
|
||||||
|
lang="fr"
|
||||||
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
class="rounded hover:text-brand-primary-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-brand-primary-500 dark:hover:text-brand-primary-300"
|
||||||
>Accessibilité</a
|
>Accessibilité</a
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,14 +16,16 @@
|
|||||||
class="h-9 w-9 shrink-0"
|
class="h-9 w-9 shrink-0"
|
||||||
/>
|
/>
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<span class="text-base font-semibold sm:text-lg">APF Portal</span>
|
<span class="text-base font-semibold sm:text-lg" i18n="@@header.wordmark">APF Portal</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 items-center gap-4 px-4 sm:px-6">
|
<div class="flex flex-1 items-center gap-4 px-4 sm:px-6">
|
||||||
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
|
<form role="search" class="mx-2 hidden flex-1 md:flex" (submit)="$event.preventDefault()">
|
||||||
<label class="sr-only" for="global-search">Search the portal</label>
|
<label class="sr-only" for="global-search" i18n="@@header.search.label"
|
||||||
|
>Search the portal</label
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
|
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
|
||||||
>
|
>
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
<input
|
<input
|
||||||
id="global-search"
|
id="global-search"
|
||||||
type="search"
|
type="search"
|
||||||
|
i18n-placeholder="@@header.search.placeholder"
|
||||||
placeholder="Search…"
|
placeholder="Search…"
|
||||||
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
|
class="w-full bg-transparent text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none dark:text-gray-100 dark:placeholder:text-gray-500"
|
||||||
/>
|
/>
|
||||||
@@ -41,6 +44,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
class="relative inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||||
|
i18n-aria-label="@@header.action.notifications"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
<app-icon name="bell" />
|
<app-icon name="bell" />
|
||||||
@@ -52,6 +56,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||||
|
i18n-aria-label="@@header.action.help"
|
||||||
aria-label="Help"
|
aria-label="Help"
|
||||||
>
|
>
|
||||||
<app-icon name="circle-help" />
|
<app-icon name="circle-help" />
|
||||||
@@ -60,11 +65,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||||
|
i18n-aria-label="@@header.action.settings"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<app-icon name="settings" />
|
<app-icon name="settings" />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
|
i18n-aria-label="@@header.action.userMenu"
|
||||||
aria-label="User menu (coming soon)"
|
aria-label="User menu (coming soon)"
|
||||||
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
class="ml-1 inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-primary-500 text-sm font-semibold text-white"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<aside
|
<aside
|
||||||
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
class="sidebar flex h-full flex-col border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||||
[class.sidebar--collapsed]="collapsed()"
|
[class.sidebar--collapsed]="collapsed()"
|
||||||
[attr.aria-label]="'Sidebar navigation'"
|
i18n-aria-label="@@sidebar.aria.label"
|
||||||
|
aria-label="Sidebar navigation"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
i18n-aria-label="@@sidebar.aria.sections"
|
||||||
|
aria-label="Sections"
|
||||||
|
class="flex-1 overflow-y-auto px-2 py-4"
|
||||||
>
|
>
|
||||||
<nav aria-label="Sections" class="flex-1 overflow-y-auto px-2 py-4">
|
|
||||||
@for (group of menu; track group.label) {
|
@for (group of menu; track group.label) {
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
@@ -55,10 +60,13 @@
|
|||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<p
|
<p
|
||||||
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
class="mb-2 px-3 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
i18n-aria-label="@@sidebar.role.aria"
|
||||||
aria-label="Current role: anonymous (signed-out)"
|
aria-label="Current role: anonymous (signed-out)"
|
||||||
>
|
>
|
||||||
Role:
|
<ng-container i18n="@@sidebar.role.label">Role:</ng-container>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">Anonymous</span>
|
<span class="font-medium text-gray-700 dark:text-gray-200" i18n="@@sidebar.role.anonymous"
|
||||||
|
>Anonymous</span
|
||||||
|
>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +75,11 @@
|
|||||||
(click)="toggle()"
|
(click)="toggle()"
|
||||||
class="sidebar__toggle"
|
class="sidebar__toggle"
|
||||||
[attr.aria-expanded]="!collapsed()"
|
[attr.aria-expanded]="!collapsed()"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="toggleAriaLabel()"
|
||||||
collapsed() ? 'Expand sidebar' : 'Collapse sidebar'
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
||||||
@if (!collapsed()) {
|
@if (!collapsed()) {
|
||||||
<span class="sidebar__label">Collapse</span>
|
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { Icon, type IconName } from '../icon/icon';
|
import { Icon, type IconName } from '../icon/icon';
|
||||||
import { LayoutStateService } from '../../state/layout-state.service';
|
import { LayoutStateService } from '../../state/layout-state.service';
|
||||||
@@ -21,6 +21,11 @@ import { LayoutStateService } from '../../state/layout-state.service';
|
|||||||
* plug in a filter without restructuring the data. The Dashboard
|
* plug in a filter without restructuring the data. The Dashboard
|
||||||
* entry uses `routerLink="/"` so the active-state styling is visible
|
* entry uses `routerLink="/"` so the active-state styling is visible
|
||||||
* on the home page; the rest are anchors until real routes exist.
|
* on the home page; the rest are anchors until real routes exist.
|
||||||
|
*
|
||||||
|
* String localisation: the menu data uses `$localize` tagged template
|
||||||
|
* literals (per ADR-0019). The literals evaluate at module load — the
|
||||||
|
* `@angular/localize/init` polyfill is loaded before the bundle so
|
||||||
|
* the active-locale value is resolved by the time MENU is built.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
@@ -40,24 +45,28 @@ export interface MenuGroup {
|
|||||||
|
|
||||||
const MENU: readonly MenuGroup[] = [
|
const MENU: readonly MenuGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: $localize`:@@sidebar.group.general:General`,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: 'layout-dashboard', routerLink: '/' },
|
{
|
||||||
{ label: 'Calendar', icon: 'calendar', href: '#' },
|
label: $localize`:@@sidebar.item.dashboard:Dashboard`,
|
||||||
|
icon: 'layout-dashboard',
|
||||||
|
routerLink: '/',
|
||||||
|
},
|
||||||
|
{ label: $localize`:@@sidebar.item.calendar:Calendar`, icon: 'calendar', href: '#' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Establishments',
|
label: $localize`:@@sidebar.group.establishments:Establishments`,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Directory', icon: 'building-2', href: '#' },
|
{ label: $localize`:@@sidebar.item.directory:Directory`, icon: 'building-2', href: '#' },
|
||||||
{ label: 'Activities', icon: 'folder', href: '#' },
|
{ label: $localize`:@@sidebar.item.activities:Activities`, icon: 'folder', href: '#' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Communication',
|
label: $localize`:@@sidebar.group.communication:Communication`,
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Messages', icon: 'mail', href: '#' },
|
{ label: $localize`:@@sidebar.item.messages:Messages`, icon: 'mail', href: '#' },
|
||||||
{ label: 'Documents', icon: 'file-text', href: '#' },
|
{ label: $localize`:@@sidebar.item.documents:Documents`, icon: 'file-text', href: '#' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -75,6 +84,12 @@ export class Sidebar {
|
|||||||
protected readonly menu = MENU;
|
protected readonly menu = MENU;
|
||||||
protected readonly collapsed = this.layout.sidebarCollapsed;
|
protected readonly collapsed = this.layout.sidebarCollapsed;
|
||||||
|
|
||||||
|
protected readonly toggleAriaLabel = computed(() =>
|
||||||
|
this.collapsed()
|
||||||
|
? $localize`:@@sidebar.toggle.aria.expand:Expand sidebar`
|
||||||
|
: $localize`:@@sidebar.toggle.aria.collapse:Collapse sidebar`,
|
||||||
|
);
|
||||||
|
|
||||||
protected toggle(): void {
|
protected toggle(): void {
|
||||||
this.layout.toggleSidebar();
|
this.layout.toggleSidebar();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
|
||||||
[cdkMenuTriggerFor]="themeMenu"
|
[cdkMenuTriggerFor]="themeMenu"
|
||||||
[attr.aria-label]="'Theme: ' + currentLabel() + ' (open menu)'"
|
[attr.aria-label]="triggerAriaLabel()"
|
||||||
>
|
>
|
||||||
<app-icon [name]="currentIcon()" />
|
<app-icon [name]="currentIcon()" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ng-template #themeMenu>
|
<ng-template #themeMenu>
|
||||||
<div cdkMenu class="theme-switcher__menu" role="menu" aria-label="Theme">
|
<div
|
||||||
|
cdkMenu
|
||||||
|
class="theme-switcher__menu"
|
||||||
|
role="menu"
|
||||||
|
i18n-aria-label="@@theme.menu.aria"
|
||||||
|
aria-label="Theme"
|
||||||
|
>
|
||||||
@for (option of options; track option.mode) {
|
@for (option of options; track option.mode) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ interface ModeOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MODE_OPTIONS: readonly ModeOption[] = [
|
const MODE_OPTIONS: readonly ModeOption[] = [
|
||||||
{ mode: 'light', label: 'Light', icon: 'sun' },
|
{ mode: 'light', label: $localize`:@@theme.mode.light:Light`, icon: 'sun' },
|
||||||
{ mode: 'dark', label: 'Dark', icon: 'moon' },
|
{ mode: 'dark', label: $localize`:@@theme.mode.dark:Dark`, icon: 'moon' },
|
||||||
{ mode: 'auto', label: 'System', icon: 'monitor' },
|
{ mode: 'auto', label: $localize`:@@theme.mode.system:System`, icon: 'monitor' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -60,7 +60,12 @@ export class ThemeSwitcher {
|
|||||||
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor',
|
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.icon ?? 'monitor',
|
||||||
);
|
);
|
||||||
protected readonly currentLabel = computed(
|
protected readonly currentLabel = computed(
|
||||||
() => MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ?? 'System',
|
() =>
|
||||||
|
MODE_OPTIONS.find((o) => o.mode === this.mode())?.label ??
|
||||||
|
$localize`:@@theme.mode.system:System`,
|
||||||
|
);
|
||||||
|
protected readonly triggerAriaLabel = computed(
|
||||||
|
() => $localize`:@@theme.trigger.aria:Theme: ${this.currentLabel()}:mode: (open menu)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
protected select(mode: ThemeMode): void {
|
protected select(mode: ThemeMode): void {
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
<section class="mx-auto max-w-3xl px-6 py-12">
|
<section class="mx-auto max-w-3xl px-6 py-12">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
|
<h1
|
||||||
|
class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
|
||||||
|
i18n="@@page.home.title"
|
||||||
|
>
|
||||||
Welcome to APF Portal
|
Welcome to APF Portal
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300">
|
<p
|
||||||
|
class="mt-4 text-base leading-relaxed text-gray-700 dark:text-gray-300"
|
||||||
|
i18n="@@page.home.intro"
|
||||||
|
>
|
||||||
This is the placeholder home page for the APF Portal. Real content lands once the comms team and
|
This is the placeholder home page for the APF Portal. Real content lands once the comms team and
|
||||||
the accessibility review panel sign off on the copy.
|
the accessibility review panel sign off on the copy.
|
||||||
</p>
|
</p>
|
||||||
@@ -11,26 +17,40 @@
|
|||||||
aria-labelledby="system-status-heading"
|
aria-labelledby="system-status-heading"
|
||||||
class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
class="mt-10 rounded-md border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<h2 id="system-status-heading" class="text-base font-medium text-gray-900 dark:text-gray-100">
|
<h2
|
||||||
|
id="system-status-heading"
|
||||||
|
class="text-base font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
i18n="@@page.home.status.title"
|
||||||
|
>
|
||||||
System status
|
System status
|
||||||
</h2>
|
</h2>
|
||||||
@if (isLoading()) {
|
@if (isLoading()) {
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
|
<p
|
||||||
|
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||||
|
aria-live="polite"
|
||||||
|
i18n="@@page.home.status.loading"
|
||||||
|
>
|
||||||
Checking the backend…
|
Checking the backend…
|
||||||
</p>
|
</p>
|
||||||
} @else if (health(); as h) {
|
} @else if (health(); as h) {
|
||||||
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
<dl class="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
<dt class="text-gray-500 dark:text-gray-400">Service</dt>
|
<dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.service">Service</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.service }}</dd>
|
||||||
<dt class="text-gray-500 dark:text-gray-400">Version</dt>
|
<dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.version">Version</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd>
|
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.version }}</dd>
|
||||||
<dt class="text-gray-500 dark:text-gray-400">Uptime</dt>
|
<dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.uptime">Uptime</dt>
|
||||||
<dd class="font-medium text-gray-900 dark:text-gray-100">{{ h.uptimeSeconds }} s</dd>
|
<dd class="font-medium text-gray-900 dark:text-gray-100">
|
||||||
<dt class="text-gray-500 dark:text-gray-400">Status</dt>
|
<ng-container i18n="@@page.home.status.uptimeValue">{{ h.uptimeSeconds }} s</ng-container>
|
||||||
|
</dd>
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400" i18n="@@page.home.status.status">Status</dt>
|
||||||
<dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd>
|
<dd class="font-medium text-green-700 dark:text-green-400">{{ h.status }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
} @else if (isError()) {
|
} @else if (isError()) {
|
||||||
<p class="mt-2 text-sm text-amber-700 dark:text-amber-300" role="status">
|
<p
|
||||||
|
class="mt-2 text-sm text-amber-700 dark:text-amber-300"
|
||||||
|
role="status"
|
||||||
|
i18n="@@page.home.status.error"
|
||||||
|
>
|
||||||
Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
|
Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,207 @@
|
|||||||
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
|
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<!--
|
||||||
French translation file for portal-shell.
|
French translations for portal-shell, per ADR-0019.
|
||||||
|
|
||||||
Empty in this initial plumbing PR per ADR-0019. The first string
|
Source IDs are stable @@-prefixed values authored in the
|
||||||
sweep PR will mark every UI string in the templates with
|
templates / `$localize` calls. Trans-units are grouped by
|
||||||
`i18n="@@<id>"` and add a `<trans-unit id="<id>">` entry below
|
feature (app, footer, header, sidebar, theme, page.*) and
|
||||||
with the French translation. Until then, the production build's
|
alphabetical within each group so diffs stay readable.
|
||||||
`--localize` emits both `dist/apps/portal-shell/browser/en/...`
|
|
||||||
and `dist/apps/portal-shell/browser/fr/...`, but the FR bundle
|
|
||||||
falls back to source text for every untranslated key.
|
|
||||||
|
|
||||||
Editor convention: keep the entries grouped by feature
|
|
||||||
(header.*, sidebar.*, footer.*, page.<route>.*) and ordered
|
|
||||||
alphabetically within each group, so diffs stay readable.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!-- app -->
|
||||||
|
<trans-unit id="app.skipLink" datatype="html">
|
||||||
|
<source>Skip to main content</source>
|
||||||
|
<target>Aller au contenu principal</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<trans-unit id="footer.aria.label" datatype="html">
|
||||||
|
<source>Page footer</source>
|
||||||
|
<target>Pied de page</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.aria.legal" datatype="html">
|
||||||
|
<source>Legal</source>
|
||||||
|
<target>Mentions légales</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="footer.copyright" datatype="html">
|
||||||
|
<source>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</source>
|
||||||
|
<target>© <x id="INTERPOLATION" equiv-text="{{ year }}"/> APF France handicap</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<trans-unit id="header.action.help" datatype="html">
|
||||||
|
<source>Help</source>
|
||||||
|
<target>Aide</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.action.notifications" datatype="html">
|
||||||
|
<source>Notifications</source>
|
||||||
|
<target>Notifications</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.action.settings" datatype="html">
|
||||||
|
<source>Settings</source>
|
||||||
|
<target>Paramètres</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.action.userMenu" datatype="html">
|
||||||
|
<source>User menu (coming soon)</source>
|
||||||
|
<target>Menu utilisateur (bientôt disponible)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.search.label" datatype="html">
|
||||||
|
<source>Search the portal</source>
|
||||||
|
<target>Rechercher dans le portail</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.search.placeholder" datatype="html">
|
||||||
|
<source>Search…</source>
|
||||||
|
<target>Rechercher…</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="header.wordmark" datatype="html">
|
||||||
|
<source>APF Portal</source>
|
||||||
|
<target>Portail APF</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- sidebar -->
|
||||||
|
<trans-unit id="sidebar.aria.label" datatype="html">
|
||||||
|
<source>Sidebar navigation</source>
|
||||||
|
<target>Navigation latérale</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.aria.sections" datatype="html">
|
||||||
|
<source>Sections</source>
|
||||||
|
<target>Sections</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.group.communication" datatype="html">
|
||||||
|
<source>Communication</source>
|
||||||
|
<target>Communication</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.group.establishments" datatype="html">
|
||||||
|
<source>Establishments</source>
|
||||||
|
<target>Établissements</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.group.general" datatype="html">
|
||||||
|
<source>General</source>
|
||||||
|
<target>Général</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.activities" datatype="html">
|
||||||
|
<source>Activities</source>
|
||||||
|
<target>Activités</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.calendar" datatype="html">
|
||||||
|
<source>Calendar</source>
|
||||||
|
<target>Calendrier</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.dashboard" datatype="html">
|
||||||
|
<source>Dashboard</source>
|
||||||
|
<target>Tableau de bord</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.directory" datatype="html">
|
||||||
|
<source>Directory</source>
|
||||||
|
<target>Annuaire</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.documents" datatype="html">
|
||||||
|
<source>Documents</source>
|
||||||
|
<target>Documents</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.item.messages" datatype="html">
|
||||||
|
<source>Messages</source>
|
||||||
|
<target>Messages</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.role.anonymous" datatype="html">
|
||||||
|
<source>Anonymous</source>
|
||||||
|
<target>Anonyme</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.role.aria" datatype="html">
|
||||||
|
<source>Current role: anonymous (signed-out)</source>
|
||||||
|
<target>Rôle actuel : anonyme (non connecté)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.role.label" datatype="html">
|
||||||
|
<source>Role:</source>
|
||||||
|
<target>Rôle :</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.toggle.aria.collapse" datatype="html">
|
||||||
|
<source>Collapse sidebar</source>
|
||||||
|
<target>Replier la barre latérale</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.toggle.aria.expand" datatype="html">
|
||||||
|
<source>Expand sidebar</source>
|
||||||
|
<target>Déplier la barre latérale</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sidebar.toggle.collapse" datatype="html">
|
||||||
|
<source>Collapse</source>
|
||||||
|
<target>Replier</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- theme -->
|
||||||
|
<trans-unit id="theme.menu.aria" datatype="html">
|
||||||
|
<source>Theme</source>
|
||||||
|
<target>Thème</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="theme.mode.dark" datatype="html">
|
||||||
|
<source>Dark</source>
|
||||||
|
<target>Sombre</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="theme.mode.light" datatype="html">
|
||||||
|
<source>Light</source>
|
||||||
|
<target>Clair</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="theme.mode.system" datatype="html">
|
||||||
|
<source>System</source>
|
||||||
|
<target>Système</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="theme.trigger.aria" datatype="html">
|
||||||
|
<source>Theme: <x id="mode" equiv-text="this.currentLabel()"/> (open menu)</source>
|
||||||
|
<target>Thème : <x id="mode" equiv-text="this.currentLabel()"/> (ouvrir le menu)</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- route -->
|
||||||
|
<trans-unit id="route.accessibility.title" datatype="html">
|
||||||
|
<source>Accessibility statement · APF Portal</source>
|
||||||
|
<target>Déclaration d’accessibilité · Portail APF</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="route.home.title" datatype="html">
|
||||||
|
<source>APF Portal</source>
|
||||||
|
<target>Portail APF</target>
|
||||||
|
</trans-unit>
|
||||||
|
|
||||||
|
<!-- page.home -->
|
||||||
|
<trans-unit id="page.home.intro" datatype="html">
|
||||||
|
<source> This is the placeholder home page for the APF Portal. Real content lands once the comms team and the accessibility review panel sign off on the copy. </source>
|
||||||
|
<target> Ceci est la page d'accueil provisoire du portail APF. Le contenu définitif arrivera après validation par l'équipe communication et le panel d'audit d'accessibilité. </target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.error" datatype="html">
|
||||||
|
<source> Backend unreachable. The BFF dev server may not be running, or CORS is misconfigured. </source>
|
||||||
|
<target> Backend inaccessible. Le serveur BFF de développement est peut-être arrêté, ou la configuration CORS est incorrecte. </target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.loading" datatype="html">
|
||||||
|
<source> Checking the backend… </source>
|
||||||
|
<target> Vérification du backend… </target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.service" datatype="html">
|
||||||
|
<source>Service</source>
|
||||||
|
<target>Service</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.status" datatype="html">
|
||||||
|
<source>Status</source>
|
||||||
|
<target>Statut</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.title" datatype="html">
|
||||||
|
<source> System status </source>
|
||||||
|
<target> État du système </target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.uptime" datatype="html">
|
||||||
|
<source>Uptime</source>
|
||||||
|
<target>Disponibilité</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.uptimeValue" datatype="html">
|
||||||
|
<source><x id="INTERPOLATION" equiv-text="{{ h.uptimeSeconds }}"/> s</source>
|
||||||
|
<target><x id="INTERPOLATION" equiv-text="{{ h.uptimeSeconds }}"/> s</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.status.version" datatype="html">
|
||||||
|
<source>Version</source>
|
||||||
|
<target>Version</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="page.home.title" datatype="html">
|
||||||
|
<source> Welcome to APF Portal </source>
|
||||||
|
<target> Bienvenue sur Portail APF </target>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
"types": []
|
"types": ["@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals", "@angular/localize"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user