Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Modification de la partie web pour prendre la modification en compte
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
<app-update-banner></app-update-banner>
|
||||
|
||||
<div class="app-container">
|
||||
<app-sidebar></app-sidebar>
|
||||
|
||||
|
||||
@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
|
||||
import { SidebarComponent } from './sidebar/sidebar.component';
|
||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||
import { LayoutService } from './services/layout.service';
|
||||
import { GlobalSearchService } from './services/global-search.service';
|
||||
import { VersionCheckerService } from './services/version-checker.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
SidebarComponent,
|
||||
SecondarySidebarComponent,
|
||||
GlobalSearchComponent,
|
||||
UpdateBannerComponent,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
@@ -19,8 +29,14 @@ export class AppComponent {
|
||||
|
||||
constructor(
|
||||
private layoutService: LayoutService,
|
||||
private globalSearch: GlobalSearchService
|
||||
) {}
|
||||
private globalSearch: GlobalSearchService,
|
||||
versionChecker: VersionCheckerService,
|
||||
) {
|
||||
// Demarre la detection de mise a jour en arriere-plan.
|
||||
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
|
||||
// s'affichera automatiquement.
|
||||
versionChecker.start();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
|
||||
94
web/src/app/services/version-checker.service.ts
Normal file
94
web/src/app/services/version-checker.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un
|
||||
* onglet utilisateur est deja ouvert.
|
||||
*
|
||||
* Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version.
|
||||
* La version observee au boot est figee comme reference. Si la version polled
|
||||
* differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau
|
||||
* proposant un reload.
|
||||
*
|
||||
* Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas
|
||||
* pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VersionCheckerService {
|
||||
private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll
|
||||
|
||||
private readonly _bootVersion = signal<string | null>(null);
|
||||
private readonly _remoteVersion = signal<string | null>(null);
|
||||
private readonly _hasUpdate = computed(
|
||||
() => {
|
||||
const boot = this._bootVersion();
|
||||
const remote = this._remoteVersion();
|
||||
return boot !== null && remote !== null && boot !== remote;
|
||||
}
|
||||
);
|
||||
|
||||
/** True quand la version remote differe de celle observee au boot. */
|
||||
readonly hasUpdate = this._hasUpdate;
|
||||
/** Version observee a l'ouverture du tab (figee). */
|
||||
readonly bootVersion = this._bootVersion.asReadonly();
|
||||
/** Derniere version observee sur le backend. */
|
||||
readonly remoteVersion = this._remoteVersion.asReadonly();
|
||||
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/**
|
||||
* Demarre le polling. A appeler une fois au boot de l'app.
|
||||
* Ignore si deja demarre (idempotent).
|
||||
*/
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.fetchInitial();
|
||||
this.timer = setInterval(
|
||||
() => void this.poll(),
|
||||
VersionCheckerService.POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recharge l'app (force re-fetch index.html + assets). */
|
||||
reload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private async fetchInitial(): Promise<void> {
|
||||
// Petit delai pour laisser le bootstrap se finir avant le premier appel.
|
||||
await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS));
|
||||
const v = await this.fetchVersion();
|
||||
if (v && this._bootVersion() === null) {
|
||||
this._bootVersion.set(v);
|
||||
this._remoteVersion.set(v);
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
const v = await this.fetchVersion();
|
||||
if (v) this._remoteVersion.set(v);
|
||||
}
|
||||
|
||||
private async fetchVersion(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await firstValueFrom(
|
||||
this.http.get<{ version: string }>('/api/version'),
|
||||
);
|
||||
return resp?.version ?? null;
|
||||
} catch {
|
||||
// Backend down / restart en cours — on ignore, on retentera au prochain tick
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<div
|
||||
class="update-banner"
|
||||
*ngIf="versionChecker.hasUpdate() && !dismissed"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="banner-content">
|
||||
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||
<span>
|
||||
Une nouvelle version de LoreMind est disponible
|
||||
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
|
||||
Recharge pour profiter des dernieres ameliorations.
|
||||
</span>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button type="button" class="btn-reload" (click)="reload()">
|
||||
Recharger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-dismiss"
|
||||
(click)="dismiss()"
|
||||
title="Fermer (sera reaffiche au prochain demarrage)"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<lucide-icon [img]="X" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
background: linear-gradient(90deg, #6d28d9, #7c3aed);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-reload {
|
||||
background: #fff;
|
||||
color: #6d28d9;
|
||||
border: 0;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-dismiss {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decale le contenu de l'app vers le bas quand le bandeau est present
|
||||
// (bandeau fixed, ne pousse pas naturellement le layout).
|
||||
:host:has(.update-banner) {
|
||||
display: block;
|
||||
}
|
||||
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
34
web/src/app/shared/update-banner/update-banner.component.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LucideAngularModule, RefreshCw, X } from 'lucide-angular';
|
||||
import { VersionCheckerService } from '../../services/version-checker.service';
|
||||
|
||||
/**
|
||||
* Bandeau global affiche en haut de l'app quand une nouvelle version de
|
||||
* LoreMind a ete deployee pendant que l'utilisateur avait deja l'onglet
|
||||
* ouvert. Propose un reload en un clic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-update-banner',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './update-banner.component.html',
|
||||
styleUrls: ['./update-banner.component.scss']
|
||||
})
|
||||
export class UpdateBannerComponent {
|
||||
readonly RefreshCw = RefreshCw;
|
||||
readonly X = X;
|
||||
|
||||
/** L'utilisateur a explicitement ferme le bandeau pour cette session. */
|
||||
dismissed = false;
|
||||
|
||||
constructor(public versionChecker: VersionCheckerService) {}
|
||||
|
||||
reload(): void {
|
||||
this.versionChecker.reload();
|
||||
}
|
||||
|
||||
dismiss(): void {
|
||||
this.dismissed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user