diff --git a/public/.user.ini b/public/.user.ini deleted file mode 100644 index 082ba05..0000000 --- a/public/.user.ini +++ /dev/null @@ -1,2 +0,0 @@ -; Augmentation temporaire de la limite mémoire pour le chargement des notifications -memory_limit = 512M 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 c30f93d..ab09290 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,9 +3,7 @@ // file.update:after return function ($newFile, $oldFile) { $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); - if ($project) { - $project->rebuildStepsCache(); - // Invalider aussi le cache des notifications (commentaires sur fichiers, etc.) - $project->invalidateNotificationsCache(); + if ($project) { + $steps = $project->rebuildStepsCache(); } }; \ No newline at end of file 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 f8d9675..bde728d 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,11 +1,9 @@ template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); if ($project) { - $project->rebuildStepsCache(); - // Invalider aussi le cache des notifications (briefs validés, etc.) - $project->invalidateNotificationsCache(); + $steps = $project->rebuildStepsCache(); } }; \ No newline at end of file diff --git a/public/site/models/project.php b/public/site/models/project.php index 583bfeb..1152945 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -3,62 +3,18 @@ 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/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php index ec1714b..83e47d1 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 les champs nécessaires à l'affichage mais sans les détails lourds. + * Retourne seulement id, type, isRead, date pour économiser la mémoire. * * @param Page $project Le projet à scanner * @param User $user L'utilisateur courant @@ -72,33 +72,18 @@ class NotificationCollector foreach ($this->providers as $provider) { try { $notifications = $provider->collect($project, $user); - // Garder les champs nécessaires au frontend + // Ne garder que les champs essentiels foreach ($notifications as $notification) { - $light = [ + $all[] = [ 'id' => $notification['id'] ?? null, 'type' => $notification['type'] ?? null, 'isRead' => $notification['isRead'] ?? false, 'date' => $notification['date'] ?? null, - 'text' => $notification['text'] ?? null, - 'author' => $notification['author'] ?? null, - 'location' => $notification['location'] ?? [] + // Garder location.project.uri pour le frontend + 'location' => [ + 'project' => $notification['location']['project'] ?? [] + ] ]; - - // 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()); diff --git a/public/site/plugins/refresh-cache-button/index.js b/public/site/plugins/refresh-cache-button/index.js index 4d99982..8fc4d42 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 _(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}})})(); +(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}})})(); 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 f6b12f0..5fdb1cf 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -7,7 +7,6 @@ :icon="icon" :title="title" @click="refreshCache()" - :disabled="isProcessing" >{{ text }} @@ -25,8 +24,6 @@ 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 @@ -34,91 +31,25 @@ const title = computed(() => { }); async function refreshCache() { - isProcessing.value = true; + text.value = "En cours…"; 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; - - text.value = "En cours 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 dans le texte du bouton - const progress = Math.round((json.processed / json.total) * 100); - text.value = `En cours ${progress}%`; - - console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`); - } - - // Succès - text.value = "Terminé"; - icon.value = "check"; - theme.value = "green-icon"; - - setTimeout(() => { - location.href = location.href; - }, 2000); - - } 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 }), }; - try { - const res = await fetch("/refresh-cache.json", init); - const json = await res.json(); - - if (json.status === "error") { - throw new Error(json.message); - } + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + 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"; @@ -127,13 +58,6 @@ async function refreshSingleProject() { 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 7f93443..207d34e 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,42 +10,17 @@ return [ if ($data->pageUri === 'projects') { $projects = page('projects')->children(); + foreach ($projects as $project) { + $project->rebuildStepsCache(); - // 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()); - } + $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); + $project->update([ + 'lastCacheUpdate' => $formatter->format(time()) + ]); } - - $remaining = max(0, $total - ($offset + $processed)); - $hasMore = $remaining > 0; - return [ - 'status' => 'success', - 'message' => "Batch terminé : $processed projets traités.", - 'processed' => $offset + $processed, - 'total' => $total, - 'remaining' => $remaining, - 'hasMore' => $hasMore, - 'nextOffset' => $hasMore ? $offset + $limit : null + 'satus' => 'success', + 'message' => 'Données des pages projets rafraîchies avec succès.' ]; } else { try { @@ -66,7 +41,7 @@ return [ if (!$project) { return [ - 'status' => 'error', + 'satus' => 'error', 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' ]; } @@ -80,7 +55,7 @@ return [ return [ - 'status' => 'success', + 'satus' => 'success', 'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.' ]; } diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index 2b2eaad..222e86a 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,15 +7,17 @@ if (!$kirby->user()) { ]); } -function getProjectData($project, $user) +function getProjectData($project, $user, $collector) { - // Utiliser getNotificationsLight() avec cache pour optimiser les performances + // Utiliser collectLight() pour économiser la mémoire (seulement id, type, isRead, date) $notifications = []; - try { - $notifications = $project->getNotificationsLight($user); - } catch (\Throwable $e) { - error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage()); - $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 = []; + } } $data = [ @@ -41,12 +43,14 @@ function getProjectData($project, $user) 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))->values() - : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values(); + ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values() + : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; diff --git a/src/components/Selector.vue b/src/components/Selector.vue index c95299d..32521cb 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,149 +76,112 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); +const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +// Store const { activeTracks } = storeToRefs(useDialogStore()); -function normalizeSlug(slug) { - return slug.replace(/_/g, '-'); +// 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 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; +function toVariation(v) { + if (!v) return null; + return Array.isArray(v) ? v[v.length - 1] || null : v; } -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) { +// Initialisation : remplir le 1er select localement ET initialiser le store +onBeforeMount(() => { syncing.value = true; - const matchedVariations = findMatchingVariationsInStore(storeVariations); - - if (isCompareModeEnabled) { - currentValue.value = matchedVariations.length ? [...matchedVariations] : []; + 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]; + } } else { - currentValue.value = matchedVariations[0] || null; + // les autres ne forcent pas le store ; leur currentValue restera à null + currentValue.value = 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, - (shouldBeArray) => { + (flag) => { syncing.value = true; - currentValue.value = convertValueForCompareMode( - currentValue.value, - shouldBeArray - ); + 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; + } + } nextTick(() => (syncing.value = false)); } ); -watch(currentValue, handleVariationChange, { deep: true }); - +// Détection ajout / suppression dans le MultiSelect (côté composant) +// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` watch( - activeTracks, - (storeVariations) => { - const variationsList = Array.isArray(storeVariations) - ? storeVariations - : []; - syncCurrentValueFromStore(variationsList); + currentValue, + (newVal, oldVal) => { + if (syncing.value) return; + + 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, immediate: true } + { deep: true } ); -function removeVariationFromActiveTracks(variation) { - activeTracks.value = activeTracks.value.filter( - (track) => !areVariationsEqual(track, variation) - ); -} +// 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; -function addVariationToActiveTracks(variation) { - const isAlreadyPresent = activeTracks.value.some((track) => - areVariationsEqual(track, variation) - ); + 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 (isAlreadyPresent) return; + if (isCompareModeEnabled) { + currentValue.value = matched.length ? [...matched] : []; + } else { + currentValue.value = matched[0] || null; + } - 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]; - } -} + nextTick(() => (syncing.value = false)); + }, + { deep: true } +); -function updateActiveTracks(track, action = 'add') { - const variation = extractVariation(track); +// 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); if (!variation) return; if (!isCompareModeEnabled) { @@ -227,12 +190,34 @@ function updateActiveTracks(track, action = 'add') { } if (action === 'remove') { - removeVariationFromActiveTracks(variation); + 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]; } else { - addVariationToActiveTracks(variation); + // remplacer le 2e + activeTracks.value = [activeTracks.value[0], variation]; } } +// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -246,8 +231,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; } @@ -265,8 +250,7 @@ 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 { @@ -306,9 +290,7 @@ 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 60bcd8e..f89552b 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,14 +61,13 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute, useRouter } from 'vue-router'; +import { useRoute } 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 } = @@ -93,74 +92,41 @@ const tracks = computed(() => { return list; }); -function normalizeSlug(slug) { - return slug.replace(/_/g, '-'); -} +// ---------- INITIALISATION ---------- +// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) +onBeforeMount(() => { + // essayer la hash en priorité + let initialVariation = null; -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 hashValue = route.hash.substring(1); - const variationFromHash = findVariationByHash(hashValue); - if (variationFromHash) return variationFromHash; + const variations = tracks.value.flatMap((t) => t.variations || []); + initialVariation = + variations.find((v) => v.slug === route.hash.substring(1)) || null; } - return tracks.value[0]?.variations?.[0] || null; -} - -function initializeActiveTracks() { - const initialVariation = getInitialVariation(); - activeTracks.value = initialVariation ? [initialVariation] : []; -} - -function normalizeUrlHash() { - if (route?.hash && route.hash.includes('_')) { - const normalizedHash = normalizeSlug(route.hash); - router.replace({ ...route, hash: normalizedHash }); + // fallback : première variation du premier track + if (!initialVariation) { + initialVariation = tracks.value[0]?.variations?.[0] || null; } -} -function openCommentsIfRequested() { - if (route.query?.comments) { - isCommentsOpen.value = true; + if (initialVariation) { + activeTracks.value = [initialVariation]; + } else { + activeTracks.value = []; // aucun contenu disponible } -} +}); -function scrollToHashTarget() { +// scroll si hash présent +onMounted(() => { + if (route.query?.comments) isCommentsOpen.value = true; if (!route?.hash || route.hash.length === 0) return; - const selectorId = route.hash.replace('#', '#track--'); - const targetButton = document.querySelector(selectorId); - if (targetButton) { - targetButton.scrollIntoView(); - } -} - -onBeforeMount(() => { - initializeActiveTracks(); + const selector = route.hash.replace('#', '#track--'); + const targetBtn = document.querySelector(selector); + if (targetBtn) targetBtn.scrollIntoView(); }); -onMounted(() => { - openCommentsIfRequested(); - normalizeUrlHash(); - scrollToHashTarget(); -}); +// ---------- COMPUTED / WATCH ---------- const isSingleImage = computed(() => { return ( @@ -173,52 +139,38 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -function updateOpenedFile(file) { - if (file) { - openedFile.value = file; +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 enableCompareModeUI() { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; -} - -function disableCompareModeUI() { - isCommentPanelEnabled.value = true; - - if (activeTracks.value.length === 2) { + // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire + if (!newValue && 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(); }); -watch( - activeTracks, - (tracks) => { - if (tracks && tracks.length > 0) { - updateUrlHash(tracks[0]); - } - }, - { deep: true } -); +// ---------- 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; +}