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 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 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/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()); 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}})})(); 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..f6b12f0 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,91 @@ 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; + + 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 }), }; - 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 +127,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.' ]; } 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 = []; 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 f89552b..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,41 +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 || []); - initialVariation = - variations.find((v) => v.slug === route.hash.substring(1)) || null; + const hashValue = route.hash.substring(1); + 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 ( @@ -139,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 } +);