feat(ui): menu du compte refondu (icône + titre + sous-titre) et version officielle
CI / build-and-test (push) Successful in 15m4s

Refonte du panneau du bouton avatar (haut à droite du header) : lignes
« icône + titre gras + sous-titre gris » pour Thème (thème appliqué, pastilles
dépliées au clic), Administration, API (openapi.json), Préférences, Guide
d'utilisation, À propos (version + tagline + code source) et Sessions ;
Déconnexion isolée après séparateur et pied de page « Version ».

Ajout de src/app/version.ts (APP_VERSION, alignée sur le tag semver du
registre : 1.0.59 pour cette livraison) affichée dans le menu.

Clés i18n FR + EN ajoutées des deux côtés, ligne README « Menu du compte
refondu » dans le roadmap.
This commit is contained in:
2026-10-02 21:34:05 -04:00
parent 326455adfe
commit 6add43e730
5 changed files with 187 additions and 9 deletions
+1
View File
@@ -274,6 +274,7 @@ Légende : **✅ livré** (câblé de bout en bout) · **🟡 partiellement livr
* ✅ **Import/Export playlists (JSON)** — `GET /playlists/export` (dump complet + items, format `newtube-playlists-v1`), `POST /playlists/import` (création des listes manquantes, dédoublonnage par titre au réimport, compteurs détaillés ; bornes 200 listes / 5 000 vidéos par liste) ; boutons **[Exporter] [Importer]** sur `/#/library/playlists`.
* ✅ **Page Administration** (`/#/admin`, entrée « Administration » de la section Informations dans la barre latérale) — `/healthz` + `/api/providers/metrics` rendus lisibles : statut API, mode YouTube, version et santé yt-dlp, clés actives / bannies (+ suffixes), anti-ban (cookies / PO token / proxy), cache de recherche par provider, compteurs 1 h par provider, quota YouTube du jour. Au passage : `healthz` servi aussi sous `/proxy/api/healthz` (le chemin que le front utilise en prod). *Journaux (logs) et exposition Prometheus restent à faire — voir Observabilité.*
* ✅ **Cache TTL par provider** — `server/env-ttl.mjs` (`<BASE>_<PROVIDER>` → `<BASE>` → défaut, valeurs invalides ignorées, jamais de TTL nul) branché sur les caches **details** (`DETAILS_CACHE_TTL_MS_<P>`) et **transcripts** (`TRANSCRIPT_CACHE_TTL_<P>`) en plus de la recherche (`SEARCH_CACHE_TTL_MS_<P>`). Le cache **suggest** reste global par conception : sa clé agrégée couvre plusieurs providers d'un coup, un TTL par provider demanderait de scinder le cache. Tests : `npm run test:cache`
* ✅ **Menu du compte refondu** (bouton avatar, haut à droite du header) — panneau sombre à lignes « icône + titre gras + sous-titre gris » : **Thème** (thème réellement appliqué en sous-titre, pastilles dépliées au clic), **Administration** (`/#/admin`), **API** (`/proxy/api/openapi.json` dans un nouvel onglet), **Préférences** (`/#/account/preferences`), **Guide d'utilisation** (`/#/info/utilisation`), **À propos** (version, tagline, lien code source), **Sessions** (`/#/account/sessions`), **Déconnexion** ; pied de page **Version** = version officielle de l'application (`src/app/version.ts`, alignée sur le tag semver publié par `docker/deploy-img.sh`). Clés i18n déclarées des deux côtés (FR + EN)
### 🟡 Partiellement livré
+10
View File
@@ -0,0 +1,10 @@
/**
* Version officielle de l'application NewTube, affichée dans le menu du compte
* (header) et le pied de page « À propos ».
*
* Source unique côté front : elle suit le tag semver publié par
* `docker/deploy-img.sh` (le registre incrémente le patch à chaque déploiement).
* Dernier tag publié avant cette livraison : 1.0.58 → cette livraison sort en
* 1.0.59. Bumper cette valeur à chaque livraison.
*/
export const APP_VERSION = '1.0.59';
+131 -9
View File
@@ -105,18 +105,140 @@
<span class="avatar-circle rounded-full" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
</button>
@if (userMenuOpen()) {
<!-- Panneau sombre : icône + titre gras + sous-titre gris par ligne,
action rouge séparée, pied de page « Version » sur une ligne. -->
<div class="absolute right-0 mt-2 w-[min(20rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-xl z-50" role="menu">
<div class="border-b border-slate-700 px-4 py-3">
<div class="truncate font-semibold text-slate-100">{{ user()?.username || user()?.email }}</div>
<div class="truncate text-xs text-slate-400">{{ user()?.email }}</div>
<div class="flex items-center gap-3 border-b border-slate-700 px-4 py-3">
<span class="avatar-circle rounded-full shrink-0" [ngClass]="avatarClasses()">{{ avatarLetter() }}</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ user()?.username || user()?.email }}</span>
<span class="block truncate text-xs text-slate-400">{{ user()?.email }}</span>
</span>
</div>
<div class="py-1">
<a routerLink="/account/preferences" (click)="closeUserMenu()" role="menuitem" class="block px-4 py-3 text-sm text-slate-200 hover:bg-slate-800">{{ 'menu.preferences' | t }}</a>
<div class="px-4 py-2 text-xs uppercase tracking-wide text-slate-500">{{ 'menu.appearance' | t }}</div>
<div class="px-3 pb-3">
<ng-container *ngTemplateOutlet="themeSwatches"></ng-container>
</div>
<button type="button" (click)="onLogout(); closeUserMenu()" role="menuitem" class="w-full px-4 py-3 text-left text-sm text-red-300 hover:bg-red-900/30">{{ 'menu.logout' | t }}</button>
<!-- Thème -->
<button type="button" (click)="menuThemeOpen.set(!menuThemeOpen())" role="menuitem"
[attr.aria-expanded]="menuThemeOpen()"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17a5 5 0 1 1 0-10 5 5 0 0 1 0 10zm0-8a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.theme' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ themeLabel() | t }}</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400 transition-transform" [class.rotate-180]="menuThemeOpen()"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
@if (menuThemeOpen()) {
<div class="px-3 pb-3">
<ng-container *ngTemplateOutlet="themeSwatches"></ng-container>
</div>
}
<!-- Administration -->
<a routerLink="/admin" (click)="closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2 4 5v6c0 5.55 3.84 10.74 8 12 4.16-1.26 8-6.45 8-12V5l-8-2zm0 2.18L18 6.3v4.7c0 4.52-3.02 8.76-6 9.79-2.98-1.03-6-5.27-6-9.79V6.3l6-2.12z"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'nav.admin' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.admin_sub' | t }}</span>
</span>
</a>
<!-- API -->
<a [href]="apiDocUrl()" target="_blank" rel="noopener noreferrer" (click)="closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.api' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.api_sub' | t }}</span>
</span>
</a>
<!-- Paramètres -->
<a routerLink="/account/preferences" (click)="closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 1 15.5 12 3.5 3.5 0 0 1 12 15.5Zm7.43-2.53a7.6 7.6 0 0 0 0-1.94l2.05-1.58a.5.5 0 0 0 .12-.64l-1.94-3.36a.5.5 0 0 0-.6-.22l-2.43.98a7.3 7.3 0 0 0-1.67-.97l-.37-2.58A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.37 2.58a7.7 7.7 0 0 0-1.67.97l-2.43-.98a.5.5 0 0 0-.6.22L2.07 8.58a.5.5 0 0 0 .12.64l2.05 1.58a7.9 7.9 0 0 0 0 1.94l-2.05 1.58a.5.5 0 0 0-.12.64l1.94 3.36a.5.5 0 0 0 .6.22l2.43-.98c.52.4 1.08.73 1.67.97l.37 2.58A.49.49 0 0 0 10 22h4a.49.49 0 0 0 .49-.42l.37-2.58c.59-.24 1.15-.57 1.67-.97l2.43.98a.5.5 0 0 0 .6-.22l1.94-3.36a.5.5 0 0 0-.12-.64Z"/></svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.preferences' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.settings_sub' | t }}</span>
</span>
</a>
<!-- Guide d'utilisation -->
<a routerLink="/info/utilisation" (click)="closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.guide' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.guide_sub' | t }}</span>
</span>
</a>
<!-- À propos -->
<button type="button" (click)="menuAboutOpen.set(!menuAboutOpen())" role="menuitem"
[attr.aria-expanded]="menuAboutOpen()"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3Zm7 8 .9 2.1L22 14l-2.1.9L19 17l-.9-2.1L16 14l2.1-.9L19 11Z"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.about' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.about_sub' | t }}</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400 transition-transform" [class.rotate-180]="menuAboutOpen()"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
@if (menuAboutOpen()) {
<div class="px-4 pb-3 pt-1 text-xs leading-relaxed text-slate-400">
<p class="font-semibold text-slate-200">NewTube {{ appVersion }}</p>
<p>{{ 'about.tagline' | t }}</p>
<a href="https://git.dracodev.net/Projets/NewTube" target="_blank" rel="noopener noreferrer"
class="mt-1 inline-block text-slate-300 underline underline-offset-2 hover:text-slate-100 focus-ring">{{ 'about.source' | t }}</a>
</div>
}
<!-- Sessions -->
<a routerLink="/account/sessions" (click)="closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-slate-800 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-100">{{ 'menu.sessions' | t }}</span>
<span class="block truncate text-xs text-slate-400">{{ 'menu.sessions_sub' | t }}</span>
</span>
</a>
</div>
<!-- Déconnexion -->
<div class="border-t border-slate-700 py-1">
<button type="button" (click)="onLogout(); closeUserMenu()" role="menuitem"
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-red-300 hover:bg-red-900/30 focus-ring">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold">{{ 'menu.logout' | t }}</span>
</span>
</button>
</div>
<!-- Pied de page : version officielle -->
<div class="flex items-center gap-2 border-t border-slate-700 bg-slate-800/40 px-4 py-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20.59 13.41 12 22l-9-9V3h10l7.59 7.59a2 2 0 0 1 0 2.82Z"/><line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
<span class="text-xs font-semibold text-slate-200">{{ 'menu.version' | t }}</span>
<span class="ml-auto rounded bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-200">NewTube {{ appVersion }}</span>
</div>
</div>
}
+19
View File
@@ -13,6 +13,7 @@ import { HistoryService, SearchHistoryItem } from '../../services/history.servic
import { SearchBoxComponent } from '../search/search-box.component';
import { filtersToQueryParams, type SearchFilters } from '../../app/search/filters';
import { ALL_PROVIDER_IDS_CSV, type ProviderId } from '../../app/shared/providers/provider-ids';
import { APP_VERSION } from '../../app/version';
@Component({
selector: 'app-header',
@@ -316,6 +317,15 @@ export class HeaderComponent implements AfterViewInit {
authError = signal<string | null>(null);
userMenuOpen = signal(false);
// --- Menu du compte (header, desktop) ------------------------------------
// Deux lignes dépliables : la pastille de thème et le bloc « À propos ».
readonly menuThemeOpen = signal(false);
readonly menuAboutOpen = signal(false);
/** Version officielle affichée dans le pied de page du menu. */
readonly appVersion = APP_VERSION;
/** Sous-titre de la ligne « Thème » : le thème réellement appliqué. */
readonly themeLabel = computed(() => 'theme.' + this.resolveThemeSlug(this.currentTheme()));
// --- Mobile chrome -------------------------------------------------------
// Slide-down search sheet (phones). Desktop keeps the search box inline, so
// the same <app-search-box> is only *positioned* differently by CSS.
@@ -659,11 +669,20 @@ export class HeaderComponent implements AfterViewInit {
toggleUserMenu() {
this.userMenuOpen.update(v => !v);
if (this.userMenuOpen()) {
// Repli propre à chaque ouverture : aucun panneau déplié de la session précédente.
this.menuThemeOpen.set(false);
this.menuAboutOpen.set(false);
this.moreMenuOpen.set(false);
this.mobileSearchOpen.set(false);
}
}
/** Doc OpenAPI : même règle /proxy/api que les services (dev sur 4200 → proxy Angular). */
apiDocUrl(): string {
const port = typeof window !== 'undefined' ? (window.location.port || '') : '';
return `${port && port !== '4000' ? '/proxy/api' : '/api'}/openapi.json`;
}
@HostListener('document:click', ['$event'])
onDocumentClick(ev: MouseEvent) {
if (this.userMenuOpen()) {
+26
View File
@@ -28,6 +28,19 @@ const EN: Record<string, string> = {
'menu.close': 'Close',
'menu.navigation': 'Navigation',
'menu.theme': 'Theme',
'menu.admin_sub': 'Health & provider metrics',
'menu.api': 'API',
'menu.api_sub': 'OpenAPI documentation',
'menu.settings_sub': 'Application preferences',
'menu.guide': 'Usage guide',
'menu.guide_sub': 'Open help',
'menu.about': 'About',
'menu.about_sub': 'Version, credits & info',
'menu.sessions': 'Sessions',
'menu.sessions_sub': 'Connected devices',
'menu.version': 'Version',
'about.tagline': 'Multi-provider video aggregator',
'about.source': 'Source code',
'theme.system': 'System',
'theme.black': 'Black',
'theme.dark': 'Dark',
@@ -224,6 +237,19 @@ const FR: Record<string, string> = {
'menu.close': 'Fermer',
'menu.navigation': 'Navigation',
'menu.theme': 'Thème',
'menu.admin_sub': 'Santé & métriques des fournisseurs',
'menu.api': 'API',
'menu.api_sub': 'Documentation OpenAPI',
'menu.settings_sub': 'Paramètres de l\'application',
'menu.guide': 'Guide d\'utilisation',
'menu.guide_sub': 'Ouvrir l\'aide',
'menu.about': 'À propos',
'menu.about_sub': 'Version, crédits & infos',
'menu.sessions': 'Sessions',
'menu.sessions_sub': 'Appareils connectés',
'menu.version': 'Version',
'about.tagline': 'Agrégateur vidéo multi-fournisseurs',
'about.source': 'Code source',
'theme.system': 'Système',
'theme.black': 'Noir',
'theme.dark': 'Sombre',