Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb933326f | ||
|
|
89c1cb24c0 | ||
|
|
30b7697c64 | ||
|
|
7ca72c6d82 | ||
|
|
2b5175900c | ||
|
|
f9be7fa025 | ||
|
|
78ac0940d0 | ||
|
|
f994b1c982 | ||
|
|
5f214629ef | ||
|
|
2186e33b29 | ||
|
|
c2a5bd7a85 | ||
|
|
9ce8135a3b | ||
|
|
6b80e242b8 | ||
|
|
dfb8d1038b | ||
|
|
95a8bf99cb | ||
|
|
378af9ac96 | ||
|
|
4669f03f16 | ||
|
|
a57b0c203a | ||
|
|
86db1f5a0c | ||
|
|
2791bc4462 | ||
|
|
bb71da081b | ||
|
|
e73e25b1da | ||
|
|
0a980603a4 | ||
|
|
0250dc1487 | ||
|
|
f614884da0 | ||
|
|
9d12ccb209 | ||
|
|
cfd679bc15 | ||
|
|
04d8da39fd | ||
|
|
6ff59e9b07 | ||
|
|
a7d315942a | ||
|
|
c68b51f639 |
55 changed files with 2698 additions and 513 deletions
64
.forgejo/workflows/deploy-preprod.yml
Normal file
64
.forgejo/workflows/deploy-preprod.yml
Normal 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/
|
||||||
64
.forgejo/workflows/deploy-prod.yml
Normal file
64
.forgejo/workflows/deploy-prod.yml
Normal 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
10
.gitignore
vendored
|
|
@ -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
344
CLAUDE.md
Normal 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
2
public/.user.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
; Augmentation temporaire de la limite mémoire pour le chargement des notifications
|
||||||
|
memory_limit = 512M
|
||||||
0
public/site/accounts/.gitkeep
Normal file
0
public/site/accounts/.gitkeep
Normal 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
0
public/site/cache/.gitkeep
vendored
Normal 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'),
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
175
public/site/config/routes/migrate-notifications.php
Normal file
175
public/site/config/routes/migrate-notifications.php
Normal 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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -10,34 +10,23 @@ return [
|
||||||
$user = kirby()->user();
|
$user = kirby()->user();
|
||||||
$project = page($data->projectUri);
|
$project = page($data->projectUri);
|
||||||
|
|
||||||
|
$date = new DateTime();
|
||||||
|
$formattedDate = $date->format(DateTime::ISO8601);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$newProject = $project->update([
|
$project->update([
|
||||||
"hasOptimizationRequest" => "true",
|
"hasOptimizationRequest" => "true",
|
||||||
"optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details)
|
"optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details),
|
||||||
|
// Métadonnées pour le système de notifications dérivées
|
||||||
|
"optimizationAuthor" => (string) $user->uuid(),
|
||||||
|
"optimizationAuthorName" => (string) $user->name(),
|
||||||
|
"optimizationAuthorEmail" => (string) $user->email(),
|
||||||
|
"optimizationDate" => $formattedDate,
|
||||||
|
"optimizationReadby" => [],
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $th) {
|
|
||||||
return [
|
|
||||||
"status" => "error",
|
|
||||||
"message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Note: Les notifications sont maintenant dérivées.
|
||||||
$date = new DateTime();
|
// Plus besoin d'appeler createNotification().
|
||||||
$formattedDate = $date->format(DateTime::ISO8601);
|
|
||||||
|
|
||||||
$notificationData = [
|
|
||||||
"location" => [
|
|
||||||
"page" => $newProject
|
|
||||||
],
|
|
||||||
"date" => (string) $formattedDate,
|
|
||||||
"text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)),
|
|
||||||
"author" => $user,
|
|
||||||
"id" => Str::uuid(),
|
|
||||||
"type" => "appointment-request",
|
|
||||||
];
|
|
||||||
|
|
||||||
$newProject->createNotification($notificationData);
|
|
||||||
|
|
||||||
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()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
60
public/site/config/routes/toggle-hidden-project.php
Normal file
60
public/site/config/routes/toggle-hidden-project.php
Normal 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()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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'] = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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\\Notification" => __DIR__ . "/src/Notification.php",
|
"adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php",
|
||||||
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.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",
|
||||||
|
|
||||||
// Shared classes
|
// Anciennes classes - Gardées pour rétro-compatibilité pendant migration
|
||||||
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
|
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
|
||||||
"adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php",
|
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
|
||||||
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
|
|
||||||
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
|
// Classes partagées
|
||||||
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
|
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
|
||||||
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
|
"adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php",
|
||||||
|
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
|
||||||
|
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
|
||||||
|
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
|
||||||
|
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Créer et configurer le collector
|
||||||
|
$collector = new NotificationCollector();
|
||||||
|
$collector->register(new CommentProvider());
|
||||||
|
$collector->register(new ReplyProvider());
|
||||||
|
$collector->register(new ProjectRequestProvider());
|
||||||
|
$collector->register(new AppointmentRequestProvider());
|
||||||
|
$collector->register(new ContentProvider());
|
||||||
|
|
||||||
Kirby::plugin("adrienpayet/pdc-notifications", [
|
Kirby::plugin("adrienpayet/pdc-notifications", [
|
||||||
"routes" => [
|
"options" => [
|
||||||
require(__DIR__ . "/routes/readAll.php"),
|
"collector" => $collector
|
||||||
require(__DIR__ . "/routes/read.php")
|
],
|
||||||
],
|
"routes" => [
|
||||||
|
// Nouvelles routes
|
||||||
|
require(__DIR__ . "/routes/mark-as-read.php"),
|
||||||
|
require(__DIR__ . "/routes/mark-all-read.php"),
|
||||||
|
// Anciennes routes - Gardées pour rétro-compatibilité
|
||||||
|
require(__DIR__ . "/routes/readAll.php"),
|
||||||
|
require(__DIR__ . "/routes/read.php"),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
42
public/site/plugins/notifications/routes/mark-all-read.php
Normal file
42
public/site/plugins/notifications/routes/mark-all-read.php
Normal 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
46
public/site/plugins/notifications/routes/mark-as-read.php
Normal file
46
public/site/plugins/notifications/routes/mark-as-read.php
Normal 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
181
public/site/plugins/notifications/src/NotificationCollector.php
Normal file
181
public/site/plugins/notifications/src/NotificationCollector.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}})})();
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch("/refresh-cache.json", init);
|
try {
|
||||||
const json = await res.json();
|
const res = await fetch("/refresh-cache.json", init);
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.status === "error") {
|
||||||
|
throw new Error(json.message);
|
||||||
|
}
|
||||||
|
|
||||||
if (json.status === "error") {
|
|
||||||
console.error(json);
|
|
||||||
text.value = "Erreur";
|
|
||||||
icon.value = "alert";
|
|
||||||
theme.value = "red-icon";
|
|
||||||
} else {
|
|
||||||
console.log(json);
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
|
||||||
$project->rebuildStepsCache();
|
|
||||||
|
|
||||||
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
|
// Support du batch processing
|
||||||
$project->update([
|
$offset = isset($data->offset) ? intval($data->offset) : 0;
|
||||||
'lastCacheUpdate' => $formatter->format(time())
|
$limit = isset($data->limit) ? intval($data->limit) : 10; // 10 projets par batch par défaut
|
||||||
]);
|
$total = $projects->count();
|
||||||
|
|
||||||
|
// Slice pour ne traiter qu'un batch
|
||||||
|
$batch = $projects->slice($offset, $limit);
|
||||||
|
$processed = 0;
|
||||||
|
|
||||||
|
foreach ($batch as $project) {
|
||||||
|
try {
|
||||||
|
$project->rebuildStepsCache();
|
||||||
|
$project->invalidateNotificationsCache();
|
||||||
|
|
||||||
|
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
|
||||||
|
$project->update([
|
||||||
|
'lastCacheUpdate' => $formatter->format(time())
|
||||||
|
]);
|
||||||
|
$processed++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log("Error refreshing cache for project {$project->slug()}: " . $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$remaining = max(0, $total - ($offset + $processed));
|
||||||
|
$hasMore = $remaining > 0;
|
||||||
|
|
||||||
return [
|
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.'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
public/site/sessions/.gitkeep
Normal file
0
public/site/sessions/.gitkeep
Normal 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);
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,32 @@ 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(),
|
||||||
'url' => $project->url(),
|
'url' => $project->url(),
|
||||||
'uri' => '/' . $project->uri(),
|
'uri' => '/' . $project->uri(),
|
||||||
'modified' => $project->modified('Y-MM-d'),
|
'modified' => $project->modified('Y-MM-d'),
|
||||||
'currentStep' => $project->currentStep()->value(),
|
'currentStep' => $project->currentStep()->value(),
|
||||||
'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(),
|
||||||
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
|
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($project->isDTLEnabled()) {
|
if ($project->isDTLEnabled()) {
|
||||||
$data['designToLight'] = processDTLProposals($project);
|
$data['designToLight'] = processDTLProposals($project);
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return variationA.title === variationB.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialisation : remplir le 1er select localement ET initialiser le store
|
function extractVariation(value) {
|
||||||
onBeforeMount(() => {
|
if (!value) return null;
|
||||||
|
return Array.isArray(value) ? value[value.length - 1] || null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertValueForCompareMode(value, shouldBeArray) {
|
||||||
|
if (shouldBeArray) {
|
||||||
|
return value && !Array.isArray(value) ? [value] : value;
|
||||||
|
} else {
|
||||||
|
return Array.isArray(value) ? value[0] || null : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingVariationsInStore(storeVariations) {
|
||||||
|
return storeVariations.filter((storeVar) =>
|
||||||
|
items.some((item) => areVariationsEqual(item, storeVar))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCurrentValueFromStore(storeVariations) {
|
||||||
syncing.value = true;
|
syncing.value = true;
|
||||||
|
|
||||||
if (index === 0) {
|
const matchedVariations = findMatchingVariationsInStore(storeVariations);
|
||||||
currentValue.value = items[0] || null;
|
|
||||||
// si le store est vide, initialiser avec la variation du premier sélecteur
|
if (isCompareModeEnabled) {
|
||||||
if (!activeTracks.value || activeTracks.value.length === 0) {
|
currentValue.value = matchedVariations.length ? [...matchedVariations] : [];
|
||||||
const v = toVariation(items[0]);
|
|
||||||
if (v) activeTracks.value = [v];
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// les autres ne forcent pas le store ; leur currentValue restera à null
|
currentValue.value = matchedVariations[0] || null;
|
||||||
currentValue.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => (syncing.value = false));
|
nextTick(() => (syncing.value = false));
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function detectVariationChanges(newValues, oldValues) {
|
||||||
|
const newList = Array.isArray(newValues)
|
||||||
|
? newValues
|
||||||
|
: newValues
|
||||||
|
? [newValues]
|
||||||
|
: [];
|
||||||
|
const oldList = Array.isArray(oldValues)
|
||||||
|
? oldValues
|
||||||
|
: oldValues
|
||||||
|
? [oldValues]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const addedVariation = newList.find(
|
||||||
|
(n) => !oldList.some((o) => areVariationsEqual(o, n))
|
||||||
|
);
|
||||||
|
const removedVariation = oldList.find(
|
||||||
|
(o) => !newList.some((n) => areVariationsEqual(n, o))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { addedVariation, removedVariation };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVariationChange(newValue, oldValue) {
|
||||||
|
if (syncing.value) return;
|
||||||
|
|
||||||
|
const { addedVariation, removedVariation } = detectVariationChanges(
|
||||||
|
newValue,
|
||||||
|
oldValue
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
addedVariation &&
|
||||||
|
items.some((item) => areVariationsEqual(item, addedVariation))
|
||||||
|
) {
|
||||||
|
updateActiveTracks(addedVariation, 'add');
|
||||||
|
} else if (
|
||||||
|
removedVariation &&
|
||||||
|
items.some((item) => areVariationsEqual(item, removedVariation))
|
||||||
|
) {
|
||||||
|
updateActiveTracks(removedVariation, 'remove');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quand on bascule compare mode (objet <-> tableau)
|
|
||||||
watch(
|
watch(
|
||||||
() => isCompareModeEnabled,
|
() => isCompareModeEnabled,
|
||||||
(flag) => {
|
(shouldBeArray) => {
|
||||||
syncing.value = true;
|
syncing.value = true;
|
||||||
if (flag) {
|
currentValue.value = convertValueForCompareMode(
|
||||||
if (currentValue.value && !Array.isArray(currentValue.value)) {
|
currentValue.value,
|
||||||
currentValue.value = [currentValue.value];
|
shouldBeArray
|
||||||
}
|
);
|
||||||
} else {
|
|
||||||
if (Array.isArray(currentValue.value)) {
|
|
||||||
currentValue.value = currentValue.value[0] || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextTick(() => (syncing.value = false));
|
nextTick(() => (syncing.value = false));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Détection ajout / suppression dans le MultiSelect (côté composant)
|
watch(currentValue, handleVariationChange, { deep: true });
|
||||||
// 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
|
|
||||||
// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`)
|
|
||||||
watch(
|
watch(
|
||||||
activeTracks,
|
activeTracks,
|
||||||
(newVal) => {
|
(storeVariations) => {
|
||||||
syncing.value = true;
|
const variationsList = Array.isArray(storeVariations)
|
||||||
|
? storeVariations
|
||||||
const storeList = Array.isArray(newVal) ? newVal : [];
|
: [];
|
||||||
// ne garder que les variations du store qui sont dans `items`
|
syncCurrentValueFromStore(variationsList);
|
||||||
const matched = storeList.filter((av) =>
|
|
||||||
items.some((it) => isSame(it, av))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCompareModeEnabled) {
|
|
||||||
currentValue.value = matched.length ? [...matched] : [];
|
|
||||||
} else {
|
|
||||||
currentValue.value = matched[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTick(() => (syncing.value = false));
|
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true, immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logique centrale de sélection (ajout / suppression)
|
function removeVariationFromActiveTracks(variation) {
|
||||||
// Règles :
|
activeTracks.value = activeTracks.value.filter(
|
||||||
// - mode normal -> activeTracks = [variation]
|
(track) => !areVariationsEqual(track, variation)
|
||||||
// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première
|
);
|
||||||
function selectTrack(track, action = 'add') {
|
}
|
||||||
const variation = toVariation(track);
|
|
||||||
|
function 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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
l’importer.
|
|
||||||
</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">Qu’est 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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,11 @@ 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) {
|
||||||
count += file?.comments?.length || 0;
|
if (file.type === 'image') {
|
||||||
|
count += file?.comments?.length || 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (step.files?.dynamic) {
|
if (step.files?.dynamic) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
function getVariationSlug(variation) {
|
||||||
|
return variation.slug || (variation.title ? slugify(variation.title) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVariationByHash(hashValue) {
|
||||||
|
const allVariations = tracks.value.flatMap((track) => track.variations || []);
|
||||||
|
const normalizedHash = normalizeSlug(hashValue);
|
||||||
|
|
||||||
|
return allVariations.find((variation) => {
|
||||||
|
const variationSlug = getVariationSlug(variation);
|
||||||
|
if (!variationSlug) return false;
|
||||||
|
|
||||||
|
const normalizedVariationSlug = normalizeSlug(variationSlug);
|
||||||
|
return normalizedVariationSlug === normalizedHash;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialVariation() {
|
||||||
if (route?.hash && route.hash.length > 0) {
|
if (route?.hash && route.hash.length > 0) {
|
||||||
const variations = tracks.value.flatMap((t) => t.variations || []);
|
const hashValue = route.hash.substring(1);
|
||||||
initialVariation =
|
const variationFromHash = findVariationByHash(hashValue);
|
||||||
variations.find((v) => v.slug === route.hash.substring(1)) || null;
|
if (variationFromHash) return variationFromHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback : première variation du premier track
|
return tracks.value[0]?.variations?.[0] || null;
|
||||||
if (!initialVariation) {
|
}
|
||||||
initialVariation = tracks.value[0]?.variations?.[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialVariation) {
|
function initializeActiveTracks() {
|
||||||
activeTracks.value = [initialVariation];
|
const initialVariation = getInitialVariation();
|
||||||
} else {
|
activeTracks.value = initialVariation ? [initialVariation] : [];
|
||||||
activeTracks.value = []; // aucun contenu disponible
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// scroll si hash présent
|
function normalizeUrlHash() {
|
||||||
onMounted(() => {
|
if (route?.hash && route.hash.includes('_')) {
|
||||||
if (route.query?.comments) isCommentsOpen.value = true;
|
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.
|
|
||||||
watch(isCompareModeEnabled, (newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
isCommentsOpen.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 enableCompareModeUI() {
|
||||||
if (!newValue && activeTracks.value.length === 2) {
|
isCommentsOpen.value = false;
|
||||||
|
isCommentPanelEnabled.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableCompareModeUI() {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)) ?? []
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)
|
...notification,
|
||||||
.map((notification) => ({
|
project: project,
|
||||||
...notification,
|
// isRead est maintenant fourni par le backend
|
||||||
project: project,
|
}));
|
||||||
isRead: notification.readby?.includes(user.value.uuid),
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function readNotification(notificationId, projectId) {
|
/**
|
||||||
console.log('Read notification', notificationId, projectId);
|
* Marque une notification comme lue dans le store local.
|
||||||
|
* @param {string} notificationId - L'ID de la notification
|
||||||
|
* @param {string} projectUri - L'URI du projet (optionnel, pour retrouver le projet)
|
||||||
|
*/
|
||||||
|
function markNotificationRead(notificationId, projectUri = null) {
|
||||||
|
if (!user.value?.uuid) return;
|
||||||
|
|
||||||
|
projects.value = projects.value.map((project) => {
|
||||||
|
// Si projectUri fourni, cibler le bon projet
|
||||||
|
if (projectUri && project.uri !== projectUri && `/${project.uri}` !== projectUri) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
notifications: (project.notifications || []).map((notification) =>
|
||||||
|
notification.id === notificationId
|
||||||
|
? {
|
||||||
|
...notification,
|
||||||
|
isRead: true,
|
||||||
|
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
|
||||||
|
}
|
||||||
|
: notification
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque toutes les notifications comme lues dans le store local.
|
||||||
|
*/
|
||||||
|
function markAllNotificationsRead() {
|
||||||
|
if (!user.value?.uuid) return;
|
||||||
|
|
||||||
projects.value = projects.value.map((project) => ({
|
projects.value = projects.value.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
notifications:
|
notifications: (project.notifications || []).map((notification) => ({
|
||||||
project.uuid === projectId || project.uri === projectId
|
...notification,
|
||||||
? project.notifications.map((notification) =>
|
isRead: true,
|
||||||
notification.id === notificationId
|
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
|
||||||
? {
|
})),
|
||||||
...notification,
|
|
||||||
readby: [
|
|
||||||
...new Set([...notification.readby, user.value.uuid]),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: notification
|
|
||||||
)
|
|
||||||
: project.notifications,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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/**',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue