From e73e25b1da50fb194a798309a48725cb3d02eb42 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:19:14 +0100 Subject: [PATCH 01/10] =?UTF-8?q?Ajout=20.user.ini=20:=20augmentation=20li?= =?UTF-8?q?mite=20m=C3=A9moire=20PHP=20=C3=A0=20512M?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporaire pour gérer le chargement des notifications de tous les projets. Co-Authored-By: Claude Sonnet 4.5 --- public/.user.ini | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 public/.user.ini diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..082ba05 --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,2 @@ +; Augmentation temporaire de la limite mémoire pour le chargement des notifications +memory_limit = 512M From bb71da081b53c0487721140bf26b55ffdbf793cb Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:42:20 +0100 Subject: [PATCH 02/10] =?UTF-8?q?Ajout=20du=20syst=C3=A8me=20de=20cache=20?= =?UTF-8?q?pour=20les=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : Les notifications étaient collectées à chaque requête sur projects.json, causant des problèmes de performance et de mémoire. Solution : Mise en cache des notifications par projet et par utilisateur - Nouvelle méthode getNotificationsLight() dans ProjectPage avec cache - Cache invalidé automatiquement via les hooks existants (page/file update) - Cache par utilisateur pour inclure le isRead spécifique Performance : Les notifications sont calculées une fois puis servies depuis le cache jusqu'à ce qu'un changement survienne (commentaire, brief, etc.) Co-Authored-By: Claude Sonnet 4.5 --- ...update--regenerate-project-steps-cache.php | 6 ++- public/site/models/project.php | 54 +++++++++++++++++-- public/site/templates/projects.json.php | 22 ++++---- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php index bde728d..f8d9675 100644 --- a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php @@ -1,9 +1,11 @@ template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); if ($project) { - $steps = $project->rebuildStepsCache(); + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (briefs validés, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file diff --git a/public/site/models/project.php b/public/site/models/project.php index 1152945..583bfeb 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -3,18 +3,62 @@ use adrienpayet\notifications\NotificationsPage; class ProjectPage extends NotificationsPage { - public function getSteps() { + public function getSteps() { $apiCache = kirby()->cache('api'); - $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); - + $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); + if ($stepsData === null || count($stepsData) === 0) { $this->rebuildStepsCache(); }; - - $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); + + $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); return $stepsData; } + + /** + * Récupère les notifications pour ce projet (version allégée avec cache). + * Cache par utilisateur pour inclure le isRead. + */ + public function getNotificationsLight($user) { + if (!$user) { + return []; + } + + $apiCache = kirby()->cache('api'); + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $notifications = $apiCache?->get($cacheKey); + + // Si pas en cache, collecter et cacher + if ($notifications === null) { + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + if (!$collector) { + return []; + } + + try { + $notifications = $collector->collectLight($this, $user); + $apiCache->set($cacheKey, $notifications); + } catch (\Throwable $e) { + error_log("Error caching notifications for {$this->slug()}: " . $e->getMessage()); + return []; + } + } + + return $notifications; + } + + /** + * Invalide le cache des notifications de ce projet pour tous les utilisateurs. + */ + public function invalidateNotificationsCache() { + $apiCache = kirby()->cache('api'); + // Invalider pour tous les users + foreach (kirby()->users() as $user) { + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $apiCache->remove($cacheKey); + } + } public function rebuildStepsCache() { // Create steps diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index 222e86a..2b2eaad 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,17 +7,15 @@ if (!$kirby->user()) { ]); } -function getProjectData($project, $user, $collector) +function getProjectData($project, $user) { - // Utiliser collectLight() pour économiser la mémoire (seulement id, type, isRead, date) + // Utiliser getNotificationsLight() avec cache pour optimiser les performances $notifications = []; - if ($collector) { - try { - $notifications = $collector->collectLight($project, $user); - } catch (\Throwable $e) { - error_log("Error collecting light notifications for project {$project->uri()}: " . $e->getMessage()); - $notifications = []; - } + try { + $notifications = $project->getNotificationsLight($user); + } catch (\Throwable $e) { + error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage()); + $notifications = []; } $data = [ @@ -43,14 +41,12 @@ function getProjectData($project, $user, $collector) return $data; } -// Récupérer le collector de notifications -$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector'); $currentUser = $kirby->user(); try { $children = $currentUser->role() == 'admin' - ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values() - : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values(); + ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser))->values() + : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; From 2791bc4462913b8027fd0f26f4bce50d2c379fc9 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:42:40 +0100 Subject: [PATCH 03/10] Ajout invalidation cache notifications dans hook file-update Co-Authored-By: Claude Sonnet 4.5 --- .../hooks/file-update--regenerate-project-steps-cache.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php index ab09290..c30f93d 100644 --- a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php @@ -3,7 +3,9 @@ // file.update:after return function ($newFile, $oldFile) { $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); - if ($project) { - $steps = $project->rebuildStepsCache(); + if ($project) { + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (commentaires sur fichiers, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file From 86db1f5a0c987a2bfdf2350065d02fd09e6cda00 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:55:17 +0100 Subject: [PATCH 04/10] Fix collectLight() : inclure author, text, location pour l'affichage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : collectLight() ne retournait que id/type/isRead/date, causant notification.author undefined dans le frontend. Solution : Inclure tous les champs nécessaires à l'affichage (author, text, location) mais toujours alléger en excluant les gros détails inutiles. Co-Authored-By: Claude Sonnet 4.5 --- .../src/NotificationCollector.php | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php index 83e47d1..ec1714b 100644 --- a/public/site/plugins/notifications/src/NotificationCollector.php +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -59,7 +59,7 @@ class NotificationCollector /** * Collecte uniquement les données minimales des notifications (version allégée pour listing). - * Retourne seulement id, type, isRead, date pour économiser la mémoire. + * Retourne les champs nécessaires à l'affichage mais sans les détails lourds. * * @param Page $project Le projet à scanner * @param User $user L'utilisateur courant @@ -72,18 +72,33 @@ class NotificationCollector foreach ($this->providers as $provider) { try { $notifications = $provider->collect($project, $user); - // Ne garder que les champs essentiels + // Garder les champs nécessaires au frontend foreach ($notifications as $notification) { - $all[] = [ + $light = [ 'id' => $notification['id'] ?? null, 'type' => $notification['type'] ?? null, 'isRead' => $notification['isRead'] ?? false, 'date' => $notification['date'] ?? null, - // Garder location.project.uri pour le frontend - 'location' => [ - 'project' => $notification['location']['project'] ?? [] - ] + 'text' => $notification['text'] ?? null, + 'author' => $notification['author'] ?? null, + 'location' => $notification['location'] ?? [] ]; + + // Garder les champs optionnels s'ils existent + if (isset($notification['dialogUri'])) { + $light['dialogUri'] = $notification['dialogUri']; + } + if (isset($notification['_briefUri'])) { + $light['_briefUri'] = $notification['_briefUri']; + } + if (isset($notification['_file'])) { + $light['_file'] = $notification['_file']; + } + if (isset($notification['_projectUri'])) { + $light['_projectUri'] = $notification['_projectUri']; + } + + $all[] = $light; } } catch (\Throwable $e) { error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); From a57b0c203a47c9a25bebc5486d769a82db158197 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:08:13 +0100 Subject: [PATCH 05/10] Optimisation du refresh cache avec batch processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : Le refresh cache de tous les projets timeout côté serveur à cause du trop grand nombre de projets à traiter en une seule requête. Solution : Batch processing avec indicateur de progression - Backend : traite 10 projets par batch avec offset/limit - Frontend : fait plusieurs requêtes successives et affiche la progression - Timeout réduit à 60s par batch au lieu de illimité - Bouton désactivé pendant le traitement - Ajout invalidateNotificationsCache() pour vider aussi ce cache Affichage : "15/50 (30%)" pendant le traitement, puis "Terminé (50)" Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 92 +++++++++++++++++-- .../src/routes/refresh-cache.php | 47 +++++++--- 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index 5fdb1cf..f4cf6d6 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -7,6 +7,7 @@ :icon="icon" :title="title" @click="refreshCache()" + :disabled="isProcessing" >{{ text }} @@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); +const isProcessing = ref(false); + const title = computed(() => { return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate @@ -31,25 +34,89 @@ const title = computed(() => { }); async function refreshCache() { - text.value = "En cours…"; + isProcessing.value = true; icon.value = "loader"; theme.value = "orange-icon"; + // Pour les projets multiples (batch processing) + if (pageUri === 'projects') { + await refreshAllProjects(); + } else { + await refreshSingleProject(); + } +} + +async function refreshAllProjects() { + let offset = 0; + const limit = 10; // 10 projets par batch + let hasMore = true; + let total = 0; + + try { + while (hasMore) { + const init = { + method: "POST", + "Content-Type": "application/json", + body: JSON.stringify({ + pageUri: 'projects', + offset, + limit + }), + }; + + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } + + total = json.total; + hasMore = json.hasMore; + offset = json.nextOffset; + + // Mise à jour de la progression + const progress = Math.round((json.processed / json.total) * 100); + text.value = `${json.processed}/${json.total} (${progress}%)`; + + console.log(`Batch terminé : ${json.processed}/${json.total} projets`); + } + + // Succès + text.value = `Terminé (${total})`; + icon.value = "check"; + theme.value = "green-icon"; + + setTimeout(() => { + location.href = location.href; + }, 1500); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; + } +} + +async function refreshSingleProject() { + text.value = "En cours…"; + const init = { method: "POST", "Content-Type": "application/json", body: JSON.stringify({ pageUri }), }; - const res = await fetch("/refresh-cache.json", init); - const json = await res.json(); + try { + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } - if (json.status === "error") { - console.error(json); - text.value = "Erreur"; - icon.value = "alert"; - theme.value = "red-icon"; - } else { console.log(json); text.value = "Terminé"; icon.value = "check"; @@ -58,6 +125,13 @@ async function refreshCache() { setTimeout(() => { location.href = location.href; }, 1500); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; } } diff --git a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php index 207d34e..7f93443 100644 --- a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php +++ b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php @@ -1,5 +1,5 @@ '/refresh-cache.json', @@ -10,17 +10,42 @@ return [ if ($data->pageUri === 'projects') { $projects = page('projects')->children(); - foreach ($projects as $project) { - $project->rebuildStepsCache(); - $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); - $project->update([ - 'lastCacheUpdate' => $formatter->format(time()) - ]); + // Support du batch processing + $offset = isset($data->offset) ? intval($data->offset) : 0; + $limit = isset($data->limit) ? intval($data->limit) : 10; // 10 projets par batch par défaut + $total = $projects->count(); + + // Slice pour ne traiter qu'un batch + $batch = $projects->slice($offset, $limit); + $processed = 0; + + foreach ($batch as $project) { + try { + $project->rebuildStepsCache(); + $project->invalidateNotificationsCache(); + + $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); + $project->update([ + 'lastCacheUpdate' => $formatter->format(time()) + ]); + $processed++; + } catch (\Throwable $e) { + error_log("Error refreshing cache for project {$project->slug()}: " . $e->getMessage()); + } } + + $remaining = max(0, $total - ($offset + $processed)); + $hasMore = $remaining > 0; + return [ - 'satus' => 'success', - 'message' => 'Données des pages projets rafraîchies avec succès.' + 'status' => 'success', + 'message' => "Batch terminé : $processed projets traités.", + 'processed' => $offset + $processed, + 'total' => $total, + 'remaining' => $remaining, + 'hasMore' => $hasMore, + 'nextOffset' => $hasMore ? $offset + $limit : null ]; } else { try { @@ -41,7 +66,7 @@ return [ if (!$project) { return [ - 'satus' => 'error', + 'status' => 'error', 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' ]; } @@ -55,7 +80,7 @@ return [ return [ - 'satus' => 'success', + 'status' => 'success', 'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.' ]; } From 4669f03f167fc07663afae02ffdfb149b4b3fd36 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:13:26 +0100 Subject: [PATCH 06/10] =?UTF-8?q?Am=C3=A9lioration=20affichage=20progressi?= =?UTF-8?q?on=20du=20refresh=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'une ligne de texte sous le bouton pour afficher la progression : - "Traitement : 10/50 projets (20%)" pendant le traitement - "50 projets mis à jour avec succès" à la fin - Tooltip aussi mis à jour avec la progression Le bouton affiche "En cours…" et la progression détaillée est en dessous. Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index f4cf6d6..76583dc 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -10,6 +10,9 @@ :disabled="isProcessing" >{{ text }} +
+ {{ progressText }} +
@@ -26,8 +29,12 @@ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); const isProcessing = ref(false); +const progressText = ref(""); const title = computed(() => { + if (progressText.value) { + return progressText.value; + } return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate : "Mettre à jour le cache front"; @@ -52,6 +59,8 @@ async function refreshAllProjects() { let hasMore = true; let total = 0; + text.value = "En cours…"; + try { while (hasMore) { const init = { @@ -77,23 +86,25 @@ async function refreshAllProjects() { // Mise à jour de la progression const progress = Math.round((json.processed / json.total) * 100); - text.value = `${json.processed}/${json.total} (${progress}%)`; + progressText.value = `Traitement : ${json.processed}/${json.total} projets (${progress}%)`; console.log(`Batch terminé : ${json.processed}/${json.total} projets`); } // Succès - text.value = `Terminé (${total})`; + text.value = "Terminé"; + progressText.value = `${total} projets mis à jour avec succès`; icon.value = "check"; theme.value = "green-icon"; setTimeout(() => { location.href = location.href; - }, 1500); + }, 2000); } catch (error) { console.error(error); text.value = "Erreur"; + progressText.value = error.message || "Une erreur est survenue"; icon.value = "alert"; theme.value = "red-icon"; isProcessing.value = false; From 378af9ac962723abc42dc4490b9ac0ce7736e11c Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:18:33 +0100 Subject: [PATCH 07/10] Fix : affichage progression dans le texte du bouton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La div en dessous ne s'affichait pas dans le panel Kirby. La progression s'affiche maintenant directement dans le bouton : "En cours 0%" → "En cours 20%" → "En cours 100%" → "Terminé" Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index 76583dc..f6b12f0 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -10,9 +10,6 @@ :disabled="isProcessing" >{{ text }} -
- {{ progressText }} -
@@ -29,12 +26,8 @@ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); const isProcessing = ref(false); -const progressText = ref(""); const title = computed(() => { - if (progressText.value) { - return progressText.value; - } return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate : "Mettre à jour le cache front"; @@ -59,7 +52,7 @@ async function refreshAllProjects() { let hasMore = true; let total = 0; - text.value = "En cours…"; + text.value = "En cours 0%"; try { while (hasMore) { @@ -84,16 +77,15 @@ async function refreshAllProjects() { hasMore = json.hasMore; offset = json.nextOffset; - // Mise à jour de la progression + // Mise à jour de la progression dans le texte du bouton const progress = Math.round((json.processed / json.total) * 100); - progressText.value = `Traitement : ${json.processed}/${json.total} projets (${progress}%)`; + text.value = `En cours ${progress}%`; - console.log(`Batch terminé : ${json.processed}/${json.total} projets`); + console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`); } // Succès text.value = "Terminé"; - progressText.value = `${total} projets mis à jour avec succès`; icon.value = "check"; theme.value = "green-icon"; @@ -104,7 +96,6 @@ async function refreshAllProjects() { } catch (error) { console.error(error); text.value = "Erreur"; - progressText.value = error.message || "Une erreur est survenue"; icon.value = "alert"; theme.value = "red-icon"; isProcessing.value = false; From 95a8bf99cb9a53772b7beaeaf8c4b8e366c9219b Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:19:35 +0100 Subject: [PATCH 08/10] build plugin refresh cache --- public/site/plugins/refresh-cache-button/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/site/plugins/refresh-cache-button/index.js b/public/site/plugins/refresh-cache-button/index.js index 8fc4d42..4d99982 100644 --- a/public/site/plugins/refresh-cache-button/index.js +++ b/public/site/plugins/refresh-cache-button/index.js @@ -1 +1 @@ -(function(){"use strict";function f(n,e,a,t,r,c,s,u){var o=typeof n=="function"?n.options:n;return e&&(o.render=e,o.staticRenderFns=a,o._compiled=!0),{exports:n,options:o}}const l={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:a,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),c=Vue.ref("refresh"),s=Vue.ref("aqua-icon"),u=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function o(){r.value="En cours…",c.value="loader",s.value="orange-icon";const m={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})},i=await(await fetch("/refresh-cache.json",m)).json();i.status==="error"?(console.error(i),r.value="Erreur",c.value="alert",s.value="red-icon"):(console.log(i),r.value="Terminé",c.value="check",s.value="green-icon",setTimeout(()=>{location.href=location.href},1500))}return{__sfc:!0,text:r,icon:c,theme:s,title:u,refreshCache:o}}};var h=function(){var e=this,a=e._self._c,t=e._self._setupProxy;return a("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?a("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},_=[],p=f(l,h,_);const d=p.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":d}})})(); +(function(){"use strict";function _(n,e,u,t,r,s,a,l){var c=typeof n=="function"?n.options:n;return e&&(c.render=e,c.staticRenderFns=u,c._compiled=!0),{exports:n,options:c}}const g={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:u,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),s=Vue.ref("refresh"),a=Vue.ref("aqua-icon"),l=Vue.ref(!1),c=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function T(){l.value=!0,s.value="loader",a.value="orange-icon",e==="projects"?await d():await v()}async function d(){let f=0;const h=10;let i=!0,b=0;r.value="En cours 0%";try{for(;i;){const p={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:"projects",offset:f,limit:h})},o=await(await fetch("/refresh-cache.json",p)).json();if(o.status==="error")throw new Error(o.message);b=o.total,i=o.hasMore,f=o.nextOffset;const m=Math.round(o.processed/o.total*100);r.value=`En cours ${m}%`,console.log(`Batch terminé : ${o.processed}/${o.total} projets (${m}%)`)}r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},2e3)}catch(p){console.error(p),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}async function v(){r.value="En cours…";const f={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})};try{const i=await(await fetch("/refresh-cache.json",f)).json();if(i.status==="error")throw new Error(i.message);console.log(i),r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},1500)}catch(h){console.error(h),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}return{__sfc:!0,text:r,icon:s,theme:a,isProcessing:l,title:c,refreshCache:T,refreshAllProjects:d,refreshSingleProject:v}}};var j=function(){var e=this,u=e._self._c,t=e._self._setupProxy;return u("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?u("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title,disabled:t.isProcessing},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},y=[],w=_(g,j,y);const S=w.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":S}})})(); From dfb8d1038be4aa0722485611d4f8ef5133f14810 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:29:49 +0100 Subject: [PATCH 09/10] =?UTF-8?q?Fix=20routing=20vers=20une=20piste=20sp?= =?UTF-8?q?=C3=A9cifique=20avec=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : L'URL avec hash (#serumwc_lasertone_empty) n'ouvrait pas la bonne piste/variation mais toujours la première. Cause : Incohérence entre les underscores du hash et les tirets du slug backend. slugify convertit les underscores en tirets, mais les slugs Kirby peuvent varier. Solution : Comparer le hash de 3 façons : 1. Comparaison directe 2. Hash avec underscores → tirets 3. Slug avec tirets → underscores Cela gère tous les cas de figure. Co-Authored-By: Claude Sonnet 4.5 --- .../project/virtual-sample/DynamicView.vue | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index f89552b..8273c73 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -100,8 +100,18 @@ onBeforeMount(() => { if (route?.hash && route.hash.length > 0) { const variations = tracks.value.flatMap((t) => t.variations || []); - initialVariation = - variations.find((v) => v.slug === route.hash.substring(1)) || null; + const hashValue = route.hash.substring(1); + + // Essayer de trouver la variation soit par slug direct, soit en normalisant le hash + initialVariation = variations.find((v) => { + // Comparaison directe + if (v.slug === hashValue) return true; + // Comparaison en convertissant underscores en tirets (slugify par défaut) + if (v.slug === hashValue.replace(/_/g, '-')) return true; + // Comparaison inverse : le slug du backend pourrait avoir des underscores + if (v.slug.replace(/-/g, '_') === hashValue) return true; + return false; + }) || null; } // fallback : première variation du premier track From 6b80e242b87009c2eaeaafffa2b2e21a295f8a0e Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 13:54:36 +0100 Subject: [PATCH 10/10] Fix virtual sample routing and refactor for clarity Virtual sample variations now display correctly when loading from URL hash. Old URLs with underscores are normalized to hyphens on load. URL hash updates automatically when navigating between variations. Refactored both DynamicView and Selector components with explicit function names, removed unnecessary comments, and improved code organization. Co-Authored-By: Claude Sonnet 4.5 --- src/components/Selector.vue | 232 ++++++++++-------- .../project/virtual-sample/DynamicView.vue | 160 +++++++----- 2 files changed, 224 insertions(+), 168 deletions(-) diff --git a/src/components/Selector.vue b/src/components/Selector.vue index 32521cb..c95299d 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +const syncing = ref(false); -// Store const { activeTracks } = storeToRefs(useDialogStore()); -// Utils -function isSame(a, b) { - if (!a || !b) return false; - if (a.slug && b.slug) return a.slug === b.slug; - return a.title === b.title; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); } -function toVariation(v) { - if (!v) return null; - return Array.isArray(v) ? v[v.length - 1] || null : v; +function areVariationsEqual(variationA, variationB) { + if (!variationA || !variationB) return false; + + if (variationA.slug && variationB.slug) { + return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug); + } + + return variationA.title === variationB.title; } -// Initialisation : remplir le 1er select localement ET initialiser le store -onBeforeMount(() => { +function extractVariation(value) { + if (!value) return null; + return Array.isArray(value) ? value[value.length - 1] || null : value; +} + +function convertValueForCompareMode(value, shouldBeArray) { + if (shouldBeArray) { + return value && !Array.isArray(value) ? [value] : value; + } else { + return Array.isArray(value) ? value[0] || null : value; + } +} + +function findMatchingVariationsInStore(storeVariations) { + return storeVariations.filter((storeVar) => + items.some((item) => areVariationsEqual(item, storeVar)) + ); +} + +function syncCurrentValueFromStore(storeVariations) { syncing.value = true; - if (index === 0) { - currentValue.value = items[0] || null; - // si le store est vide, initialiser avec la variation du premier sélecteur - if (!activeTracks.value || activeTracks.value.length === 0) { - const v = toVariation(items[0]); - if (v) activeTracks.value = [v]; - } + const matchedVariations = findMatchingVariationsInStore(storeVariations); + + if (isCompareModeEnabled) { + currentValue.value = matchedVariations.length ? [...matchedVariations] : []; } else { - // les autres ne forcent pas le store ; leur currentValue restera à null - currentValue.value = null; + currentValue.value = matchedVariations[0] || null; } nextTick(() => (syncing.value = false)); -}); +} + +function detectVariationChanges(newValues, oldValues) { + const newList = Array.isArray(newValues) + ? newValues + : newValues + ? [newValues] + : []; + const oldList = Array.isArray(oldValues) + ? oldValues + : oldValues + ? [oldValues] + : []; + + const addedVariation = newList.find( + (n) => !oldList.some((o) => areVariationsEqual(o, n)) + ); + const removedVariation = oldList.find( + (o) => !newList.some((n) => areVariationsEqual(n, o)) + ); + + return { addedVariation, removedVariation }; +} + +function handleVariationChange(newValue, oldValue) { + if (syncing.value) return; + + const { addedVariation, removedVariation } = detectVariationChanges( + newValue, + oldValue + ); + + if ( + addedVariation && + items.some((item) => areVariationsEqual(item, addedVariation)) + ) { + updateActiveTracks(addedVariation, 'add'); + } else if ( + removedVariation && + items.some((item) => areVariationsEqual(item, removedVariation)) + ) { + updateActiveTracks(removedVariation, 'remove'); + } +} -// Quand on bascule compare mode (objet <-> tableau) watch( () => isCompareModeEnabled, - (flag) => { + (shouldBeArray) => { syncing.value = true; - if (flag) { - if (currentValue.value && !Array.isArray(currentValue.value)) { - currentValue.value = [currentValue.value]; - } - } else { - if (Array.isArray(currentValue.value)) { - currentValue.value = currentValue.value[0] || null; - } - } + currentValue.value = convertValueForCompareMode( + currentValue.value, + shouldBeArray + ); nextTick(() => (syncing.value = false)); } ); -// Détection ajout / suppression dans le MultiSelect (côté composant) -// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` -watch( - currentValue, - (newVal, oldVal) => { - if (syncing.value) return; +watch(currentValue, handleVariationChange, { deep: true }); - const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : []; - const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : []; - - const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n))); - const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o))); - - if (added && items.some((it) => isSame(it, added))) { - selectTrack(added, 'add'); - } else if (removed && items.some((it) => isSame(it, removed))) { - selectTrack(removed, 'remove'); - } - }, - { deep: true } -); - -// Quand activeTracks change elsewhere -> synchroniser l'affichage local -// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`) watch( activeTracks, - (newVal) => { - syncing.value = true; - - const storeList = Array.isArray(newVal) ? newVal : []; - // ne garder que les variations du store qui sont dans `items` - const matched = storeList.filter((av) => - items.some((it) => isSame(it, av)) - ); - - if (isCompareModeEnabled) { - currentValue.value = matched.length ? [...matched] : []; - } else { - currentValue.value = matched[0] || null; - } - - nextTick(() => (syncing.value = false)); + (storeVariations) => { + const variationsList = Array.isArray(storeVariations) + ? storeVariations + : []; + syncCurrentValueFromStore(variationsList); }, - { deep: true } + { deep: true, immediate: true } ); -// Logique centrale de sélection (ajout / suppression) -// Règles : -// - mode normal -> activeTracks = [variation] -// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première -function selectTrack(track, action = 'add') { - const variation = toVariation(track); +function removeVariationFromActiveTracks(variation) { + activeTracks.value = activeTracks.value.filter( + (track) => !areVariationsEqual(track, variation) + ); +} + +function addVariationToActiveTracks(variation) { + const isAlreadyPresent = activeTracks.value.some((track) => + areVariationsEqual(track, variation) + ); + + if (isAlreadyPresent) return; + + if (activeTracks.value.length === 0) { + activeTracks.value = [variation]; + } else if (activeTracks.value.length === 1) { + activeTracks.value = [activeTracks.value[0], variation]; + } else { + activeTracks.value = [activeTracks.value[0], variation]; + } +} + +function updateActiveTracks(track, action = 'add') { + const variation = extractVariation(track); if (!variation) return; if (!isCompareModeEnabled) { @@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') { } if (action === 'remove') { - const wasFirst = - activeTracks.value.length && isSame(activeTracks.value[0], variation); - activeTracks.value = activeTracks.value.filter( - (t) => !isSame(t, variation) - ); - - // si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0 - // pas d'action supplémentaire nécessaire ici (déjà assuré par le filter) - return; - } - - // action === 'add' - if (activeTracks.value.some((t) => isSame(t, variation))) { - // déjà présent -> ignore - return; - } - - if (activeTracks.value.length === 0) { - activeTracks.value = [variation]; - } else if (activeTracks.value.length === 1) { - activeTracks.value = [activeTracks.value[0], variation]; + removeVariationFromActiveTracks(variation); } else { - // remplacer le 2e - activeTracks.value = [activeTracks.value[0], variation]; + addVariationToActiveTracks(variation); } } -// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -231,8 +246,8 @@ function getFrontViewUrl(item) { } function setImage() { - return getFrontViewUrl(currentValue.value) - ? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')' + return getFrontViewUrl(currentValue.value) + ? "--image: url('" + getFrontViewUrl(currentValue.value) + "')" : undefined; } @@ -250,7 +265,8 @@ function setImage() { padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16); } .selector-dropdown.has-image, -.selector-dropdown.has-image :is(#selector-select, #selector-multiselect, [role='combobox']) { +.selector-dropdown.has-image + :is(#selector-select, #selector-multiselect, [role='combobox']) { padding-left: var(--space-64); } .selector-dropdown.has-image:before { @@ -290,7 +306,9 @@ function setImage() { cursor: pointer; } [role='combobox'] p, -.selector-dropdown [data-pc-section="labelcontainer"] > [data-pc-section='label'] { +.selector-dropdown + [data-pc-section='labelcontainer'] + > [data-pc-section='label'] { max-height: 1lh; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index 8273c73..60bcd8e 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import Interactive360 from './Interactive360.vue'; import SingleImage from './SingleImage.vue'; import Selector from '../../Selector.vue'; import slugify from 'slugify'; const route = useRoute(); +const router = useRouter(); const { page } = storeToRefs(usePageStore()); const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } = @@ -92,51 +93,74 @@ const tracks = computed(() => { return list; }); -// ---------- INITIALISATION ---------- -// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) -onBeforeMount(() => { - // essayer la hash en priorité - let initialVariation = null; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); +} +function getVariationSlug(variation) { + return variation.slug || (variation.title ? slugify(variation.title) : null); +} + +function findVariationByHash(hashValue) { + const allVariations = tracks.value.flatMap((track) => track.variations || []); + const normalizedHash = normalizeSlug(hashValue); + + return allVariations.find((variation) => { + const variationSlug = getVariationSlug(variation); + if (!variationSlug) return false; + + const normalizedVariationSlug = normalizeSlug(variationSlug); + return normalizedVariationSlug === normalizedHash; + }); +} + +function getInitialVariation() { if (route?.hash && route.hash.length > 0) { - const variations = tracks.value.flatMap((t) => t.variations || []); const hashValue = route.hash.substring(1); - - // Essayer de trouver la variation soit par slug direct, soit en normalisant le hash - initialVariation = variations.find((v) => { - // Comparaison directe - if (v.slug === hashValue) return true; - // Comparaison en convertissant underscores en tirets (slugify par défaut) - if (v.slug === hashValue.replace(/_/g, '-')) return true; - // Comparaison inverse : le slug du backend pourrait avoir des underscores - if (v.slug.replace(/-/g, '_') === hashValue) return true; - return false; - }) || null; + const variationFromHash = findVariationByHash(hashValue); + if (variationFromHash) return variationFromHash; } - // fallback : première variation du premier track - if (!initialVariation) { - initialVariation = tracks.value[0]?.variations?.[0] || null; - } + return tracks.value[0]?.variations?.[0] || null; +} - if (initialVariation) { - activeTracks.value = [initialVariation]; - } else { - activeTracks.value = []; // aucun contenu disponible - } -}); +function initializeActiveTracks() { + const initialVariation = getInitialVariation(); + activeTracks.value = initialVariation ? [initialVariation] : []; +} -// scroll si hash présent -onMounted(() => { - if (route.query?.comments) isCommentsOpen.value = true; +function normalizeUrlHash() { + if (route?.hash && route.hash.includes('_')) { + const normalizedHash = normalizeSlug(route.hash); + router.replace({ ...route, hash: normalizedHash }); + } +} + +function openCommentsIfRequested() { + if (route.query?.comments) { + isCommentsOpen.value = true; + } +} + +function scrollToHashTarget() { if (!route?.hash || route.hash.length === 0) return; - const selector = route.hash.replace('#', '#track--'); - const targetBtn = document.querySelector(selector); - if (targetBtn) targetBtn.scrollIntoView(); + const selectorId = route.hash.replace('#', '#track--'); + const targetButton = document.querySelector(selectorId); + if (targetButton) { + targetButton.scrollIntoView(); + } +} + +onBeforeMount(() => { + initializeActiveTracks(); }); -// ---------- COMPUTED / WATCH ---------- +onMounted(() => { + openCommentsIfRequested(); + normalizeUrlHash(); + scrollToHashTarget(); +}); const isSingleImage = computed(() => { return ( @@ -149,38 +173,52 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -watch( - singleFile, - (newValue) => { - if (newValue) openedFile.value = newValue; - }, - { immediate: true } -); - -// gestion du mode comparaison : fermer les commentaires, etc. -watch(isCompareModeEnabled, (newValue) => { - if (newValue) { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; - } else { - isCommentPanelEnabled.value = true; +function updateOpenedFile(file) { + if (file) { + openedFile.value = file; } +} - // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire - if (!newValue && activeTracks.value.length === 2) { +function enableCompareModeUI() { + isCommentsOpen.value = false; + isCommentPanelEnabled.value = false; +} + +function disableCompareModeUI() { + isCommentPanelEnabled.value = true; + + if (activeTracks.value.length === 2) { activeTracks.value.pop(); } +} + +function updateUrlHash(firstTrack) { + const trackSlug = getVariationSlug(firstTrack); + if (!trackSlug) return; + + const currentHash = route.hash ? route.hash.substring(1) : ''; + const normalizedTrackSlug = normalizeSlug(trackSlug); + + if (currentHash !== normalizedTrackSlug) { + router.replace({ ...route, hash: '#' + normalizedTrackSlug }); + } +} + +watch(singleFile, updateOpenedFile, { immediate: true }); + +watch(isCompareModeEnabled, (isEnabled) => { + isEnabled ? enableCompareModeUI() : disableCompareModeUI(); }); -// ---------- UTIL / helper ---------- -function getCommentsCount(track) { - if (!track || !Array.isArray(track.files)) return undefined; - let count = 0; - for (const file of track.files) { - count += file?.comments?.length || 0; - } - return count > 0 ? count : undefined; -} +watch( + activeTracks, + (tracks) => { + if (tracks && tracks.length > 0) { + updateUrlHash(tracks[0]); + } + }, + { deep: true } +);