Compare commits

...

10 commits

Author SHA1 Message Date
isUnknown
6b80e242b8 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 <noreply@anthropic.com>
2026-01-15 13:54:36 +01:00
isUnknown
dfb8d1038b Fix routing vers une piste spécifique avec hash
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 <noreply@anthropic.com>
2026-01-15 12:29:49 +01:00
isUnknown
95a8bf99cb build plugin refresh cache 2026-01-15 12:19:35 +01:00
isUnknown
378af9ac96 Fix : affichage progression dans le texte du bouton
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 <noreply@anthropic.com>
2026-01-15 12:18:33 +01:00
isUnknown
4669f03f16 Amélioration affichage progression du refresh cache
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 <noreply@anthropic.com>
2026-01-15 12:13:26 +01:00
isUnknown
a57b0c203a Optimisation du refresh cache avec batch processing
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 <noreply@anthropic.com>
2026-01-15 12:08:13 +01:00
isUnknown
86db1f5a0c Fix collectLight() : inclure author, text, location pour l'affichage
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 <noreply@anthropic.com>
2026-01-15 11:55:17 +01:00
isUnknown
2791bc4462 Ajout invalidation cache notifications dans hook file-update
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:42:40 +01:00
isUnknown
bb71da081b Ajout du système de cache pour les notifications
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 <noreply@anthropic.com>
2026-01-15 11:42:20 +01:00
isUnknown
e73e25b1da Ajout .user.ini : augmentation limite mémoire PHP à 512M
Temporaire pour gérer le chargement des notifications de tous les projets.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:19:14 +01:00
11 changed files with 437 additions and 209 deletions

2
public/.user.ini Normal file
View file

@ -0,0 +1,2 @@
; Augmentation temporaire de la limite mémoire pour le chargement des notifications
memory_limit = 512M

View file

@ -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();
}
};

View file

@ -1,9 +1,11 @@
<?php
// page.update:after && page.changeStatus:after
return function($newPage, $oldPage) {
return function($newPage, $oldPage) {
$project = $newPage->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();
}
};

View file

@ -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

View file

@ -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());

View file

@ -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}})})();

View file

@ -7,6 +7,7 @@
:icon="icon"
:title="title"
@click="refreshCache()"
:disabled="isProcessing"
>{{ text }}</k-button
>
</div>
@ -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;
}
}
</script>

View file

@ -1,5 +1,5 @@
<?php
set_time_limit(0);
set_time_limit(60);
return [
'pattern' => '/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.'
];
}

View file

@ -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 = [];

View file

@ -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;
}
</script>
@ -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;

View file

@ -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 }
);
</script>
<style>