Compare commits

...
Sign in to create a new pull request.

31 commits

Author SHA1 Message Date
isUnknown
0fb933326f Style : ajustements visuels Account.vue
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 30s
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 12:01:06 +01:00
isUnknown
89c1cb24c0 Fix persistance et format des projets masqués
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 29s
- Route toggle-hidden-project : utilisation collection Pages avec add/remove
  puis toArray() + encode YAML (pattern de toggle-favorite)
- Controller site.php : ->values() au lieu de ->data pour retourner
  un vrai tableau au lieu d'un objet

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 11:48:53 +01:00
isUnknown
30b7697c64 Ajout masquage de projets pour utilisateurs Pochet
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 28s
Backend :
- Blueprint pochet : champ hiddenProjects
- Route API toggle-hidden-project.php
- Controller site.php : retourne hiddenProjects + uuid dans projects

Frontend :
- Store user : hiddenProjects, visibleProjects, toggleHiddenProject()
- Store projects : filtrage automatique des projets masqués
- Store api : fonction toggleHiddenProject()
- Account.vue : section projets avec cartes horizontales + boutons toggle
  - Affichage pour Pochet (avec toggle) et Client (sans toggle)
  - Section client masquée pour Pochet

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 11:27:27 +01:00
isUnknown
7ca72c6d82 Extended-brief supporte maintenant images + PDF comme client-brief
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 28s
- Blueprint : ajout champs moodboard et description
- Kanban : utilise ClientBrief au lieu de SimpleDocument
- Validation : support extended-brief depuis PDF et page images
- Navigation : paths dynamiques basés sur step.slug

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 09:13:31 +01:00
isUnknown
2b5175900c Fix ordre des pistes dans virtual sample
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 30s
Respect de l'ordre des groupes défini dans le champ groups du panel.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 08:29:25 +01:00
isUnknown
f9be7fa025 fix CI
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 30s
2026-01-30 08:10:23 +01:00
isUnknown
78ac0940d0 config : enable panel install on production
Some checks failed
Deploy Preprod / Build and Deploy to Preprod (push) Failing after 28s
2026-01-30 08:02:50 +01:00
isUnknown
f994b1c982 Fix Forgejo CI workflows and add .gitkeep files
- Fix rsync exclusions to properly preserve accounts, cache, and sessions
- Remove tiles exclusion (not used in this project)
- Add .gitkeep files to track empty directories structure
- Update .gitignore to allow .gitkeep files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 15:39:50 +01:00
isUnknown
5f214629ef CI : update
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 31s
2026-01-15 15:10:50 +01:00
isUnknown
2186e33b29 fix forgejo ci
All checks were successful
Deploy Preprod / Build and Deploy to Preprod (push) Successful in 31s
2026-01-15 14:59:54 +01:00
isUnknown
c2a5bd7a85 Migrate to Forgejo
Some checks failed
Deploy Preprod / Build and Deploy to Preprod (push) Failing after 1m43s
2026-01-15 14:55:27 +01:00
isUnknown
9ce8135a3b Rename to CLAUDE.md and add code preferences section
Renamed from CLAUDE_PROJECT_OVERVIEW.md to follow standard naming convention
for automatic recognition. Added section documenting code standards and
work preferences.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 14:10:20 +01:00
isUnknown
6b80e242b8 Fix virtual sample routing and refactor for clarity
Virtual sample variations now display correctly when loading from URL hash.
Old URLs with underscores are normalized to hyphens on load. URL hash
updates automatically when navigating between variations.

Refactored both DynamicView and Selector components with explicit function
names, removed unnecessary comments, and improved code organization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 13:54:36 +01:00
isUnknown
dfb8d1038b Fix routing vers une piste spécifique avec hash
Problème : L'URL avec hash (#serumwc_lasertone_empty) n'ouvrait pas la bonne
piste/variation mais toujours la première.

Cause : Incohérence entre les underscores du hash et les tirets du slug backend.
slugify convertit les underscores en tirets, mais les slugs Kirby peuvent
varier.

Solution : Comparer le hash de 3 façons :
1. Comparaison directe
2. Hash avec underscores → tirets
3. Slug avec tirets → underscores

Cela gère tous les cas de figure.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:29:49 +01:00
isUnknown
95a8bf99cb build plugin refresh cache 2026-01-15 12:19:35 +01:00
isUnknown
378af9ac96 Fix : affichage progression dans le texte du bouton
La div en dessous ne s'affichait pas dans le panel Kirby.
La progression s'affiche maintenant directement dans le bouton :
"En cours 0%" → "En cours 20%" → "En cours 100%" → "Terminé"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:18:33 +01:00
isUnknown
4669f03f16 Amélioration affichage progression du refresh cache
Ajout d'une ligne de texte sous le bouton pour afficher la progression :
- "Traitement : 10/50 projets (20%)" pendant le traitement
- "50 projets mis à jour avec succès" à la fin
- Tooltip aussi mis à jour avec la progression

Le bouton affiche "En cours…" et la progression détaillée est en dessous.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:13:26 +01:00
isUnknown
a57b0c203a Optimisation du refresh cache avec batch processing
Problème : Le refresh cache de tous les projets timeout côté serveur à cause
du trop grand nombre de projets à traiter en une seule requête.

Solution : Batch processing avec indicateur de progression
- Backend : traite 10 projets par batch avec offset/limit
- Frontend : fait plusieurs requêtes successives et affiche la progression
- Timeout réduit à 60s par batch au lieu de illimité
- Bouton désactivé pendant le traitement
- Ajout invalidateNotificationsCache() pour vider aussi ce cache

Affichage : "15/50 (30%)" pendant le traitement, puis "Terminé (50)"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:08:13 +01:00
isUnknown
86db1f5a0c Fix collectLight() : inclure author, text, location pour l'affichage
Problème : collectLight() ne retournait que id/type/isRead/date, causant
notification.author undefined dans le frontend.

Solution : Inclure tous les champs nécessaires à l'affichage (author, text,
location) mais toujours alléger en excluant les gros détails inutiles.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:55:17 +01:00
isUnknown
2791bc4462 Ajout invalidation cache notifications dans hook file-update
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:42:40 +01:00
isUnknown
bb71da081b Ajout du système de cache pour les notifications
Problème : Les notifications étaient collectées à chaque requête sur
projects.json, causant des problèmes de performance et de mémoire.

Solution : Mise en cache des notifications par projet et par utilisateur
- Nouvelle méthode getNotificationsLight() dans ProjectPage avec cache
- Cache invalidé automatiquement via les hooks existants (page/file update)
- Cache par utilisateur pour inclure le isRead spécifique

Performance : Les notifications sont calculées une fois puis servies depuis
le cache jusqu'à ce qu'un changement survienne (commentaire, brief, etc.)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:42:20 +01:00
isUnknown
e73e25b1da Ajout .user.ini : augmentation limite mémoire PHP à 512M
Temporaire pour gérer le chargement des notifications de tous les projets.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:19:14 +01:00
isUnknown
0a980603a4 Ajout de collectLight() pour optimiser le chargement du listing des projets
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 <noreply@anthropic.com>
2026-01-15 11:18:59 +01:00
isUnknown
0250dc1487 Fix : problème de mémoire lors du chargement des projets
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 <noreply@anthropic.com>
2026-01-15 11:16:17 +01:00
isUnknown
f614884da0 update project overview 2026-01-15 11:03:50 +01:00
isUnknown
9d12ccb209 Fix : ne compter que les commentaires des images, pas ceux du PDF
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 <noreply@anthropic.com>
2026-01-15 10:58:41 +01:00
isUnknown
cfd679bc15 Suppression des composants obsolètes Intro et ModeSelection
Ces composants faisaient partie de l'ancien système de steps du Brief
qui a été supprimé. Ils ne sont plus utilisés.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:53:50 +01:00
isUnknown
04d8da39fd Simplification : Brief.vue affiche toujours Images directement
Suppression du système de steps obsolète (Intro → ModeSelection → Images).
/client-brief affiche maintenant toujours le composant Images, sans conditions
ni paramètres d'URL (?step=images).

Les briefs avec PDF sont gérés via les dialogues uniquement.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:52:12 +01:00
isUnknown
6ff59e9b07 Fix : URL correcte pour notifications de brief validé depuis PDF + redirect briefs vides
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 <noreply@anthropic.com>
2026-01-15 10:44:30 +01:00
isUnknown
a7d315942a Refonte du système de notifications : passage aux notifications dérivées
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 <noreply@anthropic.com>
2026-01-15 10:31:31 +01:00
isUnknown
c68b51f639 git : ignore claude settings 2026-01-14 14:55:12 +01:00
55 changed files with 2698 additions and 513 deletions

View file

@ -0,0 +1,64 @@
name: Deploy Preprod
on:
push:
branches:
- preprod
jobs:
build-and-deploy:
name: Build and Deploy to Preprod
runs-on: docker
container:
image: forgejo-ci-node:latest
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch preprod https://forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Install npm dependencies
run: npm install
- name: Build frontend (preprod)
run: npm run build:preprod
- name: Install composer dependencies
run: |
cd dist
composer install --no-dev --optimize-autoloader
- name: Deploy via rsync
env:
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
HOST: ${{ secrets.HOST }}
PREPROD_PATH: ${{ secrets.PREPROD_PATH }}
run: |
cd dist
echo "Deploying site/"
sshpass -p "$PASSWORD" rsync -az --delete -O --no-perms --no-owner --no-group \
--include 'accounts/' \
--exclude 'accounts/*' \
--include 'sessions/' \
--exclude 'sessions/*' \
--include 'cache/' \
--exclude 'cache/*' \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
site/ $USERNAME@$HOST:$PREPROD_PATH/site/
echo "Deploying vendor/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
vendor/ $USERNAME@$HOST:$PREPROD_PATH/vendor/
echo "Deploying kirby/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
kirby/ $USERNAME@$HOST:$PREPROD_PATH/kirby/
echo "Deploying assets/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
assets/ $USERNAME@$HOST:$PREPROD_PATH/assets/

View file

@ -0,0 +1,64 @@
name: Deploy Production
on:
push:
branches:
- main
jobs:
build-and-deploy:
name: Build and Deploy to Production
runs-on: docker
container:
image: forgejo-ci-node:latest
steps:
- name: Checkout code
run: |
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
ls -la
- name: Install npm dependencies
run: npm install
- name: Build frontend (production)
run: npm run build
- name: Install composer dependencies
run: |
cd dist
composer install --no-dev --optimize-autoloader
- name: Deploy via rsync
env:
USERNAME: ${{ secrets.USERNAME }}
PASSWORD: ${{ secrets.PASSWORD }}
HOST: ${{ secrets.HOST }}
PROD_PATH: ${{ secrets.PROD_PATH }}
run: |
cd dist
echo "Deploying site/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
--exclude 'accounts/*' \
--exclude 'cache/*' \
--exclude 'sessions/*' \
--include 'accounts/' \
--include 'cache/' \
--include 'sessions/' \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
site/ $USERNAME@$HOST:$PROD_PATH/site/
echo "Deploying vendor/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
vendor/ $USERNAME@$HOST:$PROD_PATH/vendor/
echo "Deploying kirby/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
kirby/ $USERNAME@$HOST:$PROD_PATH/kirby/
echo "Deploying assets/"
sshpass -p "$PASSWORD" rsync -az --delete -O \
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
assets/ $USERNAME@$HOST:$PROD_PATH/assets/

10
.gitignore vendored
View file

@ -62,18 +62,21 @@ public/.idea
public/site/cache/* public/site/cache/*
!public/site/cache/index.html !public/site/cache/index.html
!public/site/cache/.gitkeep
# Accounts # Accounts
# --------------- # ---------------
public/site/accounts/* public/site/accounts/*
!public/site/accounts/index.html !public/site/accounts/index.html
!public/site/accounts/.gitkeep
# Sessions # Sessions
# --------------- # ---------------
public/site/sessions/* public/site/sessions/*
!public/site/sessions/index.html !public/site/sessions/index.html
!public/site/sessions/.gitkeep
# License # License
# --------------- # ---------------
@ -91,3 +94,10 @@ public/vendor
# Content # Content
# --------------- # ---------------
/public/content /public/content
# Claude settings
# ---------------
.claude
/.claude/*

344
CLAUDE.md Normal file
View file

@ -0,0 +1,344 @@
# 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.
---
## Préférences de code et standards
### Principes généraux
**Clarté avant tout**
- Privilégier des noms de variables et fonctions explicites
- Éviter les commentaires sauf en cas de nécessité absolue
- Le code doit être auto-documenté par des noms clairs
**Organisation du code**
- Factoriser le code en petites fonctions bien nommées
- Éviter les blocs de code longs et complexes dans les hooks de lifecycle
- Une fonction = une responsabilité claire
**Ce qui est considéré comme "bricolage" (à éviter)**
- Paramètres d'URL pour gérer des états applicatifs
- Commentaires qui expliquent ce que fait le code au lieu de nommer correctement
- Abstractions prématurées ou sur-ingénierie
### Style de communication
**Feedback direct**
- Pas de politesses inutiles
- Dire immédiatement si quelque chose ne va pas
- Tester tout de suite et signaler les problèmes
**Qualité attendue**
- Standards élevés sur la qualité du code
- Pas de code médiocre accepté
- Exigence de clarté et d'explicité constante

2
public/.user.ini Normal file
View file

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

View file

View file

@ -24,6 +24,18 @@ tabs:
type: hidden type: hidden
isValidated: isValidated:
type: hidden type: hidden
validatedBy:
type: hidden
validatedByName:
type: hidden
validatedByEmail:
type: hidden
validatedAt:
type: hidden
validationReadby:
type: hidden
validationDialogUri:
type: hidden
pdf: pdf:
label: PDF label: PDF
type: files type: files

View file

@ -22,9 +22,34 @@ tabs:
fields: fields:
stepName: stepName:
type: hidden 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
validationDialogUri:
type: hidden
pdf: pdf:
label: PDF label: PDF
type: files type: files
multiple: false multiple: false
uploads: pdf uploads: pdf
description:
type: textarea
size: tiny
buttons: false
moodboard:
label: Images
type: files
template: image
layout: cards
size: medium
files: tabs/files files: tabs/files

View file

@ -21,6 +21,7 @@ tabs:
fields: fields:
lastCacheUpdate: lastCacheUpdate:
type: hidden type: hidden
# Champs pour project-request
isClientRequest: isClientRequest:
type: hidden type: hidden
default: "false" default: "false"
@ -30,6 +31,32 @@ tabs:
disabled: true disabled: true
when: when:
isClientRequest: "true" 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: currentStep:
label: Étape en cours label: Étape en cours
type: radio type: radio

View file

@ -19,3 +19,8 @@ fields:
type: pages type: pages
query: page('projects').children query: page('projects').children
width: 3/4 width: 3/4
hiddenProjects:
label: Projets masqués
type: pages
query: page('projects').children
width: 3/4

0
public/site/cache/.gitkeep vendored Normal file
View file

View file

@ -25,11 +25,13 @@ return [
'css' => 'assets/css/panel.css', 'css' => 'assets/css/panel.css',
'favicon' => 'favicon.svg', 'favicon' => 'favicon.svg',
'menu' => require(__DIR__ . '/menu.php'), 'menu' => require(__DIR__ . '/menu.php'),
'install' => 'true'
], ],
'routes' => [ 'routes' => [
require(__DIR__ . '/routes/logout.php'), require(__DIR__ . '/routes/logout.php'),
require(__DIR__ . '/routes/login.php'), require(__DIR__ . '/routes/login.php'),
require(__DIR__ . '/routes/toggle-favorite.php'), require(__DIR__ . '/routes/toggle-favorite.php'),
require(__DIR__ . '/routes/toggle-hidden-project.php'),
require(__DIR__ . '/routes/upload-images.php'), require(__DIR__ . '/routes/upload-images.php'),
require(__DIR__ . '/routes/save-page.php'), require(__DIR__ . '/routes/save-page.php'),
require(__DIR__ . '/routes/save-file.php'), require(__DIR__ . '/routes/save-file.php'),
@ -40,6 +42,7 @@ return [
require(__DIR__ . '/routes/validate-brief.php'), require(__DIR__ . '/routes/validate-brief.php'),
require(__DIR__ . '/routes/request-project-creation.php'), require(__DIR__ . '/routes/request-project-creation.php'),
require(__DIR__ . '/routes/request-optimization-appointment.php'), require(__DIR__ . '/routes/request-optimization-appointment.php'),
require(__DIR__ . '/routes/migrate-notifications.php'),
], ],
'hooks' => [ 'hooks' => [
'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'), 'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'),

View file

@ -4,6 +4,8 @@
return function ($newFile, $oldFile) { return function ($newFile, $oldFile) {
$project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project');
if ($project) { if ($project) {
$steps = $project->rebuildStepsCache(); $project->rebuildStepsCache();
// Invalider aussi le cache des notifications (commentaires sur fichiers, etc.)
$project->invalidateNotificationsCache();
} }
}; };

View file

@ -4,6 +4,8 @@
return function($newPage, $oldPage) { return function($newPage, $oldPage) {
$project = $newPage->template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); $project = $newPage->template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project');
if ($project) { if ($project) {
$steps = $project->rebuildStepsCache(); $project->rebuildStepsCache();
// Invalider aussi le cache des notifications (briefs validés, etc.)
$project->invalidateNotificationsCache();
} }
}; };

View file

@ -0,0 +1,175 @@
<?php
/**
* Script de migration unique pour le système de notifications dérivées.
*
* Ce script copie les `readby[]` des anciennes notifications vers les sources de données.
* À exécuter une seule fois après le déploiement, puis à supprimer.
*
* Usage: POST /migrate-notifications.json
*/
return [
'pattern' => '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
];
}
];

View file

@ -10,34 +10,23 @@ return [
$user = kirby()->user(); $user = kirby()->user();
$project = page($data->projectUri); $project = page($data->projectUri);
try {
$newProject = $project->update([
"hasOptimizationRequest" => "true",
"optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details)
]);
} 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(); $date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601); $formattedDate = $date->format(DateTime::ISO8601);
$notificationData = [ try {
"location" => [ $project->update([
"page" => $newProject "hasOptimizationRequest" => "true",
], "optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details),
"date" => (string) $formattedDate, // Métadonnées pour le système de notifications dérivées
"text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)), "optimizationAuthor" => (string) $user->uuid(),
"author" => $user, "optimizationAuthorName" => (string) $user->name(),
"id" => Str::uuid(), "optimizationAuthorEmail" => (string) $user->email(),
"type" => "appointment-request", "optimizationDate" => $formattedDate,
]; "optimizationReadby" => [],
]);
$newProject->createNotification($notificationData); // Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [ return [
"status" => "success", "status" => "success",
@ -45,7 +34,7 @@ return [
} catch (\Throwable $th) { } catch (\Throwable $th) {
return [ return [
"status" => "error", "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()
]; ];
} }
} }

View file

@ -11,15 +11,24 @@ return [
$client = kirby()->user()->client()->toPage()->uuid(); $client = kirby()->user()->client()->toPage()->uuid();
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$projectData = [ $projectData = [
"slug" => esc(Str::slug($data->title)), "slug" => esc(Str::slug($data->title)),
"template" => "project", "template" => "project",
"content" => [ "content" => [
"title" => esc($data->title), "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], "client" => [$client],
"isClientRequest" => "true", "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 { try {
$newProject = $projects->createChild($projectData); $newProject = $projects->createChild($projectData);
$date = new DateTime(); // Note: Les notifications sont maintenant dérivées.
$formattedDate = $date->format(DateTime::ISO8601); // Plus besoin d'appeler createNotification().
$notificationData = [
"location" => [
"page" => $newProject
],
"date" => (string) $formattedDate,
"text" => nl2br(esc($data->details)),
"author" => $user,
"id" => Str::uuid(),
"type" => "project-request",
];
$newProject->createNotification($notificationData);
return [ return [
"status" => "success", "status" => "success",

View file

@ -0,0 +1,60 @@
<?php
return [
'pattern' => '/toggle-hidden-project.json',
'method' => 'POST',
'action' => function() {
$json = file_get_contents("php://input");
$data = json_decode($json);
try {
$user = kirby()->user();
$projectUuid = $data->projectUuid;
// Récupérer la collection des projets masqués actuels
$hiddenProjects = $user->hiddenProjects()->toPages();
// Trouver le projet à toggle
$projectPage = Find::page($projectUuid);
if (!$projectPage) {
throw new Exception('Projet introuvable');
}
// Toggle: ajouter ou retirer le projet de la collection
if ($hiddenProjects->has($projectPage)) {
$hiddenProjects->remove($projectPage);
$action = 'shown';
} else {
$hiddenProjects->add($projectPage);
$action = 'hidden';
}
// Convertir la collection en array puis en YAML
$array = $hiddenProjects->toArray();
$yaml = Data::encode($array, 'yaml');
// Mettre à jour l'utilisateur
$user->update([
'hiddenProjects' => $yaml
]);
// Retourner les UUIDs pour le frontend
$hiddenProjectsUuids = [];
foreach ($hiddenProjects as $project) {
$hiddenProjectsUuids[] = $project->uuid();
}
return [
'status' => 'success',
'action' => $action,
'hiddenProjects' => $hiddenProjectsUuids
];
} catch (\Throwable $th) {
return [
'status' => 'error',
'message' => 'Impossible de modifier les projets masqués : ' . $th->getMessage() . ' in file ' . $th->getFile() . ' line ' . $th->getLine()
];
}
}
];

View file

@ -9,27 +9,31 @@ return [
$page = page($data->briefUri); $page = page($data->briefUri);
$project = $page->parent(); $project = $page->parent();
$user = kirby()->user();
try { try {
$newPage = $page->update([
'isValidated' => 'true'
]);
$timezone = new DateTimeZone('Europe/Paris'); $timezone = new DateTimeZone('Europe/Paris');
$dateTime = new DateTime('now', $timezone); $dateTime = new DateTime('now', $timezone);
$notification = [ $updateData = [
'location' => [ 'isValidated' => 'true',
'page' => $page, // Métadonnées pour le système de notifications dérivées
], 'validatedBy' => (string) $user->uuid(),
'date' => $dateTime->format('Y-m-d\TH:i:sP'), 'validatedByName' => (string) $user->name(),
'text' => "Nouveau brief", 'validatedByEmail' => (string) $user->email(),
'author' => kirby()->user(), 'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'),
'id' => Str::uuid(), 'validationReadby' => [],
'type' => 'content'
]; ];
$project->createNotification($notification); // 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().
return [ return [
"success" => "'" . $project->title()->value() . "' brief validated." "success" => "'" . $project->title()->value() . "' brief validated."

View file

@ -33,9 +33,18 @@ return function ($page, $kirby, $site) {
"title" => (string) $project->title(), "title" => (string) $project->title(),
"uri" => (string) $project->uri(), "uri" => (string) $project->uri(),
"step" => (string) $project->getStepLabel(), "step" => (string) $project->getStepLabel(),
"uuid" => (string) $project->uuid(),
]; ];
})->data; })->data;
} }
if ($kirby->user()->hiddenProjects()->exists() && $kirby->user()->hiddenProjects()->isNotEmpty()) {
$userData['hiddenProjects'] = $kirby->user()->hiddenProjects()->toPages()->map(function ($project) {
return (string) $project->uuid();
})->values();
} else {
$userData['hiddenProjects'] = [];
}
} }

View file

@ -16,6 +16,50 @@ class ProjectPage extends NotificationsPage {
return $stepsData; 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() { public function rebuildStepsCache() {
// Create steps // Create steps
$steps = []; $steps = [];
@ -118,6 +162,30 @@ class ProjectPage extends NotificationsPage {
} }
} }
// Récupérer l'ordre des groupes depuis le champ groups
$orderedGroups = $child->groups()->split();
// Réorganiser $files['dynamic'] selon l'ordre défini
if (!empty($orderedGroups)) {
$orderedDynamic = [];
foreach ($orderedGroups as $group) {
if (isset($files['dynamic'][$group])) {
$orderedDynamic[$group] = $files['dynamic'][$group];
}
}
// Ajouter les groupes non définis dans le champ à la fin
foreach ($files['dynamic'] as $group => $tracks) {
if (!isset($orderedDynamic[$group])) {
$orderedDynamic[$group] = $tracks;
}
}
$files['dynamic'] = $orderedDynamic;
}
// Toujours mettre "Autres pistes" à la fin
if (isset($files['dynamic']['Autres pistes'])) { if (isset($files['dynamic']['Autres pistes'])) {
$others = $files['dynamic']['Autres pistes']; $others = $files['dynamic']['Autres pistes'];
unset($files['dynamic']['Autres pistes']); unset($files['dynamic']['Autres pistes']);

View file

@ -46,6 +46,7 @@ return [
'author' => kirby()->user(), 'author' => kirby()->user(),
'id' => Str::uuid(), 'id' => Str::uuid(),
'type' => 'comment', 'type' => 'comment',
'readby' => [], // Pour le système de notifications dérivées
]; ];
if (isset($data->position->pageIndex)) { if (isset($data->position->pageIndex)) {
@ -62,11 +63,8 @@ return [
echo json_encode(getFileData($newFile)); echo json_encode(getFileData($newFile));
try { // Note: Les notifications sont maintenant dérivées des commentaires.
$project->createNotification($commentData); // Plus besoin d'appeler createNotification().
} catch (\Throwable $th) {
throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
}
exit; exit;
}, },

View file

@ -39,8 +39,8 @@ return [
echo json_encode(getFileData($newFile)); echo json_encode(getFileData($newFile));
$project = $page->parents()->findBy('template', 'project'); // Note: Les notifications sont maintenant dérivées des commentaires.
$project->deleteNotification($data->id); // La suppression du commentaire supprime automatiquement la notification.
exit; exit;
}, },

View file

@ -31,6 +31,7 @@ return [
"author" => kirby()->user(), "author" => kirby()->user(),
"id" => Str::uuid(), "id" => Str::uuid(),
"type" => "comment-reply", "type" => "comment-reply",
"readby" => [], // Pour le système de notifications dérivées
]; ];
$newReply = new Reply($replyData); $newReply = new Reply($replyData);
$comment['replies'][] = $newReply->toArray(); $comment['replies'][] = $newReply->toArray();
@ -41,8 +42,8 @@ return [
'comments' => $comments 'comments' => $comments
]); ]);
$project = $page->parents()->findBy("template", "project"); // Note: Les notifications sont maintenant dérivées des commentaires.
$project->createNotification($replyData); // Plus besoin d'appeler createNotification().
return getFileData($newFile); return getFileData($newFile);
} }

View file

@ -1,27 +1,54 @@
<?php <?php
load([ use adrienpayet\notifications\NotificationCollector;
"ProjectPage" => "models/ProjectPage.php", use adrienpayet\notifications\providers\CommentProvider;
], __DIR__); 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([ F::loadClasses([
// Own classes // 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\\Notification" => __DIR__ . "/src/Notification.php",
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php", "adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
// Shared classes // Classes partagées
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php", "adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
"adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php", "adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php",
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.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\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php", "adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.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", [ Kirby::plugin("adrienpayet/pdc-notifications", [
"options" => [
"collector" => $collector
],
"routes" => [ "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/readAll.php"),
require(__DIR__ . "/routes/read.php") require(__DIR__ . "/routes/read.php"),
], ],
]); ]);

View file

@ -0,0 +1,42 @@
<?php
/**
* Route pour marquer toutes les notifications comme lues.
* Parcourt tous les projets accessibles à l'utilisateur.
*/
return [
'pattern' => '(: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()
]);
}
}
];

View file

@ -0,0 +1,46 @@
<?php
/**
* Route pour marquer une notification comme lue.
* Délègue au bon provider selon le type de notification.
*/
return [
'pattern' => '(: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()
]);
}
}
];

View file

@ -0,0 +1,181 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Collecteur de notifications qui agrège tous les providers.
*
* Permet de :
* - Enregistrer des providers de notifications
* - Collecter toutes les notifications de tous les providers
* - Déléguer le markAsRead au bon provider
*/
class NotificationCollector
{
/** @var NotificationProvider[] */
private array $providers = [];
/**
* Enregistre un nouveau provider.
*/
public function register(NotificationProvider $provider): void
{
$this->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;
}
/**
* Collecte uniquement les données minimales des notifications (version allégée pour listing).
* Retourne les champs nécessaires à l'affichage mais sans les détails lourds.
*
* @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);
// Garder les champs nécessaires au frontend
foreach ($notifications as $notification) {
$light = [
'id' => $notification['id'] ?? null,
'type' => $notification['type'] ?? null,
'isRead' => $notification['isRead'] ?? false,
'date' => $notification['date'] ?? null,
'text' => $notification['text'] ?? null,
'author' => $notification['author'] ?? null,
'location' => $notification['location'] ?? []
];
// Garder 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());
}
}
// 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);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Interface pour les providers de notifications.
*
* Chaque type de notification (comment, project-request, etc.)
* a son propre provider qui sait :
* - Collecter les notifications depuis la source de données
* - Marquer une notification comme lue sur la source
*/
interface NotificationProvider
{
/**
* Retourne le type de notification géré par ce provider.
* Ex: 'comment', 'comment-reply', 'project-request'
*/
public function getType(): string;
/**
* Collecte toutes les notifications de ce type pour un projet et un utilisateur.
*
* @param Page $project Le projet à scanner
* @param User $user L'utilisateur courant (pour filtrer ses propres actions)
* @return array Liste des notifications au format standard
*/
public function collect(Page $project, User $user): array;
/**
* Marque une notification comme lue.
*
* @param string $id L'identifiant de la notification
* @param array $location Informations de localisation (ex: _file, _projectUri)
* @param User $user L'utilisateur qui marque comme lu
* @return bool True si succès, false sinon
*/
public function markAsRead(string $id, array $location, User $user): bool;
}

View file

@ -0,0 +1,111 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "appointment-request".
* Dérivé depuis les champs du projet quand hasOptimizationRequest est true.
*/
class AppointmentRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'appointment-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si pas de demande d'optimisation
if ($project->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;
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment".
* Collecte les commentaires depuis les fichiers des étapes du projet.
*/
class CommentProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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;
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "content".
* Dérivé depuis les briefs validés (isValidated = true).
*/
class ContentProvider implements NotificationProvider
{
public function getType(): string
{
return 'content';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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';
$notification = [
'id' => 'content-' . (string) $step->uuid(),
'type' => 'content',
'text' => 'Nouveau ' . strtolower($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(),
];
// Ajouter le dialogUri si présent (validation depuis PDF)
if ($step->validationDialogUri()->isNotEmpty()) {
$notification['dialogUri'] = $step->validationDialogUri()->value();
}
$notifications[] = $notification;
}
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;
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "project-request".
* Dérivé depuis les champs du projet quand isClientRequest est true.
*/
class ProjectRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'project-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si ce n'est pas une demande client
if ($project->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;
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment-reply".
* Collecte les réponses aux commentaires depuis les fichiers.
*/
class ReplyProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment-reply';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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;
}
}

View file

@ -1 +1 @@
(function(){"use strict";function f(n,e,a,t,r,c,s,u){var o=typeof n=="function"?n.options:n;return e&&(o.render=e,o.staticRenderFns=a,o._compiled=!0),{exports:n,options:o}}const l={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:a,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),c=Vue.ref("refresh"),s=Vue.ref("aqua-icon"),u=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function o(){r.value="En cours…",c.value="loader",s.value="orange-icon";const m={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})},i=await(await fetch("/refresh-cache.json",m)).json();i.status==="error"?(console.error(i),r.value="Erreur",c.value="alert",s.value="red-icon"):(console.log(i),r.value="Terminé",c.value="check",s.value="green-icon",setTimeout(()=>{location.href=location.href},1500))}return{__sfc:!0,text:r,icon:c,theme:s,title:u,refreshCache:o}}};var h=function(){var e=this,a=e._self._c,t=e._self._setupProxy;return a("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?a("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},_=[],p=f(l,h,_);const d=p.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":d}})})(); (function(){"use strict";function _(n,e,u,t,r,s,a,l){var c=typeof n=="function"?n.options:n;return e&&(c.render=e,c.staticRenderFns=u,c._compiled=!0),{exports:n,options:c}}const g={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:u,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),s=Vue.ref("refresh"),a=Vue.ref("aqua-icon"),l=Vue.ref(!1),c=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function T(){l.value=!0,s.value="loader",a.value="orange-icon",e==="projects"?await d():await v()}async function d(){let f=0;const h=10;let i=!0,b=0;r.value="En cours 0%";try{for(;i;){const p={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:"projects",offset:f,limit:h})},o=await(await fetch("/refresh-cache.json",p)).json();if(o.status==="error")throw new Error(o.message);b=o.total,i=o.hasMore,f=o.nextOffset;const m=Math.round(o.processed/o.total*100);r.value=`En cours ${m}%`,console.log(`Batch terminé : ${o.processed}/${o.total} projets (${m}%)`)}r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},2e3)}catch(p){console.error(p),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}async function v(){r.value="En cours…";const f={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})};try{const i=await(await fetch("/refresh-cache.json",f)).json();if(i.status==="error")throw new Error(i.message);console.log(i),r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},1500)}catch(h){console.error(h),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}return{__sfc:!0,text:r,icon:s,theme:a,isProcessing:l,title:c,refreshCache:T,refreshAllProjects:d,refreshSingleProject:v}}};var j=function(){var e=this,u=e._self._c,t=e._self._setupProxy;return u("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?u("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title,disabled:t.isProcessing},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},y=[],w=_(g,j,y);const S=w.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":S}})})();

View file

@ -7,6 +7,7 @@
:icon="icon" :icon="icon"
:title="title" :title="title"
@click="refreshCache()" @click="refreshCache()"
:disabled="isProcessing"
>{{ text }}</k-button >{{ text }}</k-button
> >
</div> </div>
@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({
const text = ref("Rafraîchir"); const text = ref("Rafraîchir");
const icon = ref("refresh"); const icon = ref("refresh");
const theme = ref("aqua-icon"); const theme = ref("aqua-icon");
const isProcessing = ref(false);
const title = computed(() => { const title = computed(() => {
return lastCacheUpdate?.length > 0 return lastCacheUpdate?.length > 0
? "Dernière mise à jour : " + lastCacheUpdate ? "Dernière mise à jour : " + lastCacheUpdate
@ -31,25 +34,91 @@ const title = computed(() => {
}); });
async function refreshCache() { async function refreshCache() {
text.value = "En cours…"; isProcessing.value = true;
icon.value = "loader"; icon.value = "loader";
theme.value = "orange-icon"; theme.value = "orange-icon";
// Pour les projets multiples (batch processing)
if (pageUri === 'projects') {
await refreshAllProjects();
} else {
await refreshSingleProject();
}
}
async function refreshAllProjects() {
let offset = 0;
const limit = 10; // 10 projets par batch
let hasMore = true;
let total = 0;
text.value = "En cours 0%";
try {
while (hasMore) {
const init = {
method: "POST",
"Content-Type": "application/json",
body: JSON.stringify({
pageUri: 'projects',
offset,
limit
}),
};
const res = await fetch("/refresh-cache.json", init);
const json = await res.json();
if (json.status === "error") {
throw new Error(json.message);
}
total = json.total;
hasMore = json.hasMore;
offset = json.nextOffset;
// Mise à jour de la progression dans le texte du bouton
const progress = Math.round((json.processed / json.total) * 100);
text.value = `En cours ${progress}%`;
console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`);
}
// Succès
text.value = "Terminé";
icon.value = "check";
theme.value = "green-icon";
setTimeout(() => {
location.href = location.href;
}, 2000);
} catch (error) {
console.error(error);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
isProcessing.value = false;
}
}
async function refreshSingleProject() {
text.value = "En cours…";
const init = { const init = {
method: "POST", method: "POST",
"Content-Type": "application/json", "Content-Type": "application/json",
body: JSON.stringify({ pageUri }), body: JSON.stringify({ pageUri }),
}; };
try {
const res = await fetch("/refresh-cache.json", init); const res = await fetch("/refresh-cache.json", init);
const json = await res.json(); const json = await res.json();
if (json.status === "error") { if (json.status === "error") {
console.error(json); throw new Error(json.message);
text.value = "Erreur"; }
icon.value = "alert";
theme.value = "red-icon";
} else {
console.log(json); console.log(json);
text.value = "Terminé"; text.value = "Terminé";
icon.value = "check"; icon.value = "check";
@ -58,6 +127,13 @@ async function refreshCache() {
setTimeout(() => { setTimeout(() => {
location.href = location.href; location.href = location.href;
}, 1500); }, 1500);
} catch (error) {
console.error(error);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
isProcessing.value = false;
} }
} }
</script> </script>

View file

@ -1,5 +1,5 @@
<?php <?php
set_time_limit(0); set_time_limit(60);
return [ return [
'pattern' => '/refresh-cache.json', 'pattern' => '/refresh-cache.json',
@ -10,17 +10,42 @@ return [
if ($data->pageUri === 'projects') { if ($data->pageUri === 'projects') {
$projects = page('projects')->children(); $projects = page('projects')->children();
foreach ($projects as $project) {
// 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->rebuildStepsCache();
$project->invalidateNotificationsCache();
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
$project->update([ $project->update([
'lastCacheUpdate' => $formatter->format(time()) '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 [ return [
'satus' => 'success', 'status' => 'success',
'message' => 'Données des pages projets rafraîchies avec succès.' 'message' => "Batch terminé : $processed projets traités.",
'processed' => $offset + $processed,
'total' => $total,
'remaining' => $remaining,
'hasMore' => $hasMore,
'nextOffset' => $hasMore ? $offset + $limit : null
]; ];
} else { } else {
try { try {
@ -41,7 +66,7 @@ return [
if (!$project) { if (!$project) {
return [ return [
'satus' => 'error', 'status' => 'error',
'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.'
]; ];
} }
@ -55,7 +80,7 @@ return [
return [ return [
'satus' => 'success', 'status' => 'success',
'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.' 'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.'
]; ];
} }

View file

View file

@ -1,5 +1,18 @@
<?php <?php
// Récupérer le collector de notifications pour ce projet individuel
$notificationCollector = $kirby->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 = [ $project = [
'title' => $page->title()->value(), 'title' => $page->title()->value(),
'url' => $page->url(), 'url' => $page->url(),
@ -11,7 +24,7 @@ $project = [
'steps' => $page->getSteps(), 'steps' => $page->getSteps(),
'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null, 'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null,
'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(), 'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(),
'notifications' => $page->notifications()->yaml(), 'notifications' => $notifications,
]; ];
$pageData = array_merge($genericData, $project); $pageData = array_merge($genericData, $project);

View file

@ -7,8 +7,16 @@ if (!$kirby->user()) {
]); ]);
} }
function getProjectData($project) function getProjectData($project, $user)
{ {
// Utiliser getNotificationsLight() avec cache pour optimiser les performances
$notifications = [];
try {
$notifications = $project->getNotificationsLight($user);
} catch (\Throwable $e) {
error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage());
$notifications = [];
}
$data = [ $data = [
'title' => $project->title()->value(), 'title' => $project->title()->value(),
@ -19,7 +27,7 @@ function getProjectData($project)
'status' => $project->status(), 'status' => $project->status(),
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
'steps' => $project->getSteps(), 'steps' => $project->getSteps(),
'notifications' => Yaml::decode($project->notifications()->value), 'notifications' => $notifications,
'uuid' => (string) $project->uuid(), 'uuid' => (string) $project->uuid(),
'slug' => (string) $project->slug(), 'slug' => (string) $project->slug(),
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
@ -33,8 +41,12 @@ function getProjectData($project)
return $data; return $data;
} }
$currentUser = $kirby->user();
try { 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))->values()
: $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values();
} catch (\Throwable $th) { } catch (\Throwable $th) {
throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
$children = []; $children = [];

View file

@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({
// Local state // Local state
const currentValue = ref(null); 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()); const { activeTracks } = storeToRefs(useDialogStore());
// Utils function normalizeSlug(slug) {
function isSame(a, b) { return slug.replace(/_/g, '-');
if (!a || !b) return false;
if (a.slug && b.slug) return a.slug === b.slug;
return a.title === b.title;
} }
function toVariation(v) { function areVariationsEqual(variationA, variationB) {
if (!v) return null; if (!variationA || !variationB) return false;
return Array.isArray(v) ? v[v.length - 1] || null : v;
if (variationA.slug && variationB.slug) {
return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug);
} }
// Initialisation : remplir le 1er select localement ET initialiser le store return variationA.title === variationB.title;
onBeforeMount(() => {
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];
} }
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 { } else {
// les autres ne forcent pas le store ; leur currentValue restera à null return Array.isArray(value) ? value[0] || null : value;
currentValue.value = null; }
} }
nextTick(() => (syncing.value = false)); function findMatchingVariationsInStore(storeVariations) {
}); return storeVariations.filter((storeVar) =>
items.some((item) => areVariationsEqual(item, storeVar))
// Quand on bascule compare mode (objet <-> tableau)
watch(
() => isCompareModeEnabled,
(flag) => {
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;
}
}
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;
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 function syncCurrentValueFromStore(storeVariations) {
// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`)
watch(
activeTracks,
(newVal) => {
syncing.value = true; syncing.value = true;
const storeList = Array.isArray(newVal) ? newVal : []; const matchedVariations = findMatchingVariationsInStore(storeVariations);
// ne garder que les variations du store qui sont dans `items`
const matched = storeList.filter((av) =>
items.some((it) => isSame(it, av))
);
if (isCompareModeEnabled) { if (isCompareModeEnabled) {
currentValue.value = matched.length ? [...matched] : []; currentValue.value = matchedVariations.length ? [...matchedVariations] : [];
} else { } else {
currentValue.value = matched[0] || null; currentValue.value = matchedVariations[0] || null;
} }
nextTick(() => (syncing.value = false)); nextTick(() => (syncing.value = false));
}, }
{ deep: true }
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))
); );
// Logique centrale de sélection (ajout / suppression) return { addedVariation, removedVariation };
// 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 handleVariationChange(newValue, oldValue) {
function selectTrack(track, action = 'add') { if (syncing.value) return;
const variation = toVariation(track);
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');
}
}
watch(
() => isCompareModeEnabled,
(shouldBeArray) => {
syncing.value = true;
currentValue.value = convertValueForCompareMode(
currentValue.value,
shouldBeArray
);
nextTick(() => (syncing.value = false));
}
);
watch(currentValue, handleVariationChange, { deep: true });
watch(
activeTracks,
(storeVariations) => {
const variationsList = Array.isArray(storeVariations)
? storeVariations
: [];
syncCurrentValueFromStore(variationsList);
},
{ deep: true, immediate: true }
);
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 (!variation) return;
if (!isCompareModeEnabled) { if (!isCompareModeEnabled) {
@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') {
} }
if (action === 'remove') { if (action === 'remove') {
const wasFirst = removeVariationFromActiveTracks(variation);
activeTracks.value.length && isSame(activeTracks.value[0], variation);
activeTracks.value = activeTracks.value.filter(
(t) => !isSame(t, variation)
);
// si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0
// pas d'action supplémentaire nécessaire ici (déjà assuré par le filter)
return;
}
// action === 'add'
if (activeTracks.value.some((t) => isSame(t, variation))) {
// déjà présent -> ignore
return;
}
if (activeTracks.value.length === 0) {
activeTracks.value = [variation];
} else if (activeTracks.value.length === 1) {
activeTracks.value = [activeTracks.value[0], variation];
} else { } else {
// remplacer le 2e addVariationToActiveTracks(variation);
activeTracks.value = [activeTracks.value[0], variation];
} }
} }
// Helpers pour affichage (inchangés)
function getFrontViewUrl(item) { function getFrontViewUrl(item) {
if (!item) return ''; if (!item) return '';
if (Array.isArray(item)) { if (Array.isArray(item)) {
@ -232,7 +247,7 @@ function getFrontViewUrl(item) {
function setImage() { function setImage() {
return getFrontViewUrl(currentValue.value) return getFrontViewUrl(currentValue.value)
? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')' ? "--image: url('" + getFrontViewUrl(currentValue.value) + "')"
: undefined; : undefined;
} }
</script> </script>
@ -250,7 +265,8 @@ function setImage() {
padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16); padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16);
} }
.selector-dropdown.has-image, .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); padding-left: var(--space-64);
} }
.selector-dropdown.has-image:before { .selector-dropdown.has-image:before {
@ -290,7 +306,9 @@ function setImage() {
cursor: pointer; cursor: pointer;
} }
[role='combobox'] p, [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; max-height: 1lh;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -37,7 +37,7 @@ const { step } = defineProps({
const cardsMap = { const cardsMap = {
clientBrief: ClientBrief, clientBrief: ClientBrief,
proposal: MultipleDocuments, proposal: MultipleDocuments,
extendedBrief: SimpleDocument, extendedBrief: ClientBrief,
industrialIdeation: SimpleDocument, industrialIdeation: SimpleDocument,
virtualSample: VirtualSample, virtualSample: VirtualSample,
physicalSample: PhysicalSample, physicalSample: PhysicalSample,

View file

@ -16,7 +16,7 @@
<template #header> <template #header>
<button <button
v-if=" v-if="
dialog.content.id === 'clientBrief' && ['clientBrief', 'extendedBrief'].includes(dialog.content.id) &&
dialog.content.isValidated !== true dialog.content.isValidated !== true
" "
class="btn" class="btn"
@ -91,7 +91,7 @@ const correspondingDTLProposal = computed(() => {
// Functions // Functions
async function validate() { async function validate() {
const response = await api.validateBrief( const response = await api.validateBrief(
route.path + '/client-brief', route.path + '/' + dialog.content.slug,
route.fullPath route.fullPath
); );
if (response.success) { if (response.success) {

View file

@ -1,23 +0,0 @@
<template>
<section class="h-full | grid-center">
<div
class="card | items-center | text-center | w-full max-w"
style="--row-gap: var(--space-32); --max-w: 27.5rem"
>
<h2 class="font-serif text-lg">Créez votre premier brief de projet !</h2>
<p class="text-grey-700">
Bienvenu à votre nouvel espace de projet. <br />Commencez par consulter
les inspirations <br />et partagez vos intentions !
</p>
<button
class="btn | w-full"
@click="emit('update:step', 'ModeSelection')"
>
Commencer
</button>
</div>
</section>
</template>
<script setup>
const emit = defineEmits(["update:step"]);
</script>

View file

@ -1,116 +0,0 @@
<template>
<section
class="h-full | flex flex-col justify-center items-center | mx-auto | max-w"
style="--max-w: 42rem; --row-gap: var(--space-32)"
>
<div class="flex items-baseline">
<div
@click="emit('update:step', 'Images')"
class="card card--cta | flex-1 | h-full"
style="--padding: var(--space-32); --row-gap: var(--space-32)"
>
<svg
aria-hidden="true"
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M92.8571 46.4292H64.2857C62.3133 46.4292 60.7143 48.0282 60.7143 50.0006V92.8577C60.7143 94.8302 62.3133 96.4292 64.2857 96.4292H92.8571C94.8296 96.4292 96.4286 94.8302 96.4286 92.8577V50.0006C96.4286 48.0282 94.8296 46.4292 92.8571 46.4292Z"
/>
<path
d="M92.8571 3.57202H64.2857C62.3133 3.57202 60.7143 5.171 60.7143 7.14345V21.5006C60.7143 23.473 62.3133 25.072 64.2857 25.072H92.8571C94.8296 25.072 96.4286 23.473 96.4286 21.5006V7.14345C96.4286 5.171 94.8296 3.57202 92.8571 3.57202Z"
/>
<path
d="M35.7143 3.57202H7.14284C5.17039 3.57202 3.57141 5.171 3.57141 7.14345V50.0006C3.57141 51.973 5.17039 53.572 7.14284 53.572H35.7143C37.6867 53.572 39.2857 51.973 39.2857 50.0006V7.14345C39.2857 5.171 37.6867 3.57202 35.7143 3.57202Z"
/>
<path
d="M35.7143 74.9291H7.14284C5.17039 74.9291 3.57141 76.5281 3.57141 78.5005V92.8577C3.57141 94.8301 5.17039 96.4291 7.14284 96.4291H35.7143C37.6867 96.4291 39.2857 94.8301 39.2857 92.8577V78.5005C39.2857 76.5281 37.6867 74.9291 35.7143 74.9291Z"
/>
</svg>
<h2 class="font-serif text-lg">Créer via la plateforme</h2>
<p class="text-sm text-grey-700">
Ajouter différents éléments tels que des images et du texte sur la
plateforme afin de créer votre brief.
</p>
</div>
<div
class="card card--cta | flex-1 | h-full"
style="--padding: var(--space-32); --row-gap: var(--space-32)"
>
<svg
aria-hidden="true"
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M3.57153 75.0001V82.143C3.57153 85.9318 5.07663 89.5654 7.75572 92.2445C10.4348 94.9236 14.0684 96.4287 17.8572 96.4287H82.143C85.9318 96.4287 89.5654 94.9236 92.2445 92.2445C94.9236 89.5654 96.4287 85.9318 96.4287 82.143V75.0001M28.5715 28.5715L50.0001 3.57153M50.0001 3.57153L71.4287 28.5715M50.0001 3.57153V67.8573"
/>
</svg>
<label class="font-serif text-lg" for="upload-pdf">
Ajouter un PDF
<input
id="upload-pdf"
type="file"
@change="addPdf($event, page.uri, true)"
accept="application/pdf"
ref="pdfInput"
hidden
/>
</label>
<p class="text-sm text-grey-700">
Vous avez déjà constitué votre brief en amont et souhaitez directement
limporter.
</p>
</div>
</div>
<div
class="card | bg-grey-200 | items-center | text-center | w-full"
style="--padding: var(--space-32); --row-gap: var(--space-16)"
>
<h2 class="font-serif text-lg">Quest ce que le brief ?</h2>
<p class="text-sm text-grey-700">
Le brief est un outil créatif qui permet de définir les perspectives
esthétiques de votre projet.
</p>
</div>
</section>
</template>
<script setup>
import { ref } from "vue";
import { usePageStore } from "../../../stores/page";
import { storeToRefs } from "pinia";
import { useBriefStore } from "../../../stores/brief";
const emit = defineEmits("update:step");
const { page } = storeToRefs(usePageStore());
const { addPdf } = useBriefStore();
const pdfInput = ref(null);
</script>
<style scoped>
label[for="upload-pdf"]::after {
content: "";
display: block;
position: absolute;
inset: 0;
cursor: pointer;
}
</style>

View file

@ -49,6 +49,6 @@ const pdf = computed(() => {
}); });
function goToImagesBrief() { function goToImagesBrief() {
router.push(location.pathname + "/client-brief?step=images"); router.push(location.pathname + "/" + step.slug);
} }
</script> </script>

View file

@ -56,9 +56,12 @@ const commentsCount = computed(() => {
let count = 0; let count = 0;
if (Array.isArray(step.files)) { if (Array.isArray(step.files)) {
// Ne compter que les commentaires des images, pas des documents (PDFs)
for (const file of step.files) { for (const file of step.files) {
if (file.type === 'image') {
count += file?.comments?.length || 0; count += file?.comments?.length || 0;
} }
}
} else { } else {
if (step.files?.dynamic) { if (step.files?.dynamic) {
for (const variation of allVariations) { for (const variation of allVariations) {

View file

@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia';
import { usePageStore } from '../../../stores/page'; import { usePageStore } from '../../../stores/page';
import { useDialogStore } from '../../../stores/dialog'; import { useDialogStore } from '../../../stores/dialog';
import { useVirtualSampleStore } from '../../../stores/virtualSample'; import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Interactive360 from './Interactive360.vue'; import Interactive360 from './Interactive360.vue';
import SingleImage from './SingleImage.vue'; import SingleImage from './SingleImage.vue';
import Selector from '../../Selector.vue'; import Selector from '../../Selector.vue';
import slugify from 'slugify'; import slugify from 'slugify';
const route = useRoute(); const route = useRoute();
const router = useRouter();
const { page } = storeToRefs(usePageStore()); const { page } = storeToRefs(usePageStore());
const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } = const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } =
@ -92,41 +93,74 @@ const tracks = computed(() => {
return list; return list;
}); });
// ---------- INITIALISATION ---------- function normalizeSlug(slug) {
// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) return slug.replace(/_/g, '-');
onBeforeMount(() => {
// essayer la hash en priorité
let initialVariation = null;
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;
} }
// fallback : première variation du premier track function getVariationSlug(variation) {
if (!initialVariation) { return variation.slug || (variation.title ? slugify(variation.title) : null);
initialVariation = tracks.value[0]?.variations?.[0] || null;
} }
if (initialVariation) { function findVariationByHash(hashValue) {
activeTracks.value = [initialVariation]; const allVariations = tracks.value.flatMap((track) => track.variations || []);
} else { const normalizedHash = normalizeSlug(hashValue);
activeTracks.value = []; // aucun contenu disponible
} return allVariations.find((variation) => {
const variationSlug = getVariationSlug(variation);
if (!variationSlug) return false;
const normalizedVariationSlug = normalizeSlug(variationSlug);
return normalizedVariationSlug === normalizedHash;
}); });
}
// scroll si hash présent function getInitialVariation() {
onMounted(() => { if (route?.hash && route.hash.length > 0) {
if (route.query?.comments) isCommentsOpen.value = true; const hashValue = route.hash.substring(1);
const variationFromHash = findVariationByHash(hashValue);
if (variationFromHash) return variationFromHash;
}
return tracks.value[0]?.variations?.[0] || null;
}
function initializeActiveTracks() {
const initialVariation = getInitialVariation();
activeTracks.value = initialVariation ? [initialVariation] : [];
}
function normalizeUrlHash() {
if (route?.hash && route.hash.includes('_')) {
const normalizedHash = normalizeSlug(route.hash);
router.replace({ ...route, hash: normalizedHash });
}
}
function openCommentsIfRequested() {
if (route.query?.comments) {
isCommentsOpen.value = true;
}
}
function scrollToHashTarget() {
if (!route?.hash || route.hash.length === 0) return; if (!route?.hash || route.hash.length === 0) return;
const selector = route.hash.replace('#', '#track--'); const selectorId = route.hash.replace('#', '#track--');
const targetBtn = document.querySelector(selector); const targetButton = document.querySelector(selectorId);
if (targetBtn) targetBtn.scrollIntoView(); if (targetButton) {
targetButton.scrollIntoView();
}
}
onBeforeMount(() => {
initializeActiveTracks();
}); });
// ---------- COMPUTED / WATCH ---------- onMounted(() => {
openCommentsIfRequested();
normalizeUrlHash();
scrollToHashTarget();
});
const isSingleImage = computed(() => { const isSingleImage = computed(() => {
return ( return (
@ -139,38 +173,52 @@ const singleFile = computed(() => {
return isSingleImage.value ? activeTracks.value[0].files[0] : null; return isSingleImage.value ? activeTracks.value[0].files[0] : null;
}); });
watch( function updateOpenedFile(file) {
singleFile, if (file) {
(newValue) => { openedFile.value = file;
if (newValue) openedFile.value = newValue; }
}, }
{ immediate: true }
);
// gestion du mode comparaison : fermer les commentaires, etc. function enableCompareModeUI() {
watch(isCompareModeEnabled, (newValue) => {
if (newValue) {
isCommentsOpen.value = false; isCommentsOpen.value = false;
isCommentPanelEnabled.value = false; isCommentPanelEnabled.value = false;
} else {
isCommentPanelEnabled.value = true;
} }
// quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire function disableCompareModeUI() {
if (!newValue && activeTracks.value.length === 2) { isCommentPanelEnabled.value = true;
if (activeTracks.value.length === 2) {
activeTracks.value.pop(); 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 ---------- watch(
function getCommentsCount(track) { activeTracks,
if (!track || !Array.isArray(track.files)) return undefined; (tracks) => {
let count = 0; if (tracks && tracks.length > 0) {
for (const file of track.files) { updateUrlHash(tracks[0]);
count += file?.comments?.length || 0;
}
return count > 0 ? count : undefined;
} }
},
{ deep: true }
);
</script> </script>
<style> <style>

View file

@ -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) { async function readNotification(notificationId, projectId) {
const headers = { const headers = {
method: "POST", 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() { async function readAllNotifications() {
try { try {
const response = await fetch("/read-all-notifications.json"); const response = await fetch("/read-all-notifications.json");
@ -236,6 +289,32 @@ export const useApiStore = defineStore("api", () => {
} }
} }
async function toggleHiddenProject(projectUuid) {
const headers = {
method: "POST",
body: JSON.stringify({ projectUuid }),
};
try {
const response = await fetch("/toggle-hidden-project.json", headers);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === "success") {
userStore.toggleHiddenProject(projectUuid);
console.log("Projet masqué/affiché avec succès.");
return data;
} else {
throw new Error(data.message);
}
} catch (error) {
console.error("Erreur lors du toggle du projet masqué:", error);
throw error;
}
}
return { return {
fetchData, fetchData,
fetchRoute, fetchRoute,
@ -243,6 +322,11 @@ export const useApiStore = defineStore("api", () => {
updateComment, updateComment,
deleteComment, deleteComment,
replyComment, replyComment,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
toggleHiddenProject,
// Anciennes fonctions (rétro-compatibilité)
readNotification, readNotification,
readAllNotifications, readAllNotifications,
validateBrief, validateBrief,

View file

@ -1,5 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useApiStore } from './api.js'; import { useApiStore } from './api.js';
import { useUserStore } from './user.js';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
export const useProjectsStore = defineStore('projects', () => { export const useProjectsStore = defineStore('projects', () => {
@ -7,25 +8,40 @@ export const useProjectsStore = defineStore('projects', () => {
const projects = ref(null); const projects = ref(null);
const currentProjects = computed(() => { const currentProjects = computed(() => {
const userStore = useUserStore();
const hiddenProjectUuids = userStore.hiddenProjects || [];
return ( return (
projects.value projects.value
?.filter((project) => project.status === 'listed') ?.filter((project) =>
project.status === 'listed' &&
!hiddenProjectUuids.includes(project.uuid)
)
.sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? [] .sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? []
); );
}); });
const draftProjects = computed(() => { const draftProjects = computed(() => {
const userStore = useUserStore();
const hiddenProjectUuids = userStore.hiddenProjects || [];
return ( return (
projects.value projects.value
?.filter((project) => project.status === 'draft') ?.filter((project) =>
project.status === 'draft' &&
!hiddenProjectUuids.includes(project.uuid)
)
.sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? [] .sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? []
); );
}); });
const archivedProjects = computed(() => { const archivedProjects = computed(() => {
const userStore = useUserStore();
const hiddenProjectUuids = userStore.hiddenProjects || [];
return ( return (
projects.value projects.value
?.filter((project) => project.status === 'unlisted') ?.filter((project) =>
project.status === 'unlisted' &&
!hiddenProjectUuids.includes(project.uuid)
)
.sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? [] .sort((a, b) => new Date(b.modified) - new Date(a.modified)) ?? []
); );
}); });

View file

@ -11,58 +11,117 @@ export const useUserStore = defineStore('user', () => {
const { projects } = storeToRefs(useProjectsStore()); 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(() => { const notifications = computed(() => {
return projects.value?.flatMap((project) => { if (!projects.value || !user.value) return [];
return projects.value.flatMap((project) => {
if (!project.notifications) return []; if (!project.notifications) return [];
return project.notifications return project.notifications.map((notification) => ({
.filter((notification) => notification.author.uuid !== user.value.uuid)
.map((notification) => ({
...notification, ...notification,
project: project, project: project,
isRead: notification.readby?.includes(user.value.uuid), // 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.
projects.value = projects.value.map((project) => ({ * @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, ...project,
notifications: notifications: (project.notifications || []).map((notification) =>
project.uuid === projectId || project.uri === projectId
? project.notifications.map((notification) =>
notification.id === notificationId notification.id === notificationId
? { ? {
...notification, ...notification,
readby: [ isRead: true,
...new Set([...notification.readby, user.value.uuid]), readby: [...new Set([...(notification.readby || []), user.value.uuid])],
],
} }
: notification : notification
) ),
: project.notifications, };
});
}
/**
* 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.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() { function readAllNotifications() {
projects.value = projects.value.map((project) => ({ markAllNotificationsRead();
...project,
notifications: project.notifications.map((notification) => ({
...notification,
readby: [...new Set([...notification.readby, user.value.uuid])],
})),
}));
} }
function canEditComment(comment) { function canEditComment(comment) {
return user.value.uuid === comment.author.uuid; return user.value.uuid === comment.author.uuid;
} }
const hiddenProjects = computed(() => {
return user.value?.hiddenProjects || [];
});
const visibleProjects = computed(() => {
if (!user.value?.projects) return [];
const projectsArray = Array.isArray(user.value.projects)
? user.value.projects
: Object.values(user.value.projects);
return projectsArray.filter(
(project) => !hiddenProjects.value.includes(project.uuid)
);
});
function toggleHiddenProject(projectUuid) {
if (!user.value) return;
const index = user.value.hiddenProjects.indexOf(projectUuid);
if (index > -1) {
user.value.hiddenProjects.splice(index, 1);
} else {
user.value.hiddenProjects.push(projectUuid);
}
}
return { return {
user, user,
isLogged, isLogged,
notifications, notifications,
hiddenProjects,
visibleProjects,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
toggleHiddenProject,
// Anciennes fonctions (rétro-compatibilité)
readNotification, readNotification,
readAllNotifications, readAllNotifications,
canEditComment, canEditComment,

View file

@ -198,6 +198,7 @@
</section> </section>
<section <section
v-if="user.role !== 'pochet'"
class="bg-white rounded-2xl px-16 py-24" class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="client-label" aria-labelledby="client-label"
> >
@ -219,7 +220,94 @@
</section> </section>
<section <section
v-if="user.hasOwnProperty('projects')" v-if="
user.hasOwnProperty('projects') &&
(user.role === 'pochet' || user.role === 'client')
"
class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="projects-label"
>
<h2 id="projects-label" class="text-grey-700 mb-24">
{{ user.role === 'pochet' ? 'Projets managés' : 'Mes projets' }}
</h2>
<div class="projects-list flow" style="--flow-space: 1rem">
<div
v-for="project in user.role === 'pochet'
? visibleProjects
: allProjectsList"
:key="project.uuid"
class="project-card | flex items-center | bg-grey-800 text-white rounded-lg px-16 py-12"
style="--column-gap: 1rem"
>
<button
v-if="user.role === 'pochet'"
@click="toggleProject(project.uuid)"
class="btn btn--sm btn--primary"
:aria-pressed="true"
:title="'Masquer le projet'"
aria-label="Masquer le projet"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
>
<path
d="M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z"
></path>
</svg>
</button>
<div>
<p class="font-medium">{{ project.title }}</p>
<p class="text-sm text-grey-600">
Étape en cours : {{ project.step }}
</p>
</div>
</div>
<template
v-if="user.role === 'pochet' && hiddenProjectsList.length > 0"
>
<div
v-for="project in hiddenProjectsList"
:key="project.uuid"
class="project-card | flex items-center | bg-grey-100 rounded-lg px-16 py-12"
style="--column-gap: 1rem; opacity: 0.5"
>
<button
@click="toggleProject(project.uuid)"
class="btn btn--sm btn--primary"
:title="'Afficher le projet'"
aria-label="Afficher le projet"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
>
<path
d="M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z"
></path>
</svg>
</button>
<div>
<p class="font-medium">{{ project.title }}</p>
<p class="text-sm text-grey-600">
Étape en cours : {{ project.step }}
</p>
</div>
</div>
</template>
</div>
</section>
<section
v-else-if="user.hasOwnProperty('projects')"
class="bg-white rounded-2xl px-16 py-24" class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="projects-label" aria-labelledby="projects-label"
> >
@ -233,9 +321,37 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useApiStore } from '../stores/api';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
const { user } = storeToRefs(useUserStore()); const userStore = useUserStore();
const { user, visibleProjects } = storeToRefs(userStore);
const api = useApiStore();
const hiddenProjectsList = computed(() => {
if (!user.value?.projects || !user.value?.hiddenProjects) return [];
const projectsArray = Array.isArray(user.value.projects)
? user.value.projects
: Object.values(user.value.projects);
return projectsArray.filter((project) =>
user.value.hiddenProjects.includes(project.uuid)
);
});
const allProjectsList = computed(() => {
if (!user.value?.projects) return [];
return Array.isArray(user.value.projects)
? user.value.projects
: Object.values(user.value.projects);
});
async function toggleProject(projectUuid) {
try {
await api.toggleHiddenProject(projectUuid);
} catch (error) {
console.error('Erreur lors du masquage/affichage du projet:', error);
}
}
// Email // Email
const email = ref(''); const email = ref('');

View file

@ -25,49 +25,18 @@
Valider et envoyer le brief Valider et envoyer le brief
</button> </button>
</header> </header>
<component :is="stepsComponents[currentStep]" @update:step="changeStep" /> <Images />
</main> </main>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import Intro from '../components/project/brief/Intro.vue';
import ModeSelection from '../components/project/brief/ModeSelection.vue';
import Images from '../components/project/brief/Images.vue'; import Images from '../components/project/brief/Images.vue';
import TitledPdfWrapper from '../components/project/TitledPdfWrapper.vue';
import { usePageStore } from '../stores/page'; import { usePageStore } from '../stores/page';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useApiStore } from '../stores/api'; import { useApiStore } from '../stores/api';
import { useRoute } from 'vue-router';
const stepsComponents = {
Intro,
ModeSelection,
Images,
TitledPdfWrapper,
};
const { page } = storeToRefs(usePageStore()); const { page } = storeToRefs(usePageStore());
const api = useApiStore(); const api = useApiStore();
const currentStep = ref(setInitialStep());
function changeStep(stepName) {
currentStep.value = stepName;
}
function setInitialStep() {
if (useRoute().query.step === 'images') {
return 'Images';
}
const hasPDF = page.value?.content?.pdf?.length !== 0;
const hasImages =
page.value.content?.moodboard.length !== 0 ||
page.value.content.description.length !== 0;
const isEmpty = !hasPDF && !hasImages;
if (isEmpty) return 'Intro';
if (hasImages) return 'Images';
}
function validate() { function validate() {
api.validateBrief(page.value.uri).then((res) => { api.validateBrief(page.value.uri).then((res) => {
location.href = '/' + page.value.parent; location.href = '/' + page.value.parent;

View file

@ -119,14 +119,24 @@ function changeTab(newValue) {
function readAll() { function readAll() {
try { try {
api.readAllNotifications(); api.markAllNotificationsRead();
} catch (error) { } catch (error) {
console.log('Could not read all notifications : ', error); console.log('Could not read all notifications : ', error);
} }
} }
// Functions // 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 = const href =
notification.type === 'appointment-request' notification.type === 'appointment-request'
? getHref(notification) + '?tab=designToLight' ? getHref(notification) + '?tab=designToLight'
@ -141,6 +151,11 @@ function handleNotificationClick(notification) {
function getHref(notification) { function getHref(notification) {
const uri = notification.location.page.uri; const uri = notification.location.page.uri;
// Pour les notifications de type "content" (brief validé), utiliser dialogUri si présent
if (notification.type === 'content' && notification.dialogUri) {
return notification.dialogUri;
}
const isDocumentBrief = const isDocumentBrief =
notification.location.page.template === 'client-brief' && notification.location.page.template === 'client-brief' &&
notification.location?.file?.type === 'document'; notification.location?.file?.type === 'document';

View file

@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => {
minify: mode === 'production' ? 'esbuild' : false, minify: mode === 'production' ? 'esbuild' : false,
}, },
server: { server: {
cors: true,
watch: { watch: {
ignored: [ ignored: [
'**/node_modules/**', '**/node_modules/**',