From 5e78e67fc8a76117345db823611e4448607ab80e Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 8 Oct 2025 15:34:53 +0200 Subject: [PATCH 01/25] remove cache busting --- public/site/snippets/header.php | 4 +-- vite.config.js | 58 ++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/public/site/snippets/header.php b/public/site/snippets/header.php index f8e4f54..a35fc34 100644 --- a/public/site/snippets/header.php +++ b/public/site/snippets/header.php @@ -25,8 +25,8 @@ - - + + diff --git a/vite.config.js b/vite.config.js index 53268a1..154116f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,33 +1,37 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; -export default defineConfig({ - plugins: [vue()], - base: '/', - build: { - outDir: 'dist', - rollupOptions: { - output: { - entryFileNames: 'assets/dist/[name].js', - assetFileNames: 'assets/dist/[name].[ext]', +export default defineConfig(({ mode }) => { + return { + plugins: [vue()], + base: '/', + build: { + outDir: 'dist', + rollupOptions: { + output: { + entryFileNames: 'assets/dist/[name].js', + assetFileNames: 'assets/dist/[name].[ext]', + }, + }, + sourcemap: mode === 'staging', + minify: mode === 'production', + }, + server: { + watch: { + ignored: [ + '**/node_modules/**', + '**/.git/**', + '**/.cache/**', + '**/.vite/**', + '**/dist/**', + '**/*.log', + '**/.idea/**', + '**/.vscode/**', + '**/public/assets/**', + '**/local/**', + '/public/**', + ], }, }, - }, - server: { - watch: { - ignored: [ - '**/node_modules/**', - '**/.git/**', - '**/.cache/**', - '**/.vite/**', - '**/dist/**', - '**/*.log', - '**/.idea/**', - '**/.vscode/**', - '**/public/assets/**', - '**/local/**', - '/public/**', - ], - }, - }, + }; }); From 95bdf4615d149635d13f6ae7f6ac41184d7763a2 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 8 Oct 2025 15:47:45 +0200 Subject: [PATCH 02/25] fix CI --- .gitlab-ci.yml | 79 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 16 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8617d19..c5d58cb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,12 +1,13 @@ stages: - build - deploy - variables: COMPOSER_ALLOW_SUPERUSER: '1' -build: +build_prod: stage: build + only: + - prod image: composer:2 script: - apk add --no-cache nodejs npm @@ -23,7 +24,27 @@ build: paths: - node_modules/ -deploy: +build_preprod: + stage: build + only: + - preprod + image: composer:2 + script: + - apk add --no-cache nodejs npm + - npm install + - npm run build:preprod + - cd dist - + composer install --no-dev --optimize-autoloader --ignore-platform-req=ext-gd + - cd .. + artifacts: + paths: + - dist/ + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - node_modules/ + +deploy_prod: stage: deploy image: node:latest only: @@ -33,18 +54,44 @@ deploy: script: - cd dist - | - rsync_deploy() { - local src=$1 - local dst=$2 - local exclude=$3 - cmd="sshpass -p \"$PASSWORD\" rsync -az --delete -O" - [[ -n $exclude ]] && cmd="$cmd $exclude" - cmd="$cmd -e 'ssh -p 2244 -o StrictHostKeyChecking=no' $src $USERNAME@$HOST:$dst" - echo "$cmd" - eval $cmd - } + rsync_deploy() { + local src=$1 + local dst=$2 + local exclude=$3 + cmd="sshpass -p \"$PASSWORD\" rsync -az --delete -O" + [[ -n $exclude ]] && cmd="$cmd $exclude" + cmd="$cmd -e 'ssh -p 2244 -o StrictHostKeyChecking=no' $src $USERNAME@$HOST:$dst" + echo "$cmd" + eval $cmd + } - rsync_deploy site/ "$PROD_PATH/site/" "--exclude 'accounts/' --exclude 'cache/' --exclude 'sessions/'" - rsync_deploy vendor/ "$PROD_PATH/vendor/" - rsync_deploy kirby/ "$PROD_PATH/kirby/" + rsync_deploy site/ "$PROD_PATH/site/" "--exclude 'accounts/' --exclude 'cache/' --exclude 'sessions/'" + rsync_deploy vendor/ "$PROD_PATH/vendor/" + rsync_deploy kirby/ "$PROD_PATH/kirby/" rsync_deploy assets/ "$PROD_PATH/assets/" "--exclude 'tiles/'" + +deploy_preprod: + stage: deploy + image: node:latest + only: + - preprod + before_script: + - apt-get update -qq && apt-get install -y rsync sshpass + script: + - cd dist + - | + rsync_deploy() { + local src=$1 + local dst=$2 + local exclude=$3 + cmd="sshpass -p \"$PASSWORD\" rsync -az --delete -O" + [[ -n $exclude ]] && cmd="$cmd $exclude" + cmd="$cmd -e 'ssh -p 2244 -o StrictHostKeyChecking=no' $src $USERNAME@$HOST:$dst" + echo "$cmd" + eval $cmd + } + + rsync_deploy site/ "$PREPROD_PATH/site/" "--exclude 'accounts/' --exclude 'cache/' --exclude 'sessions/'" + rsync_deploy vendor/ "$PREPROD_PATH/vendor/" + rsync_deploy kirby/ "$PREPROD_PATH/kirby/" + rsync_deploy assets/ "$PREPROD_PATH/assets/" "--exclude 'tiles/'" From 729d72e18a473804f0ee82b75fecacb1a1630d87 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 8 Oct 2025 15:48:35 +0200 Subject: [PATCH 03/25] fix CI --- .gitlab-ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c5d58cb..5fa295d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -39,10 +39,10 @@ build_preprod: artifacts: paths: - dist/ - cache: - key: ${CI_COMMIT_REF_SLUG} - paths: - - node_modules/ + cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - node_modules/ deploy_prod: stage: deploy From 186f7b80ba3bc5af1f81b6497a612a1aeab38915 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 8 Oct 2025 15:56:15 +0200 Subject: [PATCH 04/25] fix CI --- .gitlab-ci.yml | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3d6fd61..aa30802 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - build - deploy + variables: COMPOSER_ALLOW_SUPERUSER: '1' @@ -82,18 +83,18 @@ deploy_prod: script: - cd dist - | - rsync_deploy() { - local src=$1 - local dst=$2 - local exclude=$3 - cmd="sshpass -p \"$PASSWORD\" rsync -az --delete -O" - [[ -n $exclude ]] && cmd="$cmd $exclude" - cmd="$cmd -e 'ssh -p 2244 -o StrictHostKeyChecking=no' $src $USERNAME@$HOST:$dst" - echo "$cmd" - eval $cmd - } + rsync_deploy() { + local src=$1 + local dst=$2 + local exclude=$3 + cmd="sshpass -p \"$PASSWORD\" rsync -az --delete -O" + [[ -n $exclude ]] && cmd="$cmd $exclude" + cmd="$cmd -e 'ssh -p 2244 -o StrictHostKeyChecking=no' $src $USERNAME@$HOST:$dst" + echo "$cmd" + eval $cmd + } - rsync_deploy site/ "$PROD_PATH/site/" "--exclude 'accounts/' --exclude 'cache/' --exclude 'sessions/'" - rsync_deploy vendor/ "$PROD_PATH/vendor/" - rsync_deploy kirby/ "$PROD_PATH/kirby/" + rsync_deploy site/ "$PROD_PATH/site/" "--exclude 'accounts/' --exclude 'cache/' --exclude 'sessions/'" + rsync_deploy vendor/ "$PROD_PATH/vendor/" + rsync_deploy kirby/ "$PROD_PATH/kirby/" rsync_deploy assets/ "$PROD_PATH/assets/" "--exclude 'tiles/'" From d590c9ac458bcc17bb7d68b9003d7e6c688554b5 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 8 Oct 2025 15:58:25 +0200 Subject: [PATCH 05/25] fix CI --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa30802..6e95c05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,7 +56,7 @@ deploy_preprod: build_prod: stage: build only: - - prod + - main image: composer:2 script: - apk add --no-cache nodejs npm From c9aefe7ecfcdb9b9988176f3f0a9dd4290b3a7f6 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 15 Oct 2025 16:17:30 +0200 Subject: [PATCH 06/25] #179 --- public/site/models/project.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/site/models/project.php b/public/site/models/project.php index 1152945..df39ff1 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -159,7 +159,7 @@ class ProjectPage extends NotificationsPage { "physicalSample" => "échantillon physique", ]; - return $stepsLabel[$this->currentStep()->value()]; + return $stepsLabel[$this->currentStep()->value()] ?? "brief"; } // public function printManagers() { From c68b51f6394dd32d0c42632db9ba38bc22b9a645 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 14 Jan 2026 14:55:12 +0100 Subject: [PATCH 07/25] git : ignore claude settings --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index c192e22..bb14856 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,10 @@ public/vendor # Content # --------------- /public/content + +# Claude settings +# --------------- +.claude +/.claude/* + + From a7d315942a2be96ab875ae96ed65c4b5ac42c09f Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 10:31:31 +0100 Subject: [PATCH 08/25] =?UTF-8?q?Refonte=20du=20syst=C3=A8me=20de=20notifi?= =?UTF-8?q?cations=20:=20passage=20aux=20notifications=20d=C3=A9riv=C3=A9e?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace le système de notifications stockées par un système de providers qui dérivent les notifications des données existantes (commentaires, réponses, demandes de projet, demandes de rendez-vous, validations de brief). - Ajout du NotificationCollector et de l'interface NotificationProvider - Création de 5 providers : Comment, Reply, ProjectRequest, AppointmentRequest, Content - Métadonnées de notifications stockées directement sur les entités source - Nouvelles routes mark-as-read et mark-all-read - Mise à jour du frontend pour le nouveau système - Route de migration pour les données existantes Co-Authored-By: Claude Opus 4.5 --- public/site/blueprints/pages/client-brief.yml | 11 ++ .../site/blueprints/pages/extended-brief.yml | 13 ++ public/site/blueprints/pages/project.yml | 27 +++ public/site/config/config.php | 1 + .../config/routes/migrate-notifications.php | 175 ++++++++++++++++++ .../request-optimization-appointment.php | 41 ++-- .../routes/request-project-creation.php | 32 ++-- public/site/config/routes/validate-brief.php | 29 ++- .../site/plugins/comments/routes/create.php | 8 +- .../site/plugins/comments/routes/delete.php | 4 +- public/site/plugins/comments/routes/reply.php | 7 +- public/site/plugins/notifications/index.php | 63 +++++-- .../notifications/routes/mark-all-read.php | 42 +++++ .../notifications/routes/mark-as-read.php | 46 +++++ .../src/NotificationCollector.php | 123 ++++++++++++ .../src/NotificationProvider.php | 42 +++++ .../providers/AppointmentRequestProvider.php | 111 +++++++++++ .../src/providers/CommentProvider.php | 172 +++++++++++++++++ .../src/providers/ContentProvider.php | 128 +++++++++++++ .../src/providers/ProjectRequestProvider.php | 111 +++++++++++ .../src/providers/ReplyProvider.php | 142 ++++++++++++++ public/site/templates/projects.json.php | 51 +++-- src/stores/api.js | 57 ++++++ src/stores/user.js | 92 ++++++--- src/views/Notifications.vue | 14 +- vite.config.js | 1 + 26 files changed, 1406 insertions(+), 137 deletions(-) create mode 100644 public/site/config/routes/migrate-notifications.php create mode 100644 public/site/plugins/notifications/routes/mark-all-read.php create mode 100644 public/site/plugins/notifications/routes/mark-as-read.php create mode 100644 public/site/plugins/notifications/src/NotificationCollector.php create mode 100644 public/site/plugins/notifications/src/NotificationProvider.php create mode 100644 public/site/plugins/notifications/src/providers/AppointmentRequestProvider.php create mode 100644 public/site/plugins/notifications/src/providers/CommentProvider.php create mode 100644 public/site/plugins/notifications/src/providers/ContentProvider.php create mode 100644 public/site/plugins/notifications/src/providers/ProjectRequestProvider.php create mode 100644 public/site/plugins/notifications/src/providers/ReplyProvider.php diff --git a/public/site/blueprints/pages/client-brief.yml b/public/site/blueprints/pages/client-brief.yml index 7574be8..9df9168 100644 --- a/public/site/blueprints/pages/client-brief.yml +++ b/public/site/blueprints/pages/client-brief.yml @@ -24,6 +24,17 @@ tabs: type: hidden isValidated: type: hidden + # Champs pour notification "content" (brief validé) + validatedBy: + type: hidden + validatedByName: + type: hidden + validatedByEmail: + type: hidden + validatedAt: + type: hidden + validationReadby: + type: hidden pdf: label: PDF type: files diff --git a/public/site/blueprints/pages/extended-brief.yml b/public/site/blueprints/pages/extended-brief.yml index b65d99f..477d4a1 100644 --- a/public/site/blueprints/pages/extended-brief.yml +++ b/public/site/blueprints/pages/extended-brief.yml @@ -22,6 +22,19 @@ tabs: fields: stepName: type: hidden + isValidated: + type: hidden + # Champs pour notification "content" (brief validé) + validatedBy: + type: hidden + validatedByName: + type: hidden + validatedByEmail: + type: hidden + validatedAt: + type: hidden + validationReadby: + type: hidden pdf: label: PDF type: files diff --git a/public/site/blueprints/pages/project.yml b/public/site/blueprints/pages/project.yml index 946e238..6962aa5 100644 --- a/public/site/blueprints/pages/project.yml +++ b/public/site/blueprints/pages/project.yml @@ -21,6 +21,7 @@ tabs: fields: lastCacheUpdate: type: hidden + # Champs pour project-request isClientRequest: type: hidden default: "false" @@ -30,6 +31,32 @@ tabs: disabled: true when: isClientRequest: "true" + requestAuthor: + type: hidden + requestAuthorName: + type: hidden + requestAuthorEmail: + type: hidden + requestDate: + type: hidden + requestReadby: + type: hidden + # Champs pour appointment-request (DTL) + hasOptimizationRequest: + type: hidden + default: "false" + optimizationRequestDetails: + type: hidden + optimizationAuthor: + type: hidden + optimizationAuthorName: + type: hidden + optimizationAuthorEmail: + type: hidden + optimizationDate: + type: hidden + optimizationReadby: + type: hidden currentStep: label: Étape en cours type: radio diff --git a/public/site/config/config.php b/public/site/config/config.php index 72ea0da..37f3a33 100644 --- a/public/site/config/config.php +++ b/public/site/config/config.php @@ -40,6 +40,7 @@ return [ require(__DIR__ . '/routes/validate-brief.php'), require(__DIR__ . '/routes/request-project-creation.php'), require(__DIR__ . '/routes/request-optimization-appointment.php'), + require(__DIR__ . '/routes/migrate-notifications.php'), ], 'hooks' => [ 'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'), diff --git a/public/site/config/routes/migrate-notifications.php b/public/site/config/routes/migrate-notifications.php new file mode 100644 index 0000000..3aa033c --- /dev/null +++ b/public/site/config/routes/migrate-notifications.php @@ -0,0 +1,175 @@ + 'migrate-notifications.json', + 'method' => 'POST', + 'action' => function () { + $user = kirby()->user(); + + // Vérifier que l'utilisateur est admin + if (!$user || $user->role()->id() !== 'admin') { + return [ + 'status' => 'error', + 'message' => 'Cette action nécessite les droits administrateur.' + ]; + } + + $migrated = [ + 'comments' => 0, + 'replies' => 0, + 'project-requests' => 0, + 'appointment-requests' => 0, + 'content' => 0, + 'errors' => [] + ]; + + $projects = page('projects')->children(); + + foreach ($projects as $project) { + // Récupérer les anciennes notifications + $notifications = $project->notifications()->yaml() ?? []; + + if (empty($notifications)) { + continue; + } + + foreach ($notifications as $notification) { + try { + $type = $notification['type'] ?? 'comment'; + $id = $notification['id'] ?? null; + $readby = $notification['readby'] ?? []; + + if (empty($id) || empty($readby)) { + continue; + } + + switch ($type) { + case 'comment': + case 'comment-reply': + $fileUuid = $notification['location']['file']['uuid'] ?? null; + if (!$fileUuid) continue 2; + + $file = kirby()->file($fileUuid); + if (!$file) continue 2; + + $comments = Yaml::decode($file->comments()->value()) ?? []; + $updated = false; + + foreach ($comments as &$comment) { + // Vérifier si c'est le commentaire principal + if ($comment['id'] === $id) { + $existingReadby = $comment['readby'] ?? []; + $comment['readby'] = array_values(array_unique(array_merge($existingReadby, $readby))); + $updated = true; + $migrated['comments']++; + break; + } + + // Vérifier dans les réponses + foreach ($comment['replies'] ?? [] as &$reply) { + if ($reply['id'] === $id) { + $existingReadby = $reply['readby'] ?? []; + $reply['readby'] = array_values(array_unique(array_merge($existingReadby, $readby))); + $updated = true; + $migrated['replies']++; + break 2; + } + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + break; + + case 'project-request': + $existingReadby = $project->requestReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['requestReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($project->requestAuthor()->isEmpty() && isset($notification['author'])) { + $updateData['requestAuthor'] = $notification['author']['uuid'] ?? ''; + $updateData['requestAuthorName'] = $notification['author']['name'] ?? ''; + $updateData['requestAuthorEmail'] = $notification['author']['email'] ?? ''; + $updateData['requestDate'] = $notification['date'] ?? ''; + } + + $project->update($updateData); + $migrated['project-requests']++; + break; + + case 'appointment-request': + $existingReadby = $project->optimizationReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['optimizationReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($project->optimizationAuthor()->isEmpty() && isset($notification['author'])) { + $updateData['optimizationAuthor'] = $notification['author']['uuid'] ?? ''; + $updateData['optimizationAuthorName'] = $notification['author']['name'] ?? ''; + $updateData['optimizationAuthorEmail'] = $notification['author']['email'] ?? ''; + $updateData['optimizationDate'] = $notification['date'] ?? ''; + } + + $project->update($updateData); + $migrated['appointment-requests']++; + break; + + case 'content': + $briefUri = $notification['location']['page']['uri'] ?? null; + if (!$briefUri) continue 2; + + $brief = page($briefUri); + if (!$brief) continue 2; + + $existingReadby = $brief->validationReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['validationReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($brief->validatedBy()->isEmpty() && isset($notification['author'])) { + $updateData['validatedBy'] = $notification['author']['uuid'] ?? ''; + $updateData['validatedByName'] = $notification['author']['name'] ?? ''; + $updateData['validatedByEmail'] = $notification['author']['email'] ?? ''; + $updateData['validatedAt'] = $notification['date'] ?? ''; + } + + $brief->update($updateData); + $migrated['content']++; + break; + } + } catch (\Throwable $th) { + $migrated['errors'][] = [ + 'project' => $project->title()->value(), + 'notification_id' => $id ?? 'unknown', + 'type' => $type ?? 'unknown', + 'error' => $th->getMessage() + ]; + } + } + } + + $total = $migrated['comments'] + $migrated['replies'] + + $migrated['project-requests'] + $migrated['appointment-requests'] + + $migrated['content']; + + return [ + 'status' => 'success', + 'message' => "Migration terminée. $total notifications migrées.", + 'details' => $migrated + ]; + } +]; diff --git a/public/site/config/routes/request-optimization-appointment.php b/public/site/config/routes/request-optimization-appointment.php index 0cc94c0..d1e4b84 100644 --- a/public/site/config/routes/request-optimization-appointment.php +++ b/public/site/config/routes/request-optimization-appointment.php @@ -7,37 +7,26 @@ return [ $json = file_get_contents('php://input'); $data = json_decode($json); - $user = kirby()->user(); + $user = kirby()->user(); $project = page($data->projectUri); + $date = new DateTime(); + $formattedDate = $date->format(DateTime::ISO8601); + try { - $newProject = $project->update([ + $project->update([ "hasOptimizationRequest" => "true", - "optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details) + "optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details), + // Métadonnées pour le système de notifications dérivées + "optimizationAuthor" => (string) $user->uuid(), + "optimizationAuthorName" => (string) $user->name(), + "optimizationAuthorEmail" => (string) $user->email(), + "optimizationDate" => $formattedDate, + "optimizationReadby" => [], ]); - } catch (\Throwable $th) { - return [ - "status" => "error", - "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() - ]; - } - try { - $date = new DateTime(); - $formattedDate = $date->format(DateTime::ISO8601); - - $notificationData = [ - "location" => [ - "page" => $newProject - ], - "date" => (string) $formattedDate, - "text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)), - "author" => $user, - "id" => Str::uuid(), - "type" => "appointment-request", - ]; - - $newProject->createNotification($notificationData); + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "status" => "success", @@ -45,7 +34,7 @@ return [ } catch (\Throwable $th) { return [ "status" => "error", - "message" => "Can't create notification. " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() + "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() ]; } } diff --git a/public/site/config/routes/request-project-creation.php b/public/site/config/routes/request-project-creation.php index 9a474d7..1b9b378 100644 --- a/public/site/config/routes/request-project-creation.php +++ b/public/site/config/routes/request-project-creation.php @@ -11,15 +11,24 @@ return [ $client = kirby()->user()->client()->toPage()->uuid(); + $date = new DateTime(); + $formattedDate = $date->format(DateTime::ISO8601); + $projectData = [ "slug" => esc(Str::slug($data->title)), - "template" => "project", + "template" => "project", "content" => [ "title" => esc($data->title), - "requestDetails" => esc("Demande de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . $data->details), + "requestDetails" => esc("Demande de " . $user->name() . " (" . $user->email() . ") : \n\n" . $data->details), "client" => [$client], "isClientRequest" => "true", - "isDTLEnabled" => esc($data->isDTLEnabled) + "isDTLEnabled" => esc($data->isDTLEnabled), + // Métadonnées pour le système de notifications dérivées + "requestAuthor" => (string) $user->uuid(), + "requestAuthorName" => (string) $user->name(), + "requestAuthorEmail" => (string) $user->email(), + "requestDate" => $formattedDate, + "requestReadby" => [], ] ]; @@ -27,21 +36,8 @@ return [ try { $newProject = $projects->createChild($projectData); - $date = new DateTime(); - $formattedDate = $date->format(DateTime::ISO8601); - - $notificationData = [ - "location" => [ - "page" => $newProject - ], - "date" => (string) $formattedDate, - "text" => nl2br(esc($data->details)), - "author" => $user, - "id" => Str::uuid(), - "type" => "project-request", - ]; - - $newProject->createNotification($notificationData); + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "status" => "success", diff --git a/public/site/config/routes/validate-brief.php b/public/site/config/routes/validate-brief.php index f8e97f1..fb2bf86 100644 --- a/public/site/config/routes/validate-brief.php +++ b/public/site/config/routes/validate-brief.php @@ -9,27 +9,24 @@ return [ $page = page($data->briefUri); $project = $page->parent(); - - try { - $newPage = $page->update([ - 'isValidated' => 'true' - ]); + $user = kirby()->user(); + try { $timezone = new DateTimeZone('Europe/Paris'); $dateTime = new DateTime('now', $timezone); - $notification = [ - 'location' => [ - 'page' => $page, - ], - 'date' => $dateTime->format('Y-m-d\TH:i:sP'), - 'text' => "Nouveau brief", - 'author' => kirby()->user(), - 'id' => Str::uuid(), - 'type' => 'content' - ]; + $newPage = $page->update([ + 'isValidated' => 'true', + // Métadonnées pour le système de notifications dérivées + 'validatedBy' => (string) $user->uuid(), + 'validatedByName' => (string) $user->name(), + 'validatedByEmail' => (string) $user->email(), + 'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'), + 'validationReadby' => [], + ]); - $project->createNotification($notification); + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "success" => "'" . $project->title()->value() . "' brief validated." diff --git a/public/site/plugins/comments/routes/create.php b/public/site/plugins/comments/routes/create.php index 9db31cf..5641383 100644 --- a/public/site/plugins/comments/routes/create.php +++ b/public/site/plugins/comments/routes/create.php @@ -46,6 +46,7 @@ return [ 'author' => kirby()->user(), 'id' => Str::uuid(), 'type' => 'comment', + 'readby' => [], // Pour le système de notifications dérivées ]; if (isset($data->position->pageIndex)) { @@ -62,11 +63,8 @@ return [ echo json_encode(getFileData($newFile)); - try { - $project->createNotification($commentData); - } catch (\Throwable $th) { - throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); - } + // Note: Les notifications sont maintenant dérivées des commentaires. + // Plus besoin d'appeler createNotification(). exit; }, diff --git a/public/site/plugins/comments/routes/delete.php b/public/site/plugins/comments/routes/delete.php index 8bc9e07..5f96a96 100644 --- a/public/site/plugins/comments/routes/delete.php +++ b/public/site/plugins/comments/routes/delete.php @@ -39,8 +39,8 @@ return [ echo json_encode(getFileData($newFile)); - $project = $page->parents()->findBy('template', 'project'); - $project->deleteNotification($data->id); + // Note: Les notifications sont maintenant dérivées des commentaires. + // La suppression du commentaire supprime automatiquement la notification. exit; }, diff --git a/public/site/plugins/comments/routes/reply.php b/public/site/plugins/comments/routes/reply.php index 68aeebd..2418dfe 100644 --- a/public/site/plugins/comments/routes/reply.php +++ b/public/site/plugins/comments/routes/reply.php @@ -31,18 +31,19 @@ return [ "author" => kirby()->user(), "id" => Str::uuid(), "type" => "comment-reply", + "readby" => [], // Pour le système de notifications dérivées ]; $newReply = new Reply($replyData); $comment['replies'][] = $newReply->toArray(); } } - + $newFile = $file->update([ 'comments' => $comments ]); - $project = $page->parents()->findBy("template", "project"); - $project->createNotification($replyData); + // Note: Les notifications sont maintenant dérivées des commentaires. + // Plus besoin d'appeler createNotification(). return getFileData($newFile); } diff --git a/public/site/plugins/notifications/index.php b/public/site/plugins/notifications/index.php index 91da6f9..9b46c7e 100644 --- a/public/site/plugins/notifications/index.php +++ b/public/site/plugins/notifications/index.php @@ -1,27 +1,54 @@ "models/ProjectPage.php", -], __DIR__); +use adrienpayet\notifications\NotificationCollector; +use adrienpayet\notifications\providers\CommentProvider; +use adrienpayet\notifications\providers\ReplyProvider; +use adrienpayet\notifications\providers\ProjectRequestProvider; +use adrienpayet\notifications\providers\AppointmentRequestProvider; +use adrienpayet\notifications\providers\ContentProvider; +// Charger les classes F::loadClasses([ - // Own classes - "adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php", - "adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php", - - // Shared classes - "adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php", - "adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php", - "adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php", - "adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php", - "adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php", - "adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php", + // Nouvelles classes - Système de providers + "adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php", + "adrienpayet\\notifications\\NotificationCollector" => __DIR__ . "/src/NotificationCollector.php", + "adrienpayet\\notifications\\providers\\CommentProvider" => __DIR__ . "/src/providers/CommentProvider.php", + "adrienpayet\\notifications\\providers\\ReplyProvider" => __DIR__ . "/src/providers/ReplyProvider.php", + "adrienpayet\\notifications\\providers\\ProjectRequestProvider" => __DIR__ . "/src/providers/ProjectRequestProvider.php", + "adrienpayet\\notifications\\providers\\AppointmentRequestProvider" => __DIR__ . "/src/providers/AppointmentRequestProvider.php", + "adrienpayet\\notifications\\providers\\ContentProvider" => __DIR__ . "/src/providers/ContentProvider.php", + + // Anciennes classes - Gardées pour rétro-compatibilité pendant migration + "adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php", + "adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php", + + // Classes partagées + "adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php", + "adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php", + "adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php", + "adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php", + "adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php", + "adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php", ]); +// Créer et configurer le collector +$collector = new NotificationCollector(); +$collector->register(new CommentProvider()); +$collector->register(new ReplyProvider()); +$collector->register(new ProjectRequestProvider()); +$collector->register(new AppointmentRequestProvider()); +$collector->register(new ContentProvider()); Kirby::plugin("adrienpayet/pdc-notifications", [ - "routes" => [ - require(__DIR__ . "/routes/readAll.php"), - require(__DIR__ . "/routes/read.php") - ], + "options" => [ + "collector" => $collector + ], + "routes" => [ + // Nouvelles routes + require(__DIR__ . "/routes/mark-as-read.php"), + require(__DIR__ . "/routes/mark-all-read.php"), + // Anciennes routes - Gardées pour rétro-compatibilité + require(__DIR__ . "/routes/readAll.php"), + require(__DIR__ . "/routes/read.php"), + ], ]); diff --git a/public/site/plugins/notifications/routes/mark-all-read.php b/public/site/plugins/notifications/routes/mark-all-read.php new file mode 100644 index 0000000..c06cc80 --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-all-read.php @@ -0,0 +1,42 @@ + '(:all)mark-all-notifications-read.json', + 'method' => 'POST', + 'action' => function () { + try { + $user = kirby()->user(); + if (!$user) { + throw new Exception('User not authenticated'); + } + + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + if (!$collector) { + throw new Exception('NotificationCollector not initialized'); + } + + // Récupérer les projets selon le rôle + if ($user->role()->name() === 'admin') { + $projects = page('projects')->children()->toArray(); + } else { + $projects = $user->projects()->toPages()->toArray(); + } + + $count = $collector->markAllAsRead($projects, $user); + + return json_encode([ + 'status' => 'success', + 'message' => "$count notifications marked as read" + ]); + } catch (\Throwable $th) { + return json_encode([ + 'status' => 'error', + 'message' => $th->getMessage() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/routes/mark-as-read.php b/public/site/plugins/notifications/routes/mark-as-read.php new file mode 100644 index 0000000..736b39f --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-as-read.php @@ -0,0 +1,46 @@ + '(:all)mark-notification-read.json', + 'method' => 'POST', + 'action' => function () { + $json = file_get_contents('php://input'); + $data = json_decode($json); + + if (!$data || !isset($data->type) || !isset($data->id)) { + return json_encode([ + 'status' => 'error', + 'message' => 'Missing required fields: type, id' + ]); + } + + try { + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + + if (!$collector) { + throw new Exception('NotificationCollector not initialized'); + } + + $success = $collector->markAsRead( + $data->type, + $data->id, + (array) $data, + kirby()->user() + ); + + return json_encode([ + 'status' => $success ? 'success' : 'error', + 'message' => $success ? 'Notification marked as read' : 'Failed to mark notification as read' + ]); + } catch (\Throwable $th) { + return json_encode([ + 'status' => 'error', + 'message' => $th->getMessage() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php new file mode 100644 index 0000000..113b041 --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -0,0 +1,123 @@ +providers[$provider->getType()] = $provider; + } + + /** + * Collecte toutes les notifications de tous les providers pour un projet. + * + * @param Page $project Le projet à scanner + * @param User $user L'utilisateur courant + * @return array Liste triée par date décroissante + */ + public function collect(Page $project, User $user): array + { + $all = []; + + foreach ($this->providers as $provider) { + try { + $notifications = $provider->collect($project, $user); + $all = array_merge($all, $notifications); + } catch (\Throwable $e) { + // Log l'erreur mais continue avec les autres providers + error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); + } + } + + // Trier par date décroissante + usort($all, function ($a, $b) { + $dateA = strtotime($a['date'] ?? '0'); + $dateB = strtotime($b['date'] ?? '0'); + return $dateB - $dateA; + }); + + return $all; + } + + /** + * Marque une notification comme lue en déléguant au bon provider. + * + * @param string $type Le type de notification + * @param string $id L'identifiant de la notification + * @param array $location Informations de localisation + * @param User $user L'utilisateur qui marque comme lu + * @return bool True si succès + */ + public function markAsRead(string $type, string $id, array $location, User $user): bool + { + if (!isset($this->providers[$type])) { + error_log("NotificationCollector: Unknown notification type: $type"); + return false; + } + + try { + return $this->providers[$type]->markAsRead($id, $location, $user); + } catch (\Throwable $e) { + error_log("NotificationCollector: Error marking as read: " . $e->getMessage()); + return false; + } + } + + /** + * Marque toutes les notifications comme lues pour un utilisateur. + * + * @param Page[] $projects Liste des projets + * @param User $user L'utilisateur + * @return int Nombre de notifications marquées comme lues + */ + public function markAllAsRead(array $projects, User $user): int + { + $count = 0; + + foreach ($projects as $project) { + $notifications = $this->collect($project, $user); + + foreach ($notifications as $notification) { + if (!($notification['isRead'] ?? false)) { + $success = $this->markAsRead( + $notification['type'], + $notification['id'], + $notification, + $user + ); + if ($success) { + $count++; + } + } + } + } + + return $count; + } + + /** + * Retourne la liste des types de notifications enregistrés. + */ + public function getRegisteredTypes(): array + { + return array_keys($this->providers); + } +} diff --git a/public/site/plugins/notifications/src/NotificationProvider.php b/public/site/plugins/notifications/src/NotificationProvider.php new file mode 100644 index 0000000..cc11906 --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationProvider.php @@ -0,0 +1,42 @@ +hasOptimizationRequest()->isFalse()) { + return []; + } + + // Vérifier que les champs requis existent + if ($project->optimizationAuthor()->isEmpty()) { + return []; + } + + $userUuid = (string) $user->uuid(); + $authorUuid = $project->optimizationAuthor()->value(); + + // Ne pas notifier l'auteur de sa propre demande + if ($authorUuid === $userUuid) { + return []; + } + + $readby = $project->optimizationReadby()->isNotEmpty() + ? Yaml::decode($project->optimizationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + return [[ + 'id' => 'appointment-request-' . (string) $project->uuid(), + 'type' => 'appointment-request', + 'text' => $project->optimizationRequestDetails()->value() ?? '', + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $project->optimizationAuthorName()->value() ?? '', + 'email' => $project->optimizationAuthorEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $project->optimizationDate()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + 'template' => 'project', + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_projectUri' => $project->uri(), + ]]; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $projectUri = $location['_projectUri'] ?? null; + if (!$projectUri) { + return false; + } + + $project = page($projectUri); + if (!$project) { + return false; + } + + $readby = $project->optimizationReadby()->isNotEmpty() + ? Yaml::decode($project->optimizationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; + } + + $readby[] = $userUuid; + + $project->update([ + 'optimizationReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/CommentProvider.php b/public/site/plugins/notifications/src/providers/CommentProvider.php new file mode 100644 index 0000000..fc92ce1 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/CommentProvider.php @@ -0,0 +1,172 @@ +uuid(); + + // Parcourir toutes les étapes du projet + foreach ($project->children() as $step) { + // Parcourir tous les fichiers de chaque étape + foreach ($step->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + // Ignorer les commentaires de type reply (gérés par ReplyProvider) + if (($comment['type'] ?? 'comment') === 'comment-reply') { + continue; + } + + // Ne pas notifier l'auteur de son propre commentaire + $authorUuid = $comment['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $comment['readby'] ?? []; + + $location = $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $comment['id'], + 'type' => 'comment', + 'text' => $comment['text'] ?? '', + 'author' => $comment['author'] ?? [], + 'date' => $comment['date'] ?? '', + 'location' => $location, + 'position' => $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + // Métadonnées pour markAsRead + '_file' => (string) $file->uuid(), + '_stepUri' => $step->uri(), + ]; + } + } + + // Parcourir aussi les sous-pages (ex: tracks dans virtual-sample) + foreach ($step->children() as $subPage) { + foreach ($subPage->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + if (($comment['type'] ?? 'comment') === 'comment-reply') { + continue; + } + + $authorUuid = $comment['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $comment['readby'] ?? []; + + $location = $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $comment['id'], + 'type' => 'comment', + 'text' => $comment['text'] ?? '', + 'author' => $comment['author'] ?? [], + 'date' => $comment['date'] ?? '', + 'location' => $location, + 'position' => $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_file' => (string) $file->uuid(), + '_stepUri' => $subPage->uri(), + ]; + } + } + } + } + + return $notifications; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $fileUuid = $location['_file'] ?? null; + if (!$fileUuid) { + return false; + } + + // Trouver le fichier par UUID (peut être avec ou sans préfixe file://) + $fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid; + $file = kirby()->file($fileUri); + if (!$file) { + return false; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + return false; + } + + $userUuid = (string) $user->uuid(); + $updated = false; + + foreach ($comments as &$comment) { + if ($comment['id'] === $id) { + $comment['readby'] = $comment['readby'] ?? []; + if (!in_array($userUuid, $comment['readby'])) { + $comment['readby'][] = $userUuid; + $updated = true; + } + break; + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + + return $updated; + } +} diff --git a/public/site/plugins/notifications/src/providers/ContentProvider.php b/public/site/plugins/notifications/src/providers/ContentProvider.php new file mode 100644 index 0000000..3f09800 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ContentProvider.php @@ -0,0 +1,128 @@ +uuid(); + + // Chercher les briefs validés (client-brief et extended-brief) + $briefTemplates = ['client-brief', 'extended-brief']; + + foreach ($project->children() as $step) { + if (!in_array($step->intendedTemplate()->name(), $briefTemplates)) { + continue; + } + + // Pas de notification si le brief n'est pas validé + if ($step->isValidated()->isFalse()) { + continue; + } + + // Vérifier que les champs requis existent + if ($step->validatedBy()->isEmpty()) { + continue; + } + + $authorUuid = $step->validatedBy()->value(); + + // Ne pas notifier l'auteur de sa propre validation + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $step->validationReadby()->isNotEmpty() + ? Yaml::decode($step->validationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $stepLabel = $step->intendedTemplate()->name() === 'client-brief' + ? 'Brief client' + : 'Brief étendu'; + + $notifications[] = [ + 'id' => 'content-' . (string) $step->uuid(), + 'type' => 'content', + 'text' => "Nouveau $stepLabel validé", + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $step->validatedByName()->value() ?? '', + 'email' => $step->validatedByEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $step->validatedAt()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $step->uri(), + 'title' => (string) $step->title(), + 'template' => $step->intendedTemplate()->name(), + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_briefUri' => $step->uri(), + ]; + } + + return $notifications; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $briefUri = $location['_briefUri'] ?? null; + if (!$briefUri) { + return false; + } + + $brief = page($briefUri); + if (!$brief) { + return false; + } + + $readby = $brief->validationReadby()->isNotEmpty() + ? Yaml::decode($brief->validationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; + } + + $readby[] = $userUuid; + + $brief->update([ + 'validationReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php new file mode 100644 index 0000000..cc3d186 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php @@ -0,0 +1,111 @@ +isClientRequest()->isFalse()) { + return []; + } + + // Vérifier que les champs requis existent + if ($project->requestAuthor()->isEmpty()) { + return []; + } + + $userUuid = (string) $user->uuid(); + $authorUuid = $project->requestAuthor()->value(); + + // Ne pas notifier l'auteur de sa propre demande + if ($authorUuid === $userUuid) { + return []; + } + + $readby = $project->requestReadby()->isNotEmpty() + ? Yaml::decode($project->requestReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + return [[ + 'id' => 'project-request-' . (string) $project->uuid(), + 'type' => 'project-request', + 'text' => $project->requestDetails()->value() ?? '', + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $project->requestAuthorName()->value() ?? '', + 'email' => $project->requestAuthorEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $project->requestDate()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + 'template' => 'project', + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_projectUri' => $project->uri(), + ]]; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $projectUri = $location['_projectUri'] ?? null; + if (!$projectUri) { + return false; + } + + $project = page($projectUri); + if (!$project) { + return false; + } + + $readby = $project->requestReadby()->isNotEmpty() + ? Yaml::decode($project->requestReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; // Déjà lu + } + + $readby[] = $userUuid; + + $project->update([ + 'requestReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/ReplyProvider.php b/public/site/plugins/notifications/src/providers/ReplyProvider.php new file mode 100644 index 0000000..467b245 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ReplyProvider.php @@ -0,0 +1,142 @@ +uuid(); + + // Parcourir toutes les étapes du projet + foreach ($project->children() as $step) { + $this->collectFromPage($step, $project, $userUuid, $notifications); + + // Parcourir aussi les sous-pages (ex: tracks) + foreach ($step->children() as $subPage) { + $this->collectFromPage($subPage, $project, $userUuid, $notifications); + } + } + + return $notifications; + } + + private function collectFromPage(Page $page, Page $project, string $userUuid, array &$notifications): void + { + foreach ($page->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + $replies = $comment['replies'] ?? []; + + foreach ($replies as $reply) { + // Ne pas notifier l'auteur de sa propre réponse + $authorUuid = $reply['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $reply['readby'] ?? []; + + $location = $reply['location'] ?? $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $reply['id'], + 'type' => 'comment-reply', + 'text' => $reply['text'] ?? '', + 'author' => $reply['author'] ?? [], + 'date' => $reply['date'] ?? '', + 'location' => $location, + 'position' => $reply['position'] ?? $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + // Métadonnées pour markAsRead + '_file' => (string) $file->uuid(), + '_parentCommentId' => $comment['id'], + '_stepUri' => $page->uri(), + ]; + } + } + } + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $fileUuid = $location['_file'] ?? null; + $parentCommentId = $location['_parentCommentId'] ?? null; + + if (!$fileUuid) { + return false; + } + + // Trouver le fichier par UUID (peut être avec ou sans préfixe file://) + $fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid; + $file = kirby()->file($fileUri); + if (!$file) { + return false; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + return false; + } + + $userUuid = (string) $user->uuid(); + $updated = false; + + foreach ($comments as &$comment) { + // Si on a l'ID du parent, l'utiliser pour cibler + if ($parentCommentId && $comment['id'] !== $parentCommentId) { + continue; + } + + $replies = &$comment['replies'] ?? []; + + foreach ($replies as &$reply) { + if ($reply['id'] === $id) { + $reply['readby'] = $reply['readby'] ?? []; + if (!in_array($userUuid, $reply['readby'])) { + $reply['readby'][] = $userUuid; + $updated = true; + } + break 2; + } + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + + return $updated; + } +} diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index ba74b69..cf5a52a 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,24 +7,37 @@ if (!$kirby->user()) { ]); } -function getProjectData($project) -{ +// Récupérer le collector de notifications +$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector'); + +function getProjectData($project, $user, $collector) +{ + // Utiliser le nouveau système de notifications dérivées + $notifications = []; + if ($collector) { + try { + $notifications = $collector->collect($project, $user); + } catch (\Throwable $e) { + error_log("Error collecting notifications for project {$project->uri()}: " . $e->getMessage()); + $notifications = []; + } + } $data = [ - 'title' => $project->title()->value(), - 'url' => $project->url(), - 'uri' => '/' . $project->uri(), - 'modified' => $project->modified('Y-MM-d'), - 'currentStep' => $project->currentStep()->value(), - 'status' => $project->status(), - 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', - 'steps' => $project->getSteps(), - 'notifications' => Yaml::decode($project->notifications()->value), - 'uuid' => (string) $project->uuid(), - 'slug' => (string) $project->slug(), - 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), - 'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(), - ]; + 'title' => $project->title()->value(), + 'url' => $project->url(), + 'uri' => '/' . $project->uri(), + 'modified' => $project->modified('Y-MM-d'), + 'currentStep' => $project->currentStep()->value(), + 'status' => $project->status(), + 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', + 'steps' => $project->getSteps(), + 'notifications' => $notifications, + 'uuid' => (string) $project->uuid(), + 'slug' => (string) $project->slug(), + 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), + 'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(), + ]; if ($project->isDTLEnabled()) { $data['designToLight'] = processDTLProposals($project); @@ -33,8 +46,12 @@ function getProjectData($project) return $data; } +$currentUser = $kirby->user(); + try { - $children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values(); + $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(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; diff --git a/src/stores/api.js b/src/stores/api.js index d12707a..cbffdd8 100644 --- a/src/stores/api.js +++ b/src/stores/api.js @@ -163,6 +163,34 @@ export const useApiStore = defineStore("api", () => { } } + /** + * Marque une notification comme lue. + * @param {Object} notification - L'objet notification complet (avec type, id, _file, _projectUri, etc.) + */ + async function markNotificationRead(notification) { + const headers = { + method: "POST", + body: JSON.stringify(notification), + }; + try { + const response = await fetch("/mark-notification-read.json", headers); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + if (data.status === "error") { + throw new Error(data.message); + } + // Mettre à jour le store local + userStore.markNotificationRead(notification.id, notification.project?.uri || notification._projectUri); + return data; + } catch (error) { + console.error("Erreur lors du marquage de la notification:", error); + throw error; + } + } + + // Ancienne fonction gardée pour rétro-compatibilité async function readNotification(notificationId, projectId) { const headers = { method: "POST", @@ -215,6 +243,31 @@ export const useApiStore = defineStore("api", () => { } } + /** + * Marque toutes les notifications comme lues (nouveau système). + */ + async function markAllNotificationsRead() { + try { + const response = await fetch("/mark-all-notifications-read.json", { + method: "POST", + }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + if (data.status === "error") { + throw new Error(data.message); + } + userStore.markAllNotificationsRead(); + console.log("Toutes les notifications ont été marquées comme lues."); + return data; + } catch (error) { + console.error("Erreur lors du marquage de toutes les notifications:", error); + throw error; + } + } + + // Ancienne fonction gardée pour rétro-compatibilité async function readAllNotifications() { try { const response = await fetch("/read-all-notifications.json"); @@ -243,6 +296,10 @@ export const useApiStore = defineStore("api", () => { updateComment, deleteComment, replyComment, + // Nouvelles fonctions + markNotificationRead, + markAllNotificationsRead, + // Anciennes fonctions (rétro-compatibilité) readNotification, readAllNotifications, validateBrief, diff --git a/src/stores/user.js b/src/stores/user.js index 7beaf69..b3404df 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -11,48 +11,76 @@ export const useUserStore = defineStore('user', () => { const { projects } = storeToRefs(useProjectsStore()); + /** + * Liste des notifications agrégées depuis tous les projets. + * Les notifications sont maintenant dérivées côté backend avec isRead pré-calculé. + */ const notifications = computed(() => { - return projects.value?.flatMap((project) => { + if (!projects.value || !user.value) return []; + + return projects.value.flatMap((project) => { if (!project.notifications) return []; - return project.notifications - .filter((notification) => notification.author.uuid !== user.value.uuid) - .map((notification) => ({ - ...notification, - project: project, - isRead: notification.readby?.includes(user.value.uuid), - })); + return project.notifications.map((notification) => ({ + ...notification, + project: project, + // isRead est maintenant fourni par le backend + })); }); }); - function readNotification(notificationId, projectId) { - console.log('Read notification', notificationId, projectId); + /** + * Marque une notification comme lue dans le store local. + * @param {string} notificationId - L'ID de la notification + * @param {string} projectUri - L'URI du projet (optionnel, pour retrouver le projet) + */ + function markNotificationRead(notificationId, projectUri = null) { + if (!user.value?.uuid) return; + + projects.value = projects.value.map((project) => { + // Si projectUri fourni, cibler le bon projet + if (projectUri && project.uri !== projectUri && `/${project.uri}` !== projectUri) { + return project; + } + + return { + ...project, + notifications: (project.notifications || []).map((notification) => + notification.id === notificationId + ? { + ...notification, + isRead: true, + readby: [...new Set([...(notification.readby || []), user.value.uuid])], + } + : notification + ), + }; + }); + } + + /** + * Marque toutes les notifications comme lues dans le store local. + */ + function markAllNotificationsRead() { + if (!user.value?.uuid) return; + projects.value = projects.value.map((project) => ({ ...project, - notifications: - project.uuid === projectId || project.uri === projectId - ? project.notifications.map((notification) => - notification.id === notificationId - ? { - ...notification, - readby: [ - ...new Set([...notification.readby, user.value.uuid]), - ], - } - : notification - ) - : project.notifications, + notifications: (project.notifications || []).map((notification) => ({ + ...notification, + isRead: true, + readby: [...new Set([...(notification.readby || []), user.value.uuid])], + })), })); } + // Anciennes fonctions gardées pour rétro-compatibilité + function readNotification(notificationId, projectId) { + markNotificationRead(notificationId, projectId); + } + function readAllNotifications() { - projects.value = projects.value.map((project) => ({ - ...project, - notifications: project.notifications.map((notification) => ({ - ...notification, - readby: [...new Set([...notification.readby, user.value.uuid])], - })), - })); + markAllNotificationsRead(); } function canEditComment(comment) { @@ -63,6 +91,10 @@ export const useUserStore = defineStore('user', () => { user, isLogged, notifications, + // Nouvelles fonctions + markNotificationRead, + markAllNotificationsRead, + // Anciennes fonctions (rétro-compatibilité) readNotification, readAllNotifications, canEditComment, diff --git a/src/views/Notifications.vue b/src/views/Notifications.vue index c7db5b1..053eeba 100644 --- a/src/views/Notifications.vue +++ b/src/views/Notifications.vue @@ -119,14 +119,24 @@ function changeTab(newValue) { function readAll() { try { - api.readAllNotifications(); + api.markAllNotificationsRead(); } catch (error) { console.log('Could not read all notifications : ', error); } } // Functions -function handleNotificationClick(notification) { +async function handleNotificationClick(notification) { + // Marquer la notification comme lue + if (!notification.isRead) { + try { + await api.markNotificationRead(notification); + } catch (error) { + console.log('Could not mark notification as read:', error); + } + } + + // Naviguer vers la cible const href = notification.type === 'appointment-request' ? getHref(notification) + '?tab=designToLight' diff --git a/vite.config.js b/vite.config.js index c235442..da73536 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => { minify: mode === 'production' ? 'esbuild' : false, }, server: { + cors: true, watch: { ignored: [ '**/node_modules/**', From 6ff59e9b07454c8ffa5f625f901a7e60d02adf65 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 10:44:30 +0100 Subject: [PATCH 09/25] =?UTF-8?q?Fix=20:=20URL=20correcte=20pour=20notific?= =?UTF-8?q?ations=20de=20brief=20valid=C3=A9=20depuis=20PDF=20+=20redirect?= =?UTF-8?q?=20briefs=20vides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème 1 : Les notifications de brief validé depuis un PDF renvoyaient vers /projects/xxx/client-brief au lieu de l'URL complète avec dialog et fileIndex. Problème 2 : Les URL /projects/xxx/client-brief pour des briefs non créés affichaient une page vide au lieu de rediriger vers le kanban. Solutions : - Stocker validationDialogUri lors de la validation du brief - Utiliser ce dialogUri dans ContentProvider et Notifications.vue - Rediriger vers le projet parent si brief vide et non validé Co-Authored-By: Claude Sonnet 4.5 --- public/site/blueprints/pages/client-brief.yml | 3 ++- .../site/blueprints/pages/extended-brief.yml | 2 ++ public/site/config/routes/validate-brief.php | 11 ++++++++-- .../src/providers/ContentProvider.php | 11 ++++++++-- src/views/Brief.vue | 21 +++++++++++++++++-- src/views/Notifications.vue | 5 +++++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/public/site/blueprints/pages/client-brief.yml b/public/site/blueprints/pages/client-brief.yml index 9df9168..d7b2100 100644 --- a/public/site/blueprints/pages/client-brief.yml +++ b/public/site/blueprints/pages/client-brief.yml @@ -24,7 +24,6 @@ tabs: type: hidden isValidated: type: hidden - # Champs pour notification "content" (brief validé) validatedBy: type: hidden validatedByName: @@ -35,6 +34,8 @@ tabs: type: hidden validationReadby: type: hidden + validationDialogUri: + type: hidden pdf: label: PDF type: files diff --git a/public/site/blueprints/pages/extended-brief.yml b/public/site/blueprints/pages/extended-brief.yml index 477d4a1..5c96ee2 100644 --- a/public/site/blueprints/pages/extended-brief.yml +++ b/public/site/blueprints/pages/extended-brief.yml @@ -35,6 +35,8 @@ tabs: type: hidden validationReadby: type: hidden + validationDialogUri: + type: hidden pdf: label: PDF type: files diff --git a/public/site/config/routes/validate-brief.php b/public/site/config/routes/validate-brief.php index fb2bf86..5b16c44 100644 --- a/public/site/config/routes/validate-brief.php +++ b/public/site/config/routes/validate-brief.php @@ -15,7 +15,7 @@ return [ $timezone = new DateTimeZone('Europe/Paris'); $dateTime = new DateTime('now', $timezone); - $newPage = $page->update([ + $updateData = [ 'isValidated' => 'true', // Métadonnées pour le système de notifications dérivées 'validatedBy' => (string) $user->uuid(), @@ -23,7 +23,14 @@ return [ 'validatedByEmail' => (string) $user->email(), 'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'), 'validationReadby' => [], - ]); + ]; + + // Si un dialogUri est fourni (validation depuis PDF), le stocker + if (isset($data->dialogUri) && !empty($data->dialogUri)) { + $updateData['validationDialogUri'] = (string) $data->dialogUri; + } + + $newPage = $page->update($updateData); // Note: Les notifications sont maintenant dérivées. // Plus besoin d'appeler createNotification(). diff --git a/public/site/plugins/notifications/src/providers/ContentProvider.php b/public/site/plugins/notifications/src/providers/ContentProvider.php index 3f09800..94698ec 100644 --- a/public/site/plugins/notifications/src/providers/ContentProvider.php +++ b/public/site/plugins/notifications/src/providers/ContentProvider.php @@ -60,10 +60,10 @@ class ContentProvider implements NotificationProvider ? 'Brief client' : 'Brief étendu'; - $notifications[] = [ + $notification = [ 'id' => 'content-' . (string) $step->uuid(), 'type' => 'content', - 'text' => "Nouveau $stepLabel validé", + 'text' => 'Nouveau ' . strtolower($stepLabel) . ' validé', 'author' => [ 'uuid' => $authorUuid, 'name' => $step->validatedByName()->value() ?? '', @@ -86,6 +86,13 @@ class ContentProvider implements NotificationProvider 'isRead' => in_array($userUuid, $readby), '_briefUri' => $step->uri(), ]; + + // Ajouter le dialogUri si présent (validation depuis PDF) + if ($step->validationDialogUri()->isNotEmpty()) { + $notification['dialogUri'] = $step->validationDialogUri()->value(); + } + + $notifications[] = $notification; } return $notifications; diff --git a/src/views/Brief.vue b/src/views/Brief.vue index acc132f..5d5963e 100644 --- a/src/views/Brief.vue +++ b/src/views/Brief.vue @@ -29,7 +29,7 @@ diff --git a/src/components/project/brief/ModeSelection.vue b/src/components/project/brief/ModeSelection.vue deleted file mode 100644 index 317ca3c..0000000 --- a/src/components/project/brief/ModeSelection.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - From 9d12ccb209823391a42534f26d3b45bc64912808 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 10:58:41 +0100 Subject: [PATCH 12/25] Fix : ne compter que les commentaires des images, pas ceux du PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : Dans le kanban, la carte du brief client custom (Images) affichait aussi le nombre de commentaires du PDF, alors qu'il n'y a pas de système de commentaires pour les images du brief custom. Solution : Filtrer pour ne compter que les commentaires des fichiers de type 'image', et non tous les fichiers du step. Bonus : Suppression du paramètre obsolète ?step=images dans ClientBrief.vue Co-Authored-By: Claude Sonnet 4.5 --- src/components/project/cards/ClientBrief.vue | 2 +- src/components/project/cards/Images.vue | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/project/cards/ClientBrief.vue b/src/components/project/cards/ClientBrief.vue index ca74589..27258f6 100644 --- a/src/components/project/cards/ClientBrief.vue +++ b/src/components/project/cards/ClientBrief.vue @@ -49,6 +49,6 @@ const pdf = computed(() => { }); function goToImagesBrief() { - router.push(location.pathname + "/client-brief?step=images"); + router.push(location.pathname + "/client-brief"); } diff --git a/src/components/project/cards/Images.vue b/src/components/project/cards/Images.vue index 463bfcc..c79fec9 100644 --- a/src/components/project/cards/Images.vue +++ b/src/components/project/cards/Images.vue @@ -56,8 +56,11 @@ const commentsCount = computed(() => { let count = 0; if (Array.isArray(step.files)) { + // Ne compter que les commentaires des images, pas des documents (PDFs) for (const file of step.files) { - count += file?.comments?.length || 0; + if (file.type === 'image') { + count += file?.comments?.length || 0; + } } } else { if (step.files?.dynamic) { From f614884da0708b5095373a248be4807e32654053 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:03:50 +0100 Subject: [PATCH 13/25] update project overview --- CLAUDE_PROJECT_OVERVIEW.md | 311 +++++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 CLAUDE_PROJECT_OVERVIEW.md diff --git a/CLAUDE_PROJECT_OVERVIEW.md b/CLAUDE_PROJECT_OVERVIEW.md new file mode 100644 index 0000000..4657285 --- /dev/null +++ b/CLAUDE_PROJECT_OVERVIEW.md @@ -0,0 +1,311 @@ +# Design to Pack - Vue d'ensemble du projet + +Plateforme de gestion de projets de création de flacons de parfum pour Pochet du Courval. + +## Stack technique + +| Couche | Technologies | +|--------|-------------| +| **Backend** | Kirby CMS 4 (PHP), flat-file database | +| **Frontend** | Vue 3 + Vite 7, Pinia, Vue Router 4, PrimeVue 4.0 | +| **PDF** | @vue-pdf-viewer 2.5 | +| **3D** | Three.js (vue interactive 360) | +| **Déploiement** | GitLab CI/CD, rsync vers serveur | + +--- + +## Structure du projet + +``` +design-to-pack/ +├── src/ # App Vue.js +│ ├── assets/css/ # Styles globaux +│ ├── components/ # Composants réutilisables +│ │ ├── comments/ # Système de commentaires +│ │ ├── design-to-light/ # Feature DTL +│ │ ├── inspirations/ # Galerie inspirations +│ │ ├── notifications/ # Notifications +│ │ └── project/ # Composants projet +│ │ ├── cards/ # Cartes par type d'étape +│ │ ├── brief/ # Brief client (moodboard) +│ │ └── virtual-sample/ # Échantillon virtuel 3D +│ ├── router/ # Vue Router +│ ├── stores/ # Pinia stores +│ ├── utils/ # Utilitaires +│ ├── views/ # Pages principales +│ ├── main.js # Point d'entrée +│ └── App.vue # Composant racine +│ +├── public/ # Kirby CMS +│ ├── content/ # Données (flat-file) +│ │ ├── projects/ # Pages projets +│ │ ├── clients/ # Pages clients +│ │ ├── design-to-light/ # Page DTL +│ │ └── inspirations/ # Galerie inspirations +│ ├── site/ +│ │ ├── blueprints/ # Schémas de données +│ │ │ ├── pages/ # Blueprints des pages +│ │ │ ├── users/ # Blueprints utilisateurs +│ │ │ └── files/ # Blueprints fichiers +│ │ ├── templates/ # Templates PHP + JSON +│ │ ├── controllers/ # Contrôleurs +│ │ ├── models/ # Modèles PHP (Project, Client) +│ │ ├── plugins/ # Plugins custom +│ │ ├── snippets/ # Fragments réutilisables +│ │ └── config/ # Configuration + routes +│ └── media/ # Fichiers uploadés +│ +├── package.json +├── vite.config.js +└── .gitlab-ci.yml +``` + +--- + +## Plugins Kirby custom + +### 1. `classes/` - Classes partagées +Classes de données utilisées par comments et notifications. + +| Classe | Rôle | +|--------|------| +| `Author` | Auteur (name, email, uuid, role) | +| `Position` | Position x/y + pageIndex (marqueurs sur PDF) | +| `Location` | Localisation (page, file, parent) | +| `PageDetails` | Détails de page | +| `FileDetails` | Détails de fichier | +| `ProjectDetails` | Détails de projet | + +### 2. `comments/` - Système de commentaires +Plugin `adrienpayet/kirby4-comments` + +**Classes:** +- `BaseComment` - Classe de base +- `Comment` - Commentaire avec replies +- `Reply` - Réponse à un commentaire + +**Routes:** +| Route | Fichier | Description | +|-------|---------|-------------| +| `POST /create-comment.json` | `routes/create.php` | Créer un commentaire | +| `POST /update-comment.json` | `routes/update.php` | Modifier un commentaire | +| `POST /delete-comment.json` | `routes/delete.php` | Supprimer un commentaire | +| `POST /reply-comment.json` | `routes/reply.php` | Répondre à un commentaire | + +**Stockage:** Les commentaires sont stockés en YAML dans les métadonnées des fichiers. + +### 3. `notifications/` - Système de notifications +Plugin `adrienpayet/pdc-notifications` + +**Classes:** +- `Notification` - Notification (type, location, text, author, date, readby[]) +- `NotificationsPage` - Base pour pages avec notifications (extends Page) + +**Méthodes NotificationsPage:** +- `createNotification($data)` - Créer une notification +- `deleteNotification($id)` - Supprimer une notification +- `readNotification($id)` - Marquer comme lue (ajoute userUuid à readby) +- `readAllNotifications()` - Tout marquer comme lu + +**Routes:** +| Route | Description | +|-------|-------------| +| `POST /read-notification.json` | Marquer une notification comme lue | +| `POST /read-all-notifications.json` | Tout marquer comme lu | + +### 4. `user-projects/` - Projets autorisés par utilisateur +Plugin `adrienpayet/pdc-authorized-projects` + +**User methods:** +- `currentProjects()` - Projets actifs (listed) accessibles à l'utilisateur +- `archivedProjects()` - Projets archivés (unlisted) accessibles + +Logique: Admin = tous les projets, autres = seulement projets assignés. + +### 5. `helpers/` - Fonctions utilitaires + +| Fonction | Description | +|----------|-------------| +| `getFileData($file, $preserveQuality)` | Normalise les données fichier (thumb webp, cover, comments) | +| `getGlobalEvaluation($numberedGrade)` | Convertit note numérique en lettre A-E avec mention | +| `processDTLProposals($page)` | Traite les propositions Design to Light | +| `refreshProjectStepsCache($project, $steps)` | Rafraîchit le cache des étapes | + +### 6. `icons/` - Icônes custom panel +Plugin `adrienpayet/pochet-icons` - Icônes personnalisées pour le panel Kirby. + +### 7. `kql/` - Kirby Query Language +Plugin externe pour requêtes type GraphQL. + +### 8. `refresh-cache-button/` - Bouton refresh cache +Plugin externe ajoutant un bouton de rafraîchissement du cache dans le panel. + +--- + +## Modèles de données + +### Utilisateurs (3 rôles) + +| Rôle | Accès | +|------|-------| +| `admin` | Tous les projets, panel complet | +| `pochet` | Projets assignés uniquement, panel limité | +| `client` | Ses projets uniquement, pas de panel | + +### Projet (ProjectPage) +Hérite de `NotificationsPage`. + +**Champs principaux:** +- `title`, `status` (draft/listed/unlisted) +- `client` - Lien vers ClientPage +- `currentStep` - Étape courante +- `isDTLEnabled` - Design to Light activé + +**Étapes (children):** +1. `client-brief` - Brief client (PDF + moodboard) +2. `proposal` - Proposition commerciale (PDFs) +3. `extended-brief` - Brief étendu +4. `industrial-ideation` - Idéation industrielle (optionnel) +5. `virtual-sample` - Échantillon virtuel (pistes dynamiques + statiques) +6. `physical-sample` - Échantillon physique (médias) + +### Client (ClientPage) +- `logo`, `title` +- `projects()` - Tous les projets +- `projectsListed()` / `projectsUnlisted()` - Filtres par statut + +--- + +## Stores Pinia + +| Store | Fichier | Rôle | +|-------|---------|------| +| `api` | `stores/api.js` | Communication API (fetch, post, comments, notifications) | +| `user` | `stores/user.js` | Utilisateur courant, permissions | +| `page` | `stores/page.js` | Données de la page courante | +| `projects` | `stores/projects.js` | Liste des projets | +| `dialog` | `stores/dialog.js` | État des modales (contenu, fichier, commentaires) | +| `brief` | `stores/brief.js` | Gestion du brief client | +| `designToLight` | `stores/designToLight.js` | Feature DTL | +| `notifications` | `stores/notifications.js` | Notifications non lues | +| `virtualSample` | `stores/virtualSample.js` | État échantillon virtuel | +| `addImagesModal` | `stores/addImagesModal.js` | Modal ajout images | +| `project` | `stores/project.js` | Utilitaires projet | + +--- + +## Routes Vue + +| Path | Vue | Description | +|------|-----|-------------| +| `/` | `Home.vue` | Liste des projets | +| `/login` | `Login.vue` | Authentification | +| `/account` | `Account.vue` | Compte utilisateur | +| `/notifications` | `Notifications.vue` | Centre de notifications | +| `/inspirations` | `Inspirations.vue` | Galerie d'inspirations | +| `/design-to-light` | `DesignToLight.vue` | Feature DTL | +| `/projects/:id` | `Kanban.vue` | Détail projet (kanban) | +| `/projects/:id/client-brief` | `Brief.vue` | Brief client | +| `/projects/:id/extended-brief` | `Brief.vue` | Brief étendu | + +--- + +## API Endpoints + +### Authentification +- `POST /login.json` - Connexion (email, password) +- `GET /logout` - Déconnexion + +### Pages (JSON) +- `GET /{uri}.json` - Données page + user + +### Commentaires +- `POST /create-comment.json` +- `POST /update-comment.json` +- `POST /delete-comment.json` +- `POST /reply-comment.json` + +### Notifications +- `POST /read-notification.json` +- `POST /read-all-notifications.json` + +### Fichiers +- `POST /upload-pdf.json` +- `POST /upload-images.json` +- `POST /remove-file.json` + +### Actions +- `POST /save-page.json` +- `POST /save-file.json` +- `POST /validate-brief.json` +- `POST /toggle-favorite.json` +- `POST /request-project-creation.json` +- `POST /request-optimization-appointment.json` + +--- + +## Design to Light (DTL) + +Système d'évaluation avancée des designs de flacons. + +**Notation:** +- Note globale : A (8-10), B (6-8), C (4-6), D (2-4), E (0-2) +- Indicateurs : Design global, Bague, Épaule, Colonnes & Arêtes, Pied, Fond de Verre +- Position : Complexité, Poids + +**Propositions DTL liées à:** +- Proposition commerciale (PDF) +- Idéation industrielle (PDF) +- Échantillon virtuel - piste dynamique +- Échantillon virtuel - piste statique + +--- + +## Fichiers clés à connaître + +### Frontend +- `src/main.js` - Init app +- `src/router/router.js` - Guard + setup +- `src/router/routes.js` - Définition routes +- `src/stores/api.js` - Toute la comm API +- `src/components/Menu.vue` - Navigation latérale +- `src/components/project/DialogWrapper.vue` - Wrapper modales + +### Backend +- `public/site/config/config.php` - Routes, hooks, config +- `public/site/controllers/site.php` - Contrôleur principal +- `public/site/models/project.php` - Logique projet +- `public/site/plugins/helpers/index.php` - Fonctions utilitaires +- `public/site/blueprints/pages/project.yml` - Structure projet + +--- + +## Développement local + +```bash +# Backend +cd public +composer install +php -S localhost:8888 kirby/router.php + +# Frontend +npm install +npm run dev +``` + +## Build + +```bash +npm run build # Production +npm run build:preprod # Staging (avec sourcemaps) +``` + +--- + +## Notes importantes + +1. **Cache**: Les étapes projet sont cachées. Invalidation automatique via hooks Kirby. +2. **Permissions**: Filtrées côté serveur selon le rôle utilisateur. +3. **Commentaires**: Positionnés en % (x, y) + pageIndex pour les PDFs multi-pages. +4. **Notifications**: Stockées par projet, trackées par user UUID dans `readby[]`. +5. **Virtual Sample**: Pistes dynamiques = pages enfants, pistes statiques = fichiers. From 0250dc1487cb0d8f7775f4d4988e8abcf233680a Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:16:17 +0100 Subject: [PATCH 14/25] =?UTF-8?q?Fix=20:=20probl=C3=A8me=20de=20m=C3=A9moi?= =?UTF-8?q?re=20lors=20du=20chargement=20des=20projets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : projects.json.php collectait les notifications dérivées pour TOUS les projets d'un coup, ce qui causait un dépassement de mémoire (HTTP 500). Solution : - projects.json.php : Ne collecte plus les notifications (retourne []) - project.json.php : Collecte les notifications uniquement pour le projet affiché Les notifications seront chargées à la demande quand on ouvre un projet, pas lors du listing initial. Co-Authored-By: Claude Sonnet 4.5 --- public/site/templates/project.json.php | 15 ++++++++++++- public/site/templates/projects.json.php | 28 +++++++------------------ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/public/site/templates/project.json.php b/public/site/templates/project.json.php index a099586..3a31cd1 100644 --- a/public/site/templates/project.json.php +++ b/public/site/templates/project.json.php @@ -1,5 +1,18 @@ option('adrienpayet.pdc-notifications.collector'); +$notifications = []; + +if ($notificationCollector && $kirby->user()) { + try { + $notifications = $notificationCollector->collect($page, $kirby->user()); + } catch (\Throwable $e) { + error_log("Error collecting notifications for project {$page->uri()}: " . $e->getMessage()); + $notifications = []; + } +} + $project = [ 'title' => $page->title()->value(), 'url' => $page->url(), @@ -11,7 +24,7 @@ $project = [ 'steps' => $page->getSteps(), 'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null, 'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(), - 'notifications' => $page->notifications()->yaml(), + 'notifications' => $notifications, ]; $pageData = array_merge($genericData, $project); diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index cf5a52a..ff1d6a4 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,22 +7,10 @@ if (!$kirby->user()) { ]); } -// Récupérer le collector de notifications -$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector'); - -function getProjectData($project, $user, $collector) +function getProjectData($project) { - // Utiliser le nouveau système de notifications dérivées - $notifications = []; - if ($collector) { - try { - $notifications = $collector->collect($project, $user); - } catch (\Throwable $e) { - error_log("Error collecting notifications for project {$project->uri()}: " . $e->getMessage()); - $notifications = []; - } - } - + // Les notifications ne sont plus collectées ici pour éviter les problèmes de mémoire. + // Elles seront collectées uniquement quand on affiche un projet individuel. $data = [ 'title' => $project->title()->value(), 'url' => $project->url(), @@ -32,7 +20,7 @@ function getProjectData($project, $user, $collector) 'status' => $project->status(), 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', 'steps' => $project->getSteps(), - 'notifications' => $notifications, + 'notifications' => [], // Sera collecté à la demande dans project.json.php 'uuid' => (string) $project->uuid(), 'slug' => (string) $project->slug(), 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), @@ -46,12 +34,10 @@ function getProjectData($project, $user, $collector) return $data; } -$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(); + $children = $kirby->user()->role() == 'admin' + ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() + : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; From 0a980603a4a4db2a3eba0c59f2e126f3c28a7fe5 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:18:59 +0100 Subject: [PATCH 15/25] Ajout de collectLight() pour optimiser le chargement du listing des projets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : projects.json.php causait un dépassement mémoire en collectant toutes les notifications complètes (avec author, location, text, etc.) pour tous les projets. Solution : Nouvelle méthode collectLight() qui ne retourne que les données minimales nécessaires au frontend pour afficher les indicateurs : - id, type, isRead, date - location.project.uri (pour le filtrage) Les détails complets sont toujours chargés dans project.json.php individuel. Co-Authored-By: Claude Sonnet 4.5 --- .../src/NotificationCollector.php | 43 +++++++++++++++++++ public/site/templates/projects.json.php | 27 +++++++++--- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php index 113b041..83e47d1 100644 --- a/public/site/plugins/notifications/src/NotificationCollector.php +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -57,6 +57,49 @@ class NotificationCollector return $all; } + /** + * 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. + * + * @param Page $project Le projet à scanner + * @param User $user L'utilisateur courant + * @return array Liste triée par date décroissante + */ + public function collectLight(Page $project, User $user): array + { + $all = []; + + foreach ($this->providers as $provider) { + try { + $notifications = $provider->collect($project, $user); + // Ne garder que les champs essentiels + foreach ($notifications as $notification) { + $all[] = [ + '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'] ?? [] + ] + ]; + } + } catch (\Throwable $e) { + error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); + } + } + + // Trier par date décroissante + usort($all, function ($a, $b) { + $dateA = strtotime($a['date'] ?? '0'); + $dateB = strtotime($b['date'] ?? '0'); + return $dateB - $dateA; + }); + + return $all; + } + /** * Marque une notification comme lue en déléguant au bon provider. * diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index ff1d6a4..222e86a 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,10 +7,19 @@ if (!$kirby->user()) { ]); } -function getProjectData($project) +function getProjectData($project, $user, $collector) { - // Les notifications ne sont plus collectées ici pour éviter les problèmes de mémoire. - // Elles seront collectées uniquement quand on affiche un projet individuel. + // Utiliser collectLight() pour économiser la mémoire (seulement id, type, isRead, date) + $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 = [ 'title' => $project->title()->value(), 'url' => $project->url(), @@ -20,7 +29,7 @@ function getProjectData($project) 'status' => $project->status(), 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', 'steps' => $project->getSteps(), - 'notifications' => [], // Sera collecté à la demande dans project.json.php + 'notifications' => $notifications, 'uuid' => (string) $project->uuid(), 'slug' => (string) $project->slug(), 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), @@ -34,10 +43,14 @@ function getProjectData($project) return $data; } +// Récupérer le collector de notifications +$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector'); +$currentUser = $kirby->user(); + try { - $children = $kirby->user()->role() == 'admin' - ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() - : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values(); + $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(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; From e73e25b1da50fb194a798309a48725cb3d02eb42 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:19:14 +0100 Subject: [PATCH 16/25] =?UTF-8?q?Ajout=20.user.ini=20:=20augmentation=20li?= =?UTF-8?q?mite=20m=C3=A9moire=20PHP=20=C3=A0=20512M?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporaire pour gérer le chargement des notifications de tous les projets. Co-Authored-By: Claude Sonnet 4.5 --- public/.user.ini | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 public/.user.ini diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..082ba05 --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,2 @@ +; Augmentation temporaire de la limite mémoire pour le chargement des notifications +memory_limit = 512M From bb71da081b53c0487721140bf26b55ffdbf793cb Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:42:20 +0100 Subject: [PATCH 17/25] =?UTF-8?q?Ajout=20du=20syst=C3=A8me=20de=20cache=20?= =?UTF-8?q?pour=20les=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : Les notifications étaient collectées à chaque requête sur projects.json, causant des problèmes de performance et de mémoire. Solution : Mise en cache des notifications par projet et par utilisateur - Nouvelle méthode getNotificationsLight() dans ProjectPage avec cache - Cache invalidé automatiquement via les hooks existants (page/file update) - Cache par utilisateur pour inclure le isRead spécifique Performance : Les notifications sont calculées une fois puis servies depuis le cache jusqu'à ce qu'un changement survienne (commentaire, brief, etc.) Co-Authored-By: Claude Sonnet 4.5 --- ...update--regenerate-project-steps-cache.php | 6 ++- public/site/models/project.php | 54 +++++++++++++++++-- public/site/templates/projects.json.php | 22 ++++---- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php index bde728d..f8d9675 100644 --- a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php @@ -1,9 +1,11 @@ template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); if ($project) { - $steps = $project->rebuildStepsCache(); + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (briefs validés, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file diff --git a/public/site/models/project.php b/public/site/models/project.php index 1152945..583bfeb 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -3,18 +3,62 @@ use adrienpayet\notifications\NotificationsPage; class ProjectPage extends NotificationsPage { - public function getSteps() { + public function getSteps() { $apiCache = kirby()->cache('api'); - $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); - + $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); + if ($stepsData === null || count($stepsData) === 0) { $this->rebuildStepsCache(); }; - - $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); + + $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); return $stepsData; } + + /** + * Récupère les notifications pour ce projet (version allégée avec cache). + * Cache par utilisateur pour inclure le isRead. + */ + public function getNotificationsLight($user) { + if (!$user) { + return []; + } + + $apiCache = kirby()->cache('api'); + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $notifications = $apiCache?->get($cacheKey); + + // Si pas en cache, collecter et cacher + if ($notifications === null) { + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + if (!$collector) { + return []; + } + + try { + $notifications = $collector->collectLight($this, $user); + $apiCache->set($cacheKey, $notifications); + } catch (\Throwable $e) { + error_log("Error caching notifications for {$this->slug()}: " . $e->getMessage()); + return []; + } + } + + return $notifications; + } + + /** + * Invalide le cache des notifications de ce projet pour tous les utilisateurs. + */ + public function invalidateNotificationsCache() { + $apiCache = kirby()->cache('api'); + // Invalider pour tous les users + foreach (kirby()->users() as $user) { + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $apiCache->remove($cacheKey); + } + } public function rebuildStepsCache() { // Create steps diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index 222e86a..2b2eaad 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,17 +7,15 @@ if (!$kirby->user()) { ]); } -function getProjectData($project, $user, $collector) +function getProjectData($project, $user) { - // Utiliser collectLight() pour économiser la mémoire (seulement id, type, isRead, date) + // Utiliser getNotificationsLight() avec cache pour optimiser les performances $notifications = []; - if ($collector) { - try { - $notifications = $collector->collectLight($project, $user); - } catch (\Throwable $e) { - error_log("Error collecting light notifications for project {$project->uri()}: " . $e->getMessage()); - $notifications = []; - } + try { + $notifications = $project->getNotificationsLight($user); + } catch (\Throwable $e) { + error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage()); + $notifications = []; } $data = [ @@ -43,14 +41,12 @@ function getProjectData($project, $user, $collector) return $data; } -// Récupérer le collector de notifications -$notificationCollector = $kirby->option('adrienpayet.pdc-notifications.collector'); $currentUser = $kirby->user(); try { $children = $currentUser->role() == 'admin' - ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values() - : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser, $notificationCollector))->values(); + ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser))->values() + : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; From 2791bc4462913b8027fd0f26f4bce50d2c379fc9 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:42:40 +0100 Subject: [PATCH 18/25] Ajout invalidation cache notifications dans hook file-update Co-Authored-By: Claude Sonnet 4.5 --- .../hooks/file-update--regenerate-project-steps-cache.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php index ab09290..c30f93d 100644 --- a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php @@ -3,7 +3,9 @@ // file.update:after return function ($newFile, $oldFile) { $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); - if ($project) { - $steps = $project->rebuildStepsCache(); + if ($project) { + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (commentaires sur fichiers, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file From 86db1f5a0c987a2bfdf2350065d02fd09e6cda00 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 11:55:17 +0100 Subject: [PATCH 19/25] Fix collectLight() : inclure author, text, location pour l'affichage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : collectLight() ne retournait que id/type/isRead/date, causant notification.author undefined dans le frontend. Solution : Inclure tous les champs nécessaires à l'affichage (author, text, location) mais toujours alléger en excluant les gros détails inutiles. Co-Authored-By: Claude Sonnet 4.5 --- .../src/NotificationCollector.php | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php index 83e47d1..ec1714b 100644 --- a/public/site/plugins/notifications/src/NotificationCollector.php +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -59,7 +59,7 @@ class NotificationCollector /** * Collecte uniquement les données minimales des notifications (version allégée pour listing). - * Retourne seulement id, type, isRead, date pour économiser la mémoire. + * Retourne les champs nécessaires à l'affichage mais sans les détails lourds. * * @param Page $project Le projet à scanner * @param User $user L'utilisateur courant @@ -72,18 +72,33 @@ class NotificationCollector foreach ($this->providers as $provider) { try { $notifications = $provider->collect($project, $user); - // Ne garder que les champs essentiels + // Garder les champs nécessaires au frontend foreach ($notifications as $notification) { - $all[] = [ + $light = [ 'id' => $notification['id'] ?? null, 'type' => $notification['type'] ?? null, 'isRead' => $notification['isRead'] ?? false, 'date' => $notification['date'] ?? null, - // Garder location.project.uri pour le frontend - 'location' => [ - 'project' => $notification['location']['project'] ?? [] - ] + 'text' => $notification['text'] ?? null, + 'author' => $notification['author'] ?? null, + 'location' => $notification['location'] ?? [] ]; + + // Garder les champs optionnels s'ils existent + if (isset($notification['dialogUri'])) { + $light['dialogUri'] = $notification['dialogUri']; + } + if (isset($notification['_briefUri'])) { + $light['_briefUri'] = $notification['_briefUri']; + } + if (isset($notification['_file'])) { + $light['_file'] = $notification['_file']; + } + if (isset($notification['_projectUri'])) { + $light['_projectUri'] = $notification['_projectUri']; + } + + $all[] = $light; } } catch (\Throwable $e) { error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); From a57b0c203a47c9a25bebc5486d769a82db158197 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:08:13 +0100 Subject: [PATCH 20/25] Optimisation du refresh cache avec batch processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : Le refresh cache de tous les projets timeout côté serveur à cause du trop grand nombre de projets à traiter en une seule requête. Solution : Batch processing avec indicateur de progression - Backend : traite 10 projets par batch avec offset/limit - Frontend : fait plusieurs requêtes successives et affiche la progression - Timeout réduit à 60s par batch au lieu de illimité - Bouton désactivé pendant le traitement - Ajout invalidateNotificationsCache() pour vider aussi ce cache Affichage : "15/50 (30%)" pendant le traitement, puis "Terminé (50)" Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 92 +++++++++++++++++-- .../src/routes/refresh-cache.php | 47 +++++++--- 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index 5fdb1cf..f4cf6d6 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -7,6 +7,7 @@ :icon="icon" :title="title" @click="refreshCache()" + :disabled="isProcessing" >{{ text }} @@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); +const isProcessing = ref(false); + const title = computed(() => { return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate @@ -31,25 +34,89 @@ const title = computed(() => { }); async function refreshCache() { - text.value = "En cours…"; + isProcessing.value = true; icon.value = "loader"; theme.value = "orange-icon"; + // Pour les projets multiples (batch processing) + if (pageUri === 'projects') { + await refreshAllProjects(); + } else { + await refreshSingleProject(); + } +} + +async function refreshAllProjects() { + let offset = 0; + const limit = 10; // 10 projets par batch + let hasMore = true; + let total = 0; + + try { + while (hasMore) { + const init = { + method: "POST", + "Content-Type": "application/json", + body: JSON.stringify({ + pageUri: 'projects', + offset, + limit + }), + }; + + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } + + total = json.total; + hasMore = json.hasMore; + offset = json.nextOffset; + + // Mise à jour de la progression + const progress = Math.round((json.processed / json.total) * 100); + text.value = `${json.processed}/${json.total} (${progress}%)`; + + console.log(`Batch terminé : ${json.processed}/${json.total} projets`); + } + + // Succès + text.value = `Terminé (${total})`; + icon.value = "check"; + theme.value = "green-icon"; + + setTimeout(() => { + location.href = location.href; + }, 1500); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; + } +} + +async function refreshSingleProject() { + text.value = "En cours…"; + const init = { method: "POST", "Content-Type": "application/json", body: JSON.stringify({ pageUri }), }; - const res = await fetch("/refresh-cache.json", init); - const json = await res.json(); + try { + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } - if (json.status === "error") { - console.error(json); - text.value = "Erreur"; - icon.value = "alert"; - theme.value = "red-icon"; - } else { console.log(json); text.value = "Terminé"; icon.value = "check"; @@ -58,6 +125,13 @@ async function refreshCache() { setTimeout(() => { location.href = location.href; }, 1500); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; } } diff --git a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php index 207d34e..7f93443 100644 --- a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php +++ b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php @@ -1,5 +1,5 @@ '/refresh-cache.json', @@ -10,17 +10,42 @@ return [ if ($data->pageUri === 'projects') { $projects = page('projects')->children(); - foreach ($projects as $project) { - $project->rebuildStepsCache(); - $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); - $project->update([ - 'lastCacheUpdate' => $formatter->format(time()) - ]); + // Support du batch processing + $offset = isset($data->offset) ? intval($data->offset) : 0; + $limit = isset($data->limit) ? intval($data->limit) : 10; // 10 projets par batch par défaut + $total = $projects->count(); + + // Slice pour ne traiter qu'un batch + $batch = $projects->slice($offset, $limit); + $processed = 0; + + foreach ($batch as $project) { + try { + $project->rebuildStepsCache(); + $project->invalidateNotificationsCache(); + + $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); + $project->update([ + 'lastCacheUpdate' => $formatter->format(time()) + ]); + $processed++; + } catch (\Throwable $e) { + error_log("Error refreshing cache for project {$project->slug()}: " . $e->getMessage()); + } } + + $remaining = max(0, $total - ($offset + $processed)); + $hasMore = $remaining > 0; + return [ - 'satus' => 'success', - 'message' => 'Données des pages projets rafraîchies avec succès.' + 'status' => 'success', + 'message' => "Batch terminé : $processed projets traités.", + 'processed' => $offset + $processed, + 'total' => $total, + 'remaining' => $remaining, + 'hasMore' => $hasMore, + 'nextOffset' => $hasMore ? $offset + $limit : null ]; } else { try { @@ -41,7 +66,7 @@ return [ if (!$project) { return [ - 'satus' => 'error', + 'status' => 'error', 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' ]; } @@ -55,7 +80,7 @@ return [ return [ - 'satus' => 'success', + 'status' => 'success', 'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.' ]; } From 4669f03f167fc07663afae02ffdfb149b4b3fd36 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:13:26 +0100 Subject: [PATCH 21/25] =?UTF-8?q?Am=C3=A9lioration=20affichage=20progressi?= =?UTF-8?q?on=20du=20refresh=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout d'une ligne de texte sous le bouton pour afficher la progression : - "Traitement : 10/50 projets (20%)" pendant le traitement - "50 projets mis à jour avec succès" à la fin - Tooltip aussi mis à jour avec la progression Le bouton affiche "En cours…" et la progression détaillée est en dessous. Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index f4cf6d6..76583dc 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -10,6 +10,9 @@ :disabled="isProcessing" >{{ text }} +
+ {{ progressText }} +
@@ -26,8 +29,12 @@ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); const isProcessing = ref(false); +const progressText = ref(""); const title = computed(() => { + if (progressText.value) { + return progressText.value; + } return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate : "Mettre à jour le cache front"; @@ -52,6 +59,8 @@ async function refreshAllProjects() { let hasMore = true; let total = 0; + text.value = "En cours…"; + try { while (hasMore) { const init = { @@ -77,23 +86,25 @@ async function refreshAllProjects() { // Mise à jour de la progression const progress = Math.round((json.processed / json.total) * 100); - text.value = `${json.processed}/${json.total} (${progress}%)`; + progressText.value = `Traitement : ${json.processed}/${json.total} projets (${progress}%)`; console.log(`Batch terminé : ${json.processed}/${json.total} projets`); } // Succès - text.value = `Terminé (${total})`; + text.value = "Terminé"; + progressText.value = `${total} projets mis à jour avec succès`; icon.value = "check"; theme.value = "green-icon"; setTimeout(() => { location.href = location.href; - }, 1500); + }, 2000); } catch (error) { console.error(error); text.value = "Erreur"; + progressText.value = error.message || "Une erreur est survenue"; icon.value = "alert"; theme.value = "red-icon"; isProcessing.value = false; From 378af9ac962723abc42dc4490b9ac0ce7736e11c Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:18:33 +0100 Subject: [PATCH 22/25] Fix : affichage progression dans le texte du bouton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La div en dessous ne s'affichait pas dans le panel Kirby. La progression s'affiche maintenant directement dans le bouton : "En cours 0%" → "En cours 20%" → "En cours 100%" → "Terminé" Co-Authored-By: Claude Sonnet 4.5 --- .../src/components/RefreshCacheButton.vue | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index 76583dc..f6b12f0 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -10,9 +10,6 @@ :disabled="isProcessing" >{{ text }} -
- {{ progressText }} -
@@ -29,12 +26,8 @@ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); const isProcessing = ref(false); -const progressText = ref(""); const title = computed(() => { - if (progressText.value) { - return progressText.value; - } return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate : "Mettre à jour le cache front"; @@ -59,7 +52,7 @@ async function refreshAllProjects() { let hasMore = true; let total = 0; - text.value = "En cours…"; + text.value = "En cours 0%"; try { while (hasMore) { @@ -84,16 +77,15 @@ async function refreshAllProjects() { hasMore = json.hasMore; offset = json.nextOffset; - // Mise à jour de la progression + // Mise à jour de la progression dans le texte du bouton const progress = Math.round((json.processed / json.total) * 100); - progressText.value = `Traitement : ${json.processed}/${json.total} projets (${progress}%)`; + text.value = `En cours ${progress}%`; - console.log(`Batch terminé : ${json.processed}/${json.total} projets`); + console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`); } // Succès text.value = "Terminé"; - progressText.value = `${total} projets mis à jour avec succès`; icon.value = "check"; theme.value = "green-icon"; @@ -104,7 +96,6 @@ async function refreshAllProjects() { } catch (error) { console.error(error); text.value = "Erreur"; - progressText.value = error.message || "Une erreur est survenue"; icon.value = "alert"; theme.value = "red-icon"; isProcessing.value = false; From 95a8bf99cb9a53772b7beaeaf8c4b8e366c9219b Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:19:35 +0100 Subject: [PATCH 23/25] build plugin refresh cache --- public/site/plugins/refresh-cache-button/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/site/plugins/refresh-cache-button/index.js b/public/site/plugins/refresh-cache-button/index.js index 8fc4d42..4d99982 100644 --- a/public/site/plugins/refresh-cache-button/index.js +++ b/public/site/plugins/refresh-cache-button/index.js @@ -1 +1 @@ -(function(){"use strict";function f(n,e,a,t,r,c,s,u){var o=typeof n=="function"?n.options:n;return e&&(o.render=e,o.staticRenderFns=a,o._compiled=!0),{exports:n,options:o}}const l={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:a,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),c=Vue.ref("refresh"),s=Vue.ref("aqua-icon"),u=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function o(){r.value="En cours…",c.value="loader",s.value="orange-icon";const m={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})},i=await(await fetch("/refresh-cache.json",m)).json();i.status==="error"?(console.error(i),r.value="Erreur",c.value="alert",s.value="red-icon"):(console.log(i),r.value="Terminé",c.value="check",s.value="green-icon",setTimeout(()=>{location.href=location.href},1500))}return{__sfc:!0,text:r,icon:c,theme:s,title:u,refreshCache:o}}};var h=function(){var e=this,a=e._self._c,t=e._self._setupProxy;return a("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?a("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},_=[],p=f(l,h,_);const d=p.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":d}})})(); +(function(){"use strict";function _(n,e,u,t,r,s,a,l){var c=typeof n=="function"?n.options:n;return e&&(c.render=e,c.staticRenderFns=u,c._compiled=!0),{exports:n,options:c}}const g={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:u,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),s=Vue.ref("refresh"),a=Vue.ref("aqua-icon"),l=Vue.ref(!1),c=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function T(){l.value=!0,s.value="loader",a.value="orange-icon",e==="projects"?await d():await v()}async function d(){let f=0;const h=10;let i=!0,b=0;r.value="En cours 0%";try{for(;i;){const p={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:"projects",offset:f,limit:h})},o=await(await fetch("/refresh-cache.json",p)).json();if(o.status==="error")throw new Error(o.message);b=o.total,i=o.hasMore,f=o.nextOffset;const m=Math.round(o.processed/o.total*100);r.value=`En cours ${m}%`,console.log(`Batch terminé : ${o.processed}/${o.total} projets (${m}%)`)}r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},2e3)}catch(p){console.error(p),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}async function v(){r.value="En cours…";const f={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})};try{const i=await(await fetch("/refresh-cache.json",f)).json();if(i.status==="error")throw new Error(i.message);console.log(i),r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},1500)}catch(h){console.error(h),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}return{__sfc:!0,text:r,icon:s,theme:a,isProcessing:l,title:c,refreshCache:T,refreshAllProjects:d,refreshSingleProject:v}}};var j=function(){var e=this,u=e._self._c,t=e._self._setupProxy;return u("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?u("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title,disabled:t.isProcessing},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},y=[],w=_(g,j,y);const S=w.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":S}})})(); From dfb8d1038be4aa0722485611d4f8ef5133f14810 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 12:29:49 +0100 Subject: [PATCH 24/25] =?UTF-8?q?Fix=20routing=20vers=20une=20piste=20sp?= =?UTF-8?q?=C3=A9cifique=20avec=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problème : L'URL avec hash (#serumwc_lasertone_empty) n'ouvrait pas la bonne piste/variation mais toujours la première. Cause : Incohérence entre les underscores du hash et les tirets du slug backend. slugify convertit les underscores en tirets, mais les slugs Kirby peuvent varier. Solution : Comparer le hash de 3 façons : 1. Comparaison directe 2. Hash avec underscores → tirets 3. Slug avec tirets → underscores Cela gère tous les cas de figure. Co-Authored-By: Claude Sonnet 4.5 --- .../project/virtual-sample/DynamicView.vue | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index f89552b..8273c73 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -100,8 +100,18 @@ onBeforeMount(() => { if (route?.hash && route.hash.length > 0) { const variations = tracks.value.flatMap((t) => t.variations || []); - initialVariation = - variations.find((v) => v.slug === route.hash.substring(1)) || null; + const hashValue = route.hash.substring(1); + + // Essayer de trouver la variation soit par slug direct, soit en normalisant le hash + initialVariation = variations.find((v) => { + // Comparaison directe + if (v.slug === hashValue) return true; + // Comparaison en convertissant underscores en tirets (slugify par défaut) + if (v.slug === hashValue.replace(/_/g, '-')) return true; + // Comparaison inverse : le slug du backend pourrait avoir des underscores + if (v.slug.replace(/-/g, '_') === hashValue) return true; + return false; + }) || null; } // fallback : première variation du premier track From 6b80e242b87009c2eaeaafffa2b2e21a295f8a0e Mon Sep 17 00:00:00 2001 From: isUnknown Date: Thu, 15 Jan 2026 13:54:36 +0100 Subject: [PATCH 25/25] Fix virtual sample routing and refactor for clarity Virtual sample variations now display correctly when loading from URL hash. Old URLs with underscores are normalized to hyphens on load. URL hash updates automatically when navigating between variations. Refactored both DynamicView and Selector components with explicit function names, removed unnecessary comments, and improved code organization. Co-Authored-By: Claude Sonnet 4.5 --- src/components/Selector.vue | 232 ++++++++++-------- .../project/virtual-sample/DynamicView.vue | 160 +++++++----- 2 files changed, 224 insertions(+), 168 deletions(-) diff --git a/src/components/Selector.vue b/src/components/Selector.vue index 32521cb..c95299d 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +const syncing = ref(false); -// Store const { activeTracks } = storeToRefs(useDialogStore()); -// Utils -function isSame(a, b) { - if (!a || !b) return false; - if (a.slug && b.slug) return a.slug === b.slug; - return a.title === b.title; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); } -function toVariation(v) { - if (!v) return null; - return Array.isArray(v) ? v[v.length - 1] || null : v; +function areVariationsEqual(variationA, variationB) { + if (!variationA || !variationB) return false; + + if (variationA.slug && variationB.slug) { + return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug); + } + + return variationA.title === variationB.title; } -// Initialisation : remplir le 1er select localement ET initialiser le store -onBeforeMount(() => { +function extractVariation(value) { + if (!value) return null; + return Array.isArray(value) ? value[value.length - 1] || null : value; +} + +function convertValueForCompareMode(value, shouldBeArray) { + if (shouldBeArray) { + return value && !Array.isArray(value) ? [value] : value; + } else { + return Array.isArray(value) ? value[0] || null : value; + } +} + +function findMatchingVariationsInStore(storeVariations) { + return storeVariations.filter((storeVar) => + items.some((item) => areVariationsEqual(item, storeVar)) + ); +} + +function syncCurrentValueFromStore(storeVariations) { syncing.value = true; - if (index === 0) { - currentValue.value = items[0] || null; - // si le store est vide, initialiser avec la variation du premier sélecteur - if (!activeTracks.value || activeTracks.value.length === 0) { - const v = toVariation(items[0]); - if (v) activeTracks.value = [v]; - } + const matchedVariations = findMatchingVariationsInStore(storeVariations); + + if (isCompareModeEnabled) { + currentValue.value = matchedVariations.length ? [...matchedVariations] : []; } else { - // les autres ne forcent pas le store ; leur currentValue restera à null - currentValue.value = null; + currentValue.value = matchedVariations[0] || null; } nextTick(() => (syncing.value = false)); -}); +} + +function detectVariationChanges(newValues, oldValues) { + const newList = Array.isArray(newValues) + ? newValues + : newValues + ? [newValues] + : []; + const oldList = Array.isArray(oldValues) + ? oldValues + : oldValues + ? [oldValues] + : []; + + const addedVariation = newList.find( + (n) => !oldList.some((o) => areVariationsEqual(o, n)) + ); + const removedVariation = oldList.find( + (o) => !newList.some((n) => areVariationsEqual(n, o)) + ); + + return { addedVariation, removedVariation }; +} + +function handleVariationChange(newValue, oldValue) { + if (syncing.value) return; + + const { addedVariation, removedVariation } = detectVariationChanges( + newValue, + oldValue + ); + + if ( + addedVariation && + items.some((item) => areVariationsEqual(item, addedVariation)) + ) { + updateActiveTracks(addedVariation, 'add'); + } else if ( + removedVariation && + items.some((item) => areVariationsEqual(item, removedVariation)) + ) { + updateActiveTracks(removedVariation, 'remove'); + } +} -// Quand on bascule compare mode (objet <-> tableau) watch( () => isCompareModeEnabled, - (flag) => { + (shouldBeArray) => { syncing.value = true; - if (flag) { - if (currentValue.value && !Array.isArray(currentValue.value)) { - currentValue.value = [currentValue.value]; - } - } else { - if (Array.isArray(currentValue.value)) { - currentValue.value = currentValue.value[0] || null; - } - } + currentValue.value = convertValueForCompareMode( + currentValue.value, + shouldBeArray + ); nextTick(() => (syncing.value = false)); } ); -// Détection ajout / suppression dans le MultiSelect (côté composant) -// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` -watch( - currentValue, - (newVal, oldVal) => { - if (syncing.value) return; +watch(currentValue, handleVariationChange, { deep: true }); - const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : []; - const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : []; - - const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n))); - const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o))); - - if (added && items.some((it) => isSame(it, added))) { - selectTrack(added, 'add'); - } else if (removed && items.some((it) => isSame(it, removed))) { - selectTrack(removed, 'remove'); - } - }, - { deep: true } -); - -// Quand activeTracks change elsewhere -> synchroniser l'affichage local -// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`) watch( activeTracks, - (newVal) => { - syncing.value = true; - - const storeList = Array.isArray(newVal) ? newVal : []; - // ne garder que les variations du store qui sont dans `items` - const matched = storeList.filter((av) => - items.some((it) => isSame(it, av)) - ); - - if (isCompareModeEnabled) { - currentValue.value = matched.length ? [...matched] : []; - } else { - currentValue.value = matched[0] || null; - } - - nextTick(() => (syncing.value = false)); + (storeVariations) => { + const variationsList = Array.isArray(storeVariations) + ? storeVariations + : []; + syncCurrentValueFromStore(variationsList); }, - { deep: true } + { deep: true, immediate: true } ); -// Logique centrale de sélection (ajout / suppression) -// Règles : -// - mode normal -> activeTracks = [variation] -// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première -function selectTrack(track, action = 'add') { - const variation = toVariation(track); +function removeVariationFromActiveTracks(variation) { + activeTracks.value = activeTracks.value.filter( + (track) => !areVariationsEqual(track, variation) + ); +} + +function addVariationToActiveTracks(variation) { + const isAlreadyPresent = activeTracks.value.some((track) => + areVariationsEqual(track, variation) + ); + + if (isAlreadyPresent) return; + + if (activeTracks.value.length === 0) { + activeTracks.value = [variation]; + } else if (activeTracks.value.length === 1) { + activeTracks.value = [activeTracks.value[0], variation]; + } else { + activeTracks.value = [activeTracks.value[0], variation]; + } +} + +function updateActiveTracks(track, action = 'add') { + const variation = extractVariation(track); if (!variation) return; if (!isCompareModeEnabled) { @@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') { } if (action === 'remove') { - const wasFirst = - activeTracks.value.length && isSame(activeTracks.value[0], variation); - activeTracks.value = activeTracks.value.filter( - (t) => !isSame(t, variation) - ); - - // si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0 - // pas d'action supplémentaire nécessaire ici (déjà assuré par le filter) - return; - } - - // action === 'add' - if (activeTracks.value.some((t) => isSame(t, variation))) { - // déjà présent -> ignore - return; - } - - if (activeTracks.value.length === 0) { - activeTracks.value = [variation]; - } else if (activeTracks.value.length === 1) { - activeTracks.value = [activeTracks.value[0], variation]; + removeVariationFromActiveTracks(variation); } else { - // remplacer le 2e - activeTracks.value = [activeTracks.value[0], variation]; + addVariationToActiveTracks(variation); } } -// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -231,8 +246,8 @@ function getFrontViewUrl(item) { } function setImage() { - return getFrontViewUrl(currentValue.value) - ? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')' + return getFrontViewUrl(currentValue.value) + ? "--image: url('" + getFrontViewUrl(currentValue.value) + "')" : undefined; } @@ -250,7 +265,8 @@ function setImage() { padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16); } .selector-dropdown.has-image, -.selector-dropdown.has-image :is(#selector-select, #selector-multiselect, [role='combobox']) { +.selector-dropdown.has-image + :is(#selector-select, #selector-multiselect, [role='combobox']) { padding-left: var(--space-64); } .selector-dropdown.has-image:before { @@ -290,7 +306,9 @@ function setImage() { cursor: pointer; } [role='combobox'] p, -.selector-dropdown [data-pc-section="labelcontainer"] > [data-pc-section='label'] { +.selector-dropdown + [data-pc-section='labelcontainer'] + > [data-pc-section='label'] { max-height: 1lh; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index 8273c73..60bcd8e 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import Interactive360 from './Interactive360.vue'; import SingleImage from './SingleImage.vue'; import Selector from '../../Selector.vue'; import slugify from 'slugify'; const route = useRoute(); +const router = useRouter(); const { page } = storeToRefs(usePageStore()); const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } = @@ -92,51 +93,74 @@ const tracks = computed(() => { return list; }); -// ---------- INITIALISATION ---------- -// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) -onBeforeMount(() => { - // essayer la hash en priorité - let initialVariation = null; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); +} +function getVariationSlug(variation) { + return variation.slug || (variation.title ? slugify(variation.title) : null); +} + +function findVariationByHash(hashValue) { + const allVariations = tracks.value.flatMap((track) => track.variations || []); + const normalizedHash = normalizeSlug(hashValue); + + return allVariations.find((variation) => { + const variationSlug = getVariationSlug(variation); + if (!variationSlug) return false; + + const normalizedVariationSlug = normalizeSlug(variationSlug); + return normalizedVariationSlug === normalizedHash; + }); +} + +function getInitialVariation() { if (route?.hash && route.hash.length > 0) { - const variations = tracks.value.flatMap((t) => t.variations || []); const hashValue = route.hash.substring(1); - - // Essayer de trouver la variation soit par slug direct, soit en normalisant le hash - initialVariation = variations.find((v) => { - // Comparaison directe - if (v.slug === hashValue) return true; - // Comparaison en convertissant underscores en tirets (slugify par défaut) - if (v.slug === hashValue.replace(/_/g, '-')) return true; - // Comparaison inverse : le slug du backend pourrait avoir des underscores - if (v.slug.replace(/-/g, '_') === hashValue) return true; - return false; - }) || null; + const variationFromHash = findVariationByHash(hashValue); + if (variationFromHash) return variationFromHash; } - // fallback : première variation du premier track - if (!initialVariation) { - initialVariation = tracks.value[0]?.variations?.[0] || null; - } + return tracks.value[0]?.variations?.[0] || null; +} - if (initialVariation) { - activeTracks.value = [initialVariation]; - } else { - activeTracks.value = []; // aucun contenu disponible - } -}); +function initializeActiveTracks() { + const initialVariation = getInitialVariation(); + activeTracks.value = initialVariation ? [initialVariation] : []; +} -// scroll si hash présent -onMounted(() => { - if (route.query?.comments) isCommentsOpen.value = true; +function normalizeUrlHash() { + if (route?.hash && route.hash.includes('_')) { + const normalizedHash = normalizeSlug(route.hash); + router.replace({ ...route, hash: normalizedHash }); + } +} + +function openCommentsIfRequested() { + if (route.query?.comments) { + isCommentsOpen.value = true; + } +} + +function scrollToHashTarget() { if (!route?.hash || route.hash.length === 0) return; - const selector = route.hash.replace('#', '#track--'); - const targetBtn = document.querySelector(selector); - if (targetBtn) targetBtn.scrollIntoView(); + const selectorId = route.hash.replace('#', '#track--'); + const targetButton = document.querySelector(selectorId); + if (targetButton) { + targetButton.scrollIntoView(); + } +} + +onBeforeMount(() => { + initializeActiveTracks(); }); -// ---------- COMPUTED / WATCH ---------- +onMounted(() => { + openCommentsIfRequested(); + normalizeUrlHash(); + scrollToHashTarget(); +}); const isSingleImage = computed(() => { return ( @@ -149,38 +173,52 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -watch( - singleFile, - (newValue) => { - if (newValue) openedFile.value = newValue; - }, - { immediate: true } -); - -// gestion du mode comparaison : fermer les commentaires, etc. -watch(isCompareModeEnabled, (newValue) => { - if (newValue) { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; - } else { - isCommentPanelEnabled.value = true; +function updateOpenedFile(file) { + if (file) { + openedFile.value = file; } +} - // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire - if (!newValue && activeTracks.value.length === 2) { +function enableCompareModeUI() { + isCommentsOpen.value = false; + isCommentPanelEnabled.value = false; +} + +function disableCompareModeUI() { + isCommentPanelEnabled.value = true; + + if (activeTracks.value.length === 2) { activeTracks.value.pop(); } +} + +function updateUrlHash(firstTrack) { + const trackSlug = getVariationSlug(firstTrack); + if (!trackSlug) return; + + const currentHash = route.hash ? route.hash.substring(1) : ''; + const normalizedTrackSlug = normalizeSlug(trackSlug); + + if (currentHash !== normalizedTrackSlug) { + router.replace({ ...route, hash: '#' + normalizedTrackSlug }); + } +} + +watch(singleFile, updateOpenedFile, { immediate: true }); + +watch(isCompareModeEnabled, (isEnabled) => { + isEnabled ? enableCompareModeUI() : disableCompareModeUI(); }); -// ---------- UTIL / helper ---------- -function getCommentsCount(track) { - if (!track || !Array.isArray(track.files)) return undefined; - let count = 0; - for (const file of track.files) { - count += file?.comments?.length || 0; - } - return count > 0 ? count : undefined; -} +watch( + activeTracks, + (tracks) => { + if (tracks && tracks.length > 0) { + updateUrlHash(tracks[0]); + } + }, + { deep: true } +);