diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java
new file mode 100644
index 0000000..05187a1
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/VersionController.java
@@ -0,0 +1,35 @@
+package com.loremind.infrastructure.web.controller;
+
+import org.springframework.boot.info.BuildProperties;
+import org.springframework.lang.Nullable;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * Endpoint public exposant la version courante du binaire.
+ *
+ * Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
+ * pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
+ * differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
+ *
+ * Volontairement public (pas d'auth) : la version est deja exposee dans le
+ * JAR / l'image Docker, aucun risque de leak.
+ */
+@RestController
+@RequestMapping("/api/version")
+public class VersionController {
+
+ private final String version;
+
+ public VersionController(@Nullable BuildProperties buildProperties) {
+ this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
+ }
+
+ @GetMapping
+ public Map getVersion() {
+ return Map.of("version", version);
+ }
+}
diff --git a/web/src/app/app.component.html b/web/src/app/app.component.html
index 32f7a01..5bd96fa 100644
--- a/web/src/app/app.component.html
+++ b/web/src/app/app.component.html
@@ -1,3 +1,5 @@
+
+
diff --git a/web/src/app/app.component.ts b/web/src/app/app.component.ts
index 662f983..5e1e924 100644
--- a/web/src/app/app.component.ts
+++ b/web/src/app/app.component.ts
@@ -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 {
diff --git a/web/src/app/services/version-checker.service.ts b/web/src/app/services/version-checker.service.ts
new file mode 100644
index 0000000..484b058
--- /dev/null
+++ b/web/src/app/services/version-checker.service.ts
@@ -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(null);
+ private readonly _remoteVersion = signal(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 | 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 {
+ // 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 {
+ const v = await this.fetchVersion();
+ if (v) this._remoteVersion.set(v);
+ }
+
+ private async fetchVersion(): Promise {
+ 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;
+ }
+ }
+}
diff --git a/web/src/app/shared/update-banner/update-banner.component.html b/web/src/app/shared/update-banner/update-banner.component.html
new file mode 100644
index 0000000..796d2f7
--- /dev/null
+++ b/web/src/app/shared/update-banner/update-banner.component.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Une nouvelle version de LoreMind est disponible
+ ({{ versionChecker.remoteVersion() }}).
+ Recharge pour profiter des dernieres ameliorations.
+
+
+
+
+
+
+
diff --git a/web/src/app/shared/update-banner/update-banner.component.scss b/web/src/app/shared/update-banner/update-banner.component.scss
new file mode 100644
index 0000000..b081069
--- /dev/null
+++ b/web/src/app/shared/update-banner/update-banner.component.scss
@@ -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;
+}
diff --git a/web/src/app/shared/update-banner/update-banner.component.ts b/web/src/app/shared/update-banner/update-banner.component.ts
new file mode 100644
index 0000000..208c17a
--- /dev/null
+++ b/web/src/app/shared/update-banner/update-banner.component.ts
@@ -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;
+ }
+}