This commit is contained in:
2026-08-10 22:06:49 +02:00
parent 2a5d9bb0fd
commit 8bff7982ff
83 changed files with 17975 additions and 4824 deletions
+3 -1
View File
@@ -12,7 +12,9 @@
"PowerShell(npm run tauri:build)",
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}} | Format-Table -AutoSize)",
"PowerShell(npm run typecheck)",
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)"
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\msi\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)",
"PowerShell(Get-ChildItem -Path \"src-tauri\\\\target\\\\release\\\\app.exe\",\"src-tauri\\\\target\\\\release\\\\bundle\\\\nsis\" -File | Select-Object Name, @{N='MB';E={[math]::Round\\($_.Length/1MB,1\\)}}, LastWriteTime | Format-Table -AutoSize)",
"PowerShell($ErrorActionPreference='SilentlyContinue'; $v = supabase --version 2>$null; if \\($v\\) { \"CLI présent : $v\" } else { \"CLI Supabase NON installé\" }; \"scoop: $\\(\\(Get-Command scoop\\).Source\\)\"; \"npx: $\\(\\(Get-Command npx\\).Source\\)\")"
]
}
}
+20 -11
View File
@@ -1,11 +1,20 @@
node_modules/
out/
dist/
*.local
.DS_Store
.vite
# .env est VOLONTAIREMENT suivi par git : il ne contient que l'URL Supabase
# et la clé "anon" (toutes deux publiques). Ça permet de passer d'une machine
# à l'autre sans le recréer. Ne mets JAMAIS de secret ici : pour un vrai
# secret, utilise .env.local (ignoré via *.local).
node_modules/
out/
dist/
*.local
.DS_Store
.vite
# .env est VOLONTAIREMENT suivi par git : il ne contient que l'URL Supabase
# et la clé "anon" (toutes deux publiques). Ça permet de passer d'une machine
# à l'autre sans le recréer. Ne mets JAMAIS de secret ici : pour un vrai
# secret, utilise .env.local (ignoré via *.local).
# Fichiers de travail de la CLI Supabase
supabase/.temp/
# Cles de signature des mises a jour (NE JAMAIS COMMITTER)
.tauri-signing-key.local.pub
# Binaire distribuable produit par `npm run tauri:build`
/release/
+155 -137
View File
@@ -1,137 +1,155 @@
# Guide de mise en place — Comptes & Panel Admin
Ce guide explique, **étape par étape**, comment activer le panel d'administration
(création / suppression de comptes, gestion des rôles) pour ton équipe.
Tu ne fais ça **qu'une seule fois**. Ensuite, tout se gère depuis l'appli.
---
## Vue d'ensemble
| Qui | Voit quoi |
|-----|-----------|
| Toi (admin) | Tout + un menu **« Administration »** pour gérer les comptes |
| Tes collègues | L'appli normale, juste un écran **« Se connecter »** |
Les clés Supabase sont **déjà intégrées** dans l'appli (fichier `.env`) : tes
collègues n'ont **rien** à configurer.
---
## Étape 1 — Vérifier le fichier `.env`
Le fichier `.env` (à la racine du projet) contient déjà ton URL et ta clé :
```
VITE_SUPABASE_URL=https://cesjrlfqeqftayhnqjkb.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGci...
```
✅ Rien à faire si c'est correct. (L'URL ne doit PAS contenir `/rest/v1/`.)
---
## Étape 2 — Mettre à jour la base de données
1. Va sur [supabase.com](https://supabase.com) → ton projet.
2. Menu **SQL Editor****New query**.
3. Copie **tout** le contenu du fichier `supabase/schema.sql` et colle-le.
4. Clique **Run**.
> C'est sans danger même si tu l'as déjà exécuté : tout est « ré-exécutable ».
> Ça ajoute le champ `is_admin` et les sécurités du panel admin.
---
## Étape 3 — Créer TON compte administrateur
Comme l'inscription publique est désactivée, on crée le **tout premier compte**
(le tien) à la main :
1. Dans Supabase → menu **Authentication****Users** → bouton **Add user**.
2. Mets ton **email** + un **mot de passe**, et coche **Auto Confirm User**.
3. Clique **Create user**.
Puis on te donne les droits admin :
4. Retourne dans **SQL Editor****New query**, colle ceci en remplaçant l'email :
```sql
update public.profiles set is_admin = true
where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
```
5. Clique **Run**.
🎉 Tu es maintenant administrateur.
---
## Étape 4 — Déployer l'Edge Function (le « serveur de confiance »)
C'est elle qui crée/supprime les comptes en sécurité. **Deux méthodes au choix.**
### Méthode A — En ligne de commande (recommandée)
Dans un terminal, à la racine du projet :
```bash
# 1. Se connecter à Supabase (ouvre le navigateur)
npx supabase login
# 2. Déployer la fonction
npx supabase functions deploy admin-users ^
--project-ref cesjrlfqeqftayhnqjkb --no-verify-jwt
```
> `--no-verify-jwt` est normal ici : la fonction vérifie elle-même que
> l'appelant est bien un admin connecté (voir le code).
> La clé `service_role` est fournie automatiquement par Supabase, tu n'as
> rien à copier.
### Méthode B — Par le tableau de bord (sans terminal)
1. Supabase → menu **Edge Functions****Create a new function**.
2. Nom : `admin-users`.
3. Colle le contenu de `supabase/functions/admin-users/index.ts`.
4. Déploie.
5. Dans les **réglages** de la fonction, **désactive « Enforce JWT Verification »**.
---
## Étape 5 — Construire et distribuer l'appli
```bash
npm run tauri:build
```
L'installateur se trouve ensuite dans :
`src-tauri/target/release/bundle/`
Envoie ce fichier à tes collègues. Les clés sont dedans : ils n'ont **rien** à régler.
---
## Au quotidien — Gérer les comptes
1. Ouvre l'appli, connecte-toi avec ton compte admin.
2. Menu **Administration** (visible uniquement pour les admins).
3. Tu peux :
- **Créer un compte** : nom, email, mot de passe provisoire, rôle, (admin ?)
- **Changer le rôle** d'un membre
- **Promouvoir / rétrograder** un admin
- **Supprimer** un compte
Communique l'email + le mot de passe provisoire à chaque membre. Il se connecte,
c'est tout. ✅
---
## Notes de sécurité
- La clé `anon` dans l'appli est **publique par nature** : aucun risque.
- La clé `service_role` (toute-puissante) **n'est jamais dans l'appli** : elle
reste cachée dans l'Edge Function, côté serveur.
- Un membre normal **ne peut pas** se promouvoir admin lui-même (bloqué côté base).
- Tu ne peux pas supprimer ni rétrograder **ton propre** compte admin (anti-blocage).
# Guide de mise en place — Comptes & Panel Admin
Ce guide explique, **étape par étape**, comment activer le panel d'administration
(création / suppression de comptes, gestion des rôles) pour ton équipe.
Tu ne fais ça **qu'une seule fois**. Ensuite, tout se gère depuis l'appli.
---
## Vue d'ensemble
| Qui | Voit quoi |
|-----|-----------|
| Toi (admin) | Tout + un menu **« Administration »** pour gérer les comptes |
| Tes collègues | L'appli normale, juste un écran **« Se connecter »** |
Les clés Supabase sont **déjà intégrées** dans l'appli (fichier `.env`) : tes
collègues n'ont **rien** à configurer.
---
## Étape 1 — Vérifier le fichier `.env`
Le fichier `.env` (à la racine du projet) contient déjà ton URL et ta clé :
```
VITE_SUPABASE_URL=https://cesjrlfqeqftayhnqjkb.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGci...
```
✅ Rien à faire si c'est correct. (L'URL ne doit PAS contenir `/rest/v1/`.)
---
## Étape 2 — Mettre à jour la base de données
1. Va sur [supabase.com](https://supabase.com) → ton projet.
2. Menu **SQL Editor****New query**.
3. Copie **tout** le contenu du fichier :
- **base neuve** → `supabase/schema.sql`
- **base déjà en service** → `supabase/update_all.sql`
4. Colle-le et clique **Run**.
> C'est sans danger même si tu l'as déjà exécuté : tout est « ré-exécutable ».
> Ça met en place `is_admin`, les commentaires, les pièces jointes et les
> sécurités du panel admin.
---
## Étape 3 — Créer TON compte administrateur
Comme l'inscription publique est désactivée, on crée le **tout premier compte**
(le tien) à la main :
1. Dans Supabase → menu **Authentication****Users** → bouton **Add user**.
2. Mets ton **email** + un **mot de passe**, et coche **Auto Confirm User**.
3. Clique **Create user**.
Puis on te donne les droits admin :
4. Retourne dans **SQL Editor****New query**, colle ceci en remplaçant l'email :
```sql
update public.profiles set is_admin = true
where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
```
5. Clique **Run**.
🎉 Tu es maintenant administrateur.
---
## Étape 4 — Déployer l'Edge Function (le « serveur de confiance »)
C'est elle qui crée/supprime les comptes en sécurité. **Deux méthodes au choix.**
### Méthode A — En ligne de commande (recommandée)
Dans un terminal, à la racine du projet :
```bash
# 1. Se connecter à Supabase (ouvre le navigateur)
npx supabase login
# 2. Déployer la fonction
npx supabase functions deploy admin-users ^
--project-ref cesjrlfqeqftayhnqjkb --no-verify-jwt
```
> `--no-verify-jwt` est normal ici : la fonction vérifie elle-même que
> l'appelant est bien un admin connecté (voir le code).
> La clé `service_role` est fournie automatiquement par Supabase, tu n'as
> rien à copier.
### Méthode B — Par le tableau de bord (sans terminal)
1. Supabase → menu **Edge Functions****Create a new function**.
2. Nom : `admin-users`.
3. Colle le contenu de `supabase/functions/admin-users/index.ts`.
4. Déploie.
5. Dans les **réglages** de la fonction, **désactive « Enforce JWT Verification »**.
---
## Étape 5 — Construire et distribuer l'appli
```bash
npm run tauri:build
```
L'installateur se trouve ensuite dans :
`src-tauri/target/release/bundle/`
Envoie ce fichier à tes collègues. Les clés sont dedans : ils n'ont **rien** à régler.
---
## Au quotidien — Gérer les comptes
1. Ouvre l'appli, connecte-toi avec ton compte admin.
2. Menu **Administration** (visible uniquement pour les admins).
3. Tu peux :
- **Créer un compte** : nom, email, mot de passe provisoire (bouton
« Générer » si tu manques d'imagination), pôles, et droits admin
- **Modifier** un membre : nom, email, mot de passe, photo, pôles
- **Promouvoir / rétrograder** un admin (avec confirmation)
- **Supprimer** un compte
Communique l'email + le mot de passe provisoire à chaque membre. Il se connecte,
c'est tout. ✅
---
## Notes de sécurité
- La clé `anon` dans l'appli est **publique par nature** : aucun risque.
- La clé `service_role` (toute-puissante) **n'est jamais dans l'appli** : elle
reste cachée dans l'Edge Function, côté serveur.
- Un membre normal **ne peut pas** se promouvoir admin lui-même (bloqué côté base).
- Tu ne peux pas supprimer ni rétrograder **ton propre** compte admin (anti-blocage).
- Depuis la v0.2, la **suppression d'un projet** est réservée à son créateur ou à
un administrateur (avant, n'importe quel membre pouvait tout effacer).
---
## ⚠️ Après la mise à jour v0.2 — redéployer la fonction
La migration v10 supprime la colonne `profiles.role`, dans laquelle l'ancienne
version de l'Edge Function écrivait. **Redéploie-la**, sinon la création de
comptes échouera :
```bash
npx supabase functions deploy admin-users --no-verify-jwt
```
+148
View File
@@ -0,0 +1,148 @@
# 🔄 Mises à jour automatiques
Plus besoin d'envoyer un `.exe` à chaque correctif. L'application va chercher
elle-même la dernière version publiée.
```
Toi Ton collègue
─── ────────────
npm run release -- 0.3.0 "…"
├─ construit + signe l'installeur
├─ le dépose sur Supabase
└─ écrit latest.json
au lancement :
lit latest.json → 0.3.0 > 0.2.0
🔔 « Version 0.3.0 disponible »
clic → installe → redémarre
```
---
## Installation (une seule fois)
### 1. Créer le bucket
Supabase → **SQL Editor****New query** → colle
[`supabase/releases_bucket.sql`](supabase/releases_bucket.sql) → **Run**.
### 2. Donner au script la clé de publication
Supabase → **Settings → API** → copie la clé **`service_role`**.
Mets-la dans le fichier `.env.local` à la racine du projet :
```env
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
```
> `.env.local` est ignoré par git (règle `*.local`). Cette clé sert **uniquement**
> à téléverser depuis ta machine : elle n'entre jamais dans l'application
> distribuée à l'équipe.
### 3. 🔑 Sauvegarder la clé de signature — **le point important**
Le fichier `.tauri-signing-key.local` à la racine du projet signe chaque mise à
jour. Les applications déjà installées **n'acceptent que** les paquets signés
avec cette clé.
> ⚠️ **Si tu la perds, aucune mise à jour ne pourra plus être installée.**
> Il faudrait générer une nouvelle clé, reconstruire, et redistribuer un `.exe`
> à la main à chaque membre de l'équipe.
Copie-la maintenant dans un endroit sûr — gestionnaire de mots de passe, clé USB,
disque de sauvegarde. **Pas** dans le dépôt git.
### 4. La dernière distribution manuelle
Les versions déjà installées chez tes collègues ne connaissent pas encore le
système de mise à jour. Il faut donc leur livrer **une dernière fois** un
installeur à la main :
```bash
npm run tauri:build
```
Envoie le fichier `src-tauri/target/release/bundle/nsis/GameDev Tracker_0.2.0_x64-setup.exe`.
C'est le dernier. À partir de cette version, tout est automatique.
---
## Publier une nouvelle version
```bash
npm run release -- 0.3.0 "Filtres multiples sur les tâches, correction du Kanban"
```
Le script :
1. écrit `0.3.0` dans `package.json` et `tauri.conf.json` ;
2. construit et **signe** l'installeur (quelques minutes) ;
3. le téléverse dans le bucket `releases` ;
4. écrit `latest.json` — c'est ce fichier que les applications lisent.
Ensuite, pense à valider les deux fichiers de version :
```bash
git add package.json src-tauri/tauri.conf.json && git commit -m "v0.3.0"
```
### Quel numéro de version ?
Format `X.Y.Z`, toujours croissant.
| Ce que tu as fait | Numéro |
|---|---|
| Correction de bug | `0.2.0``0.2.1` |
| Nouvelle fonctionnalité | `0.2.1``0.3.0` |
| Refonte, changement cassant | `0.3.0``1.0.0` |
Le texte entre guillemets s'affiche dans le bandeau chez tes collègues : écris
ce qui **change pour eux**, pas le détail technique.
---
## Côté équipe : ce qu'ils voient
Une bande fine en haut de l'application :
```
┌────────────────────────────────────────────────────────────────────┐
│ ✨ Version 0.3.0 disponible. Filtres multiples sur les tâches │
│ [⬇ Installer et redémarrer] [✕ plus tard]│
└────────────────────────────────────────────────────────────────────┘
```
- Jamais de fenêtre bloquante : on peut ignorer et continuer à travailler.
- « Plus tard » masque le bandeau ; la proposition revient au lancement suivant.
- Vérification au démarrage, puis toutes les 2 heures pour les sessions longues.
- Le menu **Aide** (touche `?`) affiche la version installée et un bouton
« Rechercher une mise à jour ».
---
## En cas de problème
| Symptôme | Cause probable | Solution |
|---|---|---|
| Aucun bandeau n'apparaît | `latest.json` absent ou version identique | Ouvre l'URL du manifeste dans un navigateur, vérifie le champ `version` |
| « Signature invalide » | L'installeur a été signé avec une autre clé | Republie avec la bonne `.tauri-signing-key.local` |
| Le script dit « bucket not found » | Étape 1 non faite | Exécute `supabase/releases_bucket.sql` |
| Le script dit « clé introuvable » | `.tauri-signing-key.local` absent | Restaure ta sauvegarde (étape 3) |
| Le téléchargement échoue chez un membre | Bucket non public | Rejoue `releases_bucket.sql` |
URL du manifeste (à ouvrir dans un navigateur pour vérifier) :
```
https://<ton-projet>.supabase.co/storage/v1/object/public/releases/latest.json
```
---
## Coût
Un installeur pèse ~8 Mo. L'offre gratuite Supabase donne 1 Go de stockage et
5 Go de transfert par mois : à 6 personnes, c'est environ **100 mises à jour par
mois** avant de s'en approcher. Tu peux supprimer les vieux installeurs du
bucket de temps en temps (garde le dernier).
+194 -90
View File
@@ -1,90 +1,194 @@
# 🎮 GameDev Tracker
Application **desktop** de gestion de projet de jeu vidéo, pensée pour une **petite équipe (5-6 personnes)**.
Construite avec **Tauri + React + TypeScript + Tailwind + Framer Motion**, base de données **gratuite Supabase**.
## ✨ Fonctionnalités
- 🔐 **Connexion / inscription** par email + mot de passe
- 👥 **Équipe, rôles & pôles** — le rôle d'un membre (programmeur, artiste, game designer…) définit son **pôle**
- 📋 **Tâches & Kanban** — glisser-déposer entre *À faire / En cours / En revue / Terminé*, tâche confiée à un **pôle** avec une ou **plusieurs personnes** assignées
- ⏱️ **Feuille de temps** — vue par semaine (7 jours en colonnes) : consigne les heures faites chaque jour sur une tâche **ou** une activité libre (réunion, veille…)
- 🐛 **Suivi de bugs** — sévérité, priorité, statut, pôle, assignation
- 🔔 **Notifications** — quand une tâche/un bug est confié à un pôle, ses membres reçoivent une notif (cloche 🔔 dans la barre latérale)
- 📊 **Tableau de bord** — avancement global, répartition des tâches, temps total, échéances
- 🔄 **Synchro temps réel** — quand un membre modifie une tâche, les autres la voient se mettre à jour
---
## 🚀 Mise en route
### 1. Créer la base de données (gratuit, une seule fois pour toute l'équipe)
1. Va sur **[supabase.com](https://supabase.com)** et crée un compte.
2. Clique **New project** (choisis un mot de passe de base de données, garde-le quelque part).
3. Attends ~2 min que le projet se crée.
4. Dans le menu de gauche : **SQL Editor → New query**.
5. Ouvre le fichier [`supabase/schema.sql`](supabase/schema.sql) de ce dépôt, **copie tout son contenu**, colle-le, puis clique **Run**.
> Cela crée les tables (profils, projets, tâches, bugs, suivi du temps, notifications), la sécurité et la synchro temps réel.
>
> 🆙 **Tu avais déjà installé une version précédente (avec les jalons) ?** Exécute plutôt, dans l'ordre, [`supabase/migration_v2.sql`](supabase/migration_v2.sql) (supprime les jalons, ajoute pôles + assignation multiple + notifications), [`supabase/migration_v3.sql`](supabase/migration_v3.sql) (feuille de temps : entrées libres type réunion) puis [`supabase/migration_v4.sql`](supabase/migration_v4.sql) (plusieurs pôles par membre) — **sans perdre tes données**.
>
> 🔁 Après la migration v4, **redéploie l'Edge Function** pour que l'admin puisse gérer les pôles multiples : `supabase functions deploy admin-users --no-verify-jwt`.
6. Menu **Settings (⚙️) → API**. Note deux valeurs :
- **Project URL** → ressemble à `https://xxxxx.supabase.co`
- **anon public** (sous *Project API keys*) → longue chaîne commençant par `eyJ...`
7. **Recommandé pour une petite équipe** : désactive la confirmation d'email pour pouvoir se connecter tout de suite après l'inscription.
- Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »** et enregistre.
- (Sinon, chaque membre devra cliquer sur un lien reçu par email avant de pouvoir se connecter.)
> ️ La clé *anon public* est faite pour être distribuée à l'app cliente : pas de secret sensible exposé.
### 2. Lancer l'application
Prérequis (déjà installés sur cette machine) : **Node.js**, **Rust**.
```bash
npm install
npm run tauri:dev
```
Au premier lancement, l'app affiche un écran de **configuration** : colle l'**URL** et la **clé anon** obtenues à l'étape 1, puis **Connecter**.
Ensuite : crée ton compte (avec ton rôle), crée un **projet**, et c'est parti. 🎉
### 3. Distribuer à l'équipe
Pour générer un installeur (`.exe`/`.msi` sur Windows) :
```bash
npm run tauri:build
```
Le fichier d'installation se trouve dans `src-tauri/target/release/bundle/`.
Envoie-le à tes coéquipiers. À leur premier lancement, ils saisissent **la même URL + clé anon** que toi — ils rejoignent ainsi le même projet partagé.
---
## 🧰 Scripts utiles
| Commande | Effet |
| --- | --- |
| `npm run tauri:dev` | Lance l'app en mode développement (rechargement à chaud) |
| `npm run tauri:build` | Génère l'installeur de l'app |
| `npm run dev` | Lance uniquement le front-end dans le navigateur (sans la fenêtre desktop) |
| `npm run typecheck` | Vérifie les types TypeScript |
## 🏗️ Architecture
```text
├─ index.html # point d'entrée du front
├─ src/ # application React (front-end)
│ ├─ pages/ # Dashboard, Kanban, Planning (feuille de temps), Bugs, Members, Admin, Login, Setup
│ ├─ components/ # Layout, UI, animations
│ └─ lib/ # client Supabase, types, contexte global, config
├─ src-tauri/ # enveloppe desktop Tauri (Rust)
└─ supabase/schema.sql # schéma de la base à exécuter dans Supabase
```
## 🔒 Note de sécurité
L'app suppose une **équipe de confiance** : tout membre connecté peut lire et modifier les données du projet (politique RLS « accès complet pour les utilisateurs authentifiés »). C'est volontaire pour rester simple sur une petite équipe. Si un jour tu veux des permissions plus fines (ex. seul le lead supprime un projet), il faudra ajuster les *policies* dans `supabase/schema.sql`.
# 🎮 GameDev Tracker
Application **desktop** de gestion de projet de jeu vidéo, pensée pour une **petite équipe (5-6 personnes)**.
**Tauri + React + TypeScript + Tailwind**, base de données **Supabase** (offre gratuite suffisante).
---
## ✨ Ce que fait l'application
| | |
|---|---|
| 📊 **Tableau de bord** | Ce que **tu** as à faire, avancement, bugs par sévérité, heures de l'équipe, dernières discussions |
| 📋 **Tâches (Kanban)** | 4 colonnes, glisser-déposer **et déplacement au clavier**, checklists, étiquettes, priorité, échéance |
| 🐛 **Bugs** | Sévérité expliquée, tri et filtres, **captures d'écran collables (Ctrl+V)**, temps passé |
| ⏱️ **Feuille de temps** | Semaine en 7 colonnes, saisie sur une tâche / un bug / une activité libre, **export CSV** |
| 📚 **Documentation** | Fiches en Markdown : GDD, conventions, notes de réunion — créées dans l'app ou **importées depuis un `.md` / `.docx`** |
| 💬 **Discussions** | Fil de commentaires sur chaque tâche et chaque bug, avec notification des personnes concernées |
| 📎 **Pièces jointes** | Fichiers et captures rattachés aux tâches et aux bugs |
| 👥 **Équipe & pôles** | Un membre appartient à un ou plusieurs pôles (programmeur, artiste, sound designer…) |
| 🔔 **Notifications** | Quand une tâche/un bug t'est confié — par ton **pôle** ou **nommément** — et à chaque message |
| 🔎 **Recherche globale** | `Ctrl + K` : tâches, bugs, personnes, actions |
| 🔄 **Temps réel** | Tout se synchronise entre les membres, sans recharger |
| 🌗 **Thème** | Clair, sombre, ou suivant le système |
| 🔄 **Mises à jour auto** | L'app se met à jour toute seule — plus de `.exe` à renvoyer ([détails](MISE_A_JOUR.md)) |
---
## 🚀 Mise en route
### 1. Créer la base de données (une seule fois, pour toute l'équipe)
1. Crée un compte sur **[supabase.com](https://supabase.com)** puis un **New project** (offre gratuite).
2. Menu **SQL Editor → New query**.
3. Colle le contenu de **[`supabase/schema.sql`](supabase/schema.sql)** et clique **Run**.
> 🆙 **Tu avais déjà une version précédente installée ?**
> Exécute plutôt **[`supabase/update_all.sql`](supabase/update_all.sql)** : il met la base à jour
> sans rien perdre (voir « Mise à jour v0.2 » plus bas).
4. Menu **Authentication → Sign In / Providers → Email** : désactive **« Confirm email »**
(sinon chaque membre doit cliquer un lien reçu par mail avant de pouvoir se connecter).
5. Menu **Settings → API** : note l'**URL du projet** et la clé **anon public**.
### 2. Configurer l'application
Crée un fichier `.env` à la racine :
```env
VITE_SUPABASE_URL=https://xxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOi...
```
> La clé *anon* est **publique par nature** : c'est la sécurité au niveau des lignes (RLS)
> de Supabase qui protège les données, pas le secret de cette clé.
### 3. Lancer
Prérequis : **Node.js** et **Rust**.
```bash
npm install
npm run tauri:dev
```
### 4. Devenir administrateur (une seule fois)
Crée ton compte, puis dans le **SQL Editor** de Supabase :
```sql
update public.profiles set is_admin = true
where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
```
Déploie ensuite l'espace d'administration (voir **[GUIDE_ADMIN.md](GUIDE_ADMIN.md)**) :
```bash
supabase functions deploy admin-users --no-verify-jwt
```
Tu peux alors créer les comptes de toute l'équipe depuis l'onglet **Administration**.
### 5. Distribuer — une seule fois
```bash
npm run tauri:build
```
Génère un installeur dans `src-tauri/target/release/bundle/nsis/`. Envoie-le à
ton équipe : **c'est la dernière fois que tu le fais à la main.**
### 6. Activer les mises à jour automatiques
Ensuite, chaque nouvelle version se publie en une commande :
```bash
npm run release -- 0.3.0 "Ce qui change pour l'équipe"
```
Tes collègues voient un bandeau « Version disponible » au lancement, cliquent, et
l'application se met à jour toute seule. Mise en place (une fois) et mode
d'emploi complet : **[MISE_A_JOUR.md](MISE_A_JOUR.md)**.
---
## ⌨️ Raccourcis clavier
| Raccourci | Action |
|---|---|
| `Ctrl + K` ou `/` | Recherche globale / palette de commandes |
| `Ctrl + 1…6` | Changer d'écran |
| `Ctrl + B` | Replier / déployer le menu latéral |
| `?` | Afficher l'aide |
| `Échap` | Fermer une fenêtre |
| `Espace` puis `← → ↑ ↓` | Déplacer une carte du Kanban **au clavier** |
| `Ctrl + Entrée` | Envoyer un commentaire |
| `Ctrl + S` | Enregistrer une fiche de documentation |
| `Alt + ← / →` | Semaine précédente / suivante (feuille de temps) |
---
## 🧱 Architecture
```
src/
lib/
supabase.ts Client unique, clés lues depuis .env
db.ts run/rows/mutate + useQuery + useRealtime ← toute la fiabilité est ici
AppContext.tsx Session, profil, membres, projets, écran courant
types.ts Types métier + libellés + helpers de formatage
tones.ts Couleurs sémantiques (badges, pastilles) — clair ET sombre
notify.ts Qui prévenir, et quand
storage.ts Avatars et pièces jointes
markdown.ts Rendu Markdown assaini + import .md/.docx
feedback.tsx Toasts et confirmations (le SEUL système de retour utilisateur)
theme.ts Clair / sombre / système
components/
ui.tsx Primitives : Button, IconButton, Field, états vide/erreur/chargement
Modal.tsx Modale : en-tête et pied fixes, piège à focus, garde anti-perte de saisie
Select.tsx Menu déroulant entièrement pilotable au clavier
people.tsx Avatars, pôles, assignation, étiquettes
EntityExtras.tsx Discussion, pièces jointes, temps passé (tâches ET bugs)
Layout.tsx Barre latérale + UNE barre supérieure + raccourcis
pages/ Un fichier par écran
supabase/
schema.sql Installation neuve
update_all.sql Mise à jour d'une base existante
migration_v10.sql La refonte v0.2 seule
migration_v11.sql La documentation seule
functions/admin-users/ Edge Function de gestion des comptes
```
### Trois règles à respecter en contribuant
1. **Aucune requête sans gestion d'erreur.** On passe par `run` / `rows` / `mutate` de
`lib/db.ts`, jamais par `const { data } = await supa()...`.
2. **Aucune couleur en dur.** Pas de `bg-amber-100` ni de `text-gray-500` : on utilise les
tokens (`bg-panel`, `text-muted`…) et le système de tons de `lib/tones.ts`.
3. **Aucun bouton écrit à la main.** `Button` / `IconButton` de `components/ui.tsx`.
Un bouton-icône a toujours un `label` (infobulle **et** lecteur d'écran).
---
## ✅ Vérifications
```bash
npm run check # types + lint
npm run build # typecheck puis build de production
npm run release -- X.Y.Z "notes" # publie une version pour toute l'équipe
```
---
## 🆙 Mise à jour v0.2
La migration `supabase/update_all.sql` :
- ajoute les tables **`comments`** et **`attachments`** (+ bucket de stockage) ;
- ajoute **`bugs.poles`** (plusieurs pôles par bug) et **`time_logs.bug_id`** (temps sur un bug) ;
- **supprime les colonnes de compatibilité** `profiles.role`, `tasks.pole`, `bugs.assignee_id`
après avoir recopié leur contenu dans les tableaux correspondants ;
- ajoute la fonction **`reorder_tasks`** (réordonnancement du Kanban en une transaction) ;
- réserve la **suppression d'un projet** à son créateur ou à un administrateur.
> ⚠️ **Redéploie l'Edge Function après la migration** — elle écrivait dans `profiles.role`,
> qui n'existe plus :
> ```bash
> supabase functions deploy admin-users --no-verify-jwt
> ```
---
## 📄 Licence
MIT.
-5
View File
@@ -1,5 +0,0 @@
Sur ton autre PC (fixe/portable)
Première fois (si le projet n'y est pas encore) :
npm install
npm run tauri:dev
+48
View File
@@ -0,0 +1,48 @@
// Configuration ESLint (format « flat », ESLint 9).
// L'application avait des commentaires `eslint-disable` un peu partout…
// sans qu'ESLint ne soit installé. Il l'est maintenant.
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default tseslint.config(
{ ignores: ['dist', 'node_modules', 'src-tauri', 'supabase/functions'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': 'off',
// Les dépendances de hooks manquantes ont causé de vrais bugs de
// rafraîchissement : c'est une erreur, pas un avertissement.
'react-hooks/exhaustive-deps': 'error',
// Avertissement, pas erreur : réagir à un changement de contexte externe
// (notification qui demande d'ouvrir une fiche, ouverture d'un menu)
// passe légitimement par un setState dans un effet.
'react-hooks/set-state-in-effect': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
eqeqeq: ['error', 'smart'],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error'
}
}
)
+34
View File
@@ -5,6 +5,40 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>GameDev Tracker</title>
<!--
Couleur de fond appliquée AVANT le CSS et le JS.
Sans ça, la toute première image peinte par le webview est blanche —
un éclair visible au démarrage, particulièrement en thème sombre.
Les valeurs reprennent --c-bg de src/index.css : toute modification
-bas doit être répercutée ici.
-->
<style>
html,
body {
margin: 0;
height: 100%;
background: #fafaf9;
}
html.dark,
html.dark body {
background: #0f0f13;
}
</style>
<script>
// Choix du thème le plus tôt possible, avant le premier rendu.
// Doit rester aligné sur src/lib/theme.ts (défaut = clair).
try {
var t = localStorage.getItem('gamedev-tracker-theme')
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
} catch (e) {
/* localStorage indisponible : on reste en clair */
}
</script>
</head>
<body>
<div id="root"></div>
+1479 -3
View File
File diff suppressed because it is too large Load Diff
+20 -5
View File
@@ -1,38 +1,53 @@
{
"name": "gamedev-tracker",
"version": "0.1.0",
"description": "Application de gestion de projet de jeu vidéo pour petite équipe",
"version": "0.2.7",
"description": "Gestion de projet de jeu video pour petite equipe (Tauri + React + Supabase)",
"type": "module",
"author": "Mathew",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"typecheck": "tsc --noEmit -p tsconfig.json"
"tauri:build": "node scripts/tauri-build.mjs",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"check": "npm run typecheck && npm run lint",
"release": "node scripts/release.mjs"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.4",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"date-fns": "^3.6.0",
"dompurify": "^3.4.13",
"framer-motion": "^11.5.4",
"lucide-react": "^0.441.0",
"mammoth": "^1.12.0",
"marked": "^18.0.9",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^20.16.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.9.0",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"typescript-eslint": "^8.66.0",
"vite": "^5.4.3"
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Génère les deux images de l'installeur NSIS à partir du logo de l'app.
python scripts/installer-art.py
NSIS impose du BMP à des tailles fixes :
· sidebar.bmp 164 x 314 — panneau de gauche (accueil et fin)
· header.bmp 150 x 57 — bandeau des pages intermédiaires
Les images sont versionnées dans src-tauri/installer/ : ce script n'est à
relancer que si le logo ou les couleurs de marque changent (il demande
Pillow, qui n'est PAS une dépendance du projet).
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "src-tauri" / "installer"
ICON = ROOT / "src-tauri" / "icons" / "128x128@2x.png"
# Dégradé du logo (public/logo.svg)
INDIGO = (99, 102, 241)
TEAL = (20, 184, 166)
INK = (16, 16, 24)
INK_SOFT = (30, 29, 48)
def font(size: int, bold: bool = False):
"""Segoe UI si disponible, repli sur la police par défaut de Pillow."""
for name in (["segoeuib.ttf", "seguisb.ttf"] if bold else ["segoeui.ttf"]):
try:
return ImageFont.truetype(f"C:/Windows/Fonts/{name}", size)
except OSError:
continue
return ImageFont.load_default()
def lerp(a, b, t):
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
def vertical_gradient(size, top, bottom):
w, h = size
img = Image.new("RGB", size)
d = ImageDraw.Draw(img)
for y in range(h):
d.line([(0, y), (w, y)], fill=lerp(top, bottom, y / max(h - 1, 1)))
return img
def horizontal_gradient(size, left, right):
w, h = size
img = Image.new("RGB", size)
d = ImageDraw.Draw(img)
for x in range(w):
d.line([(x, 0), (x, h)], fill=lerp(left, right, x / max(w - 1, 1)))
return img
def logo(px: int) -> Image.Image:
return Image.open(ICON).convert("RGBA").resize((px, px), Image.LANCZOS)
# ---------------------------------------------------------------------
# Panneau latéral 164 x 314 — fond sombre, logo, nom, liseré de marque
# ---------------------------------------------------------------------
def sidebar() -> Image.Image:
W, H = 164, 314
img = vertical_gradient((W, H), INK_SOFT, INK)
d = ImageDraw.Draw(img, "RGBA")
# Halo indigo diffus derrière le logo, pour éviter le fond plat.
cx, cy = W // 2, 96
for r in range(92, 0, -4):
alpha = int(30 * (1 - r / 92) ** 2)
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(*INDIGO, alpha))
mark = logo(76)
img.paste(mark, (cx - 38, cy - 38), mark)
title = font(15, bold=True)
sub = font(11)
d.text((cx, 168), "GameDev", font=title, fill=(255, 255, 255), anchor="mm")
d.text((cx, 186), "Tracker", font=title, fill=(255, 255, 255), anchor="mm")
d.text((cx, 210), "Gestion de projet", font=sub, fill=(150, 148, 170), anchor="mm")
d.text((cx, 225), "de jeu vidéo", font=sub, fill=(150, 148, 170), anchor="mm")
# Liseré dégradé en pied de panneau : la signature visuelle de la marque.
bar = horizontal_gradient((W, 4), INDIGO, TEAL)
img.paste(bar, (0, H - 4))
return img
# ---------------------------------------------------------------------
# Bandeau 150 x 57 — fond SOMBRE, accordé à MUI_BGCOLOR du template
# ---------------------------------------------------------------------
def header() -> Image.Image:
W, H = 150, 57
img = Image.new("RGB", (W, H), INK)
d = ImageDraw.Draw(img)
mark = logo(34)
img.paste(mark, (W - 34 - 12, (H - 34) // 2), mark)
name = font(11, bold=True)
d.text((W - 56, H // 2 - 7), "GameDev", font=name, fill=(255, 255, 255), anchor="rm")
d.text((W - 56, H // 2 + 7), "Tracker", font=name, fill=(255, 255, 255), anchor="rm")
bar = horizontal_gradient((W, 3), INDIGO, TEAL)
img.paste(bar, (0, H - 3))
return img
OUT.mkdir(parents=True, exist_ok=True)
for name, image in (("sidebar", sidebar()), ("header", header())):
path = OUT / f"{name}.bmp"
# BMP 24 bits sans alpha : le seul format que NSIS accepte partout.
image.convert("RGB").save(path, "BMP")
print(f" {path.relative_to(ROOT)} {image.width}x{image.height}")
print("\nImages générées.")
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env node
// =====================================================================
// Publication d'une nouvelle version.
//
// npm run release -- 0.3.0 "Ce qui change pour l'équipe"
//
// Ce que fait le script, dans l'ordre :
// 1. écrit le numéro de version dans package.json et tauri.conf.json
// 2. construit et SIGNE l'installeur Windows
// 3. téléverse l'installeur dans le bucket public « releases »
// 4. écrit latest.json — c'est ce fichier que les applications lisent
//
// À partir de là, chaque membre voit un bandeau « Version X disponible »
// au lancement, clique, et c'est fait.
// =====================================================================
import { execSync } from 'node:child_process'
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs'
import { join, basename } from 'node:path'
import { ROOT, KEY_PATH, signingEnv, tauriBin } from './signing.mjs'
const BUCKET = 'releases'
// ---------------------------------------------------------------------
// Petits utilitaires
// ---------------------------------------------------------------------
const c = {
ok: (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`),
step: (m) => console.log(`\n\x1b[1m\x1b[36m▸ ${m}\x1b[0m`),
info: (m) => console.log(` ${m}`),
warn: (m) => console.log(`\x1b[33m!\x1b[0m ${m}`)
}
function fail(message, hint) {
console.error(`\n\x1b[31m✗ ${message}\x1b[0m`)
if (hint) console.error(`\n${hint}\n`)
process.exit(1)
}
/** Lit .env puis .env.local (le second gagne). */
function loadEnv() {
const env = {}
for (const file of ['.env', '.env.local']) {
const path = join(ROOT, file)
if (!existsSync(path)) continue
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/i)
if (m) env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
}
}
return env
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
function writeJson(path, data) {
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
}
// ---------------------------------------------------------------------
// 1) Arguments et vérifications
// ---------------------------------------------------------------------
const [version, ...noteParts] = process.argv.slice(2)
const notes = noteParts.join(' ').trim()
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
fail(
'Numéro de version manquant ou invalide.',
'Utilisation :\n' +
' npm run release -- 1.2.3 "Ce qui change pour l\'équipe"\n\n' +
'Le numéro doit être au format X.Y.Z.'
)
}
const env = loadEnv()
const SUPABASE_URL = env.VITE_SUPABASE_URL
const SERVICE_KEY = env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL) fail('VITE_SUPABASE_URL absent du fichier .env')
if (!SERVICE_KEY) {
fail(
'SUPABASE_SERVICE_ROLE_KEY absent du fichier .env.local',
'Cette clé sert UNIQUEMENT à téléverser depuis ta machine — elle ne part\n' +
"jamais dans l'application distribuée.\n\n" +
'Récupère-la dans Supabase → Settings → API → « service_role », puis\n' +
'crée un fichier .env.local à la racine avec :\n\n' +
' SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...\n\n' +
'(.env.local est ignoré par git : la clé ne sera jamais publiée.)'
)
}
const currentPkg = readJson(join(ROOT, 'package.json'))
if (currentPkg.version === version) {
c.warn(`package.json est déjà en ${version} — on republie par-dessus.`)
}
// ---------------------------------------------------------------------
// 2) Numéro de version
// ---------------------------------------------------------------------
c.step(`Version ${version}`)
const pkgPath = join(ROOT, 'package.json')
const pkg = readJson(pkgPath)
pkg.version = version
writeJson(pkgPath, pkg)
c.ok('package.json')
const confPath = join(ROOT, 'src-tauri', 'tauri.conf.json')
const conf = readJson(confPath)
conf.version = version
writeJson(confPath, conf)
c.ok('tauri.conf.json')
// ---------------------------------------------------------------------
// 3) Construction signée
// ---------------------------------------------------------------------
c.step('Construction de l\'installeur (plusieurs minutes)')
try {
execSync(`"${tauriBin()}" build`, { cwd: ROOT, stdio: 'inherit', env: signingEnv() })
} catch {
fail('La construction a échoué. Corrige l\'erreur ci-dessus puis relance.')
}
// ---------------------------------------------------------------------
// 4) Retrouver l'installeur et sa signature
// ---------------------------------------------------------------------
c.step('Recherche des fichiers produits')
const bundleDir = join(ROOT, 'src-tauri', 'target', 'release', 'bundle')
/** Cherche le premier fichier correspondant, dans l'ordre de préférence. */
function findBundle() {
// NSIS d'abord : c'est le seul format que l'updater Windows sait installer
// sans intervention (le .msi exige des droits administrateur à chaque fois).
for (const [dir, ext] of [
['nsis', '-setup.exe'],
['msi', '.msi']
]) {
const path = join(bundleDir, dir)
if (!existsSync(path)) continue
const file = readdirSync(path).find((f) => f.endsWith(ext) && f.includes(version))
if (file) return join(path, file)
}
return null
}
const installer = findBundle()
if (!installer) {
fail(
`Aucun installeur trouvé pour la version ${version}.`,
`Regarde dans ${bundleDir}.`
)
}
const sigPath = `${installer}.sig`
if (!existsSync(sigPath)) {
fail(
'Signature absente : l\'installeur n\'a pas été signé.',
`Vérifie que ${KEY_PATH} existe et que la section "plugins.updater"\n` +
'de tauri.conf.json contient bien "pubkey".'
)
}
const signature = readFileSync(sigPath, 'utf8').trim()
const installerName = basename(installer)
const installerBytes = readFileSync(installer)
c.ok(`${installerName} (${(installerBytes.length / 1048576).toFixed(1)} Mo)`)
// ---------------------------------------------------------------------
// 5) Téléversement
// ---------------------------------------------------------------------
c.step('Téléversement vers Supabase')
async function upload(name, body, contentType) {
const res = await fetch(
`${SUPABASE_URL}/storage/v1/object/${BUCKET}/${encodeURIComponent(name)}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${SERVICE_KEY}`,
'Content-Type': contentType,
'x-upsert': 'true', // republier une version écrase l'ancienne
'cache-control': 'max-age=60'
},
body
}
)
if (!res.ok) {
const text = await res.text()
if (/bucket not found/i.test(text)) {
fail(
`Le bucket « ${BUCKET} » n'existe pas.`,
'Exécute une seule fois, dans le SQL Editor de Supabase :\n\n' +
" insert into storage.buckets (id, name, public)\n" +
` values ('${BUCKET}', '${BUCKET}', true) on conflict (id) do nothing;\n\n` +
" create policy releases_read on storage.objects\n" +
` for select using (bucket_id = '${BUCKET}');`
)
}
fail(`Téléversement de ${name} refusé (HTTP ${res.status})`, text)
}
}
await upload(installerName, installerBytes, 'application/octet-stream')
c.ok(installerName)
// ---------------------------------------------------------------------
// 6) Le manifeste — c'est lui que les applications interrogent
// ---------------------------------------------------------------------
const manifest = {
version,
notes: notes || `Version ${version}`,
pub_date: new Date().toISOString(),
platforms: {
'windows-x86_64': {
signature,
url: `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${encodeURIComponent(installerName)}`
}
}
}
await upload('latest.json', JSON.stringify(manifest, null, 2), 'application/json')
c.ok('latest.json')
// ---------------------------------------------------------------------
c.step(`Version ${version} publiée 🎉`)
c.info('Chaque membre verra le bandeau « Mise à jour disponible » au prochain')
c.info('lancement (ou dans les 2 heures si son application reste ouverte).')
c.info('')
c.info('Pense à valider les fichiers modifiés :')
c.info(' git add package.json src-tauri/tauri.conf.json && git commit -m "v' + version + '"')
console.log()
+43
View File
@@ -0,0 +1,43 @@
// =====================================================================
// Clé de signature des mises à jour — lecture commune aux scripts.
//
// Le CLI Tauri 2.11 n'honore pas TAURI_SIGNING_PRIVATE_KEY_PATH : il ne
// lit que TAURI_SIGNING_PRIVATE_KEY, qui accepte soit un chemin, soit la
// clé elle-même. On lui passe donc directement le contenu du fichier.
// =====================================================================
import { readFileSync, existsSync } from 'node:fs'
import { resolve, join } from 'node:path'
export const ROOT = resolve(import.meta.dirname, '..')
export const KEY_PATH = join(ROOT, '.tauri-signing-key.local')
/**
* Renvoie l'environnement à passer à `tauri build`, clé de signature
* incluse. Sort du programme avec un message clair si la clé manque —
* sans elle, aucune mise à jour ne serait acceptée par les installations
* existantes.
*/
export function signingEnv() {
if (!existsSync(KEY_PATH)) {
console.error(
'\n\x1b[31m✗ Clé de signature introuvable : .tauri-signing-key.local\x1b[0m\n\n' +
'Elle signe chaque mise à jour. Restaure ta sauvegarde (MISE_A_JOUR.md, étape 3).\n\n' +
"Si tu n'as PAS encore de clé — attention, les applications déjà installées\n" +
'avec une autre clé ne pourront plus jamais se mettre à jour :\n\n' +
' npx tauri signer generate --ci -p "" -w .tauri-signing-key.local\n'
)
process.exit(1)
}
return {
...process.env,
TAURI_SIGNING_PRIVATE_KEY: readFileSync(KEY_PATH, 'utf8').trim(),
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
}
}
/** Chemin du binaire Tauri local (npx inutile, et pas de shell à deviner). */
export function tauriBin() {
return join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tauri.cmd' : 'tauri')
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
// =====================================================================
// `npm run tauri:build`
//
// 1. construit et SIGNE l'application
// 2. dépose LE fichier à distribuer dans `release/`, sous son vrai nom
//
// Le setup NSIS produit dans `src-tauri/target/release/bundle/nsis/` ne
// sert QU'À l'updater (installation silencieuse des mises à jour). Il
// reste enfoui volontairement : c'est `release/GameDev Tracker.exe` qu'on
// envoie à l'équipe, et lui seul.
// =====================================================================
import { execSync } from 'node:child_process'
import { copyFileSync, mkdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { ROOT, signingEnv, tauriBin } from './signing.mjs'
const extra = process.argv.slice(2).join(' ')
execSync(`"${tauriBin()}" build ${extra}`.trim(), {
cwd: ROOT,
stdio: 'inherit',
env: signingEnv()
})
// --- Le fichier à distribuer -----------------------------------------
const built = join(ROOT, 'src-tauri', 'target', 'release', 'app.exe')
const outDir = join(ROOT, 'release')
const out = join(outDir, 'GameDev Tracker.exe')
mkdirSync(outDir, { recursive: true })
copyFileSync(built, out)
const mb = (statSync(out).size / 1048576).toFixed(1)
console.log(
`
✓ À DISTRIBUER ${out} (${mb} Mo)
` +
` C'est CE fichier qu'on envoie à l'équipe : il contient l'application
` +
` ET son écran d'installation.
` +
` (le setup NSIS enfoui dans src-tauri/target/… ne sert qu'aux
` +
` mises à jour automatiques — ne le distribue pas)
`
)
+611 -13
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -23,3 +23,15 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
# Installeur intégré (src/installer.rs) : dossiers standards + registre Windows.
dirs = "5"
[target.'cfg(windows)'.dependencies]
winreg = "0.52"
# Mises a jour automatiques (uniquement sur les plateformes de bureau).
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
+16 -11
View File
@@ -1,11 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions de base + fenetre de l'installeur integre.",
"windows": [
"main"
],
"permissions": [
"core:default",
"updater:default",
"process:allow-restart",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-close"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+310
View File
@@ -0,0 +1,310 @@
//! Installeur intégré à l'application (« self-installing portable »).
//!
//! Le `.exe` distribué EST l'application. Lancé depuis un dossier autre que
//! le dossier d'installation, il affiche son propre écran d'installation —
//! écrit en React, avec le design system de l'app — qui se recopie dans
//! `%LOCALAPPDATA%\GameDev Tracker`, crée les raccourcis, s'enregistre
//! auprès de Windows, puis se relance depuis l'emplacement installé.
//!
//! Pas de fenêtre NSIS pour la première installation.
//!
//! # Cohabitation avec les mises à jour automatiques
//!
//! Les mises à jour, elles, continuent de passer par l'installeur NSIS
//! silencieux (invisible pour l'utilisateur). Pour qu'il écrase la même
//! installation au lieu d'en créer une seconde, on reproduit ICI très
//! exactement ce que le template NSIS de Tauri écrit et relit :
//!
//! * dossier par défaut : `%LOCALAPPDATA%\{PRODUCT_NAME}`
//! (cf. `StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}"`)
//! * clé de localisation : `HKCU\Software\{PUBLISHER}\{PRODUCT_NAME}`, valeur
//! par défaut (cf. `RestorePreviousInstallLocation`)
//! * clé de désinstallation : `HKCU\…\Uninstall\{PRODUCT_NAME}`
//!
//! Toute modification de ces trois constantes doit rester alignée sur
//! `src-tauri/installer/installer.nsi`, sinon une mise à jour installerait
//! une deuxième copie à côté de la première.
#![cfg(windows)]
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Serialize;
/// Doit correspondre à `productName` de tauri.conf.json (= `${PRODUCTNAME}`).
pub const PRODUCT_NAME: &str = "GameDev Tracker";
/// Doit correspondre à `bundle.publisher` (= `${MANUFACTURER}`).
pub const PUBLISHER: &str = "Mathew";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Nom du binaire une fois installé (= `${MAINBINARYNAME}.exe`).
const BINARY_NAME: &str = "app.exe";
fn uninstall_key() -> String {
format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{}",
PRODUCT_NAME
)
}
/// `Software\{PUBLISHER}\{PRODUCT_NAME}` — c'est là que NSIS va relire le
/// dossier d'installation existant.
fn manu_product_key() -> String {
format!("Software\\{}\\{}", PUBLISHER, PRODUCT_NAME)
}
/// `%LOCALAPPDATA%\GameDev Tracker` — le même défaut que NSIS en mode
/// « currentUser ».
pub fn default_install_dir() -> Option<PathBuf> {
dirs::data_local_dir().map(|d| d.join(PRODUCT_NAME))
}
fn installed_exe_in(dir: &Path) -> PathBuf {
dir.join(BINARY_NAME)
}
fn canon(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
/// Dossier d'installation enregistré, s'il existe.
pub fn registered_install_dir() -> Option<PathBuf> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// 1) La clé que NSIS relit en priorité.
if let Ok(key) = hkcu.open_subkey(manu_product_key()) {
if let Ok(dir) = key.get_value::<String, _>("") {
if !dir.is_empty() {
return Some(PathBuf::from(dir));
}
}
}
// 2) Repli : l'entrée « Applications installées » de Windows.
// NSIS y écrit le chemin ENTRE GUILLEMETS — sans ce nettoyage, on
// obtiendrait un dossier littéralement nommé `"C:\...\GameDev Tracker"`.
let key = hkcu.open_subkey(uninstall_key()).ok()?;
let dir: String = key.get_value("InstallLocation").ok()?;
let dir = dir.trim().trim_matches('"').to_string();
(!dir.is_empty()).then(|| PathBuf::from(dir))
}
/// Le binaire en cours d'exécution est-il celui qui est installé ?
///
/// C'est ce test qui décide, au démarrage, entre « afficher l'installeur »
/// et « démarrer l'application ».
pub fn is_running_installed() -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
let Some(dir) = exe.parent() else {
return false;
};
let dir = canon(dir);
if let Some(registered) = registered_install_dir() {
if canon(&registered) == dir {
return true;
}
}
// Même sans registre : si on tourne depuis le dossier d'installation
// par défaut, c'est qu'on est installé (cas d'une mise à jour NSIS qui
// aurait réécrit les clés autrement).
default_install_dir().map(|d| canon(&d) == dir).unwrap_or(false)
}
/// Recopie le binaire courant dans le dossier d'installation.
fn copy_exe(dst_dir: &Path) -> Result<PathBuf, String> {
let src = std::env::current_exe().map_err(|e| format!("Binaire courant introuvable : {e}"))?;
let dst = installed_exe_in(dst_dir);
std::fs::create_dir_all(dst_dir)
.map_err(|e| format!("Création de « {} » impossible : {e}", dst_dir.display()))?;
// Réinstallation par-dessus : on écrase.
if dst.exists() {
std::fs::remove_file(&dst).ok();
}
std::fs::copy(&src, &dst).map_err(|e| format!("Copie impossible : {e}"))?;
Ok(dst)
}
/// Écrit un raccourci `.lnk` via WScript.Shell — évite d'embarquer une
/// bibliothèque COM entière pour cette seule opération.
fn write_lnk(lnk: &Path, target: &Path) -> Result<(), String> {
let script = format!(
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{}');\
$s.TargetPath='{}';$s.WorkingDirectory='{}';$s.Description='{}';$s.Save()",
lnk.display(),
target.display(),
target.parent().map(|p| p.display().to_string()).unwrap_or_default(),
PRODUCT_NAME
);
let mut cmd = Command::new("powershell");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output().map_err(|e| format!("PowerShell indisponible : {e}"))?;
if !out.status.success() {
return Err(format!(
"Raccourci « {} » non créé : {}",
lnk.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
fn create_start_menu_shortcut(target: &Path) -> Result<(), String> {
let dir = dirs::data_dir()
.ok_or("APPDATA introuvable")?
.join("Microsoft/Windows/Start Menu/Programs");
std::fs::create_dir_all(&dir).ok();
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
}
fn create_desktop_shortcut(target: &Path) -> Result<(), String> {
let dir = dirs::desktop_dir().ok_or("Bureau introuvable")?;
write_lnk(&dir.join(format!("{PRODUCT_NAME}.lnk")), target)
}
fn dir_size_kb(dir: &Path) -> u32 {
fn walk(p: &Path) -> u64 {
let mut total = 0;
if let Ok(entries) = std::fs::read_dir(p) {
for e in entries.flatten() {
match e.metadata() {
Ok(m) if m.is_dir() => total += walk(&e.path()),
Ok(m) => total += m.len(),
Err(_) => {}
}
}
}
total
}
((walk(dir) / 1024) as u32).max(1)
}
/// Enregistre l'application auprès de Windows : elle apparaît dans
/// « Paramètres → Applications », avec un bouton de désinstallation.
fn write_registry(exe: &Path, install_dir: &Path) -> Result<(), String> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let exe_s = exe.display().to_string();
let dir_s = install_dir.display().to_string();
// 1) Localisation — c'est CETTE clé que l'updater NSIS relit pour
// réinstaller au même endroit.
let (loc, _) = hkcu
.create_subkey(manu_product_key())
.map_err(|e| format!("Registre (localisation) : {e}"))?;
loc.set_value("", &dir_s).map_err(|e| format!("Registre : {e}"))?;
// 2) Entrée « Applications installées ».
let (key, _) = hkcu
.create_subkey(uninstall_key())
.map_err(|e| format!("Registre (désinstallation) : {e}"))?;
let set = |name: &str, value: &str| key.set_value(name, &value.to_string());
set("DisplayName", PRODUCT_NAME).ok();
set("DisplayVersion", VERSION).ok();
set("Publisher", PUBLISHER).ok();
set("InstallLocation", &dir_s).ok();
set("DisplayIcon", &exe_s).ok();
set("UninstallString", &format!("\"{exe_s}\" --uninstall")).ok();
set("QuietUninstallString", &format!("\"{exe_s}\" --uninstall --silent")).ok();
// MainBinaryName : lu par l'updater NSIS pour nettoyer l'ancien binaire.
set("MainBinaryName", BINARY_NAME).ok();
key.set_value("NoModify", &1u32).ok();
key.set_value("NoRepair", &1u32).ok();
key.set_value("EstimatedSize", &dir_size_kb(install_dir)).ok();
Ok(())
}
// =====================================================================
// Commandes exposées au front
// =====================================================================
#[derive(Serialize)]
pub struct InstallerStatus {
/// `true` = on tourne depuis le dossier installé → démarrer l'app.
pub running_installed: bool,
/// Une installation existe déjà (réinstallation / mise à jour manuelle).
pub already_installed: bool,
pub install_dir: String,
pub version: String,
pub product_name: String,
}
#[tauri::command]
pub fn installer_status() -> InstallerStatus {
let dir = registered_install_dir()
.or_else(default_install_dir)
.unwrap_or_default();
InstallerStatus {
running_installed: is_running_installed(),
already_installed: registered_install_dir()
.map(|d| installed_exe_in(&d).exists())
.unwrap_or(false),
install_dir: dir.display().to_string(),
version: VERSION.to_string(),
product_name: PRODUCT_NAME.to_string(),
}
}
/// Installe : copie, raccourcis, registre. Renvoie le dossier d'installation.
#[tauri::command]
pub fn installer_install(desktop_shortcut: bool) -> Result<String, String> {
let dir = default_install_dir().ok_or("LOCALAPPDATA introuvable")?;
let exe = copy_exe(&dir)?;
// Les raccourcis ne sont pas critiques : on n'annule pas une
// installation réussie parce qu'un .lnk n'a pas pu être écrit.
if let Err(e) = create_start_menu_shortcut(&exe) {
log::warn!("{e}");
}
if desktop_shortcut {
if let Err(e) = create_desktop_shortcut(&exe) {
log::warn!("{e}");
}
}
write_registry(&exe, &dir)?;
Ok(dir.display().to_string())
}
/// Démarre la version installée puis quitte le processus courant.
#[tauri::command]
pub fn installer_launch(app: tauri::AppHandle) -> Result<(), String> {
let dir = registered_install_dir()
.or_else(default_install_dir)
.ok_or("Aucune installation enregistrée")?;
let target = installed_exe_in(&dir);
let mut cmd = Command::new(&target);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0000_0008); // DETACHED_PROCESS
}
cmd.spawn()
.map_err(|e| format!("Démarrage de « {} » impossible : {e}", target.display()))?;
// On laisse au nouveau processus le temps de prendre la main : sans ce
// délai, la fenêtre disparaît avant que la suivante n'apparaisse.
let handle = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
handle.exit(0);
});
Ok(())
}
+77 -1
View File
@@ -1,6 +1,46 @@
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[cfg(windows)]
mod installer;
/// L'application tourne-t-elle depuis son dossier d'installation ?
/// (Sur les autres plateformes il n'y a pas d'installeur intégré.)
fn running_installed() -> bool {
#[cfg(windows)]
{
installer::is_running_installed()
}
#[cfg(not(windows))]
{
true
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let mut builder = tauri::Builder::default();
// Mises à jour automatiques : l'application va chercher elle-même la
// dernière version publiée. Plus besoin de renvoyer un .exe à l'équipe.
#[cfg(desktop)]
{
builder = builder
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init());
}
// Installeur intégré : le front affiche l'écran d'installation ou
// l'application selon `installer_status()`.
#[cfg(windows)]
{
builder = builder.invoke_handler(tauri::generate_handler![
installer::installer_status,
installer::installer_install,
installer::installer_launch
]);
}
builder
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -9,6 +49,42 @@ pub fn run() {
.build(),
)?;
}
// La fenêtre est créée ICI, directement à sa forme définitive.
//
// Elle n'est volontairement PAS déclarée dans tauri.conf.json : la
// déclarer là-bas imposait de la corriger ensuite — la masquer, la
// redimensionner, la maximiser, puis l'afficher. Chacune de ces
// étapes était visible, d'où l'impression que « plusieurs petites
// fenêtres » s'ouvraient avant l'application.
//
// Créée d'un coup aux bonnes dimensions, il n'y a plus rien à
// rattraper après coup : une seule apparition, la bonne.
let window = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
// Le glisser-déposer doit rester géré par le webview (Kanban,
// dépôt de pièces jointes) et non capté par Tauri.
.disable_drag_drop_handler();
if running_installed() {
window
.title("GameDev Tracker")
.inner_size(1360.0, 860.0)
.min_inner_size(900.0, 620.0)
.resizable(true)
.maximized(true)
.build()?;
} else {
// Écran d'installation : petite fenêtre sans bordure, centrée.
window
.title("Installation de GameDev Tracker")
.inner_size(520.0, 560.0)
.resizable(false)
.maximizable(false)
.decorations(false)
.center()
.build()?;
}
Ok(())
})
.run(tauri::generate_context!())
+37 -18
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "GameDev Tracker",
"version": "0.1.0",
"version": "0.2.7",
"identifier": "com.gamedevtracker.app",
"build": {
"frontendDist": "../dist",
@@ -10,33 +10,52 @@
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "GameDev Tracker",
"width": 1280,
"height": 820,
"minWidth": 960,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true,
"backgroundColor": "#0f1117",
"dragDropEnabled": false
}
],
"windows": [],
"security": {
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; script-src 'self'"
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://*.supabase.co wss://*.supabase.co; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; font-src 'self' data:; script-src 'self'"
}
},
"bundle": {
"active": true,
"targets": "all",
"targets": [
"nsis"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"createUpdaterArtifacts": true,
"publisher": "Mathew",
"copyright": "© 2026 Mathew",
"shortDescription": "Gestion de projet de jeu vidéo",
"longDescription": "Tâches, bugs, feuille de temps et discussions pour une petite équipe de développement de jeu vidéo.",
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": [
"French"
],
"displayLanguageSelector": false,
"headerImage": "installer/header.bmp",
"sidebarImage": "installer/sidebar.bmp",
"installerIcon": "icons/icon.ico",
"template": "installer/installer.nsi"
}
}
},
"plugins": {
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY3OUVGOUREMUExQjA4OTUKUldTVkNCc2EzZm1lOTlMMVVCUHRVMnFtRTArN3cyQkhsQ3RPSmlEdjlKdGR3WWRYTnpmcjlYU3QK",
"endpoints": [
"https://cesjrlfqeqftayhnqjkb.supabase.co/storage/v1/object/public/releases/latest.json"
],
"windows": {
"installMode": "quiet"
}
}
}
}
+198 -69
View File
@@ -1,69 +1,198 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { AnimatePresence } from 'framer-motion'
import { AppProvider, useApp } from './lib/AppContext'
import { PageFade } from './components/motion'
import Setup from './pages/Setup'
import Login from './pages/Login'
import Onboarding from './pages/Onboarding'
import Layout, { Page } from './components/Layout'
import Dashboard from './pages/Dashboard'
import Kanban from './pages/Kanban'
import Planning from './pages/Planning'
import Bugs from './pages/Bugs'
import Members from './pages/Members'
import Admin from './pages/Admin'
const PAGES: Record<Page, () => JSX.Element> = {
dashboard: Dashboard,
kanban: Kanban,
planning: Planning,
bugs: Bugs,
members: Members,
admin: Admin
}
function Shell(): JSX.Element {
const { stage, profile } = useApp()
const [page, setPage] = useState<Page>('dashboard')
if (stage === 'loading') {
return (
<div className="flex h-full items-center justify-center text-muted">
<Loader2 className="animate-spin" size={28} />
</div>
)
}
if (stage === 'setup') return <Setup />
if (stage === 'login') return <Login />
// Session ouverte mais profil pas encore chargé.
if (!profile) {
return (
<div className="flex h-full items-center justify-center text-muted">
<Loader2 className="animate-spin" size={28} />
</div>
)
}
// Première connexion : accueil (nom, avatar, pôles) avant d'entrer dans l'app.
if (!profile.onboarded) return <Onboarding />
const ActivePage = PAGES[page]
return (
<Layout page={page} setPage={setPage}>
<AnimatePresence mode="wait">
<PageFade key={page}>
<ActivePage />
</PageFade>
</AnimatePresence>
</Layout>
)
}
export default function App(): JSX.Element {
return (
<AppProvider>
<Shell />
</AppProvider>
)
}
import { Component, ErrorInfo, ReactNode, Suspense, lazy, useEffect, useState } from 'react'
import { AnimatePresence } from 'framer-motion'
import { AlertTriangle, Database, RefreshCw } from 'lucide-react'
import { AppProvider, useApp } from './lib/AppContext'
import { Page } from './lib/types'
import { PageFade } from './components/motion'
import { Button, CenteredSpinner } from './components/ui'
import { InstallerStatus, resolveStartupMode } from './lib/installer'
import Login from './pages/Login'
import Onboarding from './pages/Onboarding'
import Layout from './components/Layout'
// Chargement paresseux : le bundle initial ne contient que le shell
// (authentification + mise en page). Chaque écran arrive à la demande.
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Kanban = lazy(() => import('./pages/Kanban'))
const Planning = lazy(() => import('./pages/Planning'))
const Bugs = lazy(() => import('./pages/Bugs'))
const Docs = lazy(() => import('./pages/Docs'))
const Members = lazy(() => import('./pages/Members'))
const Admin = lazy(() => import('./pages/Admin'))
const Installer = lazy(() => import('./pages/Installer'))
const PAGES: Record<Page, React.LazyExoticComponent<() => JSX.Element>> = {
dashboard: Dashboard,
kanban: Kanban,
planning: Planning,
bugs: Bugs,
docs: Docs,
members: Members,
admin: Admin
}
// ---------------------------------------------------------------------
// Filet de sécurité : une erreur de rendu ne doit pas laisser un écran
// blanc sans explication ni moyen de repartir.
// ---------------------------------------------------------------------
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error): { error: Error } {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('Erreur de rendu :', error, info.componentStack)
}
render(): ReactNode {
if (!this.state.error) return this.props.children
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-md text-center">
<AlertTriangle size={32} className="mx-auto mb-3 text-rose-500" />
<h1 className="mb-1 text-base font-semibold text-ink">Quelque chose s'est mal passé</h1>
<p className="mb-4 text-sm text-muted">{this.state.error.message}</p>
<Button
variant="primary"
icon={<RefreshCw size={15} />}
onClick={() => window.location.reload()}
>
Relancer l'application
</Button>
</div>
</div>
)
}
}
// ---------------------------------------------------------------------
/** Clés Supabase absentes du build : on explique quoi faire. */
function Unconfigured(): JSX.Element {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-lg">
<div className="mb-3 flex items-center gap-3">
<div className="rounded-lg bg-accent/12 p-2 text-accent">
<Database size={22} />
</div>
<h1 className="text-lg font-semibold text-ink">Base de données non configurée</h1>
</div>
<p className="mb-3 text-sm text-muted">
L'application a été construite sans clés Supabase. Crée un fichier <code>.env</code> à la
racine du projet avec :
</p>
<pre className="mb-3 overflow-x-auto rounded-lg border border-border bg-panel2 p-3 text-xs text-ink">
{'VITE_SUPABASE_URL=https://xxxxx.supabase.co\nVITE_SUPABASE_ANON_KEY=eyJhbGciOi...'}
</pre>
<p className="text-sm text-muted">
Ces deux valeurs se trouvent dans Supabase <strong>Settings API</strong>. Reconstruis
ensuite l'application (<code>npm run tauri:dev</code>).
</p>
</div>
</div>
)
}
function ProfileMissing({ message }: { message: string }): JSX.Element {
const { signOut } = useApp()
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-md text-center">
<AlertTriangle size={30} className="mx-auto mb-3 text-amber-500" />
<h1 className="mb-1 text-base font-semibold text-ink">Profil inaccessible</h1>
<p className="mb-4 text-sm text-muted">{message}</p>
<div className="flex justify-center gap-2">
<Button icon={<RefreshCw size={15} />} onClick={() => window.location.reload()}>
Réessayer
</Button>
<Button variant="quiet" onClick={signOut}>
Se déconnecter
</Button>
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------
function Shell(): JSX.Element {
const { stage, page, profile, profileError } = useApp()
if (stage === 'unconfigured') return <Unconfigured />
if (stage === 'loading') return <CenteredSpinner />
if (stage === 'login') return <Login />
// Session ouverte, profil pas encore là.
if (!profile) {
if (profileError) return <ProfileMissing message={profileError} />
return <CenteredSpinner />
}
// Première connexion : accueil (nom, photo, pôles) avant d'entrer dans l'app.
if (!profile.onboarded) return <Onboarding />
const ActivePage = PAGES[page]
return (
<Layout>
<AnimatePresence mode="wait">
<PageFade key={page}>
<Suspense fallback={<CenteredSpinner />}>
<ActivePage />
</Suspense>
</PageFade>
</AnimatePresence>
</Layout>
)
}
/**
* Le .exe distribué est à la fois l'application ET son installeur.
* Lancé hors de son dossier d'installation, il affiche l'écran
* d'installation ; sinon, l'application normale.
*
* La forme de la fenêtre (taille, bordure) est décidée côté Rust au
* moment de sa création : ici on ne fait que choisir quoi afficher dedans.
*/
export default function App(): JSX.Element {
const [boot, setBoot] = useState<{
mode: 'checking' | 'installer' | 'app'
status: InstallerStatus | null
}>({ mode: 'checking', status: null })
useEffect(() => {
let alive = true
;(async () => {
const { mode, status } = await resolveStartupMode()
if (alive) setBoot({ mode, status })
})()
return () => {
alive = false
}
}, [])
if (boot.mode === 'checking') return <div className="h-full bg-bg" />
if (boot.mode === 'installer') {
return (
<ErrorBoundary>
<Suspense fallback={<div className="h-full bg-bg" />}>
<Installer status={boot.status} />
</Suspense>
</ErrorBoundary>
)
}
return (
<ErrorBoundary>
<AppProvider>
<Shell />
</AppProvider>
</ErrorBoundary>
)
}
+306
View File
@@ -0,0 +1,306 @@
import { useMemo, useState } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { humanize, mutate, run } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { notifyBug } from '../lib/notify'
import {
Bug,
BugSeverity,
BugStatus,
BUG_STATUS_LABELS,
BUG_STATUS_ORDER,
Pole,
SEVERITY_HINTS,
SEVERITY_LABELS,
SEVERITY_ORDER,
severityBadge
} from '../lib/types'
import { Badge, Button, Field, Input } from './ui'
import { Modal, ModalActions } from './Modal'
import { Select } from './Select'
import { AssigneePicker, PoleChips } from './people'
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
interface Draft {
title: string
description: string
severity: BugSeverity
status: BugStatus
poles: Pole[]
assignees: string[]
}
function draftOf(bug: Bug | null): Draft {
return {
title: bug?.title ?? '',
description: bug?.description ?? '',
severity: bug?.severity ?? 'medium',
status: bug?.status ?? 'open',
poles: bug?.poles ?? [],
assignees: bug?.assignee_ids ?? []
}
}
export default function BugModal({
bug,
projectId,
onClose,
onSaved
}: {
bug: Bug | null
projectId: string
onClose: () => void
onSaved: () => Promise<void> | void
}): JSX.Element {
const { profile, members, membersLoading, memberById } = useApp()
const { toast, confirm } = useFeedback()
const initial = useMemo(() => draftOf(bug), [bug])
const [d, setD] = useState<Draft>(initial)
const [busy, setBusy] = useState(false)
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
const save = async (): Promise<void> => {
if (!d.title.trim()) {
toast('Donne un titre au bug.', 'error')
return
}
setBusy(true)
const payload = {
project_id: projectId,
title: d.title.trim(),
description: d.description.trim() || null,
severity: d.severity,
status: d.status,
poles: d.poles,
assignee_ids: d.assignees
}
try {
let entityId = bug?.id ?? null
if (bug) {
await mutate(supa().from('bugs').update(payload).eq('id', bug.id))
} else {
const created = await run<{ id: string }>(
supa()
.from('bugs')
.insert({ ...payload, reporter_id: profile?.id ?? null })
.select('id')
.single()
)
entityId = created?.id ?? null
}
if (entityId) {
const newPoles = d.poles.filter((p) => !(bug?.poles ?? []).includes(p))
const newAssignees = d.assignees.filter((a) => !(bug?.assignee_ids ?? []).includes(a))
if (newPoles.length || newAssignees.length) {
await notifyBug({
projectId,
bugId: entityId,
title: d.title.trim(),
members,
poles: newPoles,
assigneeIds: newAssignees,
actorId: profile?.id ?? null,
isNew: !bug
})
}
}
await onSaved()
toast(bug ? 'Bug enregistré.' : 'Bug signalé.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
const remove = async (): Promise<void> => {
if (!bug) return
const ok = await confirm({
title: 'Supprimer ce bug ?',
message: `« ${bug.title} » et tout ce qui y est rattaché (temps, discussion, captures) seront supprimés.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
setBusy(true)
try {
await mutate(supa().from('bugs').delete().eq('id', bug.id))
await onSaved()
toast('Bug supprimé.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
/** Passe le bug en résolu en un clic : le geste le plus fréquent. */
const resolve = async (): Promise<void> => {
if (!bug) return
setBusy(true)
try {
await mutate(supa().from('bugs').update({ status: 'resolved' }).eq('id', bug.id))
await onSaved()
toast('Bug marqué comme résolu.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
const reporter = memberById(bug?.reporter_id)
const subtitle = bug ? (
<span>
Signalé {reporter ? `par ${reporter.full_name} ` : ''}
le {format(new Date(bug.created_at), 'd MMM yyyy', { locale: fr })}
</span>
) : null
return (
<Modal
title={bug ? 'Bug' : 'Signaler un bug'}
subtitle={subtitle}
onClose={onClose}
size="lg"
dirty={dirty && !busy}
footer={
<ModalActions onDelete={bug ? remove : undefined} onCancel={onClose}>
{bug && bug.status !== 'resolved' && bug.status !== 'closed' && (
<Button onClick={resolve} disabled={busy}>
Marquer résolu
</Button>
)}
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
{bug ? 'Enregistrer' : 'Signaler'}
</Button>
</ModalActions>
}
>
<div className="space-y-4">
<Field label="Que se passe-t-il ?" required>
{({ id }) => (
<Input
id={id}
autoFocus
value={d.title}
maxLength={160}
placeholder="Le joueur traverse le mur en sautant contre la porte"
onChange={(e) => patch({ title: e.target.value })}
/>
)}
</Field>
<Field
label="Étapes pour reproduire"
hint="Ce que tu faisais, ce qui devait arriver, ce qui est arrivé. Plus c'est précis, plus c'est vite corrigé."
>
{({ id }) => (
<textarea
id={id}
className="input h-28 resize-none"
value={d.description}
placeholder={'1. Aller au niveau 2\n2. Sauter contre la porte de droite\n→ Le personnage passe à travers'}
onChange={(e) => patch({ description: e.target.value })}
/>
)}
</Field>
{/* ---- Sévérité : un seul axe d'importance, expliqué ---- */}
<div className="grid grid-cols-2 gap-3">
<Field label="Sévérité" hint={SEVERITY_HINTS[d.severity]}>
<Select
value={d.severity}
onChange={(severity) => patch({ severity })}
ariaLabel="Sévérité du bug"
options={SEVERITY_ORDER.map((s) => ({
value: s,
label: SEVERITY_LABELS[s],
hint: SEVERITY_HINTS[s]
}))}
/>
</Field>
<Field label="Statut">
<Select
value={d.status}
onChange={(status) => patch({ status })}
ariaLabel="Statut du bug"
options={BUG_STATUS_ORDER.map((s) => ({ value: s, label: BUG_STATUS_LABELS[s] }))}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Badge className={severityBadge(d.severity)}>{SEVERITY_LABELS[d.severity]}</Badge>
<span className="text-xs text-subtle">{SEVERITY_HINTS[d.severity]}</span>
</div>
{/* ---- Qui ? ---- */}
<div className="space-y-3 border-t border-border pt-4">
<Field
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
hint="Tous les membres du pôle reçoivent une notification."
>
<PoleChips
selected={d.poles}
onToggle={(p) =>
patch({
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
})
}
/>
</Field>
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
<AssigneePicker
members={members}
loading={membersLoading}
selected={d.assignees}
onChange={(assignees) => patch({ assignees })}
poles={d.poles}
/>
</Field>
</div>
{bug ? (
<div className="space-y-3 border-t border-border pt-4">
<AttachmentPanel
entity={{
kind: 'bug',
id: bug.id,
projectId,
title: bug.title,
involvedIds: bug.assignee_ids
}}
/>
<TimeSection target={{ kind: 'bug', id: bug.id }} projectId={projectId} />
<CommentThread
entity={{
kind: 'bug',
id: bug.id,
projectId,
title: bug.title,
involvedIds: [...bug.assignee_ids, ...(bug.reporter_id ? [bug.reporter_id] : [])]
}}
/>
</div>
) : (
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
Une fois le bug créé, tu pourras y coller des captures d'écran (Ctrl+V), suivre le temps
passé et en discuter avec l'équipe.
</p>
)}
</div>
</Modal>
)
}
+331
View File
@@ -0,0 +1,331 @@
// =====================================================================
// Palette de commandes (Ctrl+K) — la recherche qui manquait totalement.
// Cherche dans les tâches, les bugs et l'équipe du projet courant, et
// expose les actions fréquentes (naviguer, créer, changer de thème…).
// =====================================================================
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import {
Bug as BugIcon,
CornerDownLeft,
KanbanSquare,
Loader2,
FileText,
Search,
Sparkles,
User
} from 'lucide-react'
import { supa } from '../lib/supabase'
import { rows } from '../lib/db'
import { excerpt } from '../lib/markdown'
import { useApp } from '../lib/AppContext'
import { getTheme, setTheme } from '../lib/theme'
import {
Bug,
BUG_STATUS_LABELS,
Doc,
Task,
TASK_STATUS_LABELS,
normalizeBugs,
normalizeDocs,
normalizeTasks,
polesLabel,
memberPoles
} from '../lib/types'
import { NAV } from './Layout'
import { Avatar } from './people'
import { Kbd } from './ui'
interface Item {
id: string
group: string
label: string
hint?: string
icon: JSX.Element
run: () => void
}
/** Score simple : les correspondances en début de mot passent devant. */
function match(text: string, q: string): number {
const t = text.toLowerCase()
const i = t.indexOf(q)
if (i < 0) return -1
if (i === 0) return 0
return t[i - 1] === ' ' ? 1 : 2
}
export default function CommandPalette({ onClose }: { onClose: () => void }): JSX.Element {
const { profile, members, currentProject, projects, selectProject, setFocusEntity, setPage } =
useApp()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [tasks, setTasks] = useState<Task[]>([])
const [bugs, setBugs] = useState<Bug[]>([])
const [docs, setDocs] = useState<Doc[]>([])
const [loading, setLoading] = useState(true)
const listRef = useRef<HTMLDivElement>(null)
// Contenu du projet courant, chargé une fois à l'ouverture.
useEffect(() => {
let alive = true
const projectId = currentProject?.id
if (!projectId) {
setLoading(false)
return
}
Promise.all([
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
rows<Doc>(supa().from('documents').select('*').eq('project_id', projectId)).then(normalizeDocs)
])
.then(([t, b, d]) => {
if (!alive) return
setTasks(t)
setBugs(b)
setDocs(d)
})
.catch(() => undefined)
.finally(() => alive && setLoading(false))
return () => {
alive = false
}
}, [currentProject?.id])
const open = (kind: 'task' | 'bug', id: string): void => {
setFocusEntity({ kind, id })
setPage(kind === 'bug' ? 'bugs' : 'kanban')
onClose()
}
const items = useMemo<Item[]>(() => {
const q = query.trim().toLowerCase()
const out: Item[] = []
// --- Actions ---
const nav = NAV.filter((n) => !n.adminOnly || profile?.is_admin)
for (const n of nav) {
out.push({
id: `nav-${n.id}`,
group: 'Aller à',
label: n.label,
icon: <n.icon size={16} />,
run: () => {
setPage(n.id)
onClose()
}
})
}
out.push({
id: 'theme',
group: 'Actions',
label: 'Changer de thème (clair / sombre / système)',
icon: <Sparkles size={16} />,
run: () => {
const cur = getTheme()
setTheme(cur === 'light' ? 'dark' : cur === 'dark' ? 'system' : 'light')
onClose()
}
})
for (const p of projects) {
if (p.id === currentProject?.id) continue
out.push({
id: `proj-${p.id}`,
group: 'Projets',
label: `Ouvrir « ${p.name} »`,
icon: <KanbanSquare size={16} />,
run: () => {
selectProject(p)
onClose()
}
})
}
// --- Contenu (uniquement quand on tape) ---
if (q) {
for (const t of tasks) {
if (match(t.title, q) < 0 && match(t.description ?? '', q) < 0) continue
out.push({
id: `task-${t.id}`,
group: 'Tâches',
label: t.title,
hint: `${TASK_STATUS_LABELS[t.status]}${t.tags.length ? ' · ' + t.tags.join(', ') : ''}`,
icon: <KanbanSquare size={16} className="text-accent" />,
run: () => open('task', t.id)
})
}
for (const b of bugs) {
if (match(b.title, q) < 0 && match(b.description ?? '', q) < 0) continue
out.push({
id: `bug-${b.id}`,
group: 'Bugs',
label: b.title,
hint: BUG_STATUS_LABELS[b.status],
icon: <BugIcon size={16} className="text-rose-500" />,
run: () => open('bug', b.id)
})
}
for (const d of docs) {
if (match(d.title, q) < 0 && match(d.content, q) < 0) continue
out.push({
id: `doc-${d.id}`,
group: 'Documentation',
label: d.title,
hint: excerpt(d.content, 70),
icon: <FileText size={16} className="text-accent2" />,
run: () => {
setPage('docs')
onClose()
}
})
}
for (const m of members) {
if (match(m.full_name, q) < 0) continue
out.push({
id: `member-${m.id}`,
group: 'Équipe',
label: m.full_name,
hint: polesLabel(memberPoles(m)),
icon: <Avatar profile={m} size={18} />,
run: () => {
setPage('members')
onClose()
}
})
}
}
if (!q) return out.filter((i) => !['Tâches', 'Bugs', 'Documentation'].includes(i.group))
return out
.map((i) => ({ i, s: Math.max(match(i.label, q), match(i.group, q) >= 0 ? 3 : -1) }))
.filter((r) => r.s >= 0)
.sort((a, b) => a.s - b.s)
.map((r) => r.i)
.slice(0, 40)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, tasks, bugs, docs, members, projects, currentProject?.id, profile?.is_admin])
useEffect(() => setActive(0), [query])
useEffect(() => {
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active])
const onKeyDown = (e: React.KeyboardEvent): void => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive((i) => Math.min(i + 1, items.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault()
items[active]?.run()
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}
// En-têtes de groupe : affichés au premier élément de chaque groupe.
let lastGroup = ''
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
data-overlay
className="fixed inset-0 z-[75] flex items-start justify-center bg-black/50 p-4 pt-[12vh] backdrop-blur-[2px]"
onMouseDown={onClose}
>
<motion.div
role="dialog"
aria-modal="true"
aria-label="Rechercher"
initial={{ opacity: 0, scale: 0.97, y: -8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
onMouseDown={(e) => e.stopPropagation()}
className="flex max-h-[60vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-pop"
>
<div className="flex shrink-0 items-center gap-2.5 border-b border-border px-4">
<Search size={17} className="shrink-0 text-subtle" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Chercher une tâche, un bug, une fiche, une personne…"
aria-label="Rechercher"
className="flex-1 bg-transparent py-3.5 text-sm text-ink outline-none placeholder:text-subtle"
/>
{loading && <Loader2 size={15} className="animate-spin text-subtle" />}
</div>
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-1.5">
{items.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-subtle">
Aucun résultat pour « {query} ».
</p>
) : (
items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null
lastGroup = item.group
const on = i === active
return (
<div key={item.id}>
{header && (
<p className="px-2.5 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wide text-subtle">
{header}
</p>
)}
<button
type="button"
data-active={on}
onMouseEnter={() => setActive(i)}
onClick={item.run}
className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors ${
on ? 'bg-accent/12' : ''
}`}
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-muted">
{item.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-ink">{item.label}</span>
{item.hint && (
<span className="block truncate text-xs text-subtle">{item.hint}</span>
)}
</span>
{on && <CornerDownLeft size={14} className="shrink-0 text-subtle" />}
</button>
</div>
)
})
)}
</div>
<footer className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2 text-[11px] text-subtle">
<span className="flex items-center gap-1">
<Kbd></Kbd>
<Kbd></Kbd> naviguer
</span>
<span className="flex items-center gap-1">
<Kbd></Kbd> ouvrir
</span>
<span className="flex items-center gap-1">
<Kbd>Échap</Kbd> fermer
</span>
{!currentProject && (
<span className="ml-auto flex items-center gap-1">
<User size={11} /> Aucun projet ouvert
</span>
)}
</footer>
</motion.div>
</motion.div>,
document.body
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useMemo, useState } from 'react'
import { supa } from '../lib/supabase'
import { humanize, mutate } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { Pole, Profile, memberPoles } from '../lib/types'
import { Button, Field, Input } from './ui'
import { Modal, ModalActions } from './Modal'
import { AvatarUploader, ColorPicker, PoleChips } from './people'
/**
* Édition de SON propre profil : nom, email, mot de passe, photo, pôles.
* Accessible depuis la barre latérale et depuis la page Équipe.
*/
export default function EditProfileModal({
profile,
onClose,
onSaved
}: {
profile: Profile
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const { session } = useApp()
const { toast } = useFeedback()
const currentEmail = session?.user?.email ?? ''
const initial = useMemo(
() => ({
name: profile.full_name,
email: currentEmail,
roles: memberPoles(profile),
color: profile.avatar_color,
avatarUrl: profile.avatar_url ?? null
}),
[profile, currentEmail]
)
const [name, setName] = useState(initial.name)
const [email, setEmail] = useState(initial.email)
const [password, setPassword] = useState('')
const [roles, setRoles] = useState<Pole[]>(initial.roles)
const [color, setColor] = useState(initial.color)
const [avatarUrl, setAvatarUrl] = useState<string | null>(initial.avatarUrl)
const [busy, setBusy] = useState(false)
const dirty =
name !== initial.name ||
email !== initial.email ||
password.length > 0 ||
color !== initial.color ||
avatarUrl !== initial.avatarUrl ||
JSON.stringify(roles) !== JSON.stringify(initial.roles)
const toggleRole = (p: Pole): void =>
setRoles((cur) => (cur.includes(p) ? cur.filter((r) => r !== p) : [...cur, p]))
const save = async (): Promise<void> => {
if (!name.trim()) return toast('Le nom ne peut pas être vide.', 'error')
if (password && password.length < 6) {
return toast('Le mot de passe doit faire au moins 6 caractères.', 'error')
}
setBusy(true)
try {
// 1) Profil (nom, pôles, avatar) — modifiable par soi-même via RLS.
await mutate(
supa()
.from('profiles')
.update({
full_name: name.trim(),
roles,
avatar_color: color,
avatar_url: avatarUrl
})
.eq('id', profile.id)
)
// 2) Compte (email / mot de passe).
const authPatch: { email?: string; password?: string } = {}
if (email.trim() && email.trim() !== currentEmail) authPatch.email = email.trim()
if (password) authPatch.password = password
if (Object.keys(authPatch).length > 0) {
const { error } = await supa().auth.updateUser(authPatch)
if (error) throw error
}
await onSaved()
toast(
authPatch.email
? 'Profil enregistré. Confirme la nouvelle adresse via lemail reçu.'
: 'Profil enregistré.',
'success'
)
onClose()
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
return (
<Modal
title="Mon profil"
subtitle={currentEmail}
onClose={onClose}
dirty={dirty && !busy}
footer={
<ModalActions onCancel={onClose}>
<Button variant="primary" loading={busy} disabled={!dirty} onClick={save}>
Enregistrer
</Button>
</ModalActions>
}
>
<div className="space-y-3">
<Field label="Photo de profil">
<AvatarUploader
userId={profile.id}
name={name}
color={color}
avatarUrl={avatarUrl}
onChange={setAvatarUrl}
/>
</Field>
{!avatarUrl && (
<Field label="Couleur de tes initiales">
<ColorPicker value={color} onChange={setColor} />
</Field>
)}
<Field label="Nom complet" required>
{({ id }) => (
<Input id={id} value={name} autoComplete="name" onChange={(e) => setName(e.target.value)} />
)}
</Field>
<Field label="Email" hint="Changer d'adresse demande une confirmation par email.">
{({ id }) => (
<Input
id={id}
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
)}
</Field>
<Field label="Nouveau mot de passe" hint="Laisse vide pour ne pas le changer.">
{({ id }) => (
<Input
id={id}
type="password"
autoComplete="new-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
)}
</Field>
<Field
label="Mes pôles"
hint="Tu es notifié quand une tâche ou un bug est confié à l'un de tes pôles."
>
<PoleChips selected={roles} onToggle={toggleRole} />
</Field>
</div>
</Modal>
)
}
+638
View File
@@ -0,0 +1,638 @@
// =====================================================================
// Discussion et pièces jointes — partagés à l'identique par les tâches
// et les bugs (mêmes tables, même comportement, un seul composant).
// =====================================================================
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import {
Clock,
Download,
FileText,
ImageIcon,
MessageSquare,
Paperclip,
Plus,
Send,
Trash2,
UploadCloud
} from 'lucide-react'
import { supa } from '../lib/supabase'
import { humanize, rows, run, useRealtime } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { notifyComment } from '../lib/notify'
import {
attachmentUrl,
deleteAttachment,
listAttachments,
MAX_ATTACHMENT_BYTES,
uploadAttachment
} from '../lib/storage'
import {
Attachment,
Comment,
EntityKind,
TimeLog,
formatBytes,
formatMinutes,
isImage
} from '../lib/types'
import { Avatar } from './people'
import { Button, ErrorState, Field, Input, Skeleton } from './ui'
interface EntityRef {
kind: EntityKind
id: string
projectId: string
/** Titre de la tâche / du bug, repris dans les notifications. */
title: string
/** Personnes concernées (assignés, rapporteur…) à prévenir d'un commentaire. */
involvedIds: string[]
}
// =====================================================================
// Discussion
// =====================================================================
export function CommentThread({ entity }: { entity: EntityRef }): JSX.Element {
const { profile, members, memberById } = useApp()
const { toast, confirm } = useFeedback()
const [items, setItems] = useState<Comment[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [draft, setDraft] = useState('')
const [sending, setSending] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
try {
setItems(
await rows<Comment>(
supa()
.from('comments')
.select('*')
.eq('entity_kind', entity.kind)
.eq('entity_id', entity.id)
.order('created_at')
)
)
setError(null)
} catch (e) {
setError(humanize(e))
} finally {
setLoading(false)
}
}, [entity.kind, entity.id])
useEffect(() => {
load()
}, [load])
useRealtime(
`comments-${entity.kind}-${entity.id}`,
[{ table: 'comments', filter: `entity_id=eq.${entity.id}` }],
load
)
const send = async (): Promise<void> => {
const body = draft.trim()
if (!body || !profile) return
setSending(true)
try {
const created = await run<Comment>(
supa()
.from('comments')
.insert({
project_id: entity.projectId,
entity_kind: entity.kind,
entity_id: entity.id,
author_id: profile.id,
body
})
.select()
.single()
)
setDraft('')
if (created) setItems((list) => [...list, created])
requestAnimationFrame(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }))
await notifyComment({
projectId: entity.projectId,
entityKind: entity.kind,
entityId: entity.id,
entityTitle: entity.title,
body,
members,
involvedIds: entity.involvedIds,
actorId: profile.id,
actorName: profile.full_name
})
} catch (e) {
toast(humanize(e), 'error')
} finally {
setSending(false)
}
}
const remove = async (c: Comment): Promise<void> => {
const ok = await confirm({
title: 'Supprimer ce message ?',
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
try {
await run(supa().from('comments').delete().eq('id', c.id))
setItems((list) => list.filter((x) => x.id !== c.id))
} catch (e) {
toast(humanize(e), 'error')
}
}
return (
<section className="well p-3">
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
<MessageSquare size={16} className="text-muted" />
Discussion
{items.length > 0 && <span className="text-xs font-normal text-subtle">· {items.length}</span>}
</h3>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-3/4" />
</div>
) : error ? (
<ErrorState compact message={error} onRetry={load} />
) : items.length === 0 ? (
<p className="py-2 text-xs text-subtle">
Aucun message. Pose une question ou note une décision : tout le monde la retrouvera ici.
</p>
) : (
<ul className="max-h-64 space-y-2.5 overflow-y-auto pr-1">
{items.map((c) => {
const author = memberById(c.author_id)
const mine = c.author_id === profile?.id
return (
<li key={c.id} className="group flex gap-2.5">
<Avatar profile={author} size={26} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-ink">
{author?.full_name ?? 'Membre supprimé'}
</span>
<time
className="text-[11px] text-subtle"
dateTime={c.created_at}
title={format(new Date(c.created_at), 'PPPp', { locale: fr })}
>
{formatDistanceToNow(new Date(c.created_at), { addSuffix: true, locale: fr })}
</time>
{mine && (
<button
type="button"
onClick={() => remove(c)}
aria-label="Supprimer mon message"
className="ml-auto text-subtle opacity-0 transition hover:text-rose-600 group-hover:opacity-100"
>
<Trash2 size={13} />
</button>
)}
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-snug text-muted">
{c.body}
</p>
</div>
</li>
)
})}
<div ref={bottomRef} />
</ul>
)}
<div className="mt-3 flex items-end gap-2">
<textarea
className="input min-h-[38px] resize-none py-2"
rows={draft.includes('\n') || draft.length > 60 ? 3 : 1}
placeholder="Écrire un message… (Ctrl + Entrée pour envoyer)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
void send()
}
}}
/>
<Button
variant="primary"
icon={<Send size={15} />}
loading={sending}
disabled={!draft.trim()}
onClick={send}
>
Envoyer
</Button>
</div>
</section>
)
}
// =====================================================================
// Pièces jointes
// =====================================================================
export function AttachmentPanel({ entity }: { entity: EntityRef }): JSX.Element {
const { profile, memberById } = useApp()
const { toast, confirm } = useFeedback()
const [items, setItems] = useState<Attachment[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const load = useCallback(async () => {
try {
setItems(await listAttachments(entity.kind, entity.id))
setError(null)
} catch (e) {
setError(humanize(e))
} finally {
setLoading(false)
}
}, [entity.kind, entity.id])
useEffect(() => {
load()
}, [load])
const upload = useCallback(
async (files: File[]): Promise<void> => {
if (!profile || files.length === 0) return
setBusy(true)
try {
for (const file of files) {
const created = await uploadAttachment({
file,
projectId: entity.projectId,
entityKind: entity.kind,
entityId: entity.id,
uploaderId: profile.id
})
setItems((list) => [...list, created])
}
toast(files.length > 1 ? `${files.length} fichiers ajoutés.` : 'Fichier ajouté.', 'success')
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
},
[profile, entity.projectId, entity.kind, entity.id, toast]
)
// Coller une capture d'écran directement (Impr. écran → Ctrl+V) :
// c'est LE geste attendu quand on signale un bug.
useEffect(() => {
const onPaste = (e: ClipboardEvent): void => {
const files = Array.from(e.clipboardData?.files ?? []).filter((f) =>
f.type.startsWith('image/')
)
if (files.length === 0) return
e.preventDefault()
void upload(files)
}
document.addEventListener('paste', onPaste)
return () => document.removeEventListener('paste', onPaste)
}, [upload])
const onDrop = (e: DragEvent<HTMLDivElement>): void => {
e.preventDefault()
setDragging(false)
void upload(Array.from(e.dataTransfer.files))
}
const remove = async (a: Attachment): Promise<void> => {
const ok = await confirm({
title: 'Supprimer ce fichier ?',
message: a.name,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
try {
await deleteAttachment(a)
setItems((list) => list.filter((x) => x.id !== a.id))
} catch (e) {
toast(humanize(e), 'error')
}
}
const images = items.filter(isImage)
const files = items.filter((a) => !isImage(a))
return (
<section className="well p-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium text-ink">
<Paperclip size={16} className="text-muted" />
Pièces jointes
{items.length > 0 && <span className="text-xs font-normal text-subtle">· {items.length}</span>}
</h3>
<Button size="sm" loading={busy} onClick={() => fileRef.current?.click()}>
Ajouter
</Button>
</div>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
void upload(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
{loading ? (
<Skeleton className="h-20 w-full" />
) : error ? (
<ErrorState compact message={error} onRetry={load} />
) : (
<>
{images.length > 0 && (
<div className="mb-2 grid grid-cols-3 gap-2 sm:grid-cols-4">
{images.map((a) => (
<figure key={a.id} className="group relative overflow-hidden rounded-lg border border-border">
<a href={attachmentUrl(a.path)} target="_blank" rel="noreferrer" title={a.name}>
<img
src={attachmentUrl(a.path)}
alt={a.name}
loading="lazy"
className="h-20 w-full bg-panel2 object-cover transition-transform group-hover:scale-105"
/>
</a>
<button
type="button"
aria-label={`Supprimer ${a.name}`}
onClick={() => remove(a)}
className="absolute right-1 top-1 rounded-md bg-black/60 p-1 text-white opacity-0 transition group-hover:opacity-100 hover:bg-rose-600"
>
<Trash2 size={12} />
</button>
</figure>
))}
</div>
)}
{files.length > 0 && (
<ul className="mb-2 space-y-1">
{files.map((a) => (
<li
key={a.id}
className="group flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-1.5 text-sm"
>
<FileText size={15} className="shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate text-ink">{a.name}</span>
<span className="shrink-0 text-xs text-subtle">{formatBytes(a.size)}</span>
<a
href={attachmentUrl(a.path)}
target="_blank"
rel="noreferrer"
download={a.name}
aria-label={`Télécharger ${a.name}`}
className="shrink-0 text-subtle hover:text-accent"
>
<Download size={14} />
</a>
<button
type="button"
aria-label={`Supprimer ${a.name}`}
onClick={() => remove(a)}
className="shrink-0 text-subtle opacity-0 transition hover:text-rose-600 group-hover:opacity-100"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
{/* Zone de dépôt : glisser-déposer OU coller une capture d'écran. */}
<div
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-col items-center gap-1 rounded-lg border border-dashed px-3 py-4 text-center transition-colors ${
dragging ? 'border-accent bg-accent/8' : 'border-border'
}`}
>
{busy ? (
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent border-t-transparent" />
) : (
<UploadCloud size={20} className="text-subtle" />
)}
<p className="text-xs text-muted">
Glisse un fichier ici, ou fais <span className="kbd">Ctrl</span>{' '}
<span className="kbd">V</span> pour coller une capture d'écran
</p>
<p className="text-[11px] text-subtle">{formatBytes(MAX_ATTACHMENT_BYTES)} maximum par fichier</p>
</div>
{items.length > 0 && (
<p className="mt-2 flex items-center gap-1 text-[11px] text-subtle">
<ImageIcon size={11} />
Ajouté par {memberById(items[items.length - 1].uploader_id)?.full_name ?? 'un membre'}
</p>
)}
</>
)}
</section>
)
}
// =====================================================================
// Temps passé — fonctionne à l'identique sur une tâche ou sur un bug.
// Les bugs n'avaient aucun suivi du temps : incohérence corrigée.
// =====================================================================
export function TimeSection({
target,
projectId
}: {
target: { kind: EntityKind; id: string }
projectId: string
}): JSX.Element {
const { profile, memberById } = useApp()
const { toast, confirm } = useFeedback()
const [logs, setLogs] = useState<TimeLog[]>([])
const [loading, setLoading] = useState(true)
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const column = target.kind === 'bug' ? 'bug_id' : 'task_id'
const load = useCallback(async () => {
try {
setLogs(
await rows<TimeLog>(
supa()
.from('time_logs')
.select('*')
.eq(column, target.id)
.order('logged_at', { ascending: false })
)
)
} catch (e) {
toast(humanize(e), 'error')
} finally {
setLoading(false)
}
}, [column, target.id, toast])
useEffect(() => {
load()
}, [load])
const total = logs.reduce((s, l) => s + l.minutes, 0)
const add = async (): Promise<void> => {
const mins = (parseInt(hours || '0', 10) || 0) * 60 + (parseInt(minutes || '0', 10) || 0)
if (mins <= 0) {
toast('Indique une durée (heures et/ou minutes).', 'error')
return
}
setBusy(true)
try {
await run(
supa()
.from('time_logs')
.insert({
[column]: target.id,
project_id: projectId,
user_id: profile?.id ?? null,
minutes: mins,
note: note.trim() || null
})
)
setHours('')
setMinutes('')
setNote('')
await load()
toast('Temps ajouté.', 'success')
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
const del = async (id: string): Promise<void> => {
const ok = await confirm({
title: 'Supprimer cette entrée de temps ?',
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
try {
await run(supa().from('time_logs').delete().eq('id', id))
setLogs((list) => list.filter((l) => l.id !== id))
} catch (e) {
toast(humanize(e), 'error')
}
}
return (
<section className="well p-3">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-medium text-ink">
<Clock size={16} className="text-muted" /> Temps passé
</h3>
<span className="text-sm font-semibold text-accent2 tabular">{formatMinutes(total)}</span>
</div>
<div className="mb-3 flex flex-wrap items-end gap-2">
<Field label="Heures" className="w-[4.5rem]">
{({ id }) => (
<Input
id={id}
type="number"
min={0}
max={99}
placeholder="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
/>
)}
</Field>
<Field label="Minutes" className="w-[4.5rem]">
{({ id }) => (
<Input
id={id}
type="number"
min={0}
max={59}
placeholder="0"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
/>
)}
</Field>
<Field label="Note" className="min-w-[8rem] flex-1">
{({ id }) => (
<Input
id={id}
placeholder="Ce que tu as fait (optionnel)"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && add()}
/>
)}
</Field>
<Button icon={<Plus size={15} />} loading={busy} onClick={add}>
Ajouter
</Button>
</div>
{loading ? (
<Skeleton className="h-8 w-full" />
) : logs.length === 0 ? (
<p className="py-2 text-center text-xs text-subtle">
Aucun temps enregistré. Ces heures alimentent la feuille de temps de l'équipe.
</p>
) : (
<ul className="space-y-1.5">
{logs.map((l) => (
<li
key={l.id}
className="group flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-1.5 text-sm"
>
<Avatar profile={memberById(l.user_id)} size={22} />
<span className="shrink-0 font-medium tabular text-ink">{formatMinutes(l.minutes)}</span>
{l.note && <span className="min-w-0 truncate text-muted">— {l.note}</span>}
<span className="ml-auto shrink-0 text-xs text-subtle">
{format(new Date(l.logged_at), 'd MMM', { locale: fr })}
</span>
<button
type="button"
aria-label="Supprimer cette entrée"
onClick={() => del(l.id)}
className="shrink-0 text-subtle opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
</section>
)
}
+583 -227
View File
@@ -1,227 +1,583 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import {
LayoutDashboard,
KanbanSquare,
Clock,
Bug,
Users,
Plus,
ChevronDown,
LogOut,
Settings,
ShieldCheck,
Sun,
Moon
} from 'lucide-react'
import { useApp } from '../lib/AppContext'
import { supa } from '../lib/supabase'
import { ROLE_LABELS } from '../lib/types'
import { Theme, getTheme, setTheme } from '../lib/theme'
import { Avatar, Modal } from './ui'
import NotificationBell from './NotificationBell'
import Logo from './Logo'
export type Page =
| 'dashboard'
| 'kanban'
| 'planning'
| 'bugs'
| 'members'
| 'admin'
const NAV: { id: Page; label: string; icon: typeof LayoutDashboard; adminOnly?: boolean }[] = [
{ id: 'dashboard', label: 'Tableau de bord', icon: LayoutDashboard },
{ id: 'kanban', label: 'Tâches (Kanban)', icon: KanbanSquare },
{ id: 'planning', label: 'Feuille de temps', icon: Clock },
{ id: 'bugs', label: 'Bugs', icon: Bug },
{ id: 'members', label: 'Équipe', icon: Users },
{ id: 'admin', label: 'Administration', icon: ShieldCheck, adminOnly: true }
]
export default function Layout({
page,
setPage,
children
}: {
page: Page
setPage: (p: Page) => void
children: React.ReactNode
}): JSX.Element {
const { profile, projects, currentProject, setCurrentProject, refreshProjects, signOut } = useApp()
const [picker, setPicker] = useState(false)
const [showNew, setShowNew] = useState(false)
const [theme, setThemeState] = useState<Theme>(getTheme())
const toggleTheme = (): void => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
setThemeState(next)
}
return (
<div className="flex h-full">
{/* Barre latérale */}
<aside className="flex w-64 shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-center gap-2.5 px-4 py-4">
<Logo size={28} />
<span className="font-semibold text-ink">GameDev Tracker</span>
</div>
{/* Sélecteur de projet */}
<div className="relative px-3">
<button
className="flex w-full items-center justify-between rounded-lg border border-border bg-panel2 px-3 py-2 text-left text-sm hover:border-accent"
onClick={() => setPicker((v) => !v)}
>
<span className="truncate">
<span className="block text-[10px] uppercase text-subtle">Projet</span>
<span className="font-medium text-ink">{currentProject?.name ?? 'Aucun projet'}</span>
</span>
<ChevronDown size={16} className="text-muted" />
</button>
{picker && (
<div className="absolute left-3 right-3 z-20 mt-1 rounded-lg border border-border bg-panel2 py-1 shadow-xl">
{projects.map((p) => (
<button
key={p.id}
className="block w-full px-3 py-2 text-left text-sm hover:bg-border"
onClick={() => {
setCurrentProject(p)
setPicker(false)
}}
>
{p.name}
</button>
))}
<button
className="flex w-full items-center gap-2 border-t border-border px-3 py-2 text-left text-sm text-accent2 hover:bg-border"
onClick={() => {
setShowNew(true)
setPicker(false)
}}
>
<Plus size={14} /> Nouveau projet
</button>
</div>
)}
</div>
<nav className="mt-4 flex-1 space-y-1 px-3">
{NAV.filter((n) => !n.adminOnly || profile?.is_admin).map((n) => (
<button
key={n.id}
onClick={() => setPage(n.id)}
className={`relative flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
page === n.id
? 'text-accent'
: 'text-muted hover:bg-accent/10 hover:text-accent'
}`}
>
{page === n.id && (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-lg bg-accent/15 ring-1 ring-inset ring-accent/25"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<n.icon size={18} className="relative z-10" />
<span className="relative z-10">{n.label}</span>
</button>
))}
</nav>
{/* Pied : utilisateur */}
<div className="border-t border-border p-3">
<div className="flex items-center gap-2">
<Avatar profile={profile} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-ink">{profile?.full_name}</div>
<div className="truncate text-xs text-subtle">
{profile ? ROLE_LABELS[profile.role] : ''}
</div>
</div>
<NotificationBell setPage={setPage} />
<button
onClick={toggleTheme}
title={theme === 'dark' ? 'Passer en clair' : 'Passer en sombre'}
className="text-muted hover:text-ink"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
<button onClick={signOut} title="Se déconnecter" className="text-muted hover:text-rose-600">
<LogOut size={18} />
</button>
</div>
</div>
</aside>
{/* Contenu */}
<main className="flex-1 overflow-hidden">
{currentProject || page === 'admin' ? (
children
) : (
<div className="flex h-full flex-col items-center justify-center gap-4 text-muted">
<Settings size={40} className="text-gray-600" />
<p>Aucun projet pour le moment.</p>
<button className="btn-primary" onClick={() => setShowNew(true)}>
<Plus size={16} /> Créer le premier projet
</button>
</div>
)}
</main>
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={refreshProjects} />}
</div>
)
}
function NewProjectModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => Promise<void>
}): JSX.Element {
const { profile, setCurrentProject } = useApp()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [busy, setBusy] = useState(false)
const create = async (): Promise<void> => {
if (!name.trim()) return
setBusy(true)
const { data } = await supa()
.from('projects')
.insert({ name: name.trim(), description: desc.trim() || null, created_by: profile?.id })
.select()
.single()
await onCreated()
if (data) setCurrentProject(data)
setBusy(false)
onClose()
}
return (
<Modal title="Nouveau projet" onClose={onClose}>
<label className="label">Nom du jeu / projet</label>
<input
className="input mb-3"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Mon super jeu"
/>
<label className="label">Description (optionnel)</label>
<textarea
className="input mb-4 h-24 resize-none"
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Genre, plateforme, pitch..."
/>
<button className="btn-primary w-full" onClick={create} disabled={busy || !name.trim()}>
Créer
</button>
</Modal>
)
}
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
import {
BookOpen,
Bug,
Clock,
Folder,
KanbanSquare,
Keyboard,
LayoutDashboard,
LogOut,
Monitor,
Moon,
PanelLeftClose,
PanelLeftOpen,
Pencil,
Plus,
Search,
ShieldCheck,
Sun,
Users,
WifiOff
} from 'lucide-react'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { humanize } from '../lib/db'
import { Theme, ResolvedTheme, getTheme, resolveTheme, setTheme, watchSystemTheme } from '../lib/theme'
import { Page, plural } from '../lib/types'
import { currentVersion } from '../lib/updater'
import { Avatar } from './people'
import { Button, IconButton, Kbd } from './ui'
import { Modal, ModalActions } from './Modal'
import { Select } from './Select'
import NotificationBell from './NotificationBell'
import UpdateBanner, { UpdateCheckRow } from './UpdateBanner'
import CommandPalette from './CommandPalette'
import ProjectModal from './ProjectModal'
import Logo from './Logo'
export type { Page }
export const NAV: {
id: Page
label: string
short: string
icon: typeof LayoutDashboard
adminOnly?: boolean
}[] = [
{ id: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: LayoutDashboard },
{ id: 'kanban', label: 'Tâches', short: 'Tâches', icon: KanbanSquare },
{ id: 'planning', label: 'Feuille de temps', short: 'Temps', icon: Clock },
{ id: 'bugs', label: 'Bugs', short: 'Bugs', icon: Bug },
{ id: 'docs', label: 'Documentation', short: 'Doc', icon: BookOpen },
{ id: 'members', label: 'Équipe', short: 'Équipe', icon: Users },
{ id: 'admin', label: 'Administration', short: 'Admin', icon: ShieldCheck, adminOnly: true }
]
// ---------------------------------------------------------------------
// Barre supérieure partagée
//
// Avant, l'application empilait DEUX barres : un bandeau global de 48 px
// quasi vide, plus l'en-tête propre à chaque page. Désormais il n'y en a
// qu'une : le titre vient de la navigation, et chaque page y injecte ses
// actions via <PageActions> (un portal) et son sous-titre via
// usePageSubtitle().
// ---------------------------------------------------------------------
interface PageChrome {
slot: HTMLElement | null
setSubtitle: (s: string | null) => void
}
const ChromeCtx = createContext<PageChrome>({ slot: null, setSubtitle: () => undefined })
/** Boutons d'action de la page courante, rendus dans la barre supérieure. */
export function PageActions({ children }: { children: ReactNode }): JSX.Element | null {
const { slot } = useContext(ChromeCtx)
if (!slot) return null
return createPortal(<>{children}</>, slot)
}
/**
* Sous-titre contextuel de la page (« 3 bugs actifs », la semaine en cours…).
*
* ⚠️ Passe une CHAÎNE (ou une valeur mémoïsée) : la valeur est stockée dans un
* état, donc un élément JSX recréé à chaque rendu ferait boucler la page.
*/
export function usePageSubtitle(subtitle: string | null): void {
const { setSubtitle } = useContext(ChromeCtx)
useEffect(() => {
setSubtitle(subtitle)
return () => setSubtitle(null)
}, [subtitle, setSubtitle])
}
// ---------------------------------------------------------------------
const SIDEBAR_KEY = 'gamedev-tracker-sidebar'
export default function Layout({ children }: { children: ReactNode }): JSX.Element {
const { page, setPage, profile, projects, currentProject, online, signOut } = useApp()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_KEY) === '1')
const [showNewProject, setShowNewProject] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const [showPalette, setShowPalette] = useState(false)
const [editProfile, setEditProfile] = useState(false)
const [theme, setThemeState] = useState<Theme>(getTheme())
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(getTheme()))
const [slot, setSlot] = useState<HTMLElement | null>(null)
const [subtitle, setSubtitle] = useState<string | null>(null)
const nav = useMemo(() => NAV.filter((n) => !n.adminOnly || profile?.is_admin), [profile?.is_admin])
const active = nav.find((n) => n.id === page) ?? nav[0]
const toggleSidebar = useCallback(() => {
setCollapsed((c) => {
localStorage.setItem(SIDEBAR_KEY, c ? '0' : '1')
return !c
})
}, [])
/** clair → sombre → système → clair */
const cycleTheme = useCallback(() => {
setThemeState((cur) => {
const next: Theme = cur === 'light' ? 'dark' : cur === 'dark' ? 'system' : 'light'
setResolved(setTheme(next))
return next
})
}, [])
useEffect(() => watchSystemTheme(setResolved), [])
// Raccourcis globaux. Tous avec Ctrl/Cmd : une touche seule changeait le
// thème ou la page par simple frappe involontaire.
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent): void => {
const mod = e.ctrlKey || e.metaKey
const el = document.activeElement as HTMLElement | null
const typing =
!!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
if (mod && e.key.toLowerCase() === 'k') {
e.preventDefault()
setShowPalette(true)
return
}
if (!typing && e.key === '/') {
e.preventDefault()
setShowPalette(true)
return
}
if (mod && e.key.toLowerCase() === 'b') {
e.preventDefault()
toggleSidebar()
return
}
if (!typing && e.key === '?') {
e.preventDefault()
setShowHelp(true)
return
}
if (mod && !e.shiftKey && /^[1-9]$/.test(e.key)) {
const target = nav[Number(e.key) - 1]
if (target) {
e.preventDefault()
setPage(target.id)
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [nav, setPage, toggleSidebar])
const ThemeIcon = theme === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun
const themeLabel =
theme === 'system' ? 'Thème : système' : theme === 'dark' ? 'Thème : sombre' : 'Thème : clair'
const chrome = useMemo<PageChrome>(() => ({ slot, setSubtitle }), [slot])
return (
<ChromeCtx.Provider value={chrome}>
<div className="flex h-full">
{/* ------------------------------ Barre latérale ------------------------------ */}
<aside
className={`flex shrink-0 flex-col border-r border-border bg-sidebar transition-[width] duration-200 ease-smooth ${
collapsed ? 'w-[3.75rem]' : 'w-60'
}`}
>
<div className={`flex h-14 items-center gap-2.5 ${collapsed ? 'justify-center px-2' : 'px-4'}`}>
<Logo size={26} />
{!collapsed && <span className="truncate font-semibold text-ink">GameDev Tracker</span>}
</div>
<ProjectSwitcher collapsed={collapsed} onCreate={() => setShowNewProject(true)} />
<nav className="mt-3 flex-1 space-y-0.5 px-2" aria-label="Navigation principale">
{nav.map((n) => {
const on = page === n.id
return (
<button
key={n.id}
type="button"
onClick={() => setPage(n.id)}
title={collapsed ? n.label : undefined}
aria-current={on ? 'page' : undefined}
className={`relative flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors ${
collapsed ? 'justify-center' : ''
} ${on ? 'text-accent' : 'text-muted hover:bg-hover hover:text-ink'}`}
>
{on && (
<motion.span
layoutId="nav-active"
className="absolute inset-0 rounded-lg bg-accent/12 ring-1 ring-inset ring-accent/25"
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
)}
<n.icon size={18} className="relative z-10 shrink-0" />
{!collapsed && <span className="relative z-10 truncate">{n.label}</span>}
</button>
)
})}
</nav>
{/* Pied : utilisateur */}
<div className="border-t border-border p-2">
{collapsed ? (
<div className="flex flex-col items-center gap-1">
<button
type="button"
onClick={() => setEditProfile(true)}
title={`${profile?.full_name ?? ''} — modifier mon profil`}
className="rounded-full ring-offset-2 ring-offset-sidebar hover:ring-2 hover:ring-accent"
>
<Avatar profile={profile} size={28} />
</button>
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={16} />} onClick={cycleTheme} />
<IconButton
size="sm"
tone="danger"
label="Se déconnecter"
icon={<LogOut size={16} />}
onClick={signOut}
/>
</div>
) : (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setEditProfile(true)}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg p-1 text-left transition-colors hover:bg-hover"
>
<Avatar profile={profile} size={30} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-ink">
{profile?.full_name}
</span>
<span className="block truncate text-xs text-subtle">Modifier mon profil</span>
</span>
<Pencil size={14} className="shrink-0 text-subtle" />
</button>
<IconButton size="sm" label={themeLabel} icon={<ThemeIcon size={17} />} onClick={cycleTheme} />
<IconButton
size="sm"
tone="danger"
label="Se déconnecter"
icon={<LogOut size={17} />}
onClick={signOut}
/>
</div>
)}
</div>
</aside>
{/* ------------------------------ Contenu ------------------------------ */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<UpdateBanner />
{!online && (
<div
role="status"
className="flex shrink-0 items-center justify-center gap-2 bg-amber-500/15 py-1.5 text-xs font-medium text-amber-700 dark:text-amber-300"
>
<WifiOff size={13} />
Hors ligne tes modifications ne seront pas enregistrées tant que la connexion n'est pas revenue.
</div>
)}
{/* UNE seule barre supérieure pour toute l'application */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-panel px-4">
<IconButton
size="sm"
label={collapsed ? 'Déployer le menu (Ctrl+B)' : 'Replier le menu (Ctrl+B)'}
icon={collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
onClick={toggleSidebar}
/>
<div className="min-w-0">
<h1 className="truncate text-[15px] font-semibold leading-tight text-ink">
{active?.label}
</h1>
<p className="truncate text-xs leading-tight text-subtle">
{subtitle ?? currentProject?.name ?? 'Aucun projet'}
</p>
</div>
<div className="ml-auto flex items-center gap-2">
{/* Actions injectées par la page courante */}
<div ref={setSlot} className="flex items-center gap-2" />
<span className="mx-1 h-6 w-px bg-border" />
<button
type="button"
onClick={() => setShowPalette(true)}
className="hidden items-center gap-2 rounded-lg border border-border bg-panel2 py-1.5 pl-2.5 pr-2 text-xs text-subtle transition-colors hover:border-border-strong hover:text-muted lg:flex"
>
<Search size={14} />
Rechercher
<Kbd>Ctrl K</Kbd>
</button>
<IconButton
size="sm"
label="Rechercher (Ctrl+K)"
icon={<Search size={17} />}
onClick={() => setShowPalette(true)}
className="lg:hidden"
/>
<IconButton
size="sm"
label="Raccourcis clavier (?)"
icon={<Keyboard size={17} />}
onClick={() => setShowHelp(true)}
/>
<NotificationBell />
</div>
</header>
<div className="min-h-0 flex-1 overflow-hidden">
{currentProject || page === 'admin' || page === 'members' ? (
children
) : (
<NoProject
loading={projects.length === 0}
onCreate={() => setShowNewProject(true)}
/>
)}
</div>
</main>
{showNewProject && <ProjectModal mode="create" onClose={() => setShowNewProject(false)} />}
{editProfile && profile && <ProfileModalLazy onClose={() => setEditProfile(false)} />}
{showHelp && <ShortcutsModal nav={nav} onClose={() => setShowHelp(false)} />}
{showPalette && <CommandPalette onClose={() => setShowPalette(false)} />}
</div>
</ChromeCtx.Provider>
)
}
// ---------------------------------------------------------------------
// Sélecteur de projet
// (l'ancien menu maison ne se fermait ni au clic extérieur ni à Échap)
// ---------------------------------------------------------------------
function ProjectSwitcher({
collapsed,
onCreate
}: {
collapsed: boolean
onCreate: () => void
}): JSX.Element | null {
const { projects, currentProject, selectProject, projectsLoading } = useApp()
if (collapsed) {
return (
<div className="flex justify-center px-2">
<IconButton
size="sm"
label={currentProject?.name ?? 'Aucun projet'}
icon={<Folder size={17} />}
onClick={onCreate}
/>
</div>
)
}
if (projectsLoading) return <div className="mx-3 h-[42px] skeleton" />
const NEW = '__new__'
return (
<div className="px-3">
<Select
ariaLabel="Changer de projet"
placeholder="Aucun projet"
className="!bg-panel"
value={currentProject?.id ?? ''}
onChange={(id) => {
if (id === NEW) {
onCreate()
return
}
const p = projects.find((x) => x.id === id)
if (p) selectProject(p)
}}
options={[
...projects.map((p) => ({
value: p.id,
label: p.name,
hint: p.description ?? undefined,
group: 'Projets'
})),
{ value: NEW, label: '+ Nouveau projet', group: 'Gérer' }
]}
/>
</div>
)
}
// ---------------------------------------------------------------------
function NoProject({ loading, onCreate }: { loading: boolean; onCreate: () => void }): JSX.Element {
const { projectsLoading, projectsError, refreshProjects } = useApp()
// Avant, « Aucun projet » clignotait pendant le chargement.
if (projectsLoading || (loading && !projectsError)) {
return (
<div className="flex h-full items-center justify-center">
<div className="w-72 space-y-3">
<div className="skeleton h-6 w-1/2" />
<div className="skeleton h-24 w-full" />
</div>
</div>
)
}
if (projectsError) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="mb-3 text-sm text-muted">{projectsError}</p>
<Button onClick={refreshProjects}>Réessayer</Button>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 px-6 text-center">
<Folder size={40} className="text-subtle" />
<div>
<p className="text-sm font-medium text-ink">Aucun projet pour le moment</p>
<p className="mt-1 max-w-sm text-sm text-muted">
Un projet regroupe les tâches, les bugs et le temps passé de ton jeu.
</p>
</div>
<Button variant="primary" icon={<Plus size={16} />} onClick={onCreate}>
Créer le premier projet
</Button>
</div>
)
}
// ---------------------------------------------------------------------
function ShortcutsModal({
nav,
onClose
}: {
nav: { id: Page; label: string }[]
onClose: () => void
}): JSX.Element {
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
currentVersion().then(setVersion)
}, [])
const groups: { title: string; rows: [string, string][] }[] = [
{
title: 'Général',
rows: [
['Ctrl K', 'Rechercher / palette de commandes'],
['/', 'Rechercher'],
['Ctrl B', 'Replier ou déployer le menu'],
['?', 'Afficher cette aide'],
['Échap', 'Fermer une fenêtre']
]
},
{
title: 'Navigation',
rows: nav.map((n, i): [string, string] => [`Ctrl ${i + 1}`, n.label])
},
{
title: 'Kanban',
rows: [
['Espace', 'Prendre / déposer une carte au clavier'],
['← →', 'Changer de colonne (carte prise)'],
['↑ ↓', 'Monter / descendre (carte prise)']
]
},
{
title: 'Écriture',
rows: [['Ctrl ⏎', 'Envoyer un commentaire']]
}
]
return (
<Modal title="Raccourcis clavier" onClose={onClose} footer={
<ModalActions>
<Button variant="primary" onClick={onClose}>Compris</Button>
</ModalActions>
}>
<div className="space-y-4">
{groups.map((g) => (
<section key={g.title}>
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-subtle">
{g.title}
</h3>
<ul className="divide-y divide-border">
{g.rows.map(([key, label]) => (
<li key={key + label} className="flex items-center justify-between gap-4 py-1.5 text-sm">
<span className="text-ink">{label}</span>
<span className="flex shrink-0 gap-1">
{key.split(' ').map((k) => (
<Kbd key={k}>{k}</Kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
<p className="mt-4 text-xs text-subtle">
Les raccourcis de navigation sont inactifs pendant la saisie de texte.
</p>
<div className="mt-4">
<UpdateCheckRow version={version} />
</div>
</Modal>
)
}
// ---------------------------------------------------------------------
// Édition du profil — importée à la demande pour ne pas alourdir le shell.
// ---------------------------------------------------------------------
function ProfileModalLazy({ onClose }: { onClose: () => void }): JSX.Element | null {
const { profile, refreshProfile } = useApp()
const { toast } = useFeedback()
const [Comp, setComp] = useState<null | React.ComponentType<{
profile: NonNullable<typeof profile>
onClose: () => void
onSaved: () => Promise<void>
}>>(null)
useEffect(() => {
import('./EditProfileModal')
.then((m) => setComp(() => m.default))
.catch((e) => {
toast(humanize(e), 'error')
onClose()
})
}, [onClose, toast])
if (!Comp || !profile) return null
return <Comp profile={profile} onClose={onClose} onSaved={refreshProfile} />
}
/** Petit utilitaire de libellé réexporté pour les pages. */
export { plural }
+149
View File
@@ -0,0 +1,149 @@
// =====================================================================
// Affichage d'une fiche Markdown.
//
// Le projet n'utilise pas @tailwindcss/typography : les styles sont
// écrits ici, avec les tokens de l'application, pour que le rendu suive
// le thème clair comme sombre. Un plugin générique aurait ramené ses
// propres gris, invisibles en mode sombre.
// =====================================================================
import { useEffect, useMemo, useRef } from 'react'
import { renderMarkdown } from '../lib/markdown'
/** Un titre du document, pour construire le plan latéral. */
export interface Heading {
id: string
text: string
level: number
}
/** Identifiant d'ancre lisible et stable à partir du texte d'un titre. */
function slug(text: string): string {
return (
text
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section'
)
}
const STYLES = [
// Titres
'[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:text-ink first:[&_h1]:mt-0',
'[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:border-border [&_h2]:pb-1.5 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-ink',
'[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-ink',
'[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-ink',
// Texte
'[&_p]:my-3 [&_p]:leading-relaxed [&_p]:text-muted',
'[&_strong]:font-semibold [&_strong]:text-ink',
'[&_em]:italic',
'[&_del]:text-subtle [&_del]:line-through',
// Listes
'[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:text-muted',
'[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:text-muted',
'[&_li]:my-1 [&_li]:leading-relaxed',
'[&_li>ul]:my-1 [&_li>ol]:my-1',
// Cases à cocher des listes de tâches GFM
'[&_input[type=checkbox]]:mr-1.5 [&_input[type=checkbox]]:align-middle',
// Liens
'[&_a]:text-accent [&_a]:underline-offset-2 hover:[&_a]:underline [&_a]:break-words',
// Code
"[&_code]:rounded [&_code]:bg-panel2 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-ink",
'[&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-panel2 [&_pre]:p-3',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-[13px]',
// Citations
'[&_blockquote]:my-4 [&_blockquote]:border-l-2 [&_blockquote]:border-accent [&_blockquote]:pl-4 [&_blockquote]:text-muted [&_blockquote]:italic',
// Tableaux — le conteneur défile plutôt que la page
'[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm',
'[&_th]:border [&_th]:border-border [&_th]:bg-panel2 [&_th]:px-3 [&_th]:py-1.5 [&_th]:text-left [&_th]:font-medium [&_th]:text-ink',
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-1.5 [&_td]:text-muted',
// Divers
'[&_h1]:scroll-mt-4 [&_h2]:scroll-mt-4 [&_h3]:scroll-mt-4',
'[&_hr]:my-6 [&_hr]:border-border',
'[&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:border [&_img]:border-border'
].join(' ')
export default function Markdown({
content,
className = '',
onHeadings
}: {
content: string
className?: string
/** Reçoit les titres du document, pour afficher un plan latéral. */
onHeadings?: (headings: Heading[]) => void
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const html = useMemo(() => renderMarkdown(content), [content])
// Les ancres sont posées sur le DOM plutôt que par un moteur de rendu
// Markdown : le texte affiché fait foi, et on reste indépendant de
// l'API de `marked`, qui a déjà changé plusieurs fois de signature.
const lastSent = useRef('')
useEffect(() => {
const root = ref.current
if (!root) return
const seen = new Map<string, number>()
const found: Heading[] = []
root.querySelectorAll<HTMLElement>('h1, h2, h3').forEach((el) => {
const text = el.textContent?.trim() ?? ''
if (!text) return
// Deux sections peuvent porter le même nom : on suffixe pour que
// chaque ancre reste unique et que le clic vise la bonne.
const base = slug(text)
const n = seen.get(base) ?? 0
seen.set(base, n + 1)
const id = n === 0 ? base : `${base}-${n}`
el.id = id
found.push({ id, text, level: Number(el.tagName[1]) })
})
// Ne remonter que si le plan a réellement changé : sinon le parent
// re-rendrait en boucle.
const key = found.map((h) => `${h.level}:${h.id}`).join('|')
if (key !== lastSent.current) {
lastSent.current = key
onHeadings?.(found)
}
}, [html, onHeadings])
// Les liens externes doivent s'ouvrir dans le NAVIGATEUR, pas remplacer
// l'application par une page web — ce qui la rendrait inutilisable, la
// fenêtre n'ayant ni barre d'adresse ni bouton retour.
useEffect(() => {
const root = ref.current
if (!root) return
const onClick = (e: MouseEvent): void => {
const link = (e.target as HTMLElement).closest('a')
const href = link?.getAttribute('href')
if (!href) return
e.preventDefault()
if (href.startsWith('#')) {
root.querySelector(href)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
if (/^https?:\/\//i.test(href)) window.open(href, '_blank', 'noopener,noreferrer')
}
root.addEventListener('click', onClick)
return () => root.removeEventListener('click', onClick)
}, [html])
if (!content.trim()) {
return <p className={`text-sm italic text-subtle ${className}`}>Cette fiche est vide.</p>
}
return (
<div
ref={ref}
className={`overflow-x-auto text-sm ${STYLES} ${className}`}
// Contenu déjà assaini par DOMPurify (voir lib/markdown.ts).
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
+194
View File
@@ -0,0 +1,194 @@
// =====================================================================
// Modale — une seule implémentation pour toute l'application.
//
// Corrige trois défauts qui coûtaient cher à l'usage :
// 1. Le contenu long faisait défiler le TITRE et le bouton « Enregistrer »
// hors de l'écran → en-tête et pied sont désormais fixes.
// 2. Fermer par Échap ou par un clic à côté effaçait une saisie en cours
// sans un mot → `dirty` déclenche une demande de confirmation.
// 3. Le focus s'échappait derrière la modale → piège à focus + restitution
// du focus à l'élément d'origine à la fermeture.
// =====================================================================
import { ReactNode, useCallback, useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import { Trash2, X } from 'lucide-react'
import { useFeedback } from '../lib/feedback'
import { Button, IconButton } from './ui'
type ModalSize = 'sm' | 'md' | 'lg'
const WIDTH: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl'
}
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
export function Modal({
title,
subtitle,
children,
footer,
onClose,
size = 'md',
/** Y a-t-il une saisie non enregistrée ? Si oui, on confirme la fermeture. */
dirty = false
}: {
title: string
subtitle?: ReactNode
children: ReactNode
footer?: ReactNode
onClose: () => void
size?: ModalSize
dirty?: boolean
}): JSX.Element {
const { confirm } = useFeedback()
const panelRef = useRef<HTMLDivElement>(null)
const returnFocus = useRef<HTMLElement | null>(null)
const requestClose = useCallback(async (): Promise<void> => {
if (!dirty) {
onClose()
return
}
const ok = await confirm({
title: 'Abandonner les modifications ?',
message: "Ce que tu viens de saisir sera perdu.",
confirmLabel: 'Abandonner',
cancelLabel: 'Continuer l’édition',
danger: true
})
if (ok) onClose()
}, [dirty, onClose, confirm])
// Focus initial + restitution à la fermeture.
useEffect(() => {
returnFocus.current = document.activeElement as HTMLElement | null
const first = panelRef.current?.querySelector<HTMLElement>(
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled])'
)
// Un champ si la modale en contient un, sinon le panneau lui-même.
;(first ?? panelRef.current)?.focus({ preventScroll: true })
return () => returnFocus.current?.focus?.({ preventScroll: true })
}, [])
// Échap (sauf si un menu déroulant est ouvert : il ferme en premier)
// et piège à focus sur Tab.
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === 'Escape') {
if (document.querySelector('[data-select-open]')) return
e.preventDefault()
void requestClose()
return
}
if (e.key !== 'Tab' || !panelRef.current) return
const items = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null
)
if (items.length === 0) return
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
if (e.shiftKey && (active === first || active === panelRef.current)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [requestClose])
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.12 }}
data-overlay
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
onMouseDown={() => void requestClose()}
>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.97, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
onMouseDown={(e) => e.stopPropagation()}
className={`flex max-h-[88vh] w-full flex-col overflow-hidden rounded-xl border border-border
bg-panel shadow-pop outline-none ${WIDTH[size]}`}
>
{/* En-tête fixe */}
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-5 py-3.5">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
{subtitle && <div className="mt-0.5 text-xs text-muted">{subtitle}</div>}
</div>
<IconButton label="Fermer" size="sm" icon={<X size={18} />} onClick={() => void requestClose()} />
</header>
{/* Corps défilant */}
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
{/* Pied fixe : le bouton principal reste TOUJOURS visible */}
{footer && (
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border bg-panel px-5 py-3">
{footer}
</footer>
)}
</motion.div>
</motion.div>
)
}
/**
* Disposition normalisée du pied de modale : action destructrice à gauche,
* Annuler + action principale à droite.
*/
export function ModalActions({
onDelete,
deleteLabel = 'Supprimer',
onCancel,
cancelLabel = 'Annuler',
children
}: {
onDelete?: () => void
deleteLabel?: string
onCancel?: () => void
cancelLabel?: string
children: ReactNode
}): JSX.Element {
return (
<>
{onDelete ? (
<Button
variant="quiet"
icon={<Trash2 size={16} />}
onClick={onDelete}
className="text-rose-600 hover:bg-rose-500/10 hover:text-rose-600"
>
{deleteLabel}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{onCancel && (
<Button variant="quiet" onClick={onCancel}>
{cancelLabel}
</Button>
)}
{children}
</div>
</>
)
}
+297
View File
@@ -0,0 +1,297 @@
// =====================================================================
// Sélecteur MULTIPLE — pour filtrer sur plusieurs valeurs à la fois
// (« montre-moi les tâches d'Alex ET de Sam », « les pôles Art ET Audio »).
//
// Même comportement que <Select> : rendu en portal, retournement vers le
// haut si le bas manque de place, pilotage complet au clavier. La
// différence : on coche plusieurs entrées, et le menu reste ouvert.
// =====================================================================
import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, Search } from 'lucide-react'
export interface MultiOption {
value: string
label: string
/** Petit visuel à gauche (avatar, pastille de couleur…). */
leading?: ReactNode
hint?: string
/** Placé en tête, séparé du reste (ex : « Non assigné »). */
pinned?: boolean
}
const ROW_H = 34
const MAX_H = 320
export function MultiSelect({
label,
options,
selected,
onChange,
className = '',
searchable,
emptyLabel
}: {
/** Nom du filtre, affiché quand rien n'est sélectionné (« Membres »). */
label: string
options: MultiOption[]
selected: string[]
onChange: (values: string[]) => void
className?: string
searchable?: boolean
/** Message quand la liste est vide. */
emptyLabel?: string
}): JSX.Element {
const btnRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [pos, setPos] = useState<{ left: number; top: number; width: number } | null>(null)
const showSearch = searchable ?? options.length > 8
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
const list = q
? options.filter(
(o) => o.label.toLowerCase().includes(q) || (o.hint ?? '').toLowerCase().includes(q)
)
: options
// Les entrées « épinglées » restent en tête.
return [...list].sort((a, b) => Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)))
}, [options, query])
/** Résumé affiché sur le bouton : « Membres », « Alex », « 3 membres ». */
const summary = useMemo(() => {
if (selected.length === 0) return label
if (selected.length === 1) {
return options.find((o) => o.value === selected[0])?.label ?? label
}
return `${selected.length} · ${label.toLowerCase()}`
}, [selected, options, label])
const place = (): void => {
const r = btnRef.current?.getBoundingClientRect()
if (!r) return
const estimated = Math.min(visible.length * ROW_H + (showSearch ? 42 : 0) + 44, MAX_H)
const below = window.innerHeight - r.bottom
const flip = below < estimated + 8 && r.top > below
const width = Math.max(r.width, 220)
setPos({
left: Math.max(8, Math.min(r.left, window.innerWidth - width - 8)),
top: flip ? Math.max(8, r.top - Math.min(estimated, r.top - 8) - 4) : r.bottom + 4,
width
})
}
useLayoutEffect(() => {
if (!open) return
place()
const onMove = (): void => place()
const onDown = (e: MouseEvent): void => {
const t = e.target as Node
if (btnRef.current?.contains(t) || panelRef.current?.contains(t)) return
setOpen(false)
}
window.addEventListener('scroll', onMove, true)
window.addEventListener('resize', onMove)
document.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('scroll', onMove, true)
window.removeEventListener('resize', onMove)
document.removeEventListener('mousedown', onDown)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, visible.length, showSearch])
useEffect(() => {
if (!open) {
setQuery('')
return
}
setActive(0)
if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
useEffect(() => {
if (!open) return
panelRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active, open])
const toggle = (value: string): void =>
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value])
const onKeyDown = (e: React.KeyboardEvent): void => {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(true)
}
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
e.stopPropagation()
setOpen(false)
btnRef.current?.focus()
break
case 'ArrowDown':
e.preventDefault()
setActive((i) => Math.min(i + 1, visible.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActive((i) => Math.max(i - 1, 0))
break
case 'Home':
e.preventDefault()
setActive(0)
break
case 'End':
e.preventDefault()
setActive(visible.length - 1)
break
case 'Enter':
case ' ':
// On coche sans refermer : c'est tout l'intérêt d'un choix multiple.
if (visible[active]) {
e.preventDefault()
toggle(visible[active].value)
}
break
case 'Tab':
setOpen(false)
break
}
}
const on = selected.length > 0
return (
<>
<button
ref={btnRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`${label}${on ? `${selected.length} sélectionné(s)` : ''}`}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKeyDown}
className={`flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs
outline-none transition-colors
${on ? 'border-accent bg-accent/10 text-accent' : 'border-border bg-panel2 text-ink hover:border-border-strong'}
${open ? '!border-accent ring-2 ring-accent/20' : ''} ${className}`}
>
<span className="max-w-[10rem] truncate font-medium">{summary}</span>
<ChevronDown
size={13}
className={`shrink-0 transition-transform ${open ? 'rotate-180' : ''} ${on ? '' : 'text-muted'}`}
/>
</button>
{open &&
pos &&
createPortal(
<div
ref={panelRef}
data-select-open
onKeyDown={onKeyDown}
style={{ left: pos.left, top: pos.top, width: pos.width }}
className="fixed z-[80] flex max-h-80 flex-col overflow-hidden rounded-lg border border-border bg-panel shadow-pop"
>
{showSearch && (
<div className="relative shrink-0 border-b border-border">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle"
/>
<input
ref={searchRef}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
placeholder="Filtrer…"
aria-label={`Filtrer ${label}`}
className="w-full bg-transparent py-2 pl-8 pr-2 text-sm text-ink outline-none placeholder:text-subtle"
/>
</div>
)}
<ul role="listbox" aria-multiselectable aria-label={label} className="min-h-0 flex-1 overflow-y-auto p-1">
{visible.length === 0 && (
<li className="px-2.5 py-3 text-center text-xs text-subtle">
{emptyLabel ?? 'Aucun résultat.'}
</li>
)}
{visible.map((o, i) => {
const checked = selected.includes(o.value)
const isActive = i === active
const separated = o.pinned && visible[i + 1] && !visible[i + 1].pinned
return (
<li key={o.value} className={separated ? 'mb-1 border-b border-border pb-1' : undefined}>
<button
type="button"
role="option"
aria-selected={checked}
data-active={isActive}
onMouseEnter={() => setActive(i)}
onClick={() => toggle(o.value)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
isActive ? 'bg-accent/12' : ''
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
checked ? 'border-accent bg-accent text-accentfg' : 'border-border-strong'
}`}
>
{checked && <Check size={11} strokeWidth={3} />}
</span>
{o.leading}
<span className="min-w-0 flex-1">
<span className="block truncate text-ink">{o.label}</span>
{o.hint && <span className="block truncate text-[11px] text-subtle">{o.hint}</span>}
</span>
</button>
</li>
)
})}
</ul>
<footer className="flex shrink-0 items-center justify-between border-t border-border px-2 py-1.5">
<span className="text-[11px] text-subtle">
{selected.length === 0
? 'Tout est affiché'
: `${selected.length} sélectionné${selected.length > 1 ? 's' : ''}`}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onChange(visible.map((o) => o.value))}
className="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-ink"
>
Tout cocher
</button>
<button
type="button"
disabled={selected.length === 0}
onClick={() => onChange([])}
className="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-ink disabled:opacity-40"
>
Effacer
</button>
</div>
</footer>
</div>,
document.body
)}
</>
)
}
+262 -181
View File
@@ -1,181 +1,262 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Bell, Check, KanbanSquare, Bug as BugIcon, Trash2 } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { AppNotification } from '../lib/types'
import type { Page } from './Layout'
export default function NotificationBell({ setPage }: { setPage: (p: Page) => void }): JSX.Element {
const { profile, setFocusEntity } = useApp()
const { toast } = useFeedback()
const [items, setItems] = useState<AppNotification[]>([])
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const uid = profile?.id
const load = useCallback(async () => {
if (!uid) return
const { data } = await supa()
.from('notifications')
.select('*')
.eq('user_id', uid)
.order('created_at', { ascending: false })
.limit(30)
setItems((data ?? []) as AppNotification[])
}, [uid])
useEffect(() => {
load()
}, [load])
// Synchro temps réel : nouvelles notifications qui me sont destinées.
useEffect(() => {
if (!uid) return
const ch = supa()
.channel(`notifications-${uid}`)
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
(payload) => {
// Toast à la réception d'une nouvelle notification.
if (payload.eventType === 'INSERT') {
const n = payload.new as AppNotification
toast(n.body ? `${n.title} : ${n.body}` : n.title, 'info')
}
load()
}
)
.subscribe()
return () => {
supa().removeChannel(ch)
}
}, [uid, load, toast])
// Fermeture au clic extérieur.
useEffect(() => {
const onClick = (e: MouseEvent): void => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [])
const unread = items.filter((n) => !n.read).length
const markAllRead = async (): Promise<void> => {
if (!uid || unread === 0) return
setItems((list) => list.map((n) => ({ ...n, read: true })))
await supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
}
const openItem = async (n: AppNotification): Promise<void> => {
setOpen(false)
if (!n.read) {
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
await supa().from('notifications').update({ read: true }).eq('id', n.id)
}
// Ouvre directement la tâche / le bug concerné(e) sur la page cible.
if (n.entity_id) setFocusEntity({ kind: n.kind, id: n.entity_id })
setPage(n.kind === 'bug' ? 'bugs' : 'kanban')
}
const clearAll = async (): Promise<void> => {
if (!uid || items.length === 0) return
setItems([])
await supa().from('notifications').delete().eq('user_id', uid)
}
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
title="Notifications"
className="relative text-muted hover:text-ink"
>
<Bell size={18} />
{unread > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg">
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 340, damping: 26 }}
className="absolute bottom-8 left-0 z-40 w-80 overflow-hidden rounded-xl border border-border bg-panel shadow-2xl"
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-semibold text-ink">Notifications</span>
<div className="flex items-center gap-1">
{unread > 0 && (
<button
onClick={markAllRead}
title="Tout marquer comme lu"
className="rounded p-1 text-muted hover:bg-panel2 hover:text-ink"
>
<Check size={15} />
</button>
)}
{items.length > 0 && (
<button
onClick={clearAll}
title="Tout effacer"
className="rounded p-1 text-muted hover:bg-panel2 hover:text-rose-600"
>
<Trash2 size={15} />
</button>
)}
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{items.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-subtle">Aucune notification.</p>
) : (
items.map((n) => (
<button
key={n.id}
onClick={() => openItem(n)}
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left last:border-0 hover:bg-panel2 ${
n.read ? '' : 'bg-accent/5'
}`}
>
<span
className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${
n.kind === 'bug'
? 'bg-rose-100 text-rose-600'
: 'bg-accent/15 text-accent'
}`}
>
{n.kind === 'bug' ? <BugIcon size={14} /> : <KanbanSquare size={14} />}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="text-sm font-medium text-ink">{n.title}</span>
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
</span>
{n.body && <span className="block truncate text-xs text-muted">{n.body}</span>}
<span className="block text-[11px] text-subtle">
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, locale: fr })}
</span>
</span>
</button>
))
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
import { useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Bell, Bug as BugIcon, CheckCheck, KanbanSquare, MessageSquare, Trash2 } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { humanize, mutate, rows } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { AppNotification, EntityKind, NotificationKind } from '../lib/types'
import { ErrorState, IconButton, Skeleton } from './ui'
const ICONS: Record<NotificationKind, JSX.Element> = {
task: <KanbanSquare size={14} />,
bug: <BugIcon size={14} />,
comment: <MessageSquare size={14} />
}
const ICON_STYLES: Record<NotificationKind, string> = {
task: 'bg-accent/12 text-accent',
bug: 'bg-rose-500/12 text-rose-600 dark:text-rose-300',
comment: 'bg-teal-500/12 text-teal-600 dark:text-teal-300'
}
export default function NotificationBell(): JSX.Element {
const { profile, projects, currentProject, selectProject, setFocusEntity, setPage } = useApp()
const { toast } = useFeedback()
const [items, setItems] = useState<AppNotification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const uid = profile?.id
const load = useCallback(async () => {
if (!uid) return
try {
setItems(
await rows<AppNotification>(
supa()
.from('notifications')
.select('*')
.eq('user_id', uid)
.order('created_at', { ascending: false })
.limit(40)
)
)
setError(null)
} catch (e) {
setError(humanize(e))
} finally {
setLoading(false)
}
}, [uid])
useEffect(() => {
load()
}, [load])
// Temps réel : une notification qui m'est destinée déclenche un toast.
const seen = useRef(new Set<string>())
useEffect(() => {
if (!uid) return
const ch = supa()
.channel(`notifications-${uid}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
(payload) => {
const n = payload.new as AppNotification
if (seen.current.has(n.id)) return
seen.current.add(n.id)
toast(n.body ? `${n.title}${n.body}` : n.title, 'info')
setItems((list) => [n, ...list.filter((x) => x.id !== n.id)].slice(0, 40))
}
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'notifications', filter: `user_id=eq.${uid}` },
() => load()
)
.subscribe()
return () => {
supa().removeChannel(ch)
}
}, [uid, load, toast])
// Fermeture au clic extérieur / Échap.
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent): void => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
const unread = items.filter((n) => !n.read).length
const markAllRead = async (): Promise<void> => {
if (!uid || unread === 0) return
const before = items
setItems((list) => list.map((n) => ({ ...n, read: true })))
try {
await mutate(
supa().from('notifications').update({ read: true }).eq('user_id', uid).eq('read', false)
)
} catch (e) {
setItems(before) // on ne ment pas : si l'écriture échoue, on revient en arrière
toast(humanize(e), 'error')
}
}
const clearAll = async (): Promise<void> => {
if (!uid || items.length === 0) return
const before = items
setItems([])
try {
await mutate(supa().from('notifications').delete().eq('user_id', uid))
} catch (e) {
setItems(before)
toast(humanize(e), 'error')
}
}
const openItem = async (n: AppNotification): Promise<void> => {
setOpen(false)
if (!n.read) {
setItems((list) => list.map((x) => (x.id === n.id ? { ...x, read: true } : x)))
try {
await mutate(supa().from('notifications').update({ read: true }).eq('id', n.id))
} catch {
/* la lecture n'est pas critique */
}
}
// La notification peut concerner un AUTRE projet : on bascule dessus
// avant d'ouvrir l'élément, sinon la page ne le trouverait jamais.
if (n.project_id && n.project_id !== currentProject?.id) {
const target = projects.find((p) => p.id === n.project_id)
if (target) selectProject(target)
}
// Repli sur `kind` : les notifications créées avant la migration v10
// n'ont pas de colonne `entity_kind`.
const target: EntityKind = n.entity_kind ?? (n.kind === 'bug' ? 'bug' : 'task')
if (n.entity_id) setFocusEntity({ kind: target, id: n.entity_id })
setPage(target === 'bug' ? 'bugs' : 'kanban')
}
const projectName = (id: string | null): string | null =>
id && id !== currentProject?.id ? (projects.find((p) => p.id === id)?.name ?? null) : null
return (
<div className="relative" ref={ref}>
<div className="relative">
<IconButton
size="sm"
label={unread > 0 ? `Notifications (${unread} non lues)` : 'Notifications'}
icon={<Bell size={17} />}
active={open}
onClick={() => setOpen((v) => !v)}
/>
{unread > 0 && (
<span className="pointer-events-none absolute right-0.5 top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accentfg ring-2 ring-panel">
{unread > 9 ? '9+' : unread}
</span>
)}
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.98 }}
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
className="absolute right-0 top-11 z-40 w-[22rem] overflow-hidden rounded-xl border border-border bg-panel shadow-pop"
>
<header className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-semibold text-ink">
Notifications
{unread > 0 && <span className="ml-1.5 text-xs font-normal text-accent">{unread}</span>}
</span>
<div className="flex items-center gap-0.5">
{unread > 0 && (
<IconButton
size="sm"
label="Tout marquer comme lu"
icon={<CheckCheck size={15} />}
onClick={markAllRead}
/>
)}
{items.length > 0 && (
<IconButton
size="sm"
tone="danger"
label="Tout effacer"
icon={<Trash2 size={15} />}
onClick={clearAll}
/>
)}
</div>
</header>
<div className="max-h-[26rem] overflow-y-auto">
{loading ? (
<div className="space-y-2 p-3">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : error ? (
<div className="p-3">
<ErrorState compact message={error} onRetry={load} />
</div>
) : items.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-subtle">
Rien de neuf. Tu seras prévenu quand une tâche ou un bug te concerne.
</p>
) : (
items.map((n) => {
const other = projectName(n.project_id)
return (
<button
key={n.id}
type="button"
onClick={() => openItem(n)}
className={`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors last:border-0 hover:bg-hover ${
n.read ? '' : 'bg-accent/5'
}`}
>
<span className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${ICON_STYLES[n.kind] ?? ICON_STYLES.task}`}>
{ICONS[n.kind] ?? ICONS.task}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-ink">{n.title}</span>
{!n.read && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</span>
{n.body && <span className="block truncate text-xs text-muted">{n.body}</span>}
<span className="mt-0.5 block text-[11px] text-subtle">
{formatDistanceToNow(new Date(n.created_at), { addSuffix: true, locale: fr })}
{other && <> · projet {other}</>}
</span>
</span>
</button>
)
})
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
-21
View File
@@ -1,21 +0,0 @@
import { ReactNode } from 'react'
export default function PageHeader({
title,
subtitle,
children
}: {
title: string
subtitle?: string
children?: ReactNode
}): JSX.Element {
return (
<header className="flex items-center justify-between border-b border-border px-6 py-4">
<div>
<h1 className="text-lg font-semibold text-ink">{title}</h1>
{subtitle && <p className="text-sm text-muted">{subtitle}</p>}
</div>
<div className="flex items-center gap-2">{children}</div>
</header>
)
}
+130
View File
@@ -0,0 +1,130 @@
// Création, renommage et suppression d'un projet.
// La suppression manquait complètement : un projet créé par erreur restait
// là pour toujours. Elle est réservée à son créateur ou à un admin (RLS).
import { useState } from 'react'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { humanize } from '../lib/db'
import { Project } from '../lib/types'
import { Button, Field, Input } from './ui'
import { Modal, ModalActions } from './Modal'
export default function ProjectModal({
mode,
project,
onClose
}: {
mode: 'create' | 'edit'
project?: Project
onClose: () => void
}): JSX.Element {
const { profile, createProject, renameProject, deleteProject } = useApp()
const { toast, confirm } = useFeedback()
const [name, setName] = useState(project?.name ?? '')
const [description, setDescription] = useState(project?.description ?? '')
const [busy, setBusy] = useState(false)
const dirty =
name !== (project?.name ?? '') || description !== (project?.description ?? '')
const canSubmit = name.trim().length > 0 && (mode === 'create' || dirty)
const canDelete =
mode === 'edit' && !!project && (profile?.is_admin || project.created_by === profile?.id)
const submit = async (): Promise<void> => {
if (!canSubmit) return
setBusy(true)
try {
if (mode === 'create') {
await createProject(name, description)
toast('Projet créé.', 'success')
} else if (project) {
await renameProject(project.id, name, description)
toast('Projet mis à jour.', 'success')
}
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
const remove = async (): Promise<void> => {
if (!project) return
const ok = await confirm({
title: `Supprimer « ${project.name} » ?`,
message:
'Toutes les tâches, bugs, entrées de temps, discussions et pièces jointes de ce projet ' +
'seront définitivement supprimés. Cette action est irréversible.',
danger: true,
confirmLabel: 'Supprimer définitivement'
})
if (!ok) return
setBusy(true)
try {
await deleteProject(project.id)
toast('Projet supprimé.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
return (
<Modal
title={mode === 'create' ? 'Nouveau projet' : 'Paramètres du projet'}
onClose={onClose}
dirty={dirty && !busy}
footer={
<ModalActions
onDelete={canDelete ? remove : undefined}
deleteLabel="Supprimer le projet"
onCancel={onClose}
>
<Button variant="primary" loading={busy} disabled={!canSubmit} onClick={submit}>
{mode === 'create' ? 'Créer le projet' : 'Enregistrer'}
</Button>
</ModalActions>
}
>
<form
onSubmit={(e) => {
e.preventDefault()
void submit()
}}
className="space-y-3"
>
<Field label="Nom du jeu / projet" required>
{({ id }) => (
<Input
id={id}
autoFocus
value={name}
maxLength={80}
placeholder="Mon super jeu"
onChange={(e) => setName(e.target.value)}
/>
)}
</Field>
<Field label="Description" hint="Genre, plateforme, pitch… (optionnel)">
{({ id }) => (
<textarea
id={id}
className="input h-24 resize-none"
value={description}
maxLength={500}
onChange={(e) => setDescription(e.target.value)}
/>
)}
</Field>
{/* Permet de valider avec Entrée sans bouton visible en double. */}
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
</form>
</Modal>
)
}
+288
View File
@@ -0,0 +1,288 @@
// =====================================================================
// Menu déroulant — LA seule façon de faire un « select » dans l'app.
// (Le <select> natif n'est pas stylable et est particulièrement laid
// sous Windows / WebView2.)
//
// Rendu via un portal pour ne jamais être coupé par le défilement d'une
// modale, s'ouvre vers le haut quand le bas manque de place, et se pilote
// ENTIÈREMENT au clavier : ↑ ↓ Début Fin Entrée Échap + saisie directe.
// Au-delà de 8 entrées, un champ de recherche apparaît automatiquement.
// =====================================================================
import { Fragment, ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, Search } from 'lucide-react'
export interface SelectOption<T extends string> {
value: T
label: string
/** En-tête de groupe : affiché avant le premier élément du groupe. */
group?: string
/** Ligne secondaire (ex : le pôle d'un membre). */
hint?: string
/** Visuel à gauche : pastille de statut, avatar… */
leading?: ReactNode
disabled?: boolean
}
const ROW_H = 34
const MAX_H = 288
export function Select<T extends string>({
value,
onChange,
options,
placeholder = 'Choisir…',
className = '',
size = 'md',
disabled,
ariaLabel,
searchable
}: {
value: T
onChange: (value: T) => void
options: SelectOption<T>[]
placeholder?: string
className?: string
size?: 'sm' | 'md'
disabled?: boolean
ariaLabel?: string
/** Forcer l'affichage (ou non) du champ de recherche. */
searchable?: boolean
}): JSX.Element {
const btnRef = useRef<HTMLButtonElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const [pos, setPos] = useState<{ left: number; top: number; width: number } | null>(null)
const showSearch = searchable ?? options.length > 8
const current = options.find((o) => o.value === value)
const visible = useMemo(() => {
if (!query.trim()) return options
const q = query.trim().toLowerCase()
return options.filter(
(o) => o.label.toLowerCase().includes(q) || (o.hint ?? '').toLowerCase().includes(q)
)
}, [options, query])
const selectable = visible.filter((o) => !o.disabled)
// ------------------------------------------------------------------
// Positionnement (avec retournement vers le haut si nécessaire)
// ------------------------------------------------------------------
const place = (): void => {
const r = btnRef.current?.getBoundingClientRect()
if (!r) return
const estimated = Math.min(visible.length * ROW_H + (showSearch ? 42 : 0) + 8, MAX_H)
const below = window.innerHeight - r.bottom
const flip = below < estimated + 8 && r.top > below
setPos({
left: Math.max(8, Math.min(r.left, window.innerWidth - r.width - 8)),
top: flip ? Math.max(8, r.top - Math.min(estimated, r.top - 8) - 4) : r.bottom + 4,
width: r.width
})
}
useLayoutEffect(() => {
if (!open) return
place()
const onMove = (): void => place()
const onDown = (e: MouseEvent): void => {
const t = e.target as Node
if (btnRef.current?.contains(t) || listRef.current?.contains(t)) return
setOpen(false)
}
window.addEventListener('scroll', onMove, true)
window.addEventListener('resize', onMove)
document.addEventListener('mousedown', onDown)
return () => {
window.removeEventListener('scroll', onMove, true)
window.removeEventListener('resize', onMove)
document.removeEventListener('mousedown', onDown)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, visible.length, showSearch])
// À l'ouverture : positionne le curseur sur la valeur courante.
useEffect(() => {
if (!open) {
setQuery('')
return
}
const idx = selectable.findIndex((o) => o.value === value)
setActive(idx >= 0 ? idx : 0)
if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// Garde l'élément actif visible dans la liste.
useEffect(() => {
if (!open) return
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' })
}, [active, open])
const commit = (o: SelectOption<T>): void => {
if (o.disabled) return
onChange(o.value)
setOpen(false)
btnRef.current?.focus()
}
const onKeyDown = (e: React.KeyboardEvent): void => {
if (!open) {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(true)
}
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
e.stopPropagation() // ne ferme pas la modale par-dessous
setOpen(false)
btnRef.current?.focus()
break
case 'ArrowDown':
e.preventDefault()
setActive((i) => Math.min(i + 1, selectable.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setActive((i) => Math.max(i - 1, 0))
break
case 'Home':
e.preventDefault()
setActive(0)
break
case 'End':
e.preventDefault()
setActive(selectable.length - 1)
break
case 'Tab':
setOpen(false)
break
case 'Enter':
e.preventDefault()
if (selectable[active]) commit(selectable[active])
break
default:
// Saisie directe quand il n'y a pas de champ de recherche.
if (!showSearch && e.key.length === 1 && /\S/.test(e.key)) {
const hit = selectable.findIndex((o) => o.label.toLowerCase().startsWith(e.key.toLowerCase()))
if (hit >= 0) setActive(hit)
}
}
}
const pad = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-2 text-sm'
return (
<>
<button
ref={btnRef}
type="button"
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKeyDown}
className={`flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg border
border-border bg-panel2 text-left text-ink outline-none transition-colors
hover:border-border-strong disabled:cursor-not-allowed disabled:opacity-60
${pad} ${open ? '!border-accent ring-2 ring-accent/20' : ''} ${className}`}
>
<span className="flex min-w-0 items-center gap-2">
{current?.leading}
<span className={`truncate ${current ? 'text-ink' : 'text-subtle'}`}>
{current?.label ?? placeholder}
</span>
</span>
<ChevronDown
size={size === 'sm' ? 14 : 16}
className={`shrink-0 text-muted transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open &&
pos &&
createPortal(
<div
ref={listRef}
data-select-open
onKeyDown={onKeyDown}
style={{ left: pos.left, top: pos.top, minWidth: pos.width }}
className="fixed z-[80] overflow-hidden rounded-lg border border-border bg-panel shadow-pop"
>
{showSearch && (
<div className="relative border-b border-border">
<Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle" />
<input
ref={searchRef}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
placeholder="Filtrer…"
aria-label="Filtrer les options"
className="w-full bg-transparent py-2 pl-8 pr-2 text-sm text-ink outline-none placeholder:text-subtle"
/>
</div>
)}
<ul role="listbox" aria-label={ariaLabel} className="max-h-64 overflow-y-auto p-1">
{visible.length === 0 && (
<li className="px-2.5 py-3 text-center text-xs text-subtle">Aucun résultat.</li>
)}
{visible.map((o, i) => {
const selected = o.value === value
const isActive = selectable[active]?.value === o.value
const newGroup = o.group && o.group !== visible[i - 1]?.group
return (
<Fragment key={o.value}>
{newGroup && (
<li className="px-2.5 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wide text-subtle first:pt-1">
{o.group}
</li>
)}
<li>
<button
type="button"
role="option"
aria-selected={selected}
disabled={o.disabled}
data-active={isActive}
onMouseEnter={() => {
const idx = selectable.findIndex((s) => s.value === o.value)
if (idx >= 0) setActive(idx)
}}
onClick={() => commit(o)}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5
text-left text-sm transition-colors disabled:opacity-40
${selected ? 'font-medium text-accent' : 'text-ink'}
${isActive ? 'bg-accent/12' : ''}`}
>
{o.leading}
<span className="min-w-0 flex-1">
<span className="block truncate">{o.label}</span>
{o.hint && <span className="block truncate text-[11px] text-subtle">{o.hint}</span>}
</span>
{selected && <Check size={15} className="shrink-0" />}
</button>
</li>
</Fragment>
)
})}
</ul>
</div>,
document.body
)}
</>
)
}
+421 -373
View File
@@ -1,373 +1,421 @@
import { useEffect, useState } from 'react'
import { Trash2, Clock, Plus, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { notifyPole } from '../lib/notify'
import {
Task,
TaskStatus,
Priority,
Pole,
TimeLog,
Profile,
TASK_STATUS_ORDER,
TASK_STATUS_LABELS,
PRIORITY_LABELS,
memberPoles,
taskPoles,
formatMinutes
} from '../lib/types'
import { Modal, Select, Avatar, PoleChips } from './ui'
export default function TaskModal({
task,
defaultStatus,
projectId,
members,
onClose,
onSaved
}: {
task: Task | null
defaultStatus: TaskStatus
projectId: string
members: Profile[]
onClose: () => void
onSaved: () => Promise<void> | void
}): JSX.Element {
const { profile } = useApp()
const { toast, confirm } = useFeedback()
const [title, setTitle] = useState(task?.title ?? '')
const [description, setDescription] = useState(task?.description ?? '')
const [status, setStatus] = useState<TaskStatus>(task?.status ?? defaultStatus)
const [priority, setPriority] = useState<Priority>(task?.priority ?? 'medium')
const [poles, setPoles] = useState<Pole[]>(task ? taskPoles(task) : [])
const [assignees, setAssignees] = useState<string[]>(task?.assignee_ids ?? [])
const togglePole = (p: Pole): void =>
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
const [dueDate, setDueDate] = useState(task?.due_date ?? '')
const [busy, setBusy] = useState(false)
const toggleAssignee = (id: string): void =>
setAssignees((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
status,
priority,
pole: poles[0] ?? null, // pôle principal (compat)
poles,
assignee_ids: assignees,
due_date: dueDate || null
}
// Notifier chaque pôle nouvellement confié (création, ou pôle ajouté).
const before = task ? taskPoles(task) : []
const newPoles = poles.filter((p) => !before.includes(p))
let entityId = task?.id ?? null
if (task) {
await supa().from('tasks').update(payload).eq('id', task.id)
} else {
const { data } = await supa()
.from('tasks')
.insert({ ...payload, created_by: profile?.id ?? null })
.select('id')
.single()
entityId = (data as { id: string } | null)?.id ?? null
}
if (entityId) {
for (const pole of newPoles) {
await notifyPole({
projectId,
pole,
kind: 'task',
title: 'Nouvelle tâche',
body: title.trim(),
entityId,
actorId: profile?.id ?? null,
members
})
}
}
await onSaved()
setBusy(false)
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success')
onClose()
}
const remove = async (): Promise<void> => {
if (!task) return
const ok = await confirm({
title: 'Supprimer cette tâche ?',
message: `« ${task.title} » sera définitivement supprimée.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
await supa().from('tasks').delete().eq('id', task.id)
await onSaved()
toast('Tâche supprimée.', 'success')
onClose()
}
return (
<Modal title={task ? 'Modifier la tâche' : 'Nouvelle tâche'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Implémenter le saut du personnage"
/>
<label className="label">Description</label>
<textarea
className="input mb-3 h-24 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* Pôles — sélection multiple par pastilles (clique pour ajouter/retirer) */}
<label className="label">
Pôle(s) {poles.length > 0 && <span className="text-subtle">· {poles.length}</span>}
</label>
<div className="mb-3">
<PoleChips selected={poles} onToggle={togglePole} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as TaskStatus)}>
{TASK_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{TASK_STATUS_LABELS[s]}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div className="col-span-2">
<label className="label">Échéance</label>
<input
type="date"
className="input"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
{/* Personnes assignées (une ou plusieurs) */}
<div className="mt-3">
<label className="label">
Assigné(s) {assignees.length > 0 && <span className="text-subtle">· {assignees.length}</span>}
</label>
<div className="flex flex-wrap gap-1.5">
{members.length === 0 && (
<span className="text-xs text-subtle">Aucun membre dans l'équipe.</span>
)}
{members.map((m) => {
const on = assignees.includes(m.id)
const inPole = poles.some((p) => memberPoles(m).includes(p))
return (
<button
key={m.id}
type="button"
onClick={() => toggleAssignee(m.id)}
title={inPole ? `${m.full_name} · ce pôle` : m.full_name}
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-sm transition-colors ${
on
? 'border-accent bg-accent/15 text-ink'
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
<Avatar profile={m} size={22} />
<span>{m.full_name}</span>
{inPole && <span className="h-1.5 w-1.5 rounded-full bg-accent2" />}
</button>
)
})}
</div>
</div>
{/* Suivi du temps — disponible une fois la tâche créée */}
{task && <TimeSection taskId={task.id} projectId={projectId} members={members} />}
<div className="mt-5 flex items-center justify-between">
{task ? (
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{busy && <Loader2 className="animate-spin" size={16} />}
{task ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
function TimeSection({
taskId,
projectId,
members
}: {
taskId: string
projectId: string
members: Profile[]
}): JSX.Element {
const { profile } = useApp()
const { toast, confirm } = useFeedback()
const [logs, setLogs] = useState<TimeLog[]>([])
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [note, setNote] = useState('')
const [busy, setBusy] = useState(false)
const byId = (id: string | null): Profile | undefined =>
id ? members.find((m) => m.id === id) : undefined
const load = async (): Promise<void> => {
const { data } = await supa()
.from('time_logs')
.select('*')
.eq('task_id', taskId)
.order('logged_at', { ascending: false })
setLogs((data ?? []) as TimeLog[])
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId])
const total = logs.reduce((s, l) => s + l.minutes, 0)
const add = async (): Promise<void> => {
const mins = (parseInt(hours || '0', 10) || 0) * 60 + (parseInt(minutes || '0', 10) || 0)
if (mins <= 0) {
toast('Indique une durée valide.', 'error')
return
}
setBusy(true)
await supa().from('time_logs').insert({
task_id: taskId,
project_id: projectId,
user_id: profile?.id ?? null,
minutes: mins,
note: note.trim() || null
})
setHours('')
setMinutes('')
setNote('')
await load()
setBusy(false)
toast('Temps ajouté.', 'success')
}
const del = async (id: string): Promise<void> => {
const ok = await confirm({ title: 'Supprimer cette entrée de temps ?', danger: true, confirmLabel: 'Supprimer' })
if (!ok) return
await supa().from('time_logs').delete().eq('id', id)
await load()
}
return (
<div className="mt-5 rounded-xl border border-border bg-panel2/40 p-3">
<div className="mb-3 flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-ink">
<Clock size={16} className="text-accent2" /> Temps passé
</span>
<span className="text-sm font-semibold text-accent2">{formatMinutes(total)}</span>
</div>
{/* Ajout */}
<div className="mb-3 flex flex-wrap items-end gap-2">
<div className="w-16">
<label className="label">Heures</label>
<input
type="number"
min={0}
className="input"
placeholder="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
/>
</div>
<div className="w-16">
<label className="label">Min</label>
<input
type="number"
min={0}
max={59}
className="input"
placeholder="0"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
/>
</div>
<input
className="input flex-1"
placeholder="Note (optionnel)"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<button className="btn-ghost" onClick={add} disabled={busy}>
<Plus size={15} /> Ajouter
</button>
</div>
{/* Liste */}
{logs.length === 0 ? (
<p className="py-2 text-center text-xs text-subtle">
Aucun temps enregistré. Note combien de temps cette tâche t'a pris.
</p>
) : (
<ul className="space-y-1.5">
{logs.map((l) => (
<li
key={l.id}
className="group flex items-center gap-2 rounded-lg border border-border bg-panel px-2.5 py-1.5 text-sm"
>
<Avatar profile={byId(l.user_id)} size={22} />
<span className="font-medium text-ink">{formatMinutes(l.minutes)}</span>
{l.note && <span className="truncate text-muted"> {l.note}</span>}
<span className="ml-auto text-xs text-subtle">
{format(new Date(l.logged_at), 'd MMM', { locale: fr })}
</span>
<button
onClick={() => del(l.id)}
className="text-gray-600 opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
</div>
)
}
import { useMemo, useState } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { ListChecks, Plus, X } from 'lucide-react'
import { supa } from '../lib/supabase'
import { humanize, mutate, rows, run } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { notifyTask } from '../lib/notify'
import {
ChecklistItem,
Pole,
Priority,
PRIORITY_LABELS,
PRIORITY_ORDER,
Task,
TaskStatus,
TASK_STATUS_LABELS,
TASK_STATUS_ORDER,
checklistProgress
} from '../lib/types'
import { Button, Checkbox, Field, IconButton, Input, ProgressBar } from './ui'
import { Modal, ModalActions } from './Modal'
import { Select } from './Select'
import { AssigneePicker, PoleChips, TagInput } from './people'
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
/** Instantané des champs éditables : sert à détecter une saisie non enregistrée. */
interface Draft {
title: string
description: string
status: TaskStatus
priority: Priority
poles: Pole[]
assignees: string[]
checklist: ChecklistItem[]
tags: string[]
dueDate: string
}
function draftOf(task: Task | null, defaultStatus: TaskStatus): Draft {
return {
title: task?.title ?? '',
description: task?.description ?? '',
status: task?.status ?? defaultStatus,
priority: task?.priority ?? 'medium',
poles: task?.poles ?? [],
assignees: task?.assignee_ids ?? [],
checklist: task?.checklist ?? [],
tags: task?.tags ?? [],
dueDate: task?.due_date ?? ''
}
}
export default function TaskModal({
task,
defaultStatus,
projectId,
onClose,
onSaved
}: {
task: Task | null
defaultStatus: TaskStatus
projectId: string
onClose: () => void
onSaved: () => Promise<void> | void
}): JSX.Element {
const { profile, members, membersLoading } = useApp()
const { toast, confirm } = useFeedback()
const initial = useMemo(() => draftOf(task, defaultStatus), [task, defaultStatus])
const [d, setD] = useState<Draft>(initial)
const [newItem, setNewItem] = useState('')
const [busy, setBusy] = useState(false)
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
const progress = checklistProgress(d.checklist)
// -------------------------------------------------------------- checklist
const addChecklistItem = (): void => {
const text = newItem.trim()
if (!text) return
patch({ checklist: [...d.checklist, { id: crypto.randomUUID(), text, done: false }] })
setNewItem('')
}
// -------------------------------------------------------------- save
const save = async (): Promise<void> => {
if (!d.title.trim()) {
toast('Donne un titre à la tâche.', 'error')
return
}
setBusy(true)
const payload = {
project_id: projectId,
title: d.title.trim(),
description: d.description.trim() || null,
status: d.status,
priority: d.priority,
poles: d.poles,
assignee_ids: d.assignees,
checklist: d.checklist,
tags: d.tags,
due_date: d.dueDate || null
}
try {
let entityId = task?.id ?? null
if (task) {
await mutate(supa().from('tasks').update(payload).eq('id', task.id))
} else {
// Position = fin de la colonne. On lit le maximum existant plutôt
// que d'utiliser Date.now() : ça mélangeait deux échelles de valeurs.
const last = await rows<{ position: number }>(
supa()
.from('tasks')
.select('position')
.eq('project_id', projectId)
.eq('status', d.status)
.order('position', { ascending: false })
.limit(1)
)
const created = await run<{ id: string }>(
supa()
.from('tasks')
.insert({
...payload,
position: (last[0]?.position ?? -1) + 1,
created_by: profile?.id ?? null
})
.select('id')
.single()
)
entityId = created?.id ?? null
}
// On ne notifie que ce qui est NOUVEAU (pôle ajouté, personne ajoutée),
// en une seule requête et sans doublon.
if (entityId) {
const beforePoles = task?.poles ?? []
const beforeAssignees = task?.assignee_ids ?? []
const newPoles = d.poles.filter((p) => !beforePoles.includes(p))
const newAssignees = d.assignees.filter((a) => !beforeAssignees.includes(a))
if (newPoles.length || newAssignees.length) {
await notifyTask({
projectId,
taskId: entityId,
title: d.title.trim(),
members,
poles: newPoles,
assigneeIds: newAssignees,
actorId: profile?.id ?? null,
isNew: !task
})
}
}
await onSaved()
toast(task ? 'Tâche enregistrée.' : 'Tâche créée.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
const remove = async (): Promise<void> => {
if (!task) return
const ok = await confirm({
title: 'Supprimer cette tâche ?',
message: `« ${task.title} » et tout ce qui y est rattaché (temps, discussion, fichiers) seront supprimés.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
setBusy(true)
try {
await mutate(supa().from('tasks').delete().eq('id', task.id))
await onSaved()
toast('Tâche supprimée.', 'success')
onClose()
} catch (e) {
toast(humanize(e), 'error')
setBusy(false)
}
}
const subtitle = task ? (
<span className="flex items-center gap-2">
<span>Créée le {format(new Date(task.created_at), 'd MMM yyyy', { locale: fr })}</span>
{task.updated_at !== task.created_at && (
<span className="text-subtle">
· modifiée {format(new Date(task.updated_at), 'd MMM à HH:mm', { locale: fr })}
</span>
)}
</span>
) : null
return (
<Modal
title={task ? 'Modifier la tâche' : 'Nouvelle tâche'}
subtitle={subtitle}
onClose={onClose}
size="lg"
dirty={dirty && !busy}
footer={
<ModalActions onDelete={task ? remove : undefined} onCancel={onClose}>
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
{task ? 'Enregistrer' : 'Créer la tâche'}
</Button>
</ModalActions>
}
>
<div className="space-y-4">
<Field label="Titre" required>
{({ id }) => (
<Input
id={id}
autoFocus
value={d.title}
maxLength={160}
placeholder="Implémenter le saut du personnage"
onChange={(e) => patch({ title: e.target.value })}
/>
)}
</Field>
<Field label="Description">
{({ id }) => (
<textarea
id={id}
className="input h-24 resize-none"
value={d.description}
placeholder="Contexte, contraintes, critères d'acceptation…"
onChange={(e) => patch({ description: e.target.value })}
/>
)}
</Field>
{/* ---------------------------- Checklist ---------------------------- */}
<section>
<div className="mb-1.5 flex items-center justify-between">
<span className="label mb-0 flex items-center gap-1.5">
<ListChecks size={15} className="text-muted" /> Checklist
{progress.total > 0 && (
<span className="text-subtle">
· {progress.done}/{progress.total}
</span>
)}
</span>
{progress.total > 0 && (
<span className="text-xs font-semibold text-accent2 tabular">{progress.pct} %</span>
)}
</div>
{progress.total > 0 && <ProgressBar pct={progress.pct} barClassName="bg-accent2" className="mb-2" />}
<ul className="space-y-0.5">
{d.checklist.map((item) => (
<li
key={item.id}
className="group flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-panel2/70"
>
<Checkbox
checked={item.done}
onChange={() =>
patch({
checklist: d.checklist.map((i) =>
i.id === item.id ? { ...i, done: !i.done } : i
)
})
}
label={
<span className={item.done ? 'text-subtle line-through' : 'text-ink'}>
{item.text}
</span>
}
className="flex-1"
/>
<button
type="button"
aria-label={`Retirer « ${item.text} »`}
onClick={() => patch({ checklist: d.checklist.filter((i) => i.id !== item.id) })}
className="shrink-0 text-subtle opacity-0 transition group-hover:opacity-100 hover:text-rose-600"
>
<X size={14} />
</button>
</li>
))}
</ul>
<div className="mt-1.5 flex gap-2">
<Input
value={newItem}
placeholder="Ajouter un élément…"
onChange={(e) => setNewItem(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addChecklistItem()
}
}}
/>
<Button icon={<Plus size={15} />} onClick={addChecklistItem} disabled={!newItem.trim()}>
Ajouter
</Button>
</div>
</section>
{/* ---------------------------- Planning ---------------------------- */}
<div className="grid grid-cols-2 gap-3 border-t border-border pt-4">
<Field label="Statut">
<Select
value={d.status}
onChange={(status) => patch({ status })}
ariaLabel="Statut de la tâche"
options={TASK_STATUS_ORDER.map((s) => ({ value: s, label: TASK_STATUS_LABELS[s] }))}
/>
</Field>
<Field label="Priorité">
<Select
value={d.priority}
onChange={(priority) => patch({ priority })}
ariaLabel="Priorité de la tâche"
options={PRIORITY_ORDER.map((p) => ({ value: p, label: PRIORITY_LABELS[p] }))}
/>
</Field>
<Field
label="Échéance"
className="col-span-2"
hint={d.dueDate ? undefined : 'Optionnel — la tâche apparaîtra dans « à venir » sur le tableau de bord.'}
>
{({ id }) => (
<div className="flex items-center gap-2">
<Input
id={id}
type="date"
value={d.dueDate}
onChange={(e) => patch({ dueDate: e.target.value })}
/>
{d.dueDate && (
<IconButton
size="sm"
label="Retirer l'échéance"
icon={<X size={15} />}
onClick={() => patch({ dueDate: '' })}
/>
)}
</div>
)}
</Field>
</div>
<Field label="Étiquettes" hint="Pour regrouper et retrouver les tâches (Ctrl+K).">
<TagInput value={d.tags} onChange={(tags) => patch({ tags })} />
</Field>
{/* ---------------------------- Qui ? ---------------------------- */}
<div className="space-y-3 border-t border-border pt-4">
<Field
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
hint="Tous les membres du pôle reçoivent une notification."
>
<PoleChips
selected={d.poles}
onToggle={(p) =>
patch({
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
})
}
/>
</Field>
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
<AssigneePicker
members={members}
loading={membersLoading}
selected={d.assignees}
onChange={(assignees) => patch({ assignees })}
poles={d.poles}
/>
</Field>
</div>
{/* ------------- Disponible une fois la tâche enregistrée ------------- */}
{task ? (
<div className="space-y-3 border-t border-border pt-4">
<TimeSection target={{ kind: 'task', id: task.id }} projectId={projectId} />
<AttachmentPanel
entity={{
kind: 'task',
id: task.id,
projectId,
title: task.title,
involvedIds: task.assignee_ids
}}
/>
<CommentThread
entity={{
kind: 'task',
id: task.id,
projectId,
title: task.title,
involvedIds: [...task.assignee_ids, ...(task.created_by ? [task.created_by] : [])]
}}
/>
</div>
) : (
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
Le suivi du temps, les pièces jointes et la discussion seront disponibles dès que la
tâche sera créée.
</p>
)}
</div>
</Modal>
)
}
+142
View File
@@ -0,0 +1,142 @@
// =====================================================================
// Bandeau « une mise à jour est disponible ».
//
// Discret : une bande fine en haut de l'application, jamais une modale
// qui interrompt le travail. On peut reporter — la proposition revient
// au prochain lancement.
// =====================================================================
import { useCallback, useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Download, RefreshCw, Sparkles, X } from 'lucide-react'
import { AvailableUpdate, checkForUpdate } from '../lib/updater'
import { useFeedback } from '../lib/feedback'
import { Button, IconButton } from './ui'
/** Nouvelle vérification toutes les 2 heures pour les sessions qui durent. */
const RECHECK_MS = 2 * 60 * 60 * 1000
export default function UpdateBanner(): JSX.Element | null {
const { toast } = useFeedback()
const [update, setUpdate] = useState<AvailableUpdate | null>(null)
const [dismissed, setDismissed] = useState(false)
const [installing, setInstalling] = useState(false)
const [progress, setProgress] = useState<number | null>(null)
const look = useCallback(async () => {
const found = await checkForUpdate()
if (found) setUpdate(found)
}, [])
useEffect(() => {
// Au démarrage, mais sans retarder l'affichage de l'application.
const first = setTimeout(look, 4000)
const timer = setInterval(look, RECHECK_MS)
return () => {
clearTimeout(first)
clearInterval(timer)
}
}, [look])
if (!update || dismissed) return null
const install = async (): Promise<void> => {
setInstalling(true)
setProgress(0)
try {
// Au succès l'application redémarre : rien à faire de plus ici.
await update.install((pct) => setProgress(pct))
} catch (e) {
setInstalling(false)
setProgress(null)
toast(
`La mise à jour a échoué : ${(e as Error).message}. Réessaie plus tard ou préviens l'administrateur.`,
'error'
)
}
}
return (
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="shrink-0 overflow-hidden border-b border-accent/25 bg-accent/10"
>
<div className="flex items-center gap-3 px-4 py-2">
<Sparkles size={15} className="shrink-0 text-accent" />
<span className="min-w-0 flex-1 text-xs text-ink">
<span className="font-medium">Version {update.version} disponible.</span>{' '}
{installing ? (
<span className="text-muted">
{progress === null || progress === 0
? 'Téléchargement…'
: progress < 100
? `Téléchargement ${progress} %…`
: 'Installation, lapplication va redémarrer…'}
</span>
) : (
<span className="text-muted">
{update.notes?.split('\n')[0] || "L'application se relancera toute seule."}
</span>
)}
</span>
{installing ? (
<div className="h-1 w-32 shrink-0 overflow-hidden rounded-full bg-accent/20">
<div
className={`h-full rounded-full bg-accent transition-[width] duration-300 ${
progress === null ? 'w-1/3 animate-pulse' : ''
}`}
style={progress === null ? undefined : { width: `${progress}%` }}
/>
</div>
) : (
<>
<Button size="sm" variant="primary" icon={<Download size={13} />} onClick={install}>
Installer et redémarrer
</Button>
<IconButton
size="sm"
label="Plus tard"
icon={<X size={15} />}
onClick={() => setDismissed(true)}
/>
</>
)}
</div>
</motion.div>
</AnimatePresence>
)
}
/** Ligne « Version X · Rechercher une mise à jour », pour l'écran d'aide. */
export function UpdateCheckRow({ version }: { version: string | null }): JSX.Element {
const { toast } = useFeedback()
const [busy, setBusy] = useState(false)
const look = async (): Promise<void> => {
setBusy(true)
const found = await checkForUpdate()
setBusy(false)
toast(
found
? `Version ${found.version} disponible — le bandeau apparaît en haut de l'écran.`
: 'Tu es déjà à jour.',
found ? 'info' : 'success'
)
}
return (
<div className="flex items-center justify-between gap-4 border-t border-border pt-3 text-sm">
<span className="text-muted">
Version installée <span className="font-medium text-ink">{version ?? '—'}</span>
</span>
<Button size="sm" icon={<RefreshCw size={13} />} loading={busy} onClick={look}>
Rechercher une mise à jour
</Button>
</div>
)
}
+480
View File
@@ -0,0 +1,480 @@
// =====================================================================
// Tout ce qui représente une personne, un pôle ou une étiquette.
// =====================================================================
import { ChangeEvent, KeyboardEvent, useRef, useState } from 'react'
import { Camera, Plus, X } from 'lucide-react'
import {
Pole,
POLE_LABELS,
POLE_ORDER,
Profile,
TAG_SUGGESTIONS,
memberPoles,
poleBadge,
polesLabel,
tagBadge
} from '../lib/types'
import { uploadAvatar } from '../lib/storage'
import { useFeedback } from '../lib/feedback'
import { Badge, Button } from './ui'
// ---------------------------------------------------------------------
// Avatar
// ---------------------------------------------------------------------
function initialsOf(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.map((s) => s[0])
.slice(0, 2)
.join('')
.toUpperCase()
}
export function Avatar({
profile,
size = 32,
className = ''
}: {
profile?: Partial<Profile> | null
size?: number
className?: string
}): JSX.Element {
// Si l'image ne charge pas (URL périmée, bucket nettoyé), on retombe sur
// les initiales au lieu d'afficher une vignette cassée.
const [broken, setBroken] = useState(false)
const name = profile?.full_name ?? ''
const title = name || 'Non assigné'
if (profile?.avatar_url && !broken) {
return (
<img
src={profile.avatar_url}
alt={name}
title={title}
draggable={false}
onError={() => setBroken(true)}
className={`shrink-0 rounded-full bg-panel2 object-cover ${className}`}
style={{ width: size, height: size }}
/>
)
}
return (
<div
title={title}
aria-label={title}
className={`flex shrink-0 select-none items-center justify-center rounded-full font-semibold text-white ${className}`}
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.4),
background: profile?.avatar_color ?? 'rgb(var(--c-border-strong))'
}}
>
{name ? initialsOf(name) : ''}
</div>
)
}
/** Pile d'avatars superposés (personnes assignées à une tâche / un bug). */
export function AvatarStack({
ids,
resolve,
size = 24,
max = 3
}: {
ids: string[]
resolve: (id: string) => Profile | undefined
size?: number
max?: number
}): JSX.Element | null {
if (!ids.length) return null
const shown = ids.slice(0, max)
const extra = ids.length - shown.length
const names = ids.map((id) => resolve(id)?.full_name ?? 'Inconnu').join(', ')
return (
<div className="flex shrink-0 -space-x-1.5" title={names} aria-label={`Assigné à ${names}`}>
{shown.map((id) => (
<span key={id} className="rounded-full ring-2 ring-panel">
<Avatar profile={resolve(id)} size={size} />
</span>
))}
{extra > 0 && (
<span
className="flex items-center justify-center rounded-full bg-panel2 text-[10px] font-semibold text-muted ring-2 ring-panel"
style={{ width: size, height: size }}
>
+{extra}
</span>
)}
</div>
)
}
/** Avatar + nom + pôles, utilisé dans les listes de membres. */
export function MemberLine({
member,
size = 36,
isMe
}: {
member: Profile
size?: number
isMe?: boolean
}): JSX.Element {
return (
<div className="flex min-w-0 items-center gap-2.5">
<Avatar profile={member} size={size} />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-ink">
{member.full_name}
{isMe && <span className="ml-1.5 text-xs font-normal text-accent">(moi)</span>}
</div>
<div className="truncate text-xs text-subtle">{polesLabel(memberPoles(member))}</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------
// Pôles
// ---------------------------------------------------------------------
export function PoleBadge({ pole }: { pole: Pole }): JSX.Element {
return <Badge className={poleBadge(pole)}>{POLE_LABELS[pole]}</Badge>
}
/** Sélection de pôle(s) par pastilles cliquables. */
export function PoleChips({
selected,
onToggle,
choices = POLE_ORDER
}: {
selected: Pole[] | null | undefined
onToggle: (pole: Pole) => void
choices?: Pole[]
}): JSX.Element {
const sel = selected ?? []
return (
<div className="flex flex-wrap gap-1.5">
{choices.map((p) => {
const on = sel.includes(p)
return (
<button
key={p}
type="button"
aria-pressed={on}
onClick={() => onToggle(p)}
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
on
? `${poleBadge(p)} border-transparent ring-2 ring-accent/40`
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
{POLE_LABELS[p]}
</button>
)
})}
</div>
)
}
/** Affichage seul d'une liste de pôles (lignes de tableau, cartes). */
export function PoleList({ poles }: { poles: Pole[] }): JSX.Element {
if (poles.length === 0) return <span className="text-xs text-subtle"></span>
return (
<div className="flex flex-wrap gap-1">
{poles.map((p) => (
<PoleBadge key={p} pole={p} />
))}
</div>
)
}
// ---------------------------------------------------------------------
// Assignation de personnes
// ---------------------------------------------------------------------
/**
* Sélection des personnes assignées LA seule façon d'assigner (tâches
* comme bugs). Si `poles` est renseigné, on ne montre d'abord que les
* membres de ces pôles ; les personnes déjà choisies restent toujours
* visibles, et un lien permet d'ouvrir à toute l'équipe.
*/
export function AssigneePicker({
members,
selected,
onChange,
poles = [],
loading
}: {
members: Profile[]
selected: string[]
onChange: (ids: string[]) => void
poles?: Pole[]
loading?: boolean
}): JSX.Element {
const [showAll, setShowAll] = useState(false)
const toggle = (id: string): void =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id])
// Pendant le chargement on ne ment pas en disant « aucun membre ».
if (loading) {
return (
<div className="flex gap-1.5">
{[0, 1, 2].map((i) => (
<div key={i} className="skeleton h-8 w-28 rounded-full" />
))}
</div>
)
}
if (members.length === 0) {
return <p className="text-xs text-subtle">Aucun membre dans l'équipe pour l'instant.</p>
}
const inPole = (m: Profile): boolean => poles.some((p) => memberPoles(m).includes(p))
const filtering = poles.length > 0 && !showAll
const visible = filtering ? members.filter((m) => inPole(m) || selected.includes(m.id)) : members
const hidden = members.length - visible.length
return (
<div>
<div className="flex flex-wrap gap-1.5">
{visible.map((m) => {
const on = selected.includes(m.id)
const outside = poles.length > 0 && !inPole(m)
return (
<button
key={m.id}
type="button"
aria-pressed={on}
onClick={() => toggle(m.id)}
title={outside ? `${m.full_name} · hors pôle` : m.full_name}
className={`inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-2.5 text-sm transition-colors ${
on
? 'border-accent bg-accent/12 text-ink'
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
<Avatar profile={m} size={22} />
<span>{m.full_name}</span>
{outside && <span className="text-[10px] text-subtle">hors pôle</span>}
</button>
)
})}
{filtering && visible.length === 0 && (
<p className="text-xs text-subtle">Personne dans ce pôle pour l'instant.</p>
)}
</div>
{poles.length > 0 && (hidden > 0 || showAll) && (
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="mt-2 text-xs font-medium text-accent hover:underline"
>
{showAll ? 'Réduire aux membres du pôle' : `Afficher toute l'équipe (+${hidden})`}
</button>
)}
</div>
)
}
// ---------------------------------------------------------------------
// Étiquettes
// ---------------------------------------------------------------------
export function Tag({ label }: { label: string }): JSX.Element {
return <Badge className={tagBadge(label)}>{label}</Badge>
}
/** Champ d'édition d'étiquettes libres, avec suggestions. */
export function TagInput({
value,
onChange
}: {
value: string[]
onChange: (tags: string[]) => void
}): JSX.Element {
const [draft, setDraft] = useState('')
const add = (raw: string): void => {
const tag = raw.trim().slice(0, 24)
if (tag && !value.some((v) => v.toLowerCase() === tag.toLowerCase())) onChange([...value, tag])
setDraft('')
}
const remove = (tag: string): void => onChange(value.filter((t) => t !== tag))
const onKey = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add(draft)
} else if (e.key === 'Backspace' && !draft && value.length) {
remove(value[value.length - 1])
}
}
const suggestions = TAG_SUGGESTIONS.filter(
(s) => !value.some((v) => v.toLowerCase() === s.toLowerCase())
).slice(0, 6)
return (
<div>
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-panel2 px-2 py-1.5 focus-within:border-accent">
{value.map((t) => (
<span key={t} className={`badge ${tagBadge(t)}`}>
{t}
<button
type="button"
aria-label={`Retirer l'étiquette ${t}`}
onClick={() => remove(t)}
className="ml-0.5 opacity-70 hover:opacity-100"
>
<X size={11} />
</button>
</span>
))}
<input
className="min-w-[120px] flex-1 bg-transparent py-1 text-sm text-ink outline-none placeholder:text-subtle"
placeholder={value.length ? 'Ajouter…' : 'Étiquette puis Entrée'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
onBlur={() => draft && add(draft)}
/>
</div>
{suggestions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => add(s)}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-ink"
>
<Plus size={10} /> {s}
</button>
))}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------
// Photo de profil
// ---------------------------------------------------------------------
/** Palette d'avatars partagée. */
export const AVATAR_COLORS = [
'#7c5cff',
'#00d2b8',
'#ff5c8a',
'#ffa53c',
'#3ca8ff',
'#9bdb4d',
'#e15cff',
'#ff6b6b'
]
export function ColorPicker({
value,
onChange
}: {
value: string
onChange: (c: string) => void
}): JSX.Element {
return (
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Couleur d'avatar">
{AVATAR_COLORS.map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={value === c}
aria-label={`Couleur ${c}`}
onClick={() => onChange(c)}
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
value === c ? 'ring-2 ring-accent ring-offset-2 ring-offset-panel' : ''
}`}
style={{ background: c }}
/>
))}
</div>
)
}
/** Bloc « photo de profil » — LA seule façon d'envoyer un avatar. */
export function AvatarUploader({
userId,
name,
color,
avatarUrl,
onChange,
size = 56
}: {
userId: string
name: string
color: string
avatarUrl: string | null
onChange: (url: string | null) => void
size?: number
}): JSX.Element {
const { toast } = useFeedback()
const fileRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const pick = async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast('Choisis un fichier image.', 'error')
return
}
if (file.size > 5 * 1024 * 1024) {
toast('Image trop lourde (5 Mo maximum).', 'error')
return
}
setUploading(true)
try {
onChange(await uploadAvatar(file, userId))
} catch (err) {
toast((err as Error).message, 'error')
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
}
const preview = { full_name: name || '', avatar_color: color, avatar_url: avatarUrl }
return (
<div className="flex items-center gap-3">
<div className="relative">
<Avatar profile={preview} size={size} />
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
</div>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pick} />
<Button icon={<Camera size={16} />} onClick={() => fileRef.current?.click()} disabled={uploading}>
{avatarUrl ? 'Changer' : 'Ajouter une photo'}
</Button>
{avatarUrl && (
<button
type="button"
className="text-xs text-muted hover:text-rose-600"
onClick={() => onChange(null)}
>
Retirer
</button>
)}
</div>
)
}
+470 -218
View File
@@ -1,218 +1,470 @@
import { ReactNode, SelectHTMLAttributes, useState, KeyboardEvent } from 'react'
import { motion } from 'framer-motion'
import { X, Plus } from 'lucide-react'
import {
Profile,
Priority,
PRIORITY_LABELS,
JOB_TAGS,
tagColor,
Pole,
POLE_ORDER,
POLE_LABELS,
poleColor
} from '../lib/types'
export function Modal({
title,
children,
onClose,
wide
}: {
title: string
children: ReactNode
onClose: () => void
wide?: boolean
}): JSX.Element {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
onMouseDown={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
className={`card max-h-[90vh] w-full overflow-y-auto ${wide ? 'max-w-2xl' : 'max-w-md'}`}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink">{title}</h2>
<button onClick={onClose} className="text-muted hover:text-ink">
<X size={20} />
</button>
</div>
{children}
</motion.div>
</motion.div>
)
}
export function Avatar({ profile, size = 32 }: { profile?: Profile | null; size?: number }): JSX.Element {
const initials = (profile?.full_name ?? '?')
.split(' ')
.map((s) => s[0])
.slice(0, 2)
.join('')
.toUpperCase()
if (profile?.avatar_url) {
return (
<img
src={profile.avatar_url}
alt={profile.full_name}
title={profile.full_name}
draggable={false}
className="shrink-0 rounded-full object-cover"
style={{ width: size, height: size }}
/>
)
}
return (
<div
title={profile?.full_name ?? 'Non assigné'}
className="flex shrink-0 items-center justify-center rounded-full font-semibold text-ink"
style={{
width: size,
height: size,
fontSize: size * 0.4,
background: profile?.avatar_color ?? '#3a3f4d'
}}
>
{profile ? initials : ''}
</div>
)
}
const PRIORITY_STYLES: Record<Priority, string> = {
low: 'bg-slate-100 text-slate-600',
medium: 'bg-sky-100 text-sky-700',
high: 'bg-amber-100 text-amber-700',
urgent: 'bg-rose-100 text-rose-700'
}
export function PriorityBadge({ priority }: { priority: Priority }): JSX.Element {
return <span className={`badge ${PRIORITY_STYLES[priority]}`}>{PRIORITY_LABELS[priority]}</span>
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>): JSX.Element {
return <select {...props} className={`input ${props.className ?? ''}`} />
}
export function EmptyState({ icon, text }: { icon: ReactNode; text: string }): JSX.Element {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-subtle">
{icon}
<p className="text-sm">{text}</p>
</div>
)
}
/** Petite pastille colorée pour un tag (« job »). */
export function Tag({ label }: { label: string }): JSX.Element {
return <span className={`badge ${tagColor(label)}`}>{label}</span>
}
/**
* Sélection de pôle(s) par pastilles cliquables (comme les tags).
* Utilisé pour une tâche/bug (un pôle) comme pour un membre (plusieurs pôles).
*/
export function PoleChips({
selected,
onToggle
}: {
selected: Pole[] | null | undefined
onToggle: (pole: Pole) => void
}): JSX.Element {
const sel = selected ?? []
return (
<div className="flex flex-wrap gap-1.5">
{POLE_ORDER.map((p) => {
const on = sel.includes(p)
return (
<button
key={p}
type="button"
onClick={() => onToggle(p)}
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
on
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
{POLE_LABELS[p]}
</button>
)
})}
</div>
)
}
/** Champ d'édition de tags avec suggestions de « jobs ». */
export function TagInput({
value,
onChange
}: {
value: string[]
onChange: (tags: string[]) => void
}): JSX.Element {
const [draft, setDraft] = useState('')
const add = (raw: string): void => {
const tag = raw.trim()
if (tag && !value.some((v) => v.toLowerCase() === tag.toLowerCase())) {
onChange([...value, tag])
}
setDraft('')
}
const remove = (tag: string): void => onChange(value.filter((t) => t !== tag))
const onKey = (e: KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add(draft)
} else if (e.key === 'Backspace' && !draft && value.length) {
remove(value[value.length - 1])
}
}
const suggestions = JOB_TAGS.filter((s) => !value.some((v) => v.toLowerCase() === s.toLowerCase()))
return (
<div>
<div className="flex flex-wrap items-center gap-1.5 rounded-lg border border-border bg-panel2 px-2 py-2">
{value.map((t) => (
<span key={t} className={`badge ${tagColor(t)}`}>
{t}
<button onClick={() => remove(t)} className="ml-0.5 opacity-70 hover:opacity-100">
<X size={11} />
</button>
</span>
))}
<input
className="min-w-[100px] flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-subtle"
placeholder={value.length ? 'Ajouter…' : 'Tape un tag puis Entrée'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKey}
onBlur={() => draft && add(draft)}
/>
</div>
{suggestions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{suggestions.map((s) => (
<button
key={s}
onClick={() => add(s)}
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-ink"
>
<Plus size={10} /> {s}
</button>
))}
</div>
)}
</div>
)
}
// =====================================================================
// Primitives d'interface — LA source unique des boutons, champs, états.
// Aucune page ne doit réécrire un bouton ou un badge à la main.
// =====================================================================
import {
ReactNode,
ButtonHTMLAttributes,
InputHTMLAttributes,
forwardRef,
useId
} from 'react'
import { AlertTriangle, Loader2, RefreshCw, Search, X } from 'lucide-react'
import { Tone, TONE_DOT } from '../lib/tones'
// ---------------------------------------------------------------------
// Bouton
// ---------------------------------------------------------------------
type ButtonVariant = 'primary' | 'ghost' | 'quiet' | 'danger'
type ButtonSize = 'sm' | 'md'
const VARIANT: Record<ButtonVariant, string> = {
primary: 'btn-primary',
ghost: 'btn-ghost',
quiet: 'btn-quiet',
danger: 'btn-danger'
}
const SIZE: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1 text-xs',
md: ''
}
export function Button({
variant = 'ghost',
size = 'md',
icon,
loading,
full,
children,
className = '',
disabled,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant
size?: ButtonSize
icon?: ReactNode
loading?: boolean
full?: boolean
}): JSX.Element {
return (
<button
type="button"
{...rest}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={`${VARIANT[variant]} ${SIZE[size]} ${full ? 'w-full' : ''} ${className}`}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : icon}
{children}
</button>
)
}
/**
* Bouton contenant UNIQUEMENT une icône.
*
* Existe pour une raison précise : l'application était truffée de
* `<button className="text-muted hover:text-ink"><Icon/></button>` des
* cibles de 18 px, sans libellé accessible et sans état de survol lisible.
* Ici la zone cliquable fait 32 px (36 en « md ») et `label` est obligatoire :
* il alimente à la fois l'infobulle et le lecteur d'écran.
*/
export function IconButton({
label,
icon,
size = 'md',
tone = 'default',
active,
className = '',
...rest
}: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> & {
label: string
icon: ReactNode
size?: 'sm' | 'md'
tone?: 'default' | 'danger' | 'accent'
active?: boolean
}): JSX.Element {
const box = size === 'sm' ? 'h-8 w-8' : 'h-9 w-9'
const tones = {
default: 'text-muted hover:bg-hover hover:text-ink',
accent: 'text-accent hover:bg-accent/10',
danger: 'text-muted hover:bg-rose-500/10 hover:text-rose-600'
}
return (
<button
type="button"
title={label}
aria-label={label}
{...rest}
className={`inline-flex ${box} shrink-0 items-center justify-center rounded-lg
transition-colors disabled:cursor-not-allowed disabled:opacity-40
${active ? 'bg-accent/12 text-accent' : tones[tone]} ${className}`}
>
{icon}
</button>
)
}
// ---------------------------------------------------------------------
// Champs de formulaire
// ---------------------------------------------------------------------
/** Libellé + champ + aide, correctement reliés (`htmlFor` / `aria-describedby`). */
export function Field({
label,
hint,
error,
required,
className = '',
children
}: {
label?: string
hint?: string
error?: string | null
required?: boolean
className?: string
children: ReactNode | ((props: { id: string; describedBy?: string }) => ReactNode)
}): JSX.Element {
const id = useId()
const hintId = hint || error ? `${id}-hint` : undefined
return (
<div className={className}>
{label && (
<label htmlFor={id} className="label">
{label}
{required && <span className="ml-0.5 text-rose-600">*</span>}
</label>
)}
{typeof children === 'function' ? children({ id, describedBy: hintId }) : children}
{error ? (
<p id={hintId} className="mt-1 text-xs text-rose-600">
{error}
</p>
) : (
hint && (
<p id={hintId} className="mt-1 text-xs text-subtle">
{hint}
</p>
)
)}
</div>
)
}
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className = '', ...rest }, ref) {
return <input ref={ref} {...rest} className={`input ${className}`} />
}
)
/** Champ de recherche avec icône et bouton d'effacement. */
export function SearchInput({
value,
onChange,
placeholder = 'Rechercher…',
className = '',
autoFocus
}: {
value: string
onChange: (v: string) => void
placeholder?: string
className?: string
autoFocus?: boolean
}): JSX.Element {
return (
<div className={`relative ${className}`}>
<Search size={15} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-subtle" />
<input
type="search"
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
aria-label={placeholder}
onChange={(e) => onChange(e.target.value)}
className="input py-1.5 pl-8 pr-8 [&::-webkit-search-cancel-button]:hidden"
/>
{value && (
<button
type="button"
aria-label="Effacer la recherche"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-subtle hover:text-ink"
>
<X size={14} />
</button>
)}
</div>
)
}
/** Bascule segmentée (ex : Tâche / Autre activité). */
export function SegmentedControl<T extends string>({
options,
value,
onChange,
className = '',
ariaLabel
}: {
options: readonly (readonly [T, string])[]
value: T
onChange: (v: T) => void
className?: string
ariaLabel?: string
}): JSX.Element {
return (
<div
role="tablist"
aria-label={ariaLabel}
className={`flex rounded-lg border border-border bg-panel2 p-0.5 ${className}`}
>
{options.map(([v, l]) => (
<button
key={v}
type="button"
role="tab"
aria-selected={value === v}
onClick={() => onChange(v)}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
value === v ? 'bg-accent text-accentfg shadow-card' : 'text-muted hover:text-ink'
}`}
>
{l}
</button>
))}
</div>
)
}
export function Checkbox({
checked,
onChange,
label,
className = ''
}: {
checked: boolean
onChange: (v: boolean) => void
label: ReactNode
className?: string
}): JSX.Element {
return (
<label className={`flex cursor-pointer items-center gap-2 text-sm text-ink ${className}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="h-4 w-4 shrink-0 rounded border-border accent-[rgb(var(--c-accent))]"
/>
{label}
</label>
)
}
// ---------------------------------------------------------------------
// Affichage
// ---------------------------------------------------------------------
export function Badge({
className = '',
children
}: {
className?: string
children: ReactNode
}): JSX.Element {
return <span className={`badge ${className}`}>{children}</span>
}
/** Pastille pleine de statut (rond coloré). */
export function Dot({ tone, className = '' }: { tone: Tone; className?: string }): JSX.Element {
return <span className={`h-2 w-2 shrink-0 rounded-full ${TONE_DOT[tone]} ${className}`} />
}
export function ProgressBar({
pct,
className = '',
barClassName = 'bg-accent',
height = 'h-1.5',
label
}: {
pct: number
className?: string
barClassName?: string
height?: string
label?: string
}): JSX.Element {
const safe = Math.max(0, Math.min(100, Math.round(pct)))
return (
<div
role="progressbar"
aria-valuenow={safe}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className={`${height} w-full overflow-hidden rounded-full bg-panel2 ${className}`}
>
<div
className={`h-full rounded-full transition-[width] duration-500 ease-smooth ${barClassName}`}
style={{ width: `${safe}%` }}
/>
</div>
)
}
export function Kbd({ children }: { children: ReactNode }): JSX.Element {
return <kbd className="kbd">{children}</kbd>
}
/** Pastille d'un filtre actif, retirable d'un clic. */
export function FilterChip({
children,
onRemove
}: {
children: ReactNode
onRemove: () => void
}): JSX.Element {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-xs font-medium text-accent">
{children}
<button
type="button"
aria-label="Retirer ce filtre"
onClick={onRemove}
className="rounded-full p-0.5 transition-colors hover:bg-accent/25"
>
<X size={11} />
</button>
</span>
)
}
// ---------------------------------------------------------------------
// États d'écran : chargement / erreur / vide
// Les trois sont distincts. Afficher « Aucun bug 🎉 » pendant un
// chargement ou après une panne réseau était le pire défaut de l'app.
// ---------------------------------------------------------------------
export function Skeleton({ className = '' }: { className?: string }): JSX.Element {
return <div className={`skeleton ${className}`} />
}
/** Squelette générique de liste, pendant le premier chargement. */
export function SkeletonList({ rows = 5, className = '' }: { rows?: number; className?: string }): JSX.Element {
return (
<div className={`space-y-2 ${className}`} aria-hidden>
{Array.from({ length: rows }, (_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)
}
export function SkeletonCards({ count = 4, className = '' }: { count?: number; className?: string }): JSX.Element {
return (
<div className={className} aria-hidden>
{Array.from({ length: count }, (_, i) => (
<Skeleton key={i} className="h-[76px] w-full" />
))}
</div>
)
}
export function EmptyState({
icon,
title,
text,
action
}: {
icon: ReactNode
title: string
text?: string
action?: ReactNode
}): JSX.Element {
return (
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<div className="text-subtle">{icon}</div>
<p className="text-sm font-medium text-ink">{title}</p>
{text && <p className="max-w-sm text-sm text-muted">{text}</p>}
{action && <div className="mt-1">{action}</div>}
</div>
)
}
/** Panne : on dit ce qui s'est passé ET on propose de réessayer. */
export function ErrorState({
message,
onRetry,
compact
}: {
message: string
onRetry?: () => void
compact?: boolean
}): JSX.Element {
if (compact) {
return (
<div
role="alert"
className="flex items-center gap-2 rounded-lg border border-rose-500/30 bg-rose-500/8 px-3 py-2 text-sm text-rose-600 dark:text-rose-300"
>
<AlertTriangle size={16} className="shrink-0" />
<span className="flex-1">{message}</span>
{onRetry && (
<button onClick={onRetry} className="shrink-0 font-medium underline-offset-2 hover:underline">
Réessayer
</button>
)}
</div>
)
}
return (
<div role="alert" className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
<AlertTriangle size={36} className="text-rose-500" />
<p className="text-sm font-medium text-ink">Impossible de charger ces données</p>
<p className="max-w-md text-sm text-muted">{message}</p>
{onRetry && (
<Button icon={<RefreshCw size={15} />} onClick={onRetry} className="mt-1">
Réessayer
</Button>
)}
</div>
)
}
export function Spinner({ size = 24, className = '' }: { size?: number; className?: string }): JSX.Element {
return (
<span role="status" aria-label="Chargement">
<Loader2 size={size} className={`animate-spin text-muted ${className}`} />
</span>
)
}
export function CenteredSpinner(): JSX.Element {
return (
<div className="flex h-full items-center justify-center">
<Spinner size={28} />
</div>
)
}
/**
* Rendu conditionnel standard d'une page : chargement erreur contenu.
* Garantit qu'aucun écran n'affiche plus un faux « état vide ».
*/
export function AsyncContent({
loading,
error,
onRetry,
skeleton,
children
}: {
loading: boolean
error: string | null
onRetry?: () => void
skeleton?: ReactNode
children: ReactNode
}): JSX.Element {
if (loading) return <>{skeleton ?? <CenteredSpinner />}</>
if (error) return <ErrorState message={error} onRetry={onRetry} />
return <>{children}</>
}
+22 -12
View File
@@ -1,12 +1,22 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL du projet Supabase intégrée au build (ex: https://xxxxx.supabase.co). */
readonly VITE_SUPABASE_URL?: string
/** Clé "anon public" Supabase intégrée au build. */
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL du projet Supabase intégrée au build (ex: https://xxxxx.supabase.co). */
readonly VITE_SUPABASE_URL?: string
/** Clé "anon public" Supabase intégrée au build. */
readonly VITE_SUPABASE_ANON_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// mammoth publie ses types pour l'entrée Node, pas pour la construction
// navigateur — qui est pourtant la seule utilisable ici (elle n'embarque
// pas les modules `fs`). On déclare donc la seule fonction employée.
declare module 'mammoth/mammoth.browser' {
export function convertToMarkdown(input: { arrayBuffer: ArrayBuffer }): Promise<{
value: string
messages: { type: string; message: string }[]
}>
}
+210 -107
View File
@@ -1,107 +1,210 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================================
Thèmes base épurée (Notion) réchauffée par un accent coloré
accent = indigo (actions), accent2 = teal (temps / progression)
============================================================ */
:root {
color-scheme: light; /* aligne selects/inputs/scrollbars natifs sur le thème */
--c-bg: 251 251 250;
--c-panel: 255 255 255;
--c-panel2: 244 243 246;
--c-sidebar: 248 247 250;
--c-border: 232 231 235;
--c-ink: 45 43 56;
--c-muted: 112 110 122;
--c-subtle: 152 150 162;
--c-accent: 99 90 232; /* indigo/violet */
--c-accentfg: 255 255 255;
--c-accent2: 13 148 136; /* teal */
}
.dark {
color-scheme: dark;
--c-bg: 23 23 27;
--c-panel: 31 31 37;
--c-panel2: 40 40 48;
--c-sidebar: 19 19 24;
--c-border: 51 51 60;
--c-ink: 234 234 238;
--c-muted: 160 158 172;
--c-subtle: 124 122 136;
--c-accent: 129 121 248; /* indigo clair sur fond sombre */
--c-accentfg: 255 255 255;
--c-accent2: 45 212 191; /* teal clair */
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg text-ink antialiased;
font-family: ui-sans-serif, 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
letter-spacing: -0.01em;
}
h1,
h2,
h3 {
letter-spacing: -0.02em;
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--c-border)) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: rgb(var(--c-border));
border-radius: 9999px;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-accent text-accentfg shadow-sm hover:opacity-90 active:scale-[0.98];
}
.btn-ghost {
@apply btn bg-panel2 text-ink hover:bg-border;
}
.input {
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/15 placeholder:text-subtle;
}
/* Selects : flèche maison + options aux couleurs de l'appli (au lieu du style natif brut) */
select.input {
@apply cursor-pointer pr-8;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1.05rem;
}
select.input option {
background: rgb(var(--c-panel));
color: rgb(var(--c-ink));
}
.label {
@apply mb-1 block text-xs font-medium text-muted;
}
.card {
@apply rounded-xl border border-border bg-panel p-4 shadow-sm transition-colors;
}
.badge {
@apply inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium;
}
}
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ============================================================
THÈME une seule échelle de surfaces, deux couleurs d'accent.
Surfaces (du fond vers l'avant) : bg < sidebar < panel < panel2
Texte (du plus fort au plus faible) : ink > muted > subtle
accent = bleu ciel TOUT ce qui est action, navigation, sélection
accent2 = teal UNIQUEMENT le temps (heures, feuille de temps)
Rien d'autre : un token = un sens.
============================================================ */
:root {
color-scheme: light; /* aligne inputs date/scrollbars natifs sur le thème */
--c-bg: 250 250 249;
--c-sidebar: 246 246 248;
--c-panel: 255 255 255;
--c-panel2: 243 243 246;
--c-hover: 236 236 241;
--c-border: 228 228 234;
--c-border-strong: 208 208 217;
--c-ink: 32 31 42;
--c-muted: 104 102 118;
--c-subtle: 146 144 160;
/* Bleu ciel. On prend le ton « 600 » et non « 500 » : le texte blanc des
boutons principaux doit rester lisible dessus (contraste 4,6:1). */
--c-accent: 2 132 199;
--c-accent-soft: 224 242 254;
--c-accentfg: 255 255 255;
--c-accent2: 13 148 136;
/* Élévation : en clair, une vraie ombre portée suffit. */
--shadow-card: 0 1px 2px rgb(16 15 30 / 0.05);
--shadow-raised: 0 2px 6px rgb(16 15 30 / 0.07), 0 1px 2px rgb(16 15 30 / 0.05);
--shadow-pop: 0 12px 32px -8px rgb(16 15 30 / 0.18), 0 2px 8px rgb(16 15 30 / 0.08);
}
.dark {
color-scheme: dark;
--c-bg: 15 15 19;
--c-sidebar: 19 19 24;
--c-panel: 26 26 32;
--c-panel2: 36 36 44;
--c-hover: 46 46 56;
/* Bordures nettement plus contrastées : en sombre, ce sont ELLES qui
dessinent la hiérarchie les ombres portées y sont invisibles. */
--c-border: 48 48 58;
--c-border-strong: 68 68 82;
--c-ink: 235 235 240;
--c-muted: 163 161 176;
--c-subtle: 126 124 140;
/* Sur fond sombre, un bleu ciel plus clair : c'est lui qui doit ressortir. */
--c-accent: 56 189 248;
--c-accent-soft: 12 74 110;
--c-accentfg: 255 255 255;
--c-accent2: 45 212 191;
/* En sombre on combine une ombre profonde ET un liseré clair interne,
sinon les cartes « flottent » sans se détacher du fond. */
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.4), inset 0 1px 0 rgb(255 255 255 / 0.03);
--shadow-raised: 0 4px 12px -2px rgb(0 0 0 / 0.5), inset 0 1px 0 rgb(255 255 255 / 0.04);
--shadow-pop: 0 16px 40px -8px rgb(0 0 0 / 0.7), inset 0 1px 0 rgb(255 255 255 / 0.05);
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg text-ink antialiased;
font-family: 'Inter var', 'Inter', ui-sans-serif, 'Segoe UI', system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
letter-spacing: -0.011em;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'tnum' 0;
}
h1,
h2,
h3 {
letter-spacing: -0.021em;
}
/* Les chiffres alignés verticalement dans les tableaux et compteurs. */
.tabular {
font-variant-numeric: tabular-nums;
}
* {
scrollbar-width: thin;
scrollbar-color: rgb(var(--c-border-strong)) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: rgb(var(--c-border-strong));
border: 3px solid transparent;
background-clip: padding-box;
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background: rgb(var(--c-subtle));
border: 3px solid transparent;
background-clip: padding-box;
}
/* Accessibilité clavier : anneau cohérent sur tout élément interactif,
sans polluer la navigation à la souris. */
:focus-visible {
outline: 2px solid rgb(var(--c-accent));
outline-offset: 2px;
border-radius: 0.5rem;
}
/* Respect du réglage système « réduire les animations ». */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
/* ---------- Boutons ---------- */
.btn {
@apply inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-3 py-1.5
text-sm font-medium transition-all duration-150
disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-accent text-accentfg shadow-raised hover:brightness-110 active:scale-[0.98];
}
/* Bouton secondaire : bordé, donc visible sur TOUTES les surfaces
l'ancien `bg-panel2` disparaissait dans les barres de filtres. */
.btn-ghost {
@apply btn border border-border bg-panel text-ink shadow-card
hover:border-border-strong hover:bg-hover active:scale-[0.98];
}
.btn-quiet {
@apply btn text-muted hover:bg-hover hover:text-ink;
}
.btn-danger {
@apply btn bg-rose-600 text-white shadow-raised hover:bg-rose-700 active:scale-[0.98];
}
/* ---------- Champs ---------- */
.input {
@apply w-full rounded-lg border border-border bg-panel2 px-3 py-2 text-sm text-ink
outline-none transition-colors placeholder:text-subtle
focus:border-accent focus:ring-2 focus:ring-accent/20
disabled:cursor-not-allowed disabled:opacity-60;
}
.input-sm {
@apply rounded-md px-2 py-1 text-xs;
}
.label {
@apply mb-1.5 block text-xs font-medium text-muted;
}
/* ---------- Surfaces ---------- */
.card {
@apply rounded-xl border border-border bg-panel p-4 shadow-card;
}
.surface {
@apply rounded-xl border border-border bg-panel;
}
/* Zone de travail encastrée (colonnes Kanban, encadrés dans les modales). */
.well {
@apply rounded-xl border border-border bg-panel2/60;
}
/* ---------- Divers ---------- */
.badge {
@apply inline-flex items-center gap-1 whitespace-nowrap rounded-md px-2 py-0.5
text-xs font-medium;
}
.kbd {
@apply inline-flex h-5 min-w-5 items-center justify-center rounded border border-border
bg-panel2 px-1.5 font-mono text-[11px] font-medium text-muted;
}
.toolbar {
@apply flex flex-wrap items-center gap-2 border-b border-border bg-panel px-6 py-2.5;
}
.skeleton {
@apply animate-pulse rounded-md bg-panel2;
}
.link {
@apply text-accent underline-offset-2 hover:underline;
}
}
+338 -129
View File
@@ -1,129 +1,338 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'
import { Session } from '@supabase/supabase-js'
import { initSupabase, supa, hasClient } from './supabase'
import { getConfig, setConfig } from './config'
import { Profile, Project } from './types'
type Stage = 'loading' | 'setup' | 'login' | 'ready'
export interface FocusEntity {
kind: 'task' | 'bug'
id: string
}
interface AppState {
stage: Stage
session: Session | null
profile: Profile | null
projects: Project[]
currentProject: Project | null
setCurrentProject: (p: Project) => void
saveConfig: (url: string, key: string) => Promise<void>
refreshProjects: () => Promise<void>
refreshProfile: () => Promise<void>
signOut: () => Promise<void>
// Élément à ouvrir (depuis une notification) : la page cible s'en saisit.
focusEntity: FocusEntity | null
setFocusEntity: (f: FocusEntity | null) => void
}
const Ctx = createContext<AppState | null>(null)
export function useApp(): AppState {
const v = useContext(Ctx)
if (!v) throw new Error('useApp doit être utilisé dans <AppProvider>')
return v
}
export function AppProvider({ children }: { children: ReactNode }): JSX.Element {
const [stage, setStage] = useState<Stage>('loading')
const [session, setSession] = useState<Session | null>(null)
const [profile, setProfile] = useState<Profile | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [currentProject, setCurrentProject] = useState<Project | null>(null)
const [focusEntity, setFocusEntity] = useState<FocusEntity | null>(null)
const refreshProfile = useCallback(async () => {
const uid = (await supa().auth.getUser()).data.user?.id
if (!uid) return
const { data } = await supa().from('profiles').select('*').eq('id', uid).single()
if (data) setProfile(data as Profile)
}, [])
const refreshProjects = useCallback(async () => {
const { data } = await supa().from('projects').select('*').order('created_at')
const list = (data ?? []) as Project[]
setProjects(list)
setCurrentProject((prev) => {
if (prev) return list.find((p) => p.id === prev.id) ?? list[0] ?? null
return list[0] ?? null
})
}, [])
// Démarrage : charge la config locale puis tente de restaurer la session.
useEffect(() => {
;(async () => {
const cfg = getConfig()
if (!cfg.supabaseUrl || !cfg.supabaseAnonKey) {
setStage('setup')
return
}
initSupabase(cfg.supabaseUrl, cfg.supabaseAnonKey)
const { data } = await supa().auth.getSession()
setSession(data.session)
supa().auth.onAuthStateChange((_e, s) => setSession(s))
setStage(data.session ? 'ready' : 'login')
})()
}, [])
// Quand la session change, on (re)charge profil + projets.
useEffect(() => {
if (!hasClient()) return
if (session) {
setStage('ready')
refreshProfile()
refreshProjects()
} else if (stage !== 'setup' && stage !== 'loading') {
setStage('login')
setProfile(null)
setProjects([])
setCurrentProject(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session])
const saveConfig = async (url: string, key: string): Promise<void> => {
setConfig({ supabaseUrl: url.trim(), supabaseAnonKey: key.trim() })
initSupabase(url.trim(), key.trim())
const { data } = await supa().auth.getSession()
setSession(data.session)
supa().auth.onAuthStateChange((_e, s) => setSession(s))
setStage(data.session ? 'ready' : 'login')
}
const signOut = async (): Promise<void> => {
await supa().auth.signOut()
setSession(null)
}
return (
<Ctx.Provider
value={{
stage,
session,
profile,
projects,
currentProject,
setCurrentProject,
saveConfig,
refreshProjects,
refreshProfile,
signOut,
focusEntity,
setFocusEntity
}}
>
{children}
</Ctx.Provider>
)
}
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
ReactNode,
useCallback,
useRef
} from 'react'
import { Session } from '@supabase/supabase-js'
import { supa, isConfigured } from './supabase'
import { rows, run, mutate, humanize, useRealtime } from './db'
import { Profile, Project, EntityKind, Page } from './types'
type Stage = 'loading' | 'unconfigured' | 'login' | 'ready'
/** Élément à ouvrir après navigation (clic sur une notification, palette…). */
export interface FocusEntity {
kind: EntityKind
id: string
}
interface AppState {
stage: Stage
session: Session | null
/** Écran affiché. Vit ici pour que n'importe quelle page puisse naviguer
* (une notification, la palette, une carte du tableau de bord). */
page: Page
setPage: (p: Page) => void
profile: Profile | null
profileError: string | null
refreshProfile: () => Promise<void>
/** Tous les membres de l'équipe — chargés UNE fois ici, plus dans chaque page. */
members: Profile[]
membersLoading: boolean
memberById: (id: string | null | undefined) => Profile | undefined
refreshMembers: () => Promise<void>
projects: Project[]
projectsLoading: boolean
projectsError: string | null
currentProject: Project | null
selectProject: (p: Project) => void
refreshProjects: () => Promise<void>
createProject: (name: string, description: string) => Promise<Project>
renameProject: (id: string, name: string, description: string) => Promise<void>
deleteProject: (id: string) => Promise<void>
/** Connexion internet (bandeau « hors ligne »). */
online: boolean
signOut: () => Promise<void>
focusEntity: FocusEntity | null
setFocusEntity: (f: FocusEntity | null) => void
}
const Ctx = createContext<AppState | null>(null)
export function useApp(): AppState {
const v = useContext(Ctx)
if (!v) throw new Error('useApp doit être utilisé dans <AppProvider>')
return v
}
const LAST_PROJECT_KEY = 'gamedev-tracker-last-project'
export function AppProvider({ children }: { children: ReactNode }): JSX.Element {
const [stage, setStage] = useState<Stage>(isConfigured ? 'loading' : 'unconfigured')
const [session, setSession] = useState<Session | null>(null)
const [profile, setProfile] = useState<Profile | null>(null)
const [profileError, setProfileError] = useState<string | null>(null)
const [members, setMembers] = useState<Profile[]>([])
const [membersLoading, setMembersLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([])
const [projectsLoading, setProjectsLoading] = useState(true)
const [projectsError, setProjectsError] = useState<string | null>(null)
const [currentProjectId, setCurrentProjectId] = useState<string | null>(
() => localStorage.getItem(LAST_PROJECT_KEY)
)
const [page, setPage] = useState<Page>('dashboard')
const [focusEntity, setFocusEntity] = useState<FocusEntity | null>(null)
const [online, setOnline] = useState(() => navigator.onLine)
const userId = session?.user?.id ?? null
// ------------------------------------------------------------------
// Connexion internet
// ------------------------------------------------------------------
useEffect(() => {
const up = (): void => setOnline(true)
const down = (): void => setOnline(false)
window.addEventListener('online', up)
window.addEventListener('offline', down)
return () => {
window.removeEventListener('online', up)
window.removeEventListener('offline', down)
}
}, [])
// ------------------------------------------------------------------
// Session — un seul abonnement, correctement libéré
// ------------------------------------------------------------------
useEffect(() => {
if (!isConfigured) return
let alive = true
supa()
.auth.getSession()
.then(({ data }) => {
if (!alive) return
setSession(data.session)
setStage(data.session ? 'ready' : 'login')
})
.catch(() => {
if (alive) setStage('login')
})
const { data: sub } = supa().auth.onAuthStateChange((_event, s) => {
if (!alive) return
setSession(s)
setStage(s ? 'ready' : 'login')
if (!s) {
setProfile(null)
setProjects([])
setMembers([])
}
})
return () => {
alive = false
sub.subscription.unsubscribe() // manquait avant : listeners empilés
}
}, [])
// ------------------------------------------------------------------
// Profil de l'utilisateur connecté
// ------------------------------------------------------------------
const refreshProfile = useCallback(async () => {
if (!userId) return
try {
// L'id vient de la session déjà en mémoire : plus d'appel réseau
// `auth.getUser()` inutile à chaque rafraîchissement.
const data = await run<Profile>(
supa().from('profiles').select('*').eq('id', userId).maybeSingle()
)
setProfile(data)
setProfileError(data ? null : "Ton profil est introuvable. Contacte un administrateur.")
} catch (e) {
setProfileError(humanize(e))
}
}, [userId])
// ------------------------------------------------------------------
// Membres de l'équipe (partagés par toute l'application)
// ------------------------------------------------------------------
const refreshMembers = useCallback(async () => {
if (!userId) return
try {
setMembers(await rows<Profile>(supa().from('profiles').select('*').order('full_name')))
} catch {
// Non bloquant : les pages affichent les avatars en repli « ? ».
} finally {
setMembersLoading(false)
}
}, [userId])
// ------------------------------------------------------------------
// Projets
// ------------------------------------------------------------------
const refreshProjects = useCallback(async () => {
if (!userId) return
try {
const list = await rows<Project>(supa().from('projects').select('*').order('created_at'))
setProjects(list)
setProjectsError(null)
} catch (e) {
setProjectsError(humanize(e))
} finally {
setProjectsLoading(false)
}
}, [userId])
useEffect(() => {
if (!userId) return
refreshProfile()
refreshMembers()
refreshProjects()
}, [userId, refreshProfile, refreshMembers, refreshProjects])
// Synchro temps réel de l'équipe et des projets : un collègue crée un
// projet ou change d'avatar → tout le monde le voit sans recharger.
useRealtime(userId ? `team-${userId}` : null, [{ table: 'profiles' }], () => {
refreshMembers()
refreshProfile()
})
useRealtime(userId ? `projects-${userId}` : null, [{ table: 'projects' }], refreshProjects)
// Le projet courant est dérivé de la liste : il ne peut jamais pointer
// vers un projet supprimé (c'était possible avant).
const currentProject = useMemo(() => {
if (projects.length === 0) return null
return projects.find((p) => p.id === currentProjectId) ?? projects[0]
}, [projects, currentProjectId])
// Mémorise le dernier projet ouvert pour le retrouver au prochain lancement.
const lastSaved = useRef<string | null>(null)
useEffect(() => {
if (currentProject && currentProject.id !== lastSaved.current) {
lastSaved.current = currentProject.id
localStorage.setItem(LAST_PROJECT_KEY, currentProject.id)
}
}, [currentProject])
const selectProject = useCallback((p: Project) => setCurrentProjectId(p.id), [])
// Si l'utilisateur n'est plus administrateur, on le sort de l'écran d'admin.
useEffect(() => {
if (page === 'admin' && profile && !profile.is_admin) setPage('dashboard')
}, [page, profile])
const createProject = useCallback(
async (name: string, description: string): Promise<Project> => {
const created = await run<Project>(
supa()
.from('projects')
.insert({
name: name.trim(),
description: description.trim() || null,
created_by: userId
})
.select()
.single()
)
if (!created) throw new Error("Le projet n'a pas pu être créé.")
setProjects((list) => [...list, created])
setCurrentProjectId(created.id)
return created
},
[userId]
)
const renameProject = useCallback(
async (id: string, name: string, description: string): Promise<void> => {
await mutate(
supa()
.from('projects')
.update({ name: name.trim(), description: description.trim() || null })
.eq('id', id)
)
await refreshProjects()
},
[refreshProjects]
)
const deleteProject = useCallback(
async (id: string): Promise<void> => {
await mutate(supa().from('projects').delete().eq('id', id))
setProjects((list) => list.filter((p) => p.id !== id))
setCurrentProjectId((cur) => (cur === id ? null : cur))
},
[]
)
const memberById = useCallback(
(id: string | null | undefined): Profile | undefined =>
id ? members.find((m) => m.id === id) : undefined,
[members]
)
const signOut = useCallback(async (): Promise<void> => {
await supa().auth.signOut()
setSession(null)
}, [])
const value = useMemo<AppState>(
() => ({
stage,
session,
page,
setPage,
profile,
profileError,
refreshProfile,
members,
membersLoading,
memberById,
refreshMembers,
projects,
projectsLoading,
projectsError,
currentProject,
selectProject,
refreshProjects,
createProject,
renameProject,
deleteProject,
online,
signOut,
focusEntity,
setFocusEntity
}),
[
stage,
session,
page,
profile,
profileError,
refreshProfile,
members,
membersLoading,
memberById,
refreshMembers,
projects,
projectsLoading,
projectsError,
currentProject,
selectProject,
refreshProjects,
createProject,
renameProject,
deleteProject,
online,
signOut,
focusEntity
]
)
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
+17 -10
View File
@@ -1,39 +1,46 @@
// Client de l'espace administration : appelle l'Edge Function "admin-users".
// Client de l'espace administration : appelle l'Edge Function « admin-users ».
// La clé service_role n'est JAMAIS ici — tout passe par la fonction serveur.
import { supa } from './supabase'
import { MemberRole } from './types'
import { Pole } from './types'
export interface AdminUser {
id: string
email: string
full_name: string
role: MemberRole
roles: MemberRole[]
roles: Pole[]
is_admin: boolean
avatar_color: string
avatar_url: string | null
created_at: string
}
/** Appel générique de l'Edge Function avec remontée d'erreur lisible. */
/** Appel générique de l'Edge Function, avec remontée d'erreur lisible. */
async function call<T>(action: string, payload: Record<string, unknown> = {}): Promise<T> {
const { data, error } = await supa().functions.invoke('admin-users', {
body: { action, ...payload }
})
if (error) {
let msg = error.message
const ctx = (error as unknown as { context?: { json?: () => Promise<{ error?: string }> } })
.context
// Supabase enveloppe la réponse HTTP : on va y chercher le vrai message.
const ctx = (error as unknown as { context?: { json?: () => Promise<{ error?: string }> } }).context
if (ctx?.json) {
try {
const body = await ctx.json()
if (body?.error) msg = body.error
} catch {
/* ignore */
/* on garde le message d'origine */
}
}
if (/failed to send|fetch/i.test(msg)) {
msg =
"L'espace d'administration est injoignable. Vérifie que l'Edge Function « admin-users » " +
'est bien déployée (voir GUIDE_ADMIN.md).'
}
throw new Error(msg)
}
if (data && typeof data === 'object' && 'error' in data) {
throw new Error((data as { error: string }).error)
}
@@ -46,7 +53,7 @@ export const adminCreateUser = (input: {
email: string
password: string
full_name: string
roles: MemberRole[]
roles: Pole[]
is_admin: boolean
}): Promise<{ ok: true; id: string }> => call('create', input)
@@ -58,7 +65,7 @@ export const adminUpdateUser = (
full_name?: string
email?: string
password?: string
roles?: MemberRole[]
roles?: Pole[]
is_admin?: boolean
avatar_url?: string | null
}
-15
View File
@@ -1,15 +0,0 @@
import { supa } from './supabase'
/**
* Envoie une image de profil dans le bucket "avatars" et renvoie son URL
* publique. Le chemin est préfixé par l'id du membre.
*/
export async function uploadAvatar(file: File, userId: string): Promise<string> {
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
const path = `${userId}/${Date.now()}.${ext}`
const { error } = await supa()
.storage.from('avatars')
.upload(path, file, { upsert: true, cacheControl: '3600' })
if (error) throw error
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
}
-39
View File
@@ -1,39 +0,0 @@
// Stockage local des clés Supabase (la clé "anon" est publique par nature,
// localStorage convient parfaitement et évite tout code natif).
export interface AppConfig {
supabaseUrl?: string
supabaseAnonKey?: string
}
const KEY = 'gamedev-tracker-config'
/**
* Config intégrée au moment du build (fichier .env).
* Si elle existe, l'appli se connecte automatiquement à la base : tes collègues
* n'ont rien à saisir et voient directement l'écran de connexion.
*/
function getBakedConfig(): AppConfig | null {
const url = import.meta.env.VITE_SUPABASE_URL
const key = import.meta.env.VITE_SUPABASE_ANON_KEY
if (url && key) return { supabaseUrl: url, supabaseAnonKey: key }
return null
}
export function hasBakedConfig(): boolean {
return getBakedConfig() !== null
}
export function getConfig(): AppConfig {
const baked = getBakedConfig()
if (baked) return baked
try {
return JSON.parse(localStorage.getItem(KEY) ?? '{}')
} catch {
return {}
}
}
export function setConfig(config: AppConfig): void {
localStorage.setItem(KEY, JSON.stringify(config))
}
+231
View File
@@ -0,0 +1,231 @@
// =====================================================================
// Couche d'accès aux données — UNE seule façon de parler à Supabase.
//
// Règle absolue : aucune erreur n'est avalée. Avant, le code faisait
// `const { data } = await supa()...` et ignorait `error` : une panne
// réseau ou une policy RLS se traduisait par une liste vide et un
// « Aucun bug 🎉 » mensonger. Ici, toute erreur lève une DbError avec
// un message en français, que l'appelant affiche (ErrorState / toast).
// =====================================================================
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PostgrestError } from '@supabase/supabase-js'
import { supa } from './supabase'
export class DbError extends Error {
readonly code: string | undefined
readonly details: string | undefined
constructor(message: string, code?: string, details?: string) {
super(message)
this.name = 'DbError'
this.code = code
this.details = details
}
}
/** Traduit une erreur Postgrest/réseau en phrase compréhensible. */
export function humanize(err: unknown): string {
if (err instanceof DbError) return err.message
if (!err) return 'Une erreur inconnue est survenue.'
const e = err as Partial<PostgrestError> & { message?: string; status?: number }
const raw = e.message ?? String(err)
const code = e.code ?? ''
// Réseau / serveur injoignable
if (/failed to fetch|network|load failed|ECONNREFUSED/i.test(raw)) {
return 'Impossible de joindre le serveur. Vérifie ta connexion internet.'
}
// Session expirée
if (/jwt|token|not authenticated|invalid claim/i.test(raw) || e.status === 401) {
return 'Ta session a expiré. Reconnecte-toi.'
}
// RLS
if (code === '42501' || /row-level security|permission denied/i.test(raw)) {
return "Tu n'as pas les droits pour cette action."
}
// Contraintes
if (code === '23505') return 'Cet élément existe déjà.'
if (code === '23503') return "L'élément lié n'existe plus. Actualise la page."
if (code === '23514') return 'Une valeur saisie est invalide.'
// Table / colonne absente, ou cache de schéma périmé côté PostgREST.
// Le message brut (« Could not find the 'tags' column of 'tasks' in the
// schema cache ») ne veut rien dire pour l'utilisateur : on dit quoi faire.
if (
code === '42P01' ||
code === '42703' ||
code === 'PGRST204' ||
code === 'PGRST205' ||
/schema cache/i.test(raw) ||
/does not exist/i.test(raw)
) {
const missing = raw.match(/'([^']+)' column of '([^']+)'/)
const what = missing ? ` (colonne « ${missing[1]} » de « ${missing[2]} »)` : ''
return (
`La base de données n'est pas à jour${what} : exécute « supabase/update_all.sql » ` +
"dans le SQL Editor de Supabase, puis la ligne « notify pgrst, 'reload schema'; », " +
'puis relance l\'application.'
)
}
return raw
}
interface Result<T> {
data: T | null
error: PostgrestError | null
}
/** Exécute une requête et lève si elle échoue. Renvoie `data` tel quel. */
export async function run<T>(query: PromiseLike<Result<T>>): Promise<T | null> {
const { data, error } = await query
if (error) throw new DbError(humanize(error), error.code, error.details)
return data
}
/** Exécute une requête de liste : renvoie toujours un tableau (jamais null). */
export async function rows<T>(query: PromiseLike<Result<T[]>>): Promise<T[]> {
return (await run<T[]>(query)) ?? []
}
/** Exécute une requête d'écriture dont on n'exploite pas le retour. */
export async function mutate(query: PromiseLike<Result<unknown>>): Promise<void> {
await run(query)
}
// ---------------------------------------------------------------------
// useQuery — chargement de données avec états explicites
// ---------------------------------------------------------------------
export interface QueryState<T> {
data: T
loading: boolean
/** Rechargement en arrière-plan (les données affichées restent visibles). */
refreshing: boolean
error: string | null
reload: () => Promise<void>
/** Mise à jour optimiste locale, sans aller-retour serveur. */
set: (updater: T | ((prev: T) => T)) => void
}
/**
* Charge des données de façon sûre : trois états distincts (chargement /
* erreur / données), pas de « faux état vide » pendant le fetch.
*
* `enabled = false` (ex : pas encore de projet sélectionné) laisse l'état
* en chargement sans déclencher de requête.
*/
export function useQuery<T>(
fetcher: () => Promise<T>,
initial: T,
{ enabled = true }: { enabled?: boolean } = {}
): QueryState<T> {
const [data, setData] = useState<T>(initial)
const [loading, setLoading] = useState(enabled)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
// Évite un setState après démontage, et ignore les réponses obsolètes
// quand deux chargements se chevauchent (changement de projet rapide).
const alive = useRef(true)
const seq = useRef(0)
const loaded = useRef(false)
useEffect(() => {
alive.current = true
return () => {
alive.current = false
}
}, [])
const reload = useCallback(async () => {
if (!enabled) return
const id = ++seq.current
if (loaded.current) setRefreshing(true)
else setLoading(true)
try {
const result = await fetcher()
if (!alive.current || id !== seq.current) return
setData(result)
setError(null)
loaded.current = true
} catch (e) {
if (!alive.current || id !== seq.current) return
setError(humanize(e))
} finally {
if (alive.current && id === seq.current) {
setLoading(false)
setRefreshing(false)
}
}
}, [enabled, fetcher])
useEffect(() => {
if (!enabled) return
void reload()
}, [enabled, reload])
const set = useCallback((updater: T | ((prev: T) => T)) => {
setData((prev) => (typeof updater === 'function' ? (updater as (p: T) => T)(prev) : updater))
}, [])
return { data, loading, refreshing, error, reload, set }
}
// ---------------------------------------------------------------------
// useRealtime — abonnement Postgres changes, groupé et débouncé
// ---------------------------------------------------------------------
export interface RealtimeTable {
table: string
/** Filtre côté serveur, ex : `project_id=eq.${id}`. Toujours le préciser
* quand c'est possible : sans lui, TOUTES les écritures de la table
* (tous projets confondus) réveillent la page. */
filter?: string
}
/**
* S'abonne à plusieurs tables sur un seul canal et appelle `onChange` de
* façon débouncée (une rafale d'UPDATE = un seul rechargement).
*/
export function useRealtime(
channel: string | null,
tables: RealtimeTable[],
onChange: () => void
): void {
// La ref évite de re-souscrire le canal à chaque rendu quand l'appelant
// passe une fonction anonyme ; elle est synchronisée dans un effet, jamais
// pendant le rendu.
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
// Sérialisé pour servir de dépendance stable sans re-souscrire à chaque rendu.
const key = JSON.stringify(tables)
useEffect(() => {
if (!channel) return
let timer: ReturnType<typeof setTimeout> | null = null
const ping = (): void => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => onChangeRef.current(), 120)
}
const list = JSON.parse(key) as RealtimeTable[]
let ch = supa().channel(channel)
for (const t of list) {
ch = ch.on(
'postgres_changes',
{ event: '*', schema: 'public', table: t.table, ...(t.filter ? { filter: t.filter } : {}) },
ping
)
}
ch.subscribe()
return () => {
if (timer) clearTimeout(timer)
supa().removeChannel(ch)
}
}, [channel, key])
}
+115 -33
View File
@@ -1,17 +1,30 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useMemo,
useRef,
ReactNode
} from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
// =====================================================================
// Notifications (toasts) + confirmations in-app — remplace alert/confirm
// Notifications (toasts) + confirmations — remplace alert()/confirm().
// UN seul système de retour utilisateur dans toute l'application : les
// bandeaux d'erreur locaux (<p class="text-rose-600">) ont disparu.
// =====================================================================
type ToastKind = 'success' | 'error' | 'info'
interface Toast {
id: number
kind: ToastKind
message: string
}
interface ConfirmOptions {
title: string
message?: string
@@ -19,6 +32,7 @@ interface ConfirmOptions {
cancelLabel?: string
danger?: boolean
}
interface ConfirmState extends ConfirmOptions {
resolve: (ok: boolean) => void
}
@@ -36,16 +50,47 @@ export function useFeedback(): FeedbackApi {
return v
}
const ICONS: Record<ToastKind, JSX.Element> = {
success: <CheckCircle2 size={18} className="text-emerald-600 dark:text-emerald-400" />,
error: <AlertTriangle size={18} className="text-rose-600 dark:text-rose-400" />,
info: <Info size={18} className="text-accent" />
}
let nextId = 1
export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Element {
const [toasts, setToasts] = useState<Toast[]>([])
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
const dismiss = useCallback((id: number) => {
const t = timers.current.get(id)
if (t) {
clearTimeout(t)
timers.current.delete(id)
}
setToasts((list) => list.filter((x) => x.id !== id))
}, [])
const toast = useCallback((message: string, kind: ToastKind = 'info') => {
const id = nextId++
setToasts((t) => [...t, { id, kind, message }])
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3800)
setToasts((list) => {
// Pas deux fois le même message à l'écran (rafales temps réel).
const deduped = list.filter((t) => t.message !== message)
// Au plus 4 : au-delà, ça masque l'interface.
return [...deduped, { id, kind, message }].slice(-4)
})
// Une erreur reste plus longtemps : on doit avoir le temps de la lire.
const delay = kind === 'error' ? 6500 : 3800
timers.current.set(
id,
setTimeout(() => dismiss(id), delay)
)
}, [dismiss])
useEffect(() => {
const map = timers.current
return () => map.forEach(clearTimeout)
}, [])
const confirm = useCallback(
@@ -54,37 +99,60 @@ export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Ele
[]
)
const closeConfirm = (ok: boolean): void => {
confirmState?.resolve(ok)
setConfirmState(null)
}
const closeConfirm = useCallback((ok: boolean): void => {
setConfirmState((cur) => {
cur?.resolve(ok)
return null
})
}, [])
// Échap = annuler. Entrée = confirmer, SAUF pour une action destructrice :
// valider une suppression d'une frappe involontaire serait impardonnable.
useEffect(() => {
if (!confirmState) return
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === 'Escape') {
e.stopPropagation()
closeConfirm(false)
} else if (e.key === 'Enter' && !confirmState.danger) {
closeConfirm(true)
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [confirmState, closeConfirm])
const api = useMemo(() => ({ toast, confirm }), [toast, confirm])
return (
<Ctx.Provider value={{ toast, confirm }}>
<Ctx.Provider value={api}>
{children}
{/* Toasts (coin bas-droite) */}
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
<AnimatePresence>
{/* Toasts — annoncés aux lecteurs d'écran (ils étaient muets avant). */}
<div
role="status"
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2"
>
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, x: 40, scale: 0.95 }}
initial={{ opacity: 0, x: 32, scale: 0.96 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.95 }}
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
className="pointer-events-auto flex items-start gap-2 rounded-xl border border-border bg-panel px-3 py-2.5 shadow-xl"
exit={{ opacity: 0, x: 32, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 360, damping: 30 }}
className="pointer-events-auto flex items-start gap-2.5 rounded-xl border border-border bg-panel px-3.5 py-3 shadow-pop"
>
<span className="mt-0.5 shrink-0">
{t.kind === 'success' && <CheckCircle2 size={18} className="text-emerald-600" />}
{t.kind === 'error' && <AlertTriangle size={18} className="text-rose-600" />}
{t.kind === 'info' && <Info size={18} className="text-sky-600" />}
</span>
<p className="flex-1 text-sm text-ink">{t.message}</p>
<span className="mt-px shrink-0">{ICONS[t.kind]}</span>
<p className="flex-1 text-sm leading-snug text-ink">{t.message}</p>
<button
onClick={() => setToasts((list) => list.filter((x) => x.id !== t.id))}
className="text-subtle hover:text-ink"
type="button"
aria-label="Fermer"
onClick={() => dismiss(t.id)}
className="shrink-0 text-subtle transition-colors hover:text-ink"
>
<X size={14} />
</button>
@@ -100,30 +168,44 @@ export function FeedbackProvider({ children }: { children: ReactNode }): JSX.Ele
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
data-overlay
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[2px]"
onMouseDown={() => closeConfirm(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
role="alertdialog"
aria-modal="true"
aria-label={confirmState.title}
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
className="card w-full max-w-sm"
transition={{ type: 'spring', stiffness: 340, damping: 28 }}
className="w-full max-w-sm rounded-xl border border-border bg-panel p-5 shadow-pop"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-2 flex items-center gap-2">
{confirmState.danger && <AlertTriangle size={20} className="text-rose-600" />}
{confirmState.danger && (
<AlertTriangle size={19} className="shrink-0 text-rose-600 dark:text-rose-400" />
)}
<h2 className="text-base font-semibold text-ink">{confirmState.title}</h2>
</div>
{confirmState.message && (
<p className="mb-4 text-sm text-muted">{confirmState.message}</p>
<p className="mb-1 text-sm leading-relaxed text-muted">{confirmState.message}</p>
)}
<div className="mt-2 flex justify-end gap-2">
<button className="btn-ghost" onClick={() => closeConfirm(false)}>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="btn-ghost"
// Sur une action destructrice, le focus part sur « Annuler ».
autoFocus={confirmState.danger}
onClick={() => closeConfirm(false)}
>
{confirmState.cancelLabel ?? 'Annuler'}
</button>
<button
className={confirmState.danger ? 'btn bg-rose-500 text-ink hover:bg-rose-600' : 'btn-primary'}
type="button"
className={confirmState.danger ? 'btn-danger' : 'btn-primary'}
autoFocus={!confirmState.danger}
onClick={() => closeConfirm(true)}
>
{confirmState.confirmLabel ?? 'Confirmer'}
+40
View File
@@ -0,0 +1,40 @@
// =====================================================================
// Filtres multi-valeurs — sémantique partagée par le Kanban et les bugs.
// =====================================================================
/** Valeur spéciale : « non assigné » / « sans pôle ». */
export const NONE = '__none__'
/**
* Un élément passe le filtre s'il correspond à AU MOINS UNE des valeurs
* cochées (OU logique).
*
* C'est ce qu'on attend en cochant « Alex » + « Sam » : les tâches d'Alex
* **et** celles de Sam pas seulement celles les deux sont assignés.
* Aucune case cochée = aucun filtre, tout passe.
*/
export function matchesAny(selected: string[], values: readonly string[]): boolean {
if (selected.length === 0) return true
if (values.length === 0) return selected.includes(NONE)
return values.some((v) => selected.includes(v))
}
/** Idem pour une valeur unique (priorité, sévérité, statut…). */
export function matchesOne(selected: string[], value: string): boolean {
return selected.length === 0 || selected.includes(value)
}
/**
* Relit des filtres enregistrés en tolérant l'ancien format à valeur
* unique (`assignee: "id"` `assignees: ["id"]`), pour ne pas perdre les
* préférences de ceux qui utilisaient déjà l'application.
*/
export function readList(value: unknown, legacy?: unknown): string[] {
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string')
if (typeof legacy === 'string' && legacy) return [legacy]
return []
}
export function readText(value: unknown): string {
return typeof value === 'string' ? value : ''
}
+88
View File
@@ -0,0 +1,88 @@
// =====================================================================
// Installeur intégré — côté front.
//
// Le .exe distribué EST l'application. Lancé depuis ailleurs que son
// dossier d'installation, il affiche son propre écran d'installation
// (src/pages/Installer.tsx) au lieu de démarrer l'app.
//
// Voir src-tauri/src/installer.rs pour la partie système.
// =====================================================================
import { isDesktop } from './updater'
export interface InstallerStatus {
running_installed: boolean
already_installed: boolean
install_dir: string
version: string
product_name: string
}
export type StartupMode = 'installer' | 'app'
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const { invoke } = await import('@tauri-apps/api/core')
return invoke<T>(cmd, args)
}
/**
* Décide, au démarrage, entre l'écran d'installation et l'application.
*
* En cas de doute on démarre l'APPLICATION : mieux vaut lancer l'app depuis
* un dossier temporaire que bloquer quelqu'un sur un installeur inutile.
*/
export async function resolveStartupMode(): Promise<{
mode: StartupMode
status: InstallerStatus | null
}> {
if (!isDesktop()) return { mode: 'app', status: null }
try {
const status = await invoke<InstallerStatus>('installer_status')
return { mode: status.running_installed ? 'app' : 'installer', status }
} catch (e) {
console.warn('Statut dinstallation indisponible :', e)
return { mode: 'app', status: null }
}
}
/** Copie l'application, crée les raccourcis, l'enregistre auprès de Windows. */
export function install(desktopShortcut: boolean): Promise<string> {
return invoke<string>('installer_install', { desktopShortcut })
}
/** Démarre la version installée et ferme le processus courant. */
export function launchInstalled(): Promise<void> {
return invoke<void>('installer_launch')
}
// ---------------------------------------------------------------------
// Fenêtre
//
// Rien ici ne redimensionne ni n'affiche la fenêtre : elle est créée
// directement à la bonne forme côté Rust (src-tauri/src/lib.rs). Ne
// restent que les commandes de la barre de titre maison de l'installeur,
// puisque la fenêtre y est sans décoration Windows.
// ---------------------------------------------------------------------
export async function closeWindow(): Promise<void> {
if (!isDesktop()) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().close()
}
export async function minimizeWindow(): Promise<void> {
if (!isDesktop()) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().minimize()
}
/** Permet de déplacer la fenêtre sans barre de titre, en tirant sur l'en-tête. */
export async function startDragging(): Promise<void> {
if (!isDesktop()) return
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
await getCurrentWindow().startDragging()
} catch {
/* sans conséquence */
}
}
+119
View File
@@ -0,0 +1,119 @@
// =====================================================================
// Markdown — rendu et import de fichiers.
//
// Le Markdown est le format pivot de la documentation : un .docx importé
// y est converti immédiatement. Une seule représentation en base, donc
// des fiches lisibles, cherchables et modifiables dans l'application.
// =====================================================================
import DOMPurify from 'dompurify'
import { marked } from 'marked'
marked.setOptions({ gfm: true, breaks: true })
/**
* Convertit du Markdown en HTML **assaini**.
*
* DOMPurify n'est pas une précaution de principe : le contenu vient d'un
* fichier importé, potentiellement produit par un outil tiers. Sans lui,
* un `<img onerror=…>` glissé dans un .docx s'exécuterait dans l'app.
*/
export function renderMarkdown(md: string): string {
const html = marked.parse(md ?? '', { async: false })
return DOMPurify.sanitize(html, {
// Les liens s'ouvrent dans le navigateur (voir Markdown.tsx) ; on
// interdit tout ce qui pourrait exécuter du code ou charger un cadre.
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'style']
})
}
/** Texte brut d'une fiche : sert à la recherche et aux extraits. */
export function markdownToText(md: string): string {
return (md ?? '')
.replace(/```[\s\S]*?```/g, ' ') // blocs de code
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // liens → libellé
.replace(/[#>*_`~|-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/** Extrait de N caractères, coupé sur un mot. */
export function excerpt(md: string, max = 160): string {
const text = markdownToText(md)
if (text.length <= max) return text
return `${text.slice(0, text.lastIndexOf(' ', max) || max)}`
}
/** Premier titre de niveau 1, s'il y en a un — sert de titre à l'import. */
export function firstHeading(md: string): string | null {
const m = (md ?? '').match(/^\s*#\s+(.+)$/m)
return m ? m[1].trim() : null
}
// ---------------------------------------------------------------------
// Import de fichiers
// ---------------------------------------------------------------------
/** Extensions acceptées à l'import, pour l'attribut `accept` du champ. */
export const IMPORT_ACCEPT = '.md,.markdown,.txt,.docx'
export interface ImportedDoc {
title: string
content: string
sourceName: string
}
const MAX_IMPORT_BYTES = 5 * 1024 * 1024
/** Nom du fichier sans son extension — titre par défaut. */
function baseName(name: string): string {
return name.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim()
}
/**
* Lit un fichier et le ramène à du Markdown.
*
* `.docx` passe par mammoth ; le paquet est lourd (~200 Ko) et n'est donc
* chargé qu'au moment où quelqu'un importe réellement un fichier Word.
*/
export async function importFile(file: File): Promise<ImportedDoc> {
if (file.size > MAX_IMPORT_BYTES) {
throw new Error('Fichier trop lourd : 5 Mo maximum.')
}
const ext = (file.name.split('.').pop() ?? '').toLowerCase()
let content: string
if (ext === 'docx') {
const mammoth = await import('mammoth/mammoth.browser')
const buffer = await file.arrayBuffer()
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
content = result.value
if (!content.trim()) {
throw new Error("Ce document Word semble vide, ou son contenu n'a pas pu être lu.")
}
} else if (ext === 'md' || ext === 'markdown' || ext === 'txt' || ext === '') {
content = await file.text()
} else if (ext === 'doc') {
throw new Error(
"L'ancien format Word (.doc) n'est pas lisible. Ouvre le fichier dans Word " +
'et enregistre-le en .docx, puis réessaie.'
)
} else if (ext === 'pdf') {
throw new Error(
"Le PDF n'est pas pris en charge : son texte n'est pas structuré et la fiche " +
'serait illisible. Copie-colle le contenu dans une nouvelle fiche.'
)
} else {
throw new Error(`Format « .${ext} » non pris en charge. Formats acceptés : ${IMPORT_ACCEPT}.`)
}
return {
// Le titre du document prime sur le nom du fichier : plus fiable.
title: firstHeading(content) ?? baseName(file.name) ?? 'Sans titre',
content: content.trim(),
sourceName: file.name
}
}
+139 -25
View File
@@ -1,35 +1,149 @@
import { supa } from './supabase'
import { Pole, Profile, NotificationKind, memberPoles } from './types'
// =====================================================================
// Notifications — qui prévenir, et quand.
//
// Avant, seuls les membres d'un PÔLE étaient notifiés : assigner
// nommément quelqu'un hors pôle ne le prévenait jamais. Et un membre
// appartenant à deux pôles d'une même tâche recevait deux fois la même
// notification, via autant de requêtes séquentielles.
//
// Ici : on calcule l'ensemble des destinataires (pôles assignés),
// on retire l'auteur de l'action, on dédoublonne, et on insère TOUT
// en une seule requête.
// =====================================================================
/**
* Crée une notification pour chaque membre du pôle destinataire
* (sauf l'auteur de l'action). Utilisé lorsqu'une tâche ou un bug
* est confié à un pôle.
*/
export async function notifyPole(opts: {
projectId: string
pole: Pole
kind: NotificationKind
title: string // ex : « Nouvelle tâche »
body: string // titre de la tâche / du bug
entityId: string
actorId: string | null
import { supa } from './supabase'
import { EntityKind, NotificationKind, Pole, Profile, memberPoles } from './types'
interface Recipients {
members: Profile[]
}): Promise<void> {
const recipients = opts.members.filter(
(m) => memberPoles(m).includes(opts.pole) && m.id !== opts.actorId
)
/** Pôles destinataires (tous leurs membres seront prévenus). */
poles?: Pole[]
/** Personnes nommément assignées (prévenues même hors pôle). */
assigneeIds?: string[]
/** Auteur de l'action : jamais notifié de sa propre action. */
actorId: string | null
}
/** Ensemble dédoublonné des identifiants à prévenir. */
function resolveRecipients({ members, poles = [], assigneeIds = [], actorId }: Recipients): string[] {
const ids = new Set<string>()
for (const m of members) {
if (poles.some((p) => memberPoles(m).includes(p))) ids.add(m.id)
}
for (const id of assigneeIds) ids.add(id)
if (actorId) ids.delete(actorId)
// On ne notifie que des membres réellement existants.
const known = new Set(members.map((m) => m.id))
return [...ids].filter((id) => known.has(id))
}
interface NotifyOptions extends Recipients {
projectId: string
kind: NotificationKind
entityKind: EntityKind
entityId: string
title: string
body: string
/** Pôle mis en avant dans la notification (informatif). */
pole?: Pole | null
}
/** Envoie une notification à tous les destinataires — en UNE requête. */
export async function notify(opts: NotifyOptions): Promise<void> {
const recipients = resolveRecipients(opts)
if (recipients.length === 0) return
const rows = recipients.map((m) => ({
user_id: m.id,
const rows = recipients.map((user_id) => ({
user_id,
project_id: opts.projectId,
kind: opts.kind,
title: opts.title,
body: opts.body,
entity_kind: opts.entityKind,
entity_id: opts.entityId,
pole: opts.pole
title: opts.title,
body: opts.body.slice(0, 160),
pole: opts.pole ?? null
}))
await supa().from('notifications').insert(rows)
// Une notification qui échoue ne doit jamais faire échouer l'action
// métier (la tâche est déjà enregistrée) : on avale silencieusement.
const { error } = await supa().from('notifications').insert(rows)
if (error) console.warn('Notification non envoyée :', error.message)
}
/** Nouvelle tâche / nouveau pôle ou assigné sur une tâche. */
export function notifyTask(opts: {
projectId: string
taskId: string
title: string
members: Profile[]
poles: Pole[]
assigneeIds: string[]
actorId: string | null
isNew: boolean
}): Promise<void> {
return notify({
projectId: opts.projectId,
kind: 'task',
entityKind: 'task',
entityId: opts.taskId,
title: opts.isNew ? 'Nouvelle tâche' : 'Tâche qui te concerne',
body: opts.title,
members: opts.members,
poles: opts.poles,
assigneeIds: opts.assigneeIds,
actorId: opts.actorId,
pole: opts.poles[0] ?? null
})
}
/** Nouveau bug / nouveau pôle ou assigné sur un bug. */
export function notifyBug(opts: {
projectId: string
bugId: string
title: string
members: Profile[]
poles: Pole[]
assigneeIds: string[]
actorId: string | null
isNew: boolean
}): Promise<void> {
return notify({
projectId: opts.projectId,
kind: 'bug',
entityKind: 'bug',
entityId: opts.bugId,
title: opts.isNew ? 'Nouveau bug' : 'Bug qui te concerne',
body: opts.title,
members: opts.members,
poles: opts.poles,
assigneeIds: opts.assigneeIds,
actorId: opts.actorId,
pole: opts.poles[0] ?? null
})
}
/** Nouveau commentaire : on prévient les gens impliqués sur l'élément. */
export function notifyComment(opts: {
projectId: string
entityKind: EntityKind
entityId: string
entityTitle: string
body: string
members: Profile[]
/** Assignés + auteur/rapporteur de l'élément commenté. */
involvedIds: string[]
actorId: string | null
actorName: string
}): Promise<void> {
return notify({
projectId: opts.projectId,
kind: 'comment',
entityKind: opts.entityKind,
entityId: opts.entityId,
title: `${opts.actorName} a commenté « ${opts.entityTitle} »`,
body: opts.body,
members: opts.members,
assigneeIds: opts.involvedIds,
actorId: opts.actorId
})
}
+137
View File
@@ -0,0 +1,137 @@
// =====================================================================
// Fichiers : photos de profil (bucket "avatars") et pièces jointes
// (bucket "attachments").
// =====================================================================
import { supa } from './supabase'
import { Attachment, EntityKind } from './types'
import { rows, run } from './db'
/** 10 Mo — largement suffisant pour une capture d'écran ou un log. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024
function storageError(err: { message: string }, what: string): Error {
const m = err.message.toLowerCase()
if (
m.includes('bucket') ||
m.includes('not found') ||
m.includes('row-level') ||
m.includes('violates') ||
m.includes('permission')
) {
return new Error(
`Le stockage ${what} n'est pas configuré sur Supabase : exécute ` +
'« supabase/update_all.sql » dans le SQL Editor, puis réessaie.'
)
}
return new Error(err.message)
}
/** Nom de fichier sûr : pas d'accent ni d'espace dans un chemin de bucket. */
function slug(name: string): string {
return name
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // accents décomposés par NFD
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-')
.slice(-80)
}
// ---------------------------------------------------------------------
// Avatars
// ---------------------------------------------------------------------
/**
* Envoie une photo de profil et renvoie son URL publique. Les anciennes
* photos du même membre sont supprimées : sans ça, le bucket accumulait
* indéfiniment tous les avatars jamais choisis.
*/
export async function uploadAvatar(file: File, userId: string): Promise<string> {
const ext = (file.name.split('.').pop() || 'png').toLowerCase()
const path = `${userId}/${Date.now()}.${ext}`
const { error } = await supa()
.storage.from('avatars')
.upload(path, file, { cacheControl: '3600' })
if (error) throw storageError(error, 'des photos de profil')
// Ménage : on retire les versions précédentes (best effort).
try {
const { data: old } = await supa().storage.from('avatars').list(userId)
const stale = (old ?? []).map((f) => `${userId}/${f.name}`).filter((p) => p !== path)
if (stale.length) await supa().storage.from('avatars').remove(stale)
} catch {
/* sans conséquence */
}
return supa().storage.from('avatars').getPublicUrl(path).data.publicUrl
}
// ---------------------------------------------------------------------
// Pièces jointes
// ---------------------------------------------------------------------
export function attachmentUrl(path: string): string {
return supa().storage.from('attachments').getPublicUrl(path).data.publicUrl
}
export function listAttachments(kind: EntityKind, entityId: string): Promise<Attachment[]> {
return rows<Attachment>(
supa()
.from('attachments')
.select('*')
.eq('entity_kind', kind)
.eq('entity_id', entityId)
.order('created_at')
)
}
export async function uploadAttachment(opts: {
file: File
projectId: string
entityKind: EntityKind
entityId: string
uploaderId: string
}): Promise<Attachment> {
const { file, projectId, entityKind, entityId, uploaderId } = opts
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error('Fichier trop lourd : 10 Mo maximum.')
}
const path = `${projectId}/${entityKind}/${entityId}/${crypto.randomUUID()}-${slug(file.name)}`
const { error } = await supa()
.storage.from('attachments')
.upload(path, file, { cacheControl: '3600', contentType: file.type || undefined })
if (error) throw storageError(error, 'des pièces jointes')
const created = await run<Attachment>(
supa()
.from('attachments')
.insert({
project_id: projectId,
entity_kind: entityKind,
entity_id: entityId,
uploader_id: uploaderId,
name: file.name,
path,
size: file.size,
mime: file.type || null
})
.select()
.single()
)
if (!created) throw new Error("La pièce jointe n'a pas pu être enregistrée.")
return created
}
export async function deleteAttachment(a: Attachment): Promise<void> {
await run(supa().from('attachments').delete().eq('id', a.id))
// Le fichier physique ensuite : si ça échoue, la ligne est déjà partie
// et l'utilisateur ne voit plus rien d'incohérent.
try {
await supa().storage.from('attachments').remove([a.path])
} catch {
/* sans conséquence */
}
}
+33 -25
View File
@@ -1,25 +1,33 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js'
let client: SupabaseClient | null = null
/** Crée (ou recrée) le client Supabase à partir des clés fournies. */
export function initSupabase(url: string, anonKey: string): SupabaseClient {
client = createClient(url, anonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
storageKey: 'gamedev-tracker-auth'
}
})
return client
}
/** Récupère le client courant (lève une erreur si non initialisé). */
export function supa(): SupabaseClient {
if (!client) throw new Error('Supabase non initialisé. Configure les clés dans Réglages.')
return client
}
export function hasClient(): boolean {
return client !== null
}
import { createClient, SupabaseClient } from '@supabase/supabase-js'
// Les clés sont intégrées au build depuis le fichier `.env`, versionné avec
// le projet. L'URL et la clé « anon » sont publiques par nature :
// c'est la RLS de Supabase qui protège les données, pas le secret de la clé.
const URL = import.meta.env.VITE_SUPABASE_URL
const ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY
/** L'application a-t-elle été construite avec des clés Supabase ? */
export const isConfigured = Boolean(URL && ANON_KEY)
const client: SupabaseClient | null = URL && ANON_KEY
? createClient(URL, ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
storageKey: 'gamedev-tracker-auth'
},
// Une équipe de 5-6 personnes n'a pas besoin de plus : évite de saturer
// le quota temps réel du plan gratuit lors des rafales d'écriture.
realtime: { params: { eventsPerSecond: 5 } }
})
: null
export function supa(): SupabaseClient {
if (!client) {
throw new Error(
"L'application n'a pas été configurée : renseigne VITE_SUPABASE_URL et " +
'VITE_SUPABASE_ANON_KEY dans le fichier .env, puis reconstruis.'
)
}
return client
}
+50 -17
View File
@@ -1,17 +1,50 @@
// Gestion du thème clair / sombre (persisté localement).
export type Theme = 'light' | 'dark'
const KEY = 'gamedev-tracker-theme'
export function getTheme(): Theme {
return localStorage.getItem(KEY) === 'dark' ? 'dark' : 'light'
}
export function applyTheme(t: Theme): void {
document.documentElement.classList.toggle('dark', t === 'dark')
}
export function setTheme(t: Theme): void {
localStorage.setItem(KEY, t)
applyTheme(t)
}
// Thème clair / sombre / système.
//
// Le défaut est le mode CLAIR : c'est l'apparence de référence de
// l'application. Le mode « système » reste disponible dans le sélecteur
// (bouton de la barre latérale, ou palette Ctrl+K) pour qui préfère
// suivre le réglage de Windows.
export type Theme = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
const KEY = 'gamedev-tracker-theme'
const media = (): MediaQueryList | null =>
typeof window.matchMedia === 'function' ? window.matchMedia('(prefers-color-scheme: dark)') : null
export function getTheme(): Theme {
const v = localStorage.getItem(KEY)
// Rien de choisi = clair. On ne suit le système que si on l'a demandé.
return v === 'dark' || v === 'light' || v === 'system' ? v : 'light'
}
export function resolveTheme(t: Theme): ResolvedTheme {
if (t !== 'system') return t
return media()?.matches ? 'dark' : 'light'
}
export function applyTheme(t: Theme): ResolvedTheme {
const resolved = resolveTheme(t)
document.documentElement.classList.toggle('dark', resolved === 'dark')
// Aligne aussi la couleur de fond native de la fenêtre (évite un flash
// blanc au démarrage en thème sombre).
document.documentElement.style.colorScheme = resolved
return resolved
}
export function setTheme(t: Theme): ResolvedTheme {
localStorage.setItem(KEY, t)
return applyTheme(t)
}
/** Réagit au changement de thème du système quand on est en mode « système ». */
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
const mq = media()
if (!mq) return () => undefined
const handler = (): void => {
if (getTheme() === 'system') onChange(applyTheme('system'))
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
+70
View File
@@ -0,0 +1,70 @@
// =====================================================================
// Système de « tons » — source unique de vérité pour la couleur
// sémantique (badges, pastilles, barres, textes d'accentuation).
//
// Chaque ton s'adapte AU THÈME : classes claires par défaut, variantes
// `dark:` pour le mode sombre. Aucune page ne doit écrire de
// `bg-amber-100` ou `text-gray-500` en dur : tout passe par ici.
// =====================================================================
export type Tone =
| 'slate'
| 'sky'
| 'amber'
| 'rose'
| 'emerald'
| 'violet'
| 'fuchsia'
| 'cyan'
| 'orange'
| 'lime'
| 'indigo'
| 'teal'
/** Badge « doux » : fond teinté + texte lisible, en clair comme en sombre. */
export const TONE_BADGE: Record<Tone, string> = {
slate: 'bg-slate-500/10 text-slate-600 dark:bg-slate-400/15 dark:text-slate-300',
sky: 'bg-sky-500/10 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300',
amber: 'bg-amber-500/12 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300',
rose: 'bg-rose-500/10 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300',
emerald: 'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300',
violet: 'bg-violet-500/10 text-violet-700 dark:bg-violet-400/15 dark:text-violet-300',
fuchsia: 'bg-fuchsia-500/10 text-fuchsia-700 dark:bg-fuchsia-400/15 dark:text-fuchsia-300',
cyan: 'bg-cyan-500/10 text-cyan-700 dark:bg-cyan-400/15 dark:text-cyan-300',
orange: 'bg-orange-500/12 text-orange-700 dark:bg-orange-400/15 dark:text-orange-300',
lime: 'bg-lime-500/12 text-lime-700 dark:bg-lime-400/15 dark:text-lime-300',
indigo: 'bg-indigo-500/10 text-indigo-700 dark:bg-indigo-400/15 dark:text-indigo-300',
teal: 'bg-teal-500/10 text-teal-700 dark:bg-teal-400/15 dark:text-teal-300'
}
/** Aplat plein : pastille de statut, barre de progression, liseré. */
export const TONE_DOT: Record<Tone, string> = {
slate: 'bg-slate-400',
sky: 'bg-sky-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
emerald: 'bg-emerald-500',
violet: 'bg-violet-500',
fuchsia: 'bg-fuchsia-500',
cyan: 'bg-cyan-500',
orange: 'bg-orange-500',
lime: 'bg-lime-500',
indigo: 'bg-indigo-500',
teal: 'bg-teal-500'
}
/** Texte seul coloré (chiffres clés, libellés d'accentuation). */
export const TONE_TEXT: Record<Tone, string> = {
slate: 'text-slate-600 dark:text-slate-300',
sky: 'text-sky-600 dark:text-sky-300',
amber: 'text-amber-600 dark:text-amber-300',
rose: 'text-rose-600 dark:text-rose-300',
emerald: 'text-emerald-600 dark:text-emerald-300',
violet: 'text-violet-600 dark:text-violet-300',
fuchsia: 'text-fuchsia-600 dark:text-fuchsia-300',
cyan: 'text-cyan-600 dark:text-cyan-300',
orange: 'text-orange-600 dark:text-orange-300',
lime: 'text-lime-600 dark:text-lime-300',
indigo: 'text-indigo-600 dark:text-indigo-300',
teal: 'text-teal-600 dark:text-teal-300'
}
+483 -235
View File
@@ -1,235 +1,483 @@
// Types métier alignés sur le schéma SQL Supabase (voir supabase/schema.sql).
export type MemberRole =
| 'lead'
| 'programmer'
| 'artist'
| 'game_designer'
| 'sound'
| 'writer'
| 'qa'
| 'other'
export const ROLE_LABELS: Record<MemberRole, string> = {
lead: 'Chef de projet',
programmer: 'Programmeur',
artist: 'Artiste',
game_designer: 'Game Designer',
sound: 'Sound Designer',
writer: 'Scénariste',
qa: 'Testeur (QA)',
other: 'Autre'
}
// Un « pôle » = un métier/équipe. On réutilise les rôles comme pôles :
// une tâche/bug est confiée à un pôle, et la notification part à tous les
// membres de ce pôle. Les libellés sont ceux des rôles.
export type Pole = MemberRole
export const POLE_LABELS: Record<Pole, string> = ROLE_LABELS
// Ordre d'affichage des pôles dans les listes déroulantes.
export const POLE_ORDER: Pole[] = [
'programmer',
'artist',
'game_designer',
'sound',
'writer',
'qa',
'lead',
'other'
]
// Une couleur DISTINCTE par pôle (pas de collision entre deux rôles).
const POLE_COLORS: Record<Pole, string> = {
lead: 'bg-amber-100 text-amber-700',
programmer: 'bg-sky-100 text-sky-700',
artist: 'bg-fuchsia-100 text-fuchsia-700',
game_designer: 'bg-violet-100 text-violet-700',
sound: 'bg-emerald-100 text-emerald-700',
writer: 'bg-orange-100 text-orange-700',
qa: 'bg-cyan-100 text-cyan-700',
other: 'bg-slate-100 text-slate-600'
}
export function poleColor(pole: Pole): string {
return POLE_COLORS[pole] ?? POLE_COLORS.other
}
// Pôles « critiques » : donnent un accès élargi, attribués par un admin
// uniquement (jamais auto-assignables lors de l'onboarding).
export const CRITICAL_POLES: Pole[] = ['lead']
// Pôles qu'un membre peut se donner lui-même à l'inscription.
export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter(
(p) => !CRITICAL_POLES.includes(p)
)
// Tags « jobs » suggérés (l'utilisateur peut aussi en taper d'autres).
export const JOB_TAGS: string[] = [
'Programmation',
'Art',
'Game Design',
'Audio',
'Écriture',
'QA',
'Level Design',
'UI/UX'
]
// Couleur stable d'un tag à partir de son texte (pour l'affichage).
const TAG_PALETTE = [
'bg-violet-100 text-violet-700',
'bg-sky-100 text-sky-700',
'bg-emerald-100 text-emerald-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
'bg-cyan-100 text-cyan-700',
'bg-fuchsia-100 text-fuchsia-700',
'bg-lime-100 text-lime-700'
]
export function tagColor(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) >>> 0
return TAG_PALETTE[h % TAG_PALETTE.length]
}
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
todo: 'À faire',
in_progress: 'En cours',
review: 'En revue',
done: 'Terminé'
}
export const TASK_STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'review', 'done']
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export const PRIORITY_LABELS: Record<Priority, string> = {
low: 'Basse',
medium: 'Moyenne',
high: 'Haute',
urgent: 'Urgente'
}
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
low: 'Mineur',
medium: 'Moyen',
high: 'Majeur',
critical: 'Critique'
}
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
open: 'Ouvert',
in_progress: 'En cours',
resolved: 'Résolu',
closed: 'Fermé'
}
export interface Profile {
id: string
full_name: string
role: MemberRole // rôle principal (= roles[0]) conservé pour l'affichage simple
roles: MemberRole[] // tous les pôles auxquels le membre appartient
avatar_color: string
avatar_url: string | null // photo de profil (sinon initiales + couleur)
is_admin: boolean
onboarded: boolean // a terminé l'accueil (nom, avatar, pôles)
created_at: string
}
/** Liste des pôles d'un membre (avec repli sur le rôle principal). */
export function memberPoles(p: { role?: MemberRole; roles?: MemberRole[] }): Pole[] {
if (p.roles && p.roles.length) return p.roles
return p.role ? [p.role] : []
}
/** Liste des pôles d'une tâche (avec repli sur l'ancien pôle unique). */
export function taskPoles(t: { pole?: Pole | null; poles?: Pole[] }): Pole[] {
if (t.poles && t.poles.length) return t.poles
return t.pole ? [t.pole] : []
}
export interface Project {
id: string
name: string
description: string | null
created_by: string | null
created_at: string
}
export interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
priority: Priority
pole: Pole | null // pôle principal (= poles[0]) conservé pour compat
poles: Pole[] // tous les pôles concernés par la tâche
assignee_ids: string[]
due_date: string | null
position: number
tags: string[]
created_by: string | null
created_at: string
updated_at: string
}
export interface TimeLog {
id: string
project_id: string | null
task_id: string | null // null = activité libre (réunion, etc.)
label: string | null // libellé quand il n'y a pas de tâche liée
user_id: string | null
minutes: number
note: string | null
logged_at: string
created_at: string
}
/** Convertit des minutes en libellé court : 90 -> "1 h 30", 45 -> "45 min". */
export function formatMinutes(min: number): string {
if (!min) return '0 min'
const h = Math.floor(min / 60)
const m = min % 60
if (h === 0) return `${m} min`
if (m === 0) return `${h} h`
return `${h} h ${String(m).padStart(2, '0')}`
}
export interface Bug {
id: string
project_id: string
title: string
description: string | null
severity: BugSeverity
status: BugStatus
priority: Priority
pole: Pole | null
assignee_id: string | null
reporter_id: string | null
created_at: string
updated_at: string
}
export type NotificationKind = 'task' | 'bug'
export interface AppNotification {
id: string
user_id: string
project_id: string | null
kind: NotificationKind
title: string
body: string | null
entity_id: string | null
pole: Pole | null
read: boolean
created_at: string
}
// Types métier alignés sur le schéma SQL Supabase (voir supabase/schema.sql).
//
// v10 : les colonnes « de compatibilité » (profiles.role, tasks.pole,
// bugs.assignee_id) ont disparu. Il n'y a plus qu'une seule source de
// vérité par notion : `roles[]`, `poles[]`, `assignee_ids[]`.
import { Tone, TONE_BADGE, TONE_DOT } from './tones'
// ---------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------
/** Les écrans de l'application (l'onglet « admin » est réservé aux admins). */
export type Page = 'dashboard' | 'kanban' | 'planning' | 'bugs' | 'docs' | 'members' | 'admin'
// ---------------------------------------------------------------------
// Pôles (métiers)
// ---------------------------------------------------------------------
export type Pole =
| 'lead'
| 'programmer'
| 'artist'
| 'game_designer'
| 'sound'
| 'writer'
| 'qa'
| 'other'
export const POLE_LABELS: Record<Pole, string> = {
lead: 'Chef de projet',
programmer: 'Programmeur',
artist: 'Artiste',
game_designer: 'Game Designer',
sound: 'Sound Designer',
writer: 'Scénariste',
qa: 'Testeur (QA)',
other: 'Autre'
}
/** Ordre d'affichage des pôles dans les listes et sélecteurs. */
export const POLE_ORDER: Pole[] = [
'programmer',
'artist',
'game_designer',
'sound',
'writer',
'qa',
'lead',
'other'
]
/** Un TON DISTINCT par pôle (aucune collision entre deux métiers). */
export const POLE_TONE: Record<Pole, Tone> = {
lead: 'amber',
programmer: 'sky',
artist: 'fuchsia',
game_designer: 'violet',
sound: 'emerald',
writer: 'orange',
qa: 'cyan',
other: 'slate'
}
export const poleBadge = (pole: Pole): string => TONE_BADGE[POLE_TONE[pole] ?? 'slate']
export const poleDot = (pole: Pole): string => TONE_DOT[POLE_TONE[pole] ?? 'slate']
/** Pôles « critiques » : attribués par un admin uniquement. */
export const CRITICAL_POLES: Pole[] = ['lead']
/** Pôles qu'un membre peut se donner lui-même à l'inscription. */
export const SELF_ASSIGNABLE_POLES: Pole[] = POLE_ORDER.filter((p) => !CRITICAL_POLES.includes(p))
// ---------------------------------------------------------------------
// Étiquettes libres sur les tâches
// ---------------------------------------------------------------------
/** Étiquettes suggérées (l'utilisateur peut en taper d'autres). */
export const TAG_SUGGESTIONS: string[] = [
'Gameplay',
'UI/UX',
'Level Design',
'Audio',
'Narration',
'Optimisation',
'Build',
'Marketing'
]
const TAG_TONES: Tone[] = ['violet', 'sky', 'emerald', 'amber', 'rose', 'cyan', 'fuchsia', 'lime']
/** Ton stable d'une étiquette, dérivé de son texte (même tag = même couleur). */
export function tagBadge(tag: string): string {
let h = 0
for (let i = 0; i < tag.length; i++) h = (h * 31 + tag.charCodeAt(i)) >>> 0
return TONE_BADGE[TAG_TONES[h % TAG_TONES.length]]
}
// ---------------------------------------------------------------------
// Checklists
// ---------------------------------------------------------------------
export interface ChecklistItem {
id: string
text: string
done: boolean
}
export interface Progress {
done: number
total: number
pct: number
}
export function checklistProgress(items: ChecklistItem[] | null | undefined): Progress {
const list = items ?? []
const total = list.length
const done = list.filter((i) => i.done).length
return { done, total, pct: total ? Math.round((done / total) * 100) : 0 }
}
// ---------------------------------------------------------------------
// Tâches
// ---------------------------------------------------------------------
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done'
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
todo: 'À faire',
in_progress: 'En cours',
review: 'En revue',
done: 'Terminé'
}
export const TASK_STATUS_ORDER: TaskStatus[] = ['todo', 'in_progress', 'review', 'done']
export const TASK_STATUS_TONE: Record<TaskStatus, Tone> = {
todo: 'slate',
in_progress: 'sky',
review: 'amber',
done: 'emerald'
}
export const taskStatusBadge = (s: TaskStatus): string => TONE_BADGE[TASK_STATUS_TONE[s]]
export const taskStatusDot = (s: TaskStatus): string => TONE_DOT[TASK_STATUS_TONE[s]]
export type Priority = 'low' | 'medium' | 'high' | 'urgent'
export const PRIORITY_LABELS: Record<Priority, string> = {
low: 'Basse',
medium: 'Moyenne',
high: 'Haute',
urgent: 'Urgente'
}
export const PRIORITY_ORDER: Priority[] = ['urgent', 'high', 'medium', 'low']
/** Poids pour trier : plus grand = plus prioritaire. */
export const PRIORITY_WEIGHT: Record<Priority, number> = {
urgent: 3,
high: 2,
medium: 1,
low: 0
}
export const PRIORITY_TONE: Record<Priority, Tone> = {
low: 'slate',
medium: 'sky',
high: 'amber',
urgent: 'rose'
}
export const priorityBadge = (p: Priority): string => TONE_BADGE[PRIORITY_TONE[p]]
export const priorityDot = (p: Priority): string => TONE_DOT[PRIORITY_TONE[p]]
// ---------------------------------------------------------------------
// Bugs
// ---------------------------------------------------------------------
// Un bug n'a QU'UN axe d'importance : la sévérité (l'impact constaté).
// L'ancien champ « priorité » faisait doublon et personne ne savait quoi
// y mettre — il n'est plus exposé dans l'interface.
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical'
export const SEVERITY_LABELS: Record<BugSeverity, string> = {
low: 'Mineur',
medium: 'Moyen',
high: 'Majeur',
critical: 'Bloquant'
}
/** Aide affichée sous le sélecteur : dit quoi choisir sans réfléchir. */
export const SEVERITY_HINTS: Record<BugSeverity, string> = {
low: 'Détail cosmétique, on peut vivre avec.',
medium: 'Gênant, mais il existe un contournement.',
high: 'Casse une fonctionnalité importante.',
critical: 'Le jeu plante ou devient injouable.'
}
export const SEVERITY_ORDER: BugSeverity[] = ['critical', 'high', 'medium', 'low']
export const SEVERITY_WEIGHT: Record<BugSeverity, number> = {
critical: 3,
high: 2,
medium: 1,
low: 0
}
export const SEVERITY_TONE: Record<BugSeverity, Tone> = {
low: 'slate',
medium: 'amber',
high: 'orange',
critical: 'rose'
}
export const severityBadge = (s: BugSeverity): string => TONE_BADGE[SEVERITY_TONE[s]]
export const severityDot = (s: BugSeverity): string => TONE_DOT[SEVERITY_TONE[s]]
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed'
export const BUG_STATUS_LABELS: Record<BugStatus, string> = {
open: 'Ouvert',
in_progress: 'En cours',
resolved: 'Résolu',
closed: 'Fermé'
}
export const BUG_STATUS_ORDER: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed']
/** Un bug « actif » demande encore du travail. */
export const ACTIVE_BUG_STATUSES: BugStatus[] = ['open', 'in_progress']
export const BUG_STATUS_TONE: Record<BugStatus, Tone> = {
open: 'rose',
in_progress: 'sky',
resolved: 'emerald',
closed: 'slate'
}
export const bugStatusBadge = (s: BugStatus): string => TONE_BADGE[BUG_STATUS_TONE[s]]
export const bugStatusDot = (s: BugStatus): string => TONE_DOT[BUG_STATUS_TONE[s]]
// ---------------------------------------------------------------------
// Entités
// ---------------------------------------------------------------------
export interface Profile {
id: string
full_name: string
roles: Pole[]
avatar_color: string
avatar_url: string | null
is_admin: boolean
onboarded: boolean
created_at: string
}
/** Pôles d'un membre (tableau toujours défini, même pour un profil partiel). */
export function memberPoles(p: { roles?: Pole[] | null }): Pole[] {
return p.roles ?? []
}
/** Libellé lisible des pôles d'un membre, pour les sous-titres. */
export function polesLabel(poles: Pole[]): string {
if (poles.length === 0) return 'Aucun pôle'
return poles.map((p) => POLE_LABELS[p]).join(' · ')
}
export interface Project {
id: string
name: string
description: string | null
created_by: string | null
created_at: string
}
export interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
priority: Priority
poles: Pole[]
assignee_ids: string[]
checklist: ChecklistItem[]
tags: string[]
due_date: string | null
position: number
created_by: string | null
created_at: string
updated_at: string
}
export interface Bug {
id: string
project_id: string
title: string
description: string | null
severity: BugSeverity
status: BugStatus
poles: Pole[]
assignee_ids: string[]
reporter_id: string | null
created_at: string
updated_at: string
}
/** Une entrée de temps porte sur une tâche, OU un bug, OU rien (activité libre). */
export type TimeTarget = 'task' | 'bug' | 'other'
export const TIME_TARGET_LABELS: Record<TimeTarget, string> = {
task: 'Tâche',
bug: 'Bug',
other: 'Autre'
}
export interface TimeLog {
id: string
project_id: string | null
task_id: string | null
bug_id: string | null
label: string | null // libellé quand ni tâche ni bug
user_id: string | null
minutes: number
note: string | null
logged_at: string
created_at: string
}
export function timeTargetOf(l: Pick<TimeLog, 'task_id' | 'bug_id'>): TimeTarget {
if (l.task_id) return 'task'
if (l.bug_id) return 'bug'
return 'other'
}
// ---------------------------------------------------------------------
// Normalisation des lignes venues de la base
//
// Les types promettent des tableaux ; la base, elle, peut renvoyer un
// champ absent (colonne pas encore migrée) ou NULL (donnée héritée). Un
// seul `undefined` faisait planter tout l'écran sur un `.map()`.
// On tient la promesse ICI, une fois au chargement, plutôt que de semer
// des `?? []` défensifs dans chaque composant.
// ---------------------------------------------------------------------
export function normalizeTask(row: Task): Task {
return {
...row,
poles: row.poles ?? [],
assignee_ids: row.assignee_ids ?? [],
checklist: row.checklist ?? [],
tags: row.tags ?? []
}
}
export function normalizeBug(row: Bug): Bug {
return {
...row,
poles: row.poles ?? [],
assignee_ids: row.assignee_ids ?? []
}
}
export const normalizeTasks = (list: Task[]): Task[] => list.map(normalizeTask)
export const normalizeBugs = (list: Bug[]): Bug[] => list.map(normalizeBug)
// ---------------------------------------------------------------------
// Documentation
// ---------------------------------------------------------------------
/**
* Une fiche de documentation : GDD, conventions, notes de réunion
*
* `content` est du Markdown, toujours. Un fichier Word importé est
* converti à l'import (voir lib/markdown.ts) : une seule représentation
* en base, donc des fiches cherchables et modifiables dans l'app.
*/
export interface Doc {
id: string
project_id: string
title: string
content: string
tags: string[]
/** Nom du fichier d'origine si la fiche vient d'un import. */
source_name: string | null
created_by: string | null
updated_by: string | null
created_at: string
updated_at: string
}
export function normalizeDoc(row: Doc): Doc {
return { ...row, tags: row.tags ?? [], content: row.content ?? '' }
}
export const normalizeDocs = (list: Doc[]): Doc[] => list.map(normalizeDoc)
// ---------------------------------------------------------------------
// Commentaires & pièces jointes (partagés entre tâches et bugs)
// ---------------------------------------------------------------------
/** Ce à quoi un commentaire / un fichier peut être rattaché. */
export type EntityKind = 'task' | 'bug'
export const ENTITY_LABELS: Record<EntityKind, string> = {
task: 'tâche',
bug: 'bug'
}
export interface Comment {
id: string
project_id: string
entity_kind: EntityKind
entity_id: string
author_id: string | null
body: string
created_at: string
updated_at: string
}
export interface Attachment {
id: string
project_id: string
entity_kind: EntityKind
entity_id: string
uploader_id: string | null
name: string
path: string
size: number
mime: string | null
created_at: string
}
export function isImage(a: Attachment): boolean {
return (a.mime ?? '').startsWith('image/')
}
// ---------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------
export type NotificationKind = 'task' | 'bug' | 'comment'
export interface AppNotification {
id: string
user_id: string
project_id: string | null
kind: NotificationKind
/** Sur quoi pointe la notification (une notif de commentaire ouvre la
* tâche ou le bug commenté). */
entity_kind: EntityKind
entity_id: string | null
title: string
body: string | null
pole: Pole | null
read: boolean
created_at: string
}
// ---------------------------------------------------------------------
// Formatage
// ---------------------------------------------------------------------
/** 90 → « 1 h 30 » · 45 → « 45 min » · 0 → « 0 min ». */
export function formatMinutes(min: number): string {
if (!min) return '0 min'
const sign = min < 0 ? '-' : ''
const abs = Math.abs(min)
const h = Math.floor(abs / 60)
const m = abs % 60
if (h === 0) return `${sign}${m} min`
if (m === 0) return `${sign}${h} h`
return `${sign}${h} h ${String(m).padStart(2, '0')}`
}
/** 1536 → « 1,5 Mo ». Pour la taille des pièces jointes. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`
}
/** Accord singulier/pluriel : plural(1,'membre') → « 1 membre ». */
export function plural(n: number, singular: string, plural?: string): string {
return `${n} ${n > 1 ? (plural ?? `${singular}s`) : singular}`
}
+87
View File
@@ -0,0 +1,87 @@
// =====================================================================
// Mises à jour automatiques.
//
// L'application va chercher elle-même la dernière version publiée dans
// le bucket public « releases » de Supabase. Plus besoin d'envoyer un
// .exe à chaque correctif : le membre clique sur « Installer », l'app
// se met à jour et redémarre.
//
// Le paquet est signé : Tauri refuse toute mise à jour qui ne vient pas
// de la clé privée du projet. Un fichier déposé par erreur — ou par
// quelqu'un d'autre — sur le bucket ne sera jamais installé.
// =====================================================================
/** Tourne-t-on dans l'application desktop (et pas dans un navigateur) ? */
export function isDesktop(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
}
export interface AvailableUpdate {
version: string
/** Notes de version saisies au moment de la publication. */
notes: string | null
date: string | null
/** Lance le téléchargement puis l'installation. `onProgress` : 0 → 100. */
install: (onProgress?: (pct: number) => void) => Promise<void>
}
/**
* Cherche une mise à jour. Renvoie `null` s'il n'y en a pas, si on n'est
* pas dans l'application desktop, ou si le serveur est injoignable
* une panne de mise à jour ne doit jamais gêner le travail en cours.
*/
export async function checkForUpdate(): Promise<AvailableUpdate | null> {
if (!isDesktop()) return null
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return null
return {
version: update.version,
notes: update.body ?? null,
date: update.date ?? null,
install: async (onProgress) => {
let downloaded = 0
let total = 0
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
total = event.data.contentLength ?? 0
onProgress?.(0)
break
case 'Progress':
downloaded += event.data.chunkLength
// Sans taille annoncée, on ne peut pas calculer de pourcentage :
// on laisse la barre en indéterminé plutôt que d'inventer.
if (total > 0) onProgress?.(Math.min(99, Math.round((downloaded / total) * 100)))
break
case 'Finished':
onProgress?.(100)
break
}
})
const { relaunch } = await import('@tauri-apps/plugin-process')
await relaunch()
}
}
} catch (e) {
// Hors ligne, bucket vide, manifeste malformé… : on reste silencieux.
console.warn('Vérification de mise à jour impossible :', e)
return null
}
}
/** Version actuellement installée, pour l'afficher dans l'interface. */
export async function currentVersion(): Promise<string | null> {
if (!isDesktop()) return null
try {
const { getVersion } = await import('@tauri-apps/api/app')
return await getVersion()
} catch {
return null
}
}
-18
View File
@@ -1,18 +0,0 @@
import { useEffect, useState } from 'react'
import { supa } from './supabase'
import { Profile } from './types'
/** Charge la liste des membres (profils) pour les listes d'assignation. */
export function useMembers(): { members: Profile[]; byId: (id: string | null) => Profile | undefined } {
const [members, setMembers] = useState<Profile[]>([])
useEffect(() => {
supa()
.from('profiles')
.select('*')
.order('full_name')
.then(({ data }) => setMembers((data ?? []) as Profile[]))
}, [])
const byId = (id: string | null): Profile | undefined =>
id ? members.find((m) => m.id === id) : undefined
return { members, byId }
}
+21 -17
View File
@@ -1,17 +1,21 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { FeedbackProvider } from './lib/feedback'
import { applyTheme, getTheme } from './lib/theme'
import './index.css'
// Applique le thème enregistré avant le premier rendu (évite le flash).
applyTheme(getTheme())
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<FeedbackProvider>
<App />
</FeedbackProvider>
</React.StrictMode>
)
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { FeedbackProvider } from './lib/feedback'
import { applyTheme, getTheme } from './lib/theme'
import './index.css'
// Applique le thème AVANT le premier rendu (évite le flash de couleur).
applyTheme(getTheme())
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<FeedbackProvider>
<App />
</FeedbackProvider>
</React.StrictMode>
)
// L'affichage de la fenêtre est piloté par App.tsx : il doit attendre de
// savoir si l'on démarre en mode installeur (fenêtre sans bordure, plus
// petite) ou en mode application.
+316 -327
View File
@@ -1,197 +1,231 @@
import { useEffect, useRef, useState } from 'react'
import { ShieldCheck, UserPlus, Trash2, Loader2, Crown, Mail, Pencil, Camera } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { Crown, Mail, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { MemberRole, Profile, memberPoles } from '../lib/types'
import { useQuery } from '../lib/db'
import { Pole, POLE_ORDER, POLE_LABELS, memberPoles, plural } from '../lib/types'
import {
AdminUser,
adminListUsers,
adminCreateUser,
adminDeleteUser,
adminListUsers,
adminUpdateUser
} from '../lib/admin'
import { uploadAvatar } from '../lib/avatar'
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
import PageHeader from '../components/PageHeader'
import { PageActions, usePageSubtitle } from '../components/Layout'
import { Avatar, AvatarUploader, PoleChips, PoleList } from '../components/people'
import {
AsyncContent,
Button,
Checkbox,
EmptyState,
Field,
IconButton,
Input,
SearchInput,
SkeletonList
} from '../components/ui'
import { Modal, ModalActions } from '../components/Modal'
export default function Admin(): JSX.Element {
const { profile } = useApp()
const { profile, refreshMembers } = useApp()
const { toast, confirm } = useFeedback()
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const [q, setQ] = useState('')
const load = async (): Promise<void> => {
setLoading(true)
setError('')
try {
setUsers(await adminListUsers())
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const load = useCallback(() => adminListUsers(), [])
const users = useQuery<AdminUser[]>(load, [])
const toggleRole = async (u: AdminUser, pole: MemberRole): Promise<void> => {
const current = memberPoles(u)
const roles = current.includes(pole)
? current.filter((r) => r !== pole)
: [...current, pole]
setUsers((list) =>
list.map((x) => (x.id === u.id ? { ...x, roles, role: roles[0] ?? 'other' } : x))
const shown = useMemo(() => {
const needle = q.trim().toLowerCase()
if (!needle) return users.data
return users.data.filter(
(u) => u.full_name.toLowerCase().includes(needle) || u.email.toLowerCase().includes(needle)
)
try {
await adminUpdateUser(u.id, { roles })
} catch (e) {
setError((e as Error).message)
load()
}
}, [users.data, q])
const admins = users.data.filter((u) => u.is_admin).length
const refresh = async (): Promise<void> => {
await users.reload()
await refreshMembers()
}
const toggleAdmin = async (u: AdminUser): Promise<void> => {
const ok = await confirm({
title: u.is_admin
? `Retirer les droits admin à ${u.full_name} ?`
: `Donner les droits admin à ${u.full_name} ?`,
message: u.is_admin
? "Cette personne ne pourra plus créer ni supprimer de comptes."
: 'Cette personne pourra créer, modifier et supprimer les comptes de toute l’équipe.',
confirmLabel: u.is_admin ? 'Retirer' : 'Donner les droits',
danger: u.is_admin
})
if (!ok) return
try {
await adminUpdateUser(u.id, { is_admin: !u.is_admin })
setUsers((list) => list.map((x) => (x.id === u.id ? { ...x, is_admin: !u.is_admin } : x)))
await refresh()
toast(u.is_admin ? 'Droits admin retirés.' : 'Droits admin accordés.', 'success')
} catch (e) {
setError((e as Error).message)
toast((e as Error).message, 'error')
}
}
const remove = async (u: AdminUser): Promise<void> => {
const ok = await confirm({
title: 'Supprimer ce compte ?',
message: `${u.full_name} (${u.email}) sera définitivement supprimé.`,
message: `${u.full_name} (${u.email}) sera définitivement supprimé. Son travail (tâches, temps, messages) reste dans les projets, mais n'est plus rattaché à personne.`,
danger: true,
confirmLabel: 'Supprimer'
confirmLabel: 'Supprimer le compte'
})
if (!ok) return
try {
await adminDeleteUser(u.id)
setUsers((list) => list.filter((x) => x.id !== u.id))
await refresh()
toast('Compte supprimé.', 'success')
} catch (e) {
setError((e as Error).message)
toast((e as Error).message, 'error')
}
}
usePageSubtitle(`${plural(users.data.length, 'compte')} · ${plural(admins, 'administrateur')}`)
return (
<div className="flex h-full flex-col">
<PageHeader title="Administration" subtitle={`${users.length} compte(s)`}>
<button className="btn-primary" onClick={() => setCreating(true)}>
<UserPlus size={16} /> Créer un compte
</button>
</PageHeader>
<PageActions>
<SearchInput
className="hidden w-52 md:block"
value={q}
onChange={setQ}
placeholder="Nom ou email…"
/>
<Button variant="primary" icon={<UserPlus size={16} />} onClick={() => setCreating(true)}>
Créer un compte
</Button>
</PageActions>
<div className="flex-1 overflow-y-auto p-6">
{error && (
<div className="mb-4 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
)}
{loading ? (
<div className="flex justify-center py-16 text-muted">
<Loader2 className="animate-spin" size={28} />
</div>
) : users.length === 0 ? (
<EmptyState icon={<ShieldCheck size={40} />} text="Aucun compte." />
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-panel2 text-left text-xs uppercase text-subtle">
<tr>
<th className="px-4 py-3">Membre</th>
<th className="px-4 py-3">Pôles</th>
<th className="px-4 py-3">Admin</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isMe = u.id === profile?.id
return (
<tr key={u.id} className="border-t border-border">
<td className="px-4 py-3">
<div className="font-medium text-ink">
{u.full_name}
{isMe && <span className="ml-1 text-xs text-accent2">(moi)</span>}
</div>
<div className="flex items-center gap-1 text-xs text-subtle">
<Mail size={11} /> {u.email}
</div>
</td>
<td className="px-4 py-3">
<PoleChips selected={memberPoles(u)} onToggle={(pole) => toggleRole(u, pole)} />
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleAdmin(u)}
disabled={isMe}
title={isMe ? 'Tu ne peux pas retirer ton propre statut' : 'Basculer admin'}
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs ${
u.is_admin
? 'bg-amber-100 text-amber-700'
: 'bg-panel2 text-muted hover:text-ink'
} ${isMe ? 'cursor-not-allowed opacity-60' : ''}`}
>
<Crown size={12} /> {u.is_admin ? 'Admin' : 'Membre'}
</button>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-3">
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<AsyncContent
loading={users.loading}
error={users.error}
onRetry={users.reload}
skeleton={<SkeletonList rows={5} />}
>
{shown.length === 0 ? (
<EmptyState
icon={<ShieldCheck size={40} />}
title={users.data.length === 0 ? 'Aucun compte' : 'Aucun résultat'}
text={
users.data.length === 0
? "Crée le premier compte pour inviter quelqu'un dans l'équipe."
: undefined
}
/>
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-panel2">
<tr className="text-left text-xs font-medium uppercase tracking-wide text-subtle">
<th scope="col" className="px-4 py-2.5">Membre</th>
<th scope="col" className="px-4 py-2.5">Pôles</th>
<th scope="col" className="px-4 py-2.5">Rôle</th>
<th scope="col" className="px-4 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody>
{shown.map((u) => {
const isMe = u.id === profile?.id
return (
<tr key={u.id} className="border-t border-border hover:bg-hover">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5">
<Avatar profile={u} size={32} />
<div className="min-w-0">
<div className="truncate font-medium text-ink">
{u.full_name || '—'}
{isMe && <span className="ml-1.5 text-xs text-accent">(moi)</span>}
</div>
<div className="flex items-center gap-1 truncate text-xs text-subtle">
<Mail size={11} className="shrink-0" /> {u.email}
</div>
</div>
</div>
</td>
{/* Affichage seul : l'édition se fait dans la fiche.
Avant, 8 pastilles cliquables PAR LIGNE rendaient
le tableau illisible. */}
<td className="max-w-xs px-4 py-2.5">
<PoleList poles={memberPoles(u)} />
</td>
<td className="px-4 py-2.5">
<button
onClick={() => setEditing(u)}
title="Modifier le compte"
className="text-muted hover:text-accent"
>
<Pencil size={16} />
</button>
<button
onClick={() => remove(u)}
type="button"
onClick={() => toggleAdmin(u)}
disabled={isMe}
title={isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer'}
className={`text-muted hover:text-rose-600 ${
isMe ? 'cursor-not-allowed opacity-40' : ''
}`}
title={
isMe
? 'Tu ne peux pas retirer ton propre statut'
: u.is_admin
? 'Retirer les droits admin'
: 'Donner les droits admin'
}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
u.is_admin
? 'bg-amber-500/12 text-amber-700 dark:text-amber-300'
: 'bg-panel2 text-muted hover:text-ink'
} ${isMe ? 'cursor-not-allowed opacity-60' : ''}`}
>
<Trash2 size={16} />
<Crown size={12} /> {u.is_admin ? 'Administrateur' : 'Membre'}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
<IconButton
size="sm"
label={`Modifier le compte de ${u.full_name}`}
icon={<Pencil size={16} />}
onClick={() => setEditing(u)}
/>
<IconButton
size="sm"
tone="danger"
disabled={isMe}
label={
isMe ? 'Impossible de supprimer son propre compte' : 'Supprimer le compte'
}
icon={<Trash2 size={16} />}
onClick={() => remove(u)}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</AsyncContent>
</div>
{creating && (
<CreateUserModal
<UserModal
onClose={() => setCreating(false)}
onCreated={async () => {
onSaved={async () => {
setCreating(false)
await load()
toast('Compte créé.', 'success')
await refresh()
}}
/>
)}
{editing && (
<EditUserModal
<UserModal
user={editing}
onClose={() => setEditing(null)}
onSaved={async () => {
setEditing(null)
await load()
toast('Compte mis à jour.', 'success')
await refresh()
}}
/>
)}
@@ -199,228 +233,183 @@ export default function Admin(): JSX.Element {
)
}
function CreateUserModal({
onClose,
onCreated
}: {
onClose: () => void
onCreated: () => Promise<void>
}): JSX.Element {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [fullName, setFullName] = useState('')
const [roles, setRoles] = useState<MemberRole[]>(['programmer'])
const [isAdmin, setIsAdmin] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
// =====================================================================
// Création / modification d'un compte — une seule modale pour les deux.
// =====================================================================
const toggleRole = (pole: MemberRole): void =>
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
const submit = async (): Promise<void> => {
setError('')
if (!email.trim() || password.length < 6) {
setError('Email valide et mot de passe (6+ caractères) requis.')
return
}
setBusy(true)
try {
await adminCreateUser({
email: email.trim(),
password,
full_name: fullName.trim(),
roles,
is_admin: isAdmin
})
await onCreated()
} catch (e) {
setError((e as Error).message)
setBusy(false)
}
}
return (
<Modal title="Créer un compte" onClose={onClose}>
<label className="label">Nom complet</label>
<input
className="input mb-3"
placeholder="Alex Martin"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
placeholder="alex@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Mot de passe provisoire</label>
<input
className="input mb-3"
type="text"
placeholder="au moins 6 caractères"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label className="label">Pôles (un ou plusieurs)</label>
<div className="mb-3">
<PoleChips selected={roles} onToggle={toggleRole} />
</div>
<label className="mb-4 flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
className="h-4 w-4 accent-accent"
/>
Donner les droits administrateur
</label>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Créer le compte
</button>
<p className="mt-3 text-center text-xs text-subtle">
Communique l'email et le mot de passe provisoire au membre.
</p>
</Modal>
)
}
function EditUserModal({
function UserModal({
user,
onClose,
onSaved
}: {
user: AdminUser
user?: AdminUser
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const { toast } = useFeedback()
const [fullName, setFullName] = useState(user.full_name)
const [email, setEmail] = useState(user.email)
const editMode = Boolean(user)
const [fullName, setFullName] = useState(user?.full_name ?? '')
const [email, setEmail] = useState(user?.email ?? '')
const [password, setPassword] = useState('')
const [roles, setRoles] = useState<MemberRole[]>(memberPoles(user))
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url ?? null)
const [uploading, setUploading] = useState(false)
const [roles, setRoles] = useState<Pole[]>(user ? memberPoles(user) : ['programmer'])
const [isAdmin, setIsAdmin] = useState(user?.is_admin ?? false)
const [avatarUrl, setAvatarUrl] = useState<string | null>(user?.avatar_url ?? null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const toggleRole = (pole: MemberRole): void =>
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
const toggleRole = (p: Pole): void =>
setRoles((cur) => (cur.includes(p) ? cur.filter((r) => r !== p) : [...cur, p]))
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast('Choisis un fichier image.', 'error')
return
}
setUploading(true)
try {
setAvatarUrl(await uploadAvatar(file, user.id))
} catch (err) {
toast((err as Error).message, 'error')
} finally {
setUploading(false)
}
/** Mot de passe provisoire lisible, à communiquer au nouveau membre. */
const suggestPassword = (): void => {
const words = ['pixel', 'sprite', 'shader', 'niveau', 'combo', 'boss', 'quete', 'arcade']
const w = words[Math.floor(Math.random() * words.length)]
setPassword(`${w}-${Math.floor(1000 + Math.random() * 9000)}`)
}
const submit = async (): Promise<void> => {
setError('')
if (!fullName.trim()) {
setError('Le nom ne peut pas être vide.')
return
if (!fullName.trim()) return toast('Le nom ne peut pas être vide.', 'error')
if (!email.trim() || !email.includes('@')) return toast('Adresse email invalide.', 'error')
if (!editMode && password.length < 6) {
return toast('Le mot de passe provisoire doit faire au moins 6 caractères.', 'error')
}
if (!email.trim()) {
setError('Un email valide est requis.')
return
}
if (password && password.length < 6) {
setError('Le mot de passe doit faire au moins 6 caractères.')
return
if (editMode && password && password.length < 6) {
return toast('Le nouveau mot de passe doit faire au moins 6 caractères.', 'error')
}
setBusy(true)
try {
const patch: Parameters<typeof adminUpdateUser>[1] = { roles }
if (fullName.trim() !== user.full_name) patch.full_name = fullName.trim()
if (email.trim() !== user.email) patch.email = email.trim()
if (password) patch.password = password
if ((avatarUrl ?? null) !== (user.avatar_url ?? null)) patch.avatar_url = avatarUrl
await adminUpdateUser(user.id, patch)
if (editMode && user) {
const patch: Parameters<typeof adminUpdateUser>[1] = { roles }
if (fullName.trim() !== user.full_name) patch.full_name = fullName.trim()
if (email.trim() !== user.email) patch.email = email.trim()
if (password) patch.password = password
if ((avatarUrl ?? null) !== (user.avatar_url ?? null)) patch.avatar_url = avatarUrl
await adminUpdateUser(user.id, patch)
toast('Compte mis à jour.', 'success')
} else {
await adminCreateUser({
email: email.trim(),
password,
full_name: fullName.trim(),
roles,
is_admin: isAdmin
})
toast('Compte créé.', 'success')
}
await onSaved()
} catch (e) {
setError((e as Error).message)
toast((e as Error).message, 'error')
setBusy(false)
}
}
return (
<Modal title="Modifier le compte" onClose={onClose}>
<label className="label">Photo de profil</label>
<div className="mb-3 flex items-center gap-3">
<div className="relative">
<Avatar
profile={{ full_name: fullName || '?', avatar_color: user.avatar_color, avatar_url: avatarUrl } as Profile}
size={56}
/>
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Loader2 className="animate-spin text-white" size={18} />
<Modal
title={editMode ? 'Modifier le compte' : 'Créer un compte'}
subtitle={editMode ? user?.email : "Le membre pourra se connecter tout de suite."}
onClose={onClose}
footer={
<ModalActions onCancel={onClose}>
<Button variant="primary" loading={busy} onClick={submit}>
{editMode ? 'Enregistrer' : 'Créer le compte'}
</Button>
</ModalActions>
}
>
<div className="space-y-3">
{editMode && user && (
<Field label="Photo de profil">
<AvatarUploader
userId={user.id}
name={fullName}
color={user.avatar_color}
avatarUrl={avatarUrl}
onChange={setAvatarUrl}
/>
</Field>
)}
<Field label="Nom complet" required>
{({ id }) => (
<Input
id={id}
autoFocus={!editMode}
placeholder="Alex Martin"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
)}
</Field>
<Field label="Email" required>
{({ id }) => (
<Input
id={id}
type="email"
autoComplete="off"
placeholder="alex@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
)}
</Field>
<Field
label={editMode ? 'Nouveau mot de passe' : 'Mot de passe provisoire'}
required={!editMode}
hint={
editMode
? 'Laisse vide pour ne pas le changer.'
: 'À communiquer au membre : il pourra le modifier depuis « Mon profil ».'
}
>
{({ id }) => (
<div className="flex gap-2">
<Input
id={id}
type="text"
autoComplete="new-password"
placeholder={editMode ? 'inchangé' : 'au moins 6 caractères'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button onClick={suggestPassword}>Générer</Button>
</div>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
</button>
{avatarUrl && (
<button
className="text-xs text-muted hover:text-rose-600"
onClick={() => setAvatarUrl(null)}
>
Retirer
</button>
</Field>
<Field
label="Pôles"
hint="Détermine les notifications reçues et les filtres. Un membre peut en avoir plusieurs."
>
<PoleChips selected={roles} onToggle={toggleRole} choices={POLE_ORDER} />
</Field>
{!editMode && (
<Checkbox
checked={isAdmin}
onChange={setIsAdmin}
label={
<span>
Donner les droits administrateur
<span className="block text-xs text-subtle">
Peut créer, modifier et supprimer les comptes de l'équipe.
</span>
</span>
}
/>
)}
{roles.length === 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Sans pôle, ce membre ne recevra aucune notification d'équipe.
</p>
)}
<p className="text-xs text-subtle">
Pôles disponibles : {POLE_ORDER.map((p) => POLE_LABELS[p]).join(', ')}.
</p>
</div>
<label className="label">Nom complet</label>
<input
className="input mb-3"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Nouveau mot de passe</label>
<input
className="input mb-3"
type="text"
placeholder="laisser vide pour ne pas changer"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<label className="label">Pôles (un ou plusieurs)</label>
<div className="mb-4">
<PoleChips selected={roles} onToggle={toggleRole} />
</div>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Enregistrer les modifications
</button>
</Modal>
)
}
+559 -317
View File
@@ -1,317 +1,559 @@
import { useEffect, useState, useCallback } from 'react'
import { Plus, Bug as BugIcon, Trash2 } from 'lucide-react'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useMembers } from '../lib/useMembers'
import { notifyPole } from '../lib/notify'
import {
Bug,
BugSeverity,
BugStatus,
Priority,
Pole,
SEVERITY_LABELS,
BUG_STATUS_LABELS,
PRIORITY_LABELS,
POLE_LABELS,
poleColor
} from '../lib/types'
import { Avatar, Modal, Select, EmptyState, PriorityBadge, PoleChips } from '../components/ui'
import PageHeader from '../components/PageHeader'
const SEVERITY_STYLES: Record<BugSeverity, string> = {
low: 'bg-slate-100 text-slate-600',
medium: 'bg-amber-100 text-amber-700',
high: 'bg-orange-100 text-orange-700',
critical: 'bg-rose-100 text-rose-700'
}
const STATUS_STYLES: Record<BugStatus, string> = {
open: 'bg-rose-100 text-rose-700',
in_progress: 'bg-sky-100 text-sky-700',
resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-slate-100 text-slate-500'
}
export default function Bugs(): JSX.Element {
const { currentProject, profile, focusEntity, setFocusEntity } = useApp()
const { members, byId } = useMembers()
const [bugs, setBugs] = useState<Bug[]>([])
const [filter, setFilter] = useState<BugStatus | 'all'>('all')
const [editing, setEditing] = useState<Bug | null>(null)
const [creating, setCreating] = useState(false)
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const { data } = await supa()
.from('bugs')
.select('*')
.eq('project_id', projectId)
.order('created_at', { ascending: false })
setBugs((data ?? []) as Bug[])
}, [projectId])
useEffect(() => {
load()
}, [load])
// Ouverture d'un bug demandé par une notification.
useEffect(() => {
if (focusEntity?.kind !== 'bug') return
const b = bugs.find((x) => x.id === focusEntity.id)
if (b) {
setEditing(b)
setFocusEntity(null)
}
}, [focusEntity, bugs, setFocusEntity])
const shown = bugs.filter((b) => filter === 'all' || b.status === filter)
const openCount = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
return (
<div className="flex h-full flex-col">
<PageHeader title="Bugs" subtitle={`${openCount} bug(s) actif(s)`}>
<Select
className="!w-auto"
value={filter}
onChange={(e) => setFilter(e.target.value as BugStatus | 'all')}
>
<option value="all">Tous les statuts</option>
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
<button className="btn-primary" onClick={() => setCreating(true)}>
<Plus size={16} /> Signaler un bug
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{shown.length === 0 ? (
<EmptyState icon={<BugIcon size={40} />} text="Aucun bug. 🎉" />
) : (
<table className="w-full border-separate border-spacing-y-2 text-sm">
<thead className="text-left text-xs uppercase text-subtle">
<tr>
<th className="px-3">Bug</th>
<th className="px-3">Pôle</th>
<th className="px-3">Sévérité</th>
<th className="px-3">Priorité</th>
<th className="px-3">Statut</th>
<th className="px-3">Assigné</th>
</tr>
</thead>
<tbody>
{shown.map((b) => (
<tr
key={b.id}
className="cursor-pointer border border-border bg-panel shadow-sm hover:bg-panel2"
onClick={() => setEditing(b)}
>
<td className="rounded-l-lg px-3 py-3 font-medium text-ink">{b.title}</td>
<td className="px-3">
{b.pole ? (
<span className={`badge ${poleColor(b.pole)}`}>{POLE_LABELS[b.pole]}</span>
) : (
<span className="text-xs text-subtle"></span>
)}
</td>
<td className="px-3">
<span className={`badge ${SEVERITY_STYLES[b.severity]}`}>
{SEVERITY_LABELS[b.severity]}
</span>
</td>
<td className="px-3">
<PriorityBadge priority={b.priority} />
</td>
<td className="px-3">
<span className={`badge ${STATUS_STYLES[b.status]}`}>
{BUG_STATUS_LABELS[b.status]}
</span>
</td>
<td className="rounded-r-lg px-3">
<Avatar profile={byId(b.assignee_id)} size={26} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{(creating || editing) && (
<BugModal
bug={editing}
projectId={projectId!}
reporterId={profile?.id ?? null}
members={members}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={load}
/>
)}
</div>
)
}
function BugModal({
bug,
projectId,
reporterId,
members,
onClose,
onSaved
}: {
bug: Bug | null
projectId: string
reporterId: string | null
members: ReturnType<typeof useMembers>['members']
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const [title, setTitle] = useState(bug?.title ?? '')
const [description, setDescription] = useState(bug?.description ?? '')
const [severity, setSeverity] = useState<BugSeverity>(bug?.severity ?? 'medium')
const [status, setStatus] = useState<BugStatus>(bug?.status ?? 'open')
const [priority, setPriority] = useState<Priority>(bug?.priority ?? 'medium')
const [pole, setPole] = useState<Pole | ''>(bug?.pole ?? '')
const [assignee, setAssignee] = useState(bug?.assignee_id ?? '')
const [busy, setBusy] = useState(false)
const save = async (): Promise<void> => {
if (!title.trim()) return
setBusy(true)
const payload = {
project_id: projectId,
title: title.trim(),
description: description.trim() || null,
severity,
status,
priority,
pole: pole || null,
assignee_id: assignee || null
}
const poleChanged = pole && (!bug || bug.pole !== pole)
let entityId = bug?.id ?? null
if (bug) {
await supa().from('bugs').update(payload).eq('id', bug.id)
} else {
const { data } = await supa()
.from('bugs')
.insert({ ...payload, reporter_id: reporterId })
.select('id')
.single()
entityId = (data as { id: string } | null)?.id ?? null
}
if (poleChanged && pole && entityId) {
await notifyPole({
projectId,
pole,
kind: 'bug',
title: 'Nouveau bug',
body: title.trim(),
entityId,
actorId: reporterId,
members
})
}
await onSaved()
setBusy(false)
onClose()
}
const remove = async (): Promise<void> => {
if (!bug || !confirm('Supprimer ce bug ?')) return
await supa().from('bugs').delete().eq('id', bug.id)
await onSaved()
onClose()
}
return (
<Modal title={bug ? 'Modifier le bug' : 'Signaler un bug'} onClose={onClose} wide>
<label className="label">Titre</label>
<input
className="input mb-3"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Le joueur traverse le mur en sautant"
/>
<label className="label">Description / étapes pour reproduire</label>
<textarea
className="input mb-3 h-24 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Sévérité</label>
<Select value={severity} onChange={(e) => setSeverity(e.target.value as BugSeverity)}>
{Object.entries(SEVERITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Priorité</label>
<Select value={priority} onChange={(e) => setPriority(e.target.value as Priority)}>
{Object.entries(PRIORITY_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Statut</label>
<Select value={status} onChange={(e) => setStatus(e.target.value as BugStatus)}>
{Object.entries(BUG_STATUS_LABELS).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</Select>
</div>
<div>
<label className="label">Assigné à</label>
<Select value={assignee} onChange={(e) => setAssignee(e.target.value)}>
<option value="">Personne</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.full_name}
</option>
))}
</Select>
</div>
</div>
<label className="label mt-3">Pôle</label>
<PoleChips
selected={pole ? [pole] : []}
onToggle={(p) => setPole((cur) => (cur === p ? '' : p))}
/>
<div className="mt-5 flex items-center justify-between">
{bug ? (
<button className="btn text-rose-600 hover:bg-rose-500/10" onClick={remove}>
<Trash2 size={16} /> Supprimer
</button>
) : (
<span />
)}
<button className="btn-primary" onClick={save} disabled={busy || !title.trim()}>
{bug ? 'Enregistrer' : 'Créer'}
</button>
</div>
</Modal>
)
}
import { useCallback, useEffect, useMemo, useState } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import {
ArrowDown,
ArrowUp,
Bug as BugIcon,
CheckCircle2,
Clock,
Plus,
SlidersHorizontal,
UserCheck,
X
} from 'lucide-react'
import { supa } from '../lib/supabase'
import { rows, useQuery, useRealtime } from '../lib/db'
import { NONE, matchesAny, matchesOne, readList, readText } from '../lib/filters'
import { useApp } from '../lib/AppContext'
import { usePageSubtitle, PageActions } from '../components/Layout'
import {
ACTIVE_BUG_STATUSES,
Bug,
BUG_STATUS_LABELS,
BUG_STATUS_ORDER,
BUG_STATUS_TONE,
POLE_LABELS,
POLE_ORDER,
SEVERITY_LABELS,
SEVERITY_ORDER,
SEVERITY_WEIGHT,
TimeLog,
bugStatusBadge,
formatMinutes,
memberPoles,
normalizeBugs,
plural,
poleDot,
polesLabel,
severityBadge,
severityDot
} from '../lib/types'
import { TONE_DOT } from '../lib/tones'
import {
AsyncContent,
Badge,
Button,
EmptyState,
FilterChip,
IconButton,
SearchInput,
SkeletonList
} from '../components/ui'
import { MultiOption, MultiSelect } from '../components/MultiSelect'
import { Avatar, AvatarStack, PoleList } from '../components/people'
import BugModal from '../components/BugModal'
type SortKey = 'severity' | 'status' | 'created_at' | 'title'
const FILTER_KEY = 'gamedev-tracker-bug-filters'
/** Chaque filtre accepte plusieurs valeurs (cf. Kanban). */
interface Filters {
q: string
/** Vide = bugs actifs uniquement (le réglage par défaut, le plus utile). */
statuses: string[]
severities: string[]
poles: string[]
assignees: string[]
sort: SortKey
desc: boolean
}
const EMPTY: Filters = {
q: '',
statuses: [],
severities: [],
poles: [],
assignees: [],
sort: 'severity',
desc: true
}
const SORTS: SortKey[] = ['severity', 'status', 'created_at', 'title']
function loadFilters(): Filters {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_KEY) ?? '{}') as Record<string, unknown>
const legacyStatus = raw.status === 'active' ? undefined : raw.status
return {
q: readText(raw.q),
statuses: readList(raw.statuses, legacyStatus),
severities: readList(raw.severities, raw.severity),
poles: readList(raw.poles, raw.pole),
assignees: readList(raw.assignees, raw.assignee),
sort: SORTS.includes(raw.sort as SortKey) ? (raw.sort as SortKey) : 'severity',
desc: raw.desc !== false
}
} catch {
return EMPTY
}
}
function countFilters(f: Filters): number {
return f.statuses.length + f.severities.length + f.poles.length + f.assignees.length
}
export default function Bugs(): JSX.Element {
const { currentProject, profile, members, memberById, focusEntity, setFocusEntity } = useApp()
const projectId = currentProject?.id
const [editing, setEditing] = useState<Bug | null>(null)
const [creating, setCreating] = useState(false)
const [filters, setFilters] = useState<Filters>(loadFilters)
const [showFilters, setShowFilters] = useState(() => countFilters(loadFilters()) > 0)
useEffect(() => {
localStorage.setItem(FILTER_KEY, JSON.stringify(filters))
}, [filters])
// ------------------------------------------------------------------ data
const fetchAll = useCallback(async () => {
if (!projectId) return { bugs: [] as Bug[], times: {} as Record<string, number> }
const list = await rows<Bug>(
supa().from('bugs').select('*').eq('project_id', projectId).order('created_at', { ascending: false })
).then(normalizeBugs)
const logs = await rows<Pick<TimeLog, 'bug_id' | 'minutes'>>(
supa().from('time_logs').select('bug_id, minutes').eq('project_id', projectId)
)
const times: Record<string, number> = {}
for (const l of logs) if (l.bug_id) times[l.bug_id] = (times[l.bug_id] ?? 0) + l.minutes
return { bugs: list, times }
}, [projectId])
const q = useQuery(fetchAll, { bugs: [] as Bug[], times: {} as Record<string, number> }, {
enabled: Boolean(projectId)
})
const { bugs, times } = q.data
// Temps réel — il manquait complètement sur cette page.
useRealtime(
projectId ? `bugs-${projectId}` : null,
[
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
{ table: 'time_logs', filter: `project_id=eq.${projectId}` }
],
q.reload
)
useEffect(() => {
if (focusEntity?.kind !== 'bug') return
const b = bugs.find((x) => x.id === focusEntity.id)
if (b) {
setEditing(b)
setFocusEntity(null)
}
}, [focusEntity, bugs, setFocusEntity])
useEffect(() => {
if (!editing) return
const fresh = bugs.find((b) => b.id === editing.id)
if (fresh && fresh.updated_at !== editing.updated_at) setEditing(fresh)
}, [bugs, editing])
// -------------------------------------------------------------- filtrage
const shown = useMemo(() => {
const needle = filters.q.trim().toLowerCase()
const list = bugs.filter((b) => {
// Aucun statut coché = on ne montre que les bugs encore à traiter.
if (filters.statuses.length === 0) {
if (!ACTIVE_BUG_STATUSES.includes(b.status)) return false
} else if (!filters.statuses.includes(b.status)) return false
if (!matchesOne(filters.severities, b.severity)) return false
if (!matchesAny(filters.poles, b.poles)) return false
if (!matchesAny(filters.assignees, b.assignee_ids)) return false
if (needle && !`${b.title} ${b.description ?? ''}`.toLowerCase().includes(needle)) return false
return true
})
const dir = filters.desc ? -1 : 1
return list.sort((a, b) => {
switch (filters.sort) {
case 'severity':
return (SEVERITY_WEIGHT[a.severity] - SEVERITY_WEIGHT[b.severity]) * dir
case 'status':
return (BUG_STATUS_ORDER.indexOf(a.status) - BUG_STATUS_ORDER.indexOf(b.status)) * -dir
case 'title':
return a.title.localeCompare(b.title, 'fr') * -dir
default:
return a.created_at.localeCompare(b.created_at) * dir
}
})
}, [bugs, filters])
const openCount = bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status)).length
const criticalCount = bugs.filter(
(b) => b.severity === 'critical' && ACTIVE_BUG_STATUSES.includes(b.status)
).length
const activeFilters = countFilters(filters)
// --------------------------------------------------------- options de filtre
const memberOptions = useMemo<MultiOption[]>(
() => [
{ value: NONE, label: 'Non assigné', pinned: true },
...members.map((m) => ({
value: m.id,
label: m.full_name,
hint: polesLabel(memberPoles(m)),
leading: <Avatar profile={m} size={20} />
}))
],
[members]
)
const poleOptions = useMemo<MultiOption[]>(
() => [
{ value: NONE, label: 'Sans pôle', pinned: true },
...POLE_ORDER.map((p) => ({
value: p as string,
label: POLE_LABELS[p],
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${poleDot(p)}`} />
}))
],
[]
)
const severityOptions = useMemo<MultiOption[]>(
() =>
SEVERITY_ORDER.map((sv) => ({
value: sv as string,
label: SEVERITY_LABELS[sv],
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${severityDot(sv)}`} />
})),
[]
)
const statusOptions = useMemo<MultiOption[]>(
() =>
BUG_STATUS_ORDER.map((st) => ({
value: st as string,
label: BUG_STATUS_LABELS[st],
leading: <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${TONE_DOT[BUG_STATUS_TONE[st]]}`} />
})),
[]
)
const removeFilter = (
key: 'statuses' | 'severities' | 'poles' | 'assignees',
value: string
): void => setFilters((f) => ({ ...f, [key]: f[key].filter((v) => v !== value) }))
const onlyMine = Boolean(
profile && filters.assignees.length === 1 && filters.assignees[0] === profile.id
)
const toggleMine = (): void => {
if (!profile) return
setFilters((f) => ({ ...f, assignees: onlyMine ? [] : [profile.id] }))
setShowFilters(true)
}
const toggleSort = (key: SortKey): void =>
setFilters((f) => (f.sort === key ? { ...f, desc: !f.desc } : { ...f, sort: key, desc: true }))
usePageSubtitle(
projectId
? `${currentProject?.name} · ${plural(openCount, 'bug actif', 'bugs actifs')}${
criticalCount ? ` · ${criticalCount} bloquant${criticalCount > 1 ? 's' : ''}` : ''
}`
: null
)
return (
<div className="flex h-full flex-col">
<PageActions>
<SearchInput
className="hidden w-52 md:block"
value={filters.q}
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
placeholder="Filtrer les bugs…"
/>
<IconButton
size="sm"
label="Filtres"
active={showFilters || activeFilters > 0}
icon={<SlidersHorizontal size={17} />}
onClick={() => setShowFilters((v) => !v)}
/>
<Button variant="primary" icon={<Plus size={16} />} onClick={() => setCreating(true)}>
Signaler un bug
</Button>
</PageActions>
{showFilters && (
<div className="flex flex-col gap-1.5 border-b border-border bg-panel px-6 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
className="w-44 md:hidden"
value={filters.q}
onChange={(v) => setFilters((f) => ({ ...f, q: v }))}
placeholder="Filtrer…"
/>
{profile && (
<button
type="button"
onClick={toggleMine}
aria-pressed={onlyMine}
className={`inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors ${
onlyMine
? 'border-accent bg-accent/10 text-accent'
: 'border-border bg-panel2 text-muted hover:border-border-strong hover:text-ink'
}`}
>
<UserCheck size={13} /> Mes bugs
</button>
)}
<MultiSelect
label="Membres"
options={memberOptions}
selected={filters.assignees}
onChange={(assignees) => setFilters((f) => ({ ...f, assignees }))}
emptyLabel="Aucun membre."
/>
<MultiSelect
label="Pôles"
options={poleOptions}
selected={filters.poles}
onChange={(poles) => setFilters((f) => ({ ...f, poles }))}
/>
<MultiSelect
label="Sévérités"
options={severityOptions}
selected={filters.severities}
onChange={(severities) => setFilters((f) => ({ ...f, severities }))}
/>
<MultiSelect
label="Statuts"
options={statusOptions}
selected={filters.statuses}
onChange={(statuses) => setFilters((f) => ({ ...f, statuses }))}
/>
{(activeFilters > 0 || filters.q) && (
<button
type="button"
onClick={() => setFilters({ ...EMPTY, sort: filters.sort, desc: filters.desc })}
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted hover:text-ink"
>
<X size={12} /> Tout réinitialiser
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{filters.assignees.map((id) => (
<FilterChip key={`a-${id}`} onRemove={() => removeFilter('assignees', id)}>
{id === NONE ? 'Non assigné' : (memberById(id)?.full_name ?? 'Inconnu')}
</FilterChip>
))}
{filters.poles.map((pl) => (
<FilterChip key={`p-${pl}`} onRemove={() => removeFilter('poles', pl)}>
{pl === NONE ? 'Sans pôle' : POLE_LABELS[pl as keyof typeof POLE_LABELS]}
</FilterChip>
))}
{filters.severities.map((sv) => (
<FilterChip key={`s-${sv}`} onRemove={() => removeFilter('severities', sv)}>
{SEVERITY_LABELS[sv as keyof typeof SEVERITY_LABELS]}
</FilterChip>
))}
{filters.statuses.map((st) => (
<FilterChip key={`st-${st}`} onRemove={() => removeFilter('statuses', st)}>
{BUG_STATUS_LABELS[st as keyof typeof BUG_STATUS_LABELS]}
</FilterChip>
))}
<span className="text-[11px] text-subtle">
{filters.statuses.length === 0 && 'Bugs actifs uniquement · '}
{shown.length} sur {bugs.length}
</span>
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<AsyncContent
loading={q.loading}
error={q.error}
onRetry={q.reload}
skeleton={<SkeletonList rows={6} />}
>
{shown.length === 0 ? (
bugs.length === 0 ? (
<EmptyState
icon={<CheckCircle2 size={40} />}
title="Aucun bug signalé"
text="Quand quelque chose casse, signale-le ici avec une capture d'écran : l'équipe saura quoi reproduire."
action={
<Button variant="primary" icon={<Plus size={16} />} onClick={() => setCreating(true)}>
Signaler un bug
</Button>
}
/>
) : (
<EmptyState
icon={<BugIcon size={40} />}
title="Aucun bug ne correspond aux filtres"
text={`${bugs.length} bug(s) au total dans ce projet.`}
action={
<Button
onClick={() =>
setFilters({ ...EMPTY, statuses: [...BUG_STATUS_ORDER], sort: filters.sort, desc: filters.desc })
}
>
Afficher tous les bugs
</Button>
}
/>
)
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-panel2">
<tr>
<SortHeader label="Bug" sortKey="title" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
<SortHeader label="Sévérité" sortKey="severity" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
<SortHeader label="Statut" sortKey="status" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
<SortHeader label="Pôle" />
<SortHeader label="Assigné" />
<SortHeader label="Temps" />
<SortHeader label="Signalé" sortKey="created_at" sort={filters.sort} desc={filters.desc} onSort={toggleSort} />
</tr>
</thead>
<tbody>
{shown.map((b) => {
const mins = times[b.id] ?? 0
const done = b.status === 'resolved' || b.status === 'closed'
return (
<tr
key={b.id}
tabIndex={0}
role="button"
onClick={() => setEditing(b)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEditing(b)
}
}}
className="cursor-pointer border-t border-border transition-colors hover:bg-hover"
>
<td className="max-w-[22rem] px-3 py-2.5">
<span
className={`block truncate font-medium ${done ? 'text-muted' : 'text-ink'}`}
>
{b.title}
</span>
{b.description && (
<span className="block truncate text-xs text-subtle">{b.description}</span>
)}
</td>
<td className="px-3">
<Badge className={severityBadge(b.severity)}>
{SEVERITY_LABELS[b.severity]}
</Badge>
</td>
<td className="px-3">
<Badge className={bugStatusBadge(b.status)}>
{BUG_STATUS_LABELS[b.status]}
</Badge>
</td>
<td className="px-3">
<PoleList poles={b.poles} />
</td>
<td className="px-3">
{b.assignee_ids.length ? (
<AvatarStack ids={b.assignee_ids} resolve={memberById} size={26} />
) : (
<span className="text-xs text-subtle"></span>
)}
</td>
<td className="px-3">
{mins > 0 ? (
<span className="flex items-center gap-1 text-xs text-accent2 tabular">
<Clock size={12} />
{formatMinutes(mins)}
</span>
) : (
<span className="text-xs text-subtle"></span>
)}
</td>
<td className="whitespace-nowrap px-3 text-xs text-subtle">
{format(new Date(b.created_at), 'd MMM', { locale: fr })}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</AsyncContent>
</div>
{(creating || editing) && projectId && (
<BugModal
bug={editing}
projectId={projectId}
onClose={() => {
setCreating(false)
setEditing(null)
}}
onSaved={q.reload}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------
/** En-tête de colonne, triable ou non. Défini au niveau du module : un
* composant recréé à chaque rendu remonterait le tableau entier. */
function SortHeader({
label,
sortKey,
sort,
desc,
onSort
}: {
label: string
sortKey?: SortKey
sort?: SortKey
desc?: boolean
onSort?: (k: SortKey) => void
}): JSX.Element {
return (
<th
scope="col"
aria-sort={
sortKey && sort === sortKey ? (desc ? 'descending' : 'ascending') : undefined
}
className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-subtle"
>
{sortKey && onSort ? (
<button
type="button"
onClick={() => onSort(sortKey)}
className="inline-flex items-center gap-1 hover:text-ink"
>
{label}
{sort === sortKey && (desc ? <ArrowDown size={12} /> : <ArrowUp size={12} />)}
</button>
) : (
label
)}
</th>
)
}
+603 -198
View File
@@ -1,198 +1,603 @@
import { useEffect, useState, useCallback } from 'react'
import { motion } from 'framer-motion'
import { KanbanSquare, Bug as BugIcon, Clock, CheckCircle2, CalendarClock } from 'lucide-react'
import { format, isPast, differenceInCalendarDays } from 'date-fns'
import { fr } from 'date-fns/locale'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import {
Task,
Bug,
TimeLog,
TASK_STATUS_ORDER,
TASK_STATUS_LABELS,
TaskStatus,
formatMinutes
} from '../lib/types'
import PageHeader from '../components/PageHeader'
import { Stagger, staggerItem } from '../components/motion'
const COL_COLORS: Record<TaskStatus, string> = {
todo: 'bg-slate-400',
in_progress: 'bg-sky-500',
review: 'bg-amber-500',
done: 'bg-emerald-500'
}
export default function Dashboard(): JSX.Element {
const { currentProject } = useApp()
const [tasks, setTasks] = useState<Task[]>([])
const [bugs, setBugs] = useState<Bug[]>([])
const [totalMinutes, setTotalMinutes] = useState(0)
const projectId = currentProject?.id
const load = useCallback(async () => {
if (!projectId) return
const [{ data: t }, { data: b }] = await Promise.all([
supa().from('tasks').select('*').eq('project_id', projectId),
supa().from('bugs').select('*').eq('project_id', projectId)
])
const taskList = (t ?? []) as Task[]
setTasks(taskList)
setBugs((b ?? []) as Bug[])
if (taskList.length) {
const { data: tl } = await supa()
.from('time_logs')
.select('minutes, task_id')
.in(
'task_id',
taskList.map((x) => x.id)
)
setTotalMinutes(
((tl ?? []) as Pick<TimeLog, 'minutes'>[]).reduce((s, l) => s + l.minutes, 0)
)
} else {
setTotalMinutes(0)
}
}, [projectId])
useEffect(() => {
load()
}, [load])
const doneTasks = tasks.filter((t) => t.status === 'done').length
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
const openBugs = bugs.filter((b) => b.status === 'open' || b.status === 'in_progress').length
const upcoming = tasks
.filter((t) => t.status !== 'done' && t.due_date)
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
.slice(0, 5)
return (
<div className="flex h-full flex-col">
<PageHeader title="Tableau de bord" subtitle={currentProject?.name} />
<div className="flex-1 overflow-y-auto p-6">
{/* Cartes stats */}
<Stagger className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard icon={<KanbanSquare />} label="Tâches" value={tasks.length} color="text-accent" />
<StatCard
icon={<CheckCircle2 />}
label="Terminées"
value={`${doneTasks} (${pct}%)`}
color="text-emerald-600"
/>
<StatCard icon={<BugIcon />} label="Bugs actifs" value={openBugs} color="text-rose-600" />
<StatCard
icon={<Clock />}
label="Temps total"
value={formatMinutes(totalMinutes)}
color="text-accent2"
/>
</Stagger>
{/* Avancement global */}
<div className="card mt-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-ink">Avancement global</h3>
<span className="text-sm text-muted">{pct}%</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-panel2">
<motion.div
className="h-full bg-accent2"
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
/>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Répartition des tâches */}
<div className="card">
<h3 className="mb-3 font-medium text-ink">Répartition des tâches</h3>
<div className="space-y-2">
{TASK_STATUS_ORDER.map((s) => {
const n = tasks.filter((t) => t.status === s).length
const w = tasks.length ? (n / tasks.length) * 100 : 0
return (
<div key={s}>
<div className="mb-1 flex justify-between text-xs text-muted">
<span>{TASK_STATUS_LABELS[s]}</span>
<span>{n}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-panel2">
<div className={`h-full ${COL_COLORS[s]}`} style={{ width: `${w}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Prochaines échéances (tâches) */}
<div className="card">
<h3 className="mb-3 font-medium text-ink">Tâches à échéance</h3>
{upcoming.length === 0 ? (
<p className="py-6 text-center text-sm text-subtle">Aucune échéance à venir.</p>
) : (
<ul className="space-y-2">
{upcoming.map((t) => {
const overdue = isPast(new Date(t.due_date! + 'T23:59:59'))
const d = differenceInCalendarDays(new Date(t.due_date!), new Date())
return (
<li
key={t.id}
className="flex items-center justify-between rounded-lg bg-panel2 px-3 py-2"
>
<span className="flex items-center gap-2 truncate text-sm text-ink">
<span className={`h-2 w-2 shrink-0 rounded-full ${COL_COLORS[t.status]}`} />
<span className="truncate">{t.title}</span>
</span>
<span
className={`flex shrink-0 items-center gap-1 text-xs ${
overdue ? 'text-rose-600' : 'text-muted'
}`}
>
<CalendarClock size={12} />
{format(new Date(t.due_date!), 'd MMM', { locale: fr })}
{' · '}
{overdue ? `retard ${-d}j` : d === 0 ? "auj." : `${d}j`}
</span>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
</div>
)
}
function StatCard({
icon,
label,
value,
color
}: {
icon: JSX.Element
label: string
value: string | number
color: string
}): JSX.Element {
return (
<motion.div
variants={staggerItem}
whileHover={{ y: -3 }}
className="card flex items-center gap-3"
>
<div className={`rounded-lg bg-panel2 p-2 ${color}`}>{icon}</div>
<div>
<div className="text-2xl font-semibold text-ink">{value}</div>
<div className="text-xs text-muted">{label}</div>
</div>
</motion.div>
)
}
import { useCallback, useMemo } from 'react'
import { motion } from 'framer-motion'
import {
differenceInCalendarDays,
endOfWeek,
format,
isPast,
startOfWeek,
subDays
} from 'date-fns'
import { fr } from 'date-fns/locale'
import {
AlertTriangle,
ArrowRight,
Bug as BugIcon,
CalendarClock,
CheckCircle2,
Clock,
KanbanSquare,
MessageSquare,
PartyPopper
} from 'lucide-react'
import { supa } from '../lib/supabase'
import { rows, useQuery, useRealtime } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { usePageSubtitle } from '../components/Layout'
import {
ACTIVE_BUG_STATUSES,
Bug,
BugSeverity,
Comment,
PRIORITY_LABELS,
PRIORITY_WEIGHT,
SEVERITY_LABELS,
SEVERITY_ORDER,
SEVERITY_TONE,
Task,
TASK_STATUS_LABELS,
TASK_STATUS_ORDER,
TASK_STATUS_TONE,
TimeLog,
formatMinutes,
normalizeBugs,
normalizeTasks,
plural,
priorityBadge,
taskStatusDot
} from '../lib/types'
import { TONE_DOT, TONE_TEXT } from '../lib/tones'
import { Avatar } from '../components/people'
import { AsyncContent, Badge, ProgressBar, Skeleton } from '../components/ui'
import { staggerItem, Stagger } from '../components/motion'
const DAY = (d: Date): string => format(d, 'yyyy-MM-dd')
export default function Dashboard(): JSX.Element {
const { currentProject, profile, memberById, setPage, setFocusEntity } = useApp()
const projectId = currentProject?.id
const weekStart = useMemo(() => startOfWeek(new Date(), { weekStartsOn: 1 }), [])
const weekEnd = useMemo(() => endOfWeek(new Date(), { weekStartsOn: 1 }), [])
const fetchAll = useCallback(async () => {
if (!projectId) {
return { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[], comments: [] as Comment[] }
}
const [tasks, bugs, logs, comments] = await Promise.all([
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
rows<TimeLog>(supa().from('time_logs').select('*').eq('project_id', projectId)),
rows<Comment>(
supa()
.from('comments')
.select('*')
.eq('project_id', projectId)
.order('created_at', { ascending: false })
.limit(6)
)
])
return { tasks, bugs, logs, comments }
}, [projectId])
const q = useQuery(
fetchAll,
{ tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[], comments: [] as Comment[] },
{ enabled: Boolean(projectId) }
)
const { tasks, bugs, logs, comments } = q.data
// Le tableau de bord n'avait AUCUNE synchro : ses chiffres étaient périmés
// en permanence dès qu'un coéquipier travaillait.
useRealtime(
projectId ? `dashboard-${projectId}` : null,
[
{ table: 'tasks', filter: `project_id=eq.${projectId}` },
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
{ table: 'time_logs', filter: `project_id=eq.${projectId}` },
{ table: 'comments', filter: `project_id=eq.${projectId}` }
],
q.reload
)
// ------------------------------------------------------------------ calculs
const me = profile?.id
const doneTasks = tasks.filter((t) => t.status === 'done').length
const pct = tasks.length ? Math.round((doneTasks / tasks.length) * 100) : 0
const activeBugs = bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status))
const blockers = activeBugs.filter((b) => b.severity === 'critical').length
const weekLogs = useMemo(
() => logs.filter((l) => l.logged_at >= DAY(weekStart) && l.logged_at <= DAY(weekEnd)),
[logs, weekStart, weekEnd]
)
const weekMinutes = weekLogs.reduce((s, l) => s + l.minutes, 0)
const myWeekMinutes = weekLogs.filter((l) => l.user_id === me).reduce((s, l) => s + l.minutes, 0)
const totalMinutes = logs.reduce((s, l) => s + l.minutes, 0)
/** Ce que J'AI à faire — la question à laquelle le tableau de bord doit répondre. */
const myTasks = useMemo(() => {
if (!me) return []
return tasks
.filter((t) => t.status !== 'done' && t.assignee_ids.includes(me))
.sort((a, b) => {
const ad = a.due_date ?? '9999'
const bd = b.due_date ?? '9999'
if (ad !== bd) return ad.localeCompare(bd)
return PRIORITY_WEIGHT[b.priority] - PRIORITY_WEIGHT[a.priority]
})
}, [tasks, me])
const myBugs = useMemo(
() => (me ? activeBugs.filter((b) => b.assignee_ids.includes(me)) : []),
[activeBugs, me]
)
/** Échéances : en retard d'abord, puis les 7 prochains jours. */
const deadlines = useMemo(
() =>
tasks
.filter((t) => t.status !== 'done' && t.due_date)
.sort((a, b) => (a.due_date ?? '').localeCompare(b.due_date ?? ''))
.slice(0, 6),
[tasks]
)
const overdueCount = deadlines.filter(
(t) => t.due_date && isPast(new Date(`${t.due_date}T23:59:59`))
).length
/** Temps de la semaine par membre, du plus au moins actif. */
const weekByMember = useMemo(() => {
const totals = new Map<string, number>()
for (const l of weekLogs) totals.set(l.user_id ?? '', (totals.get(l.user_id ?? '') ?? 0) + l.minutes)
return [...totals.entries()].sort((a, b) => b[1] - a[1])
}, [weekLogs])
const topWeek = weekByMember[0]?.[1] ?? 0
const severityCounts = useMemo(() => {
const map = {} as Record<BugSeverity, number>
for (const s of SEVERITY_ORDER) map[s] = activeBugs.filter((b) => b.severity === s).length
return map
}, [activeBugs])
/** Rythme des 14 derniers jours (mini-histogramme du temps saisi). */
const sparkline = useMemo(() => {
const days: { day: string; minutes: number }[] = []
for (let i = 13; i >= 0; i--) {
const d = subDays(new Date(), i)
const key = DAY(d)
days.push({ day: key, minutes: logs.filter((l) => l.logged_at === key).reduce((s, l) => s + l.minutes, 0) })
}
return days
}, [logs])
const sparkMax = Math.max(...sparkline.map((d) => d.minutes), 60)
const openTask = (id: string): void => {
setFocusEntity({ kind: 'task', id })
setPage('kanban')
}
const openBug = (id: string): void => {
setFocusEntity({ kind: 'bug', id })
setPage('bugs')
}
usePageSubtitle(
projectId
? `${currentProject?.name} · ${plural(tasks.length, 'tâche')} · ${formatMinutes(totalMinutes)} au total`
: null
)
return (
<div className="h-full overflow-y-auto p-6">
<AsyncContent
loading={q.loading}
error={q.error}
onRetry={q.reload}
skeleton={
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-[76px]" />
))}
</div>
<Skeleton className="h-64" />
</div>
}
>
{/* ------------------------------ Chiffres clés ------------------------------ */}
<Stagger className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard
icon={<KanbanSquare size={20} />}
label={myTasks.length ? 'À faire pour moi' : 'Rien pour moi'}
value={myTasks.length}
hint={myBugs.length ? `+ ${plural(myBugs.length, 'bug')}` : undefined}
tone="accent"
onClick={() => setPage('kanban')}
/>
<StatCard
icon={<CheckCircle2 size={20} />}
label="Avancement"
value={`${pct} %`}
hint={`${doneTasks}/${tasks.length} terminées`}
tone="emerald"
/>
<StatCard
icon={<BugIcon size={20} />}
label="Bugs actifs"
value={activeBugs.length}
hint={blockers ? `${blockers} bloquant${blockers > 1 ? 's' : ''}` : undefined}
tone={blockers ? 'rose' : 'slate'}
onClick={() => setPage('bugs')}
/>
<StatCard
icon={<Clock size={20} />}
label="Cette semaine"
value={formatMinutes(weekMinutes)}
hint={myWeekMinutes ? `dont ${formatMinutes(myWeekMinutes)} par moi` : 'aucune saisie'}
tone="teal"
onClick={() => setPage('planning')}
/>
</Stagger>
{/* ------------------------------ Avancement ------------------------------ */}
<section className="card mt-4">
<div className="mb-2 flex items-baseline justify-between">
<h2 className="text-sm font-medium text-ink">Avancement du projet</h2>
<span className="text-sm text-muted tabular">{pct} %</span>
</div>
<ProgressBar pct={pct} height="h-2.5" barClassName="bg-accent" label="Avancement du projet" />
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
{TASK_STATUS_ORDER.map((s) => {
const n = tasks.filter((t) => t.status === s).length
return (
<button
key={s}
type="button"
onClick={() => setPage('kanban')}
className="rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="flex items-center gap-1.5 text-xs text-muted">
<span className={`h-2 w-2 rounded-full ${taskStatusDot(s)}`} />
{TASK_STATUS_LABELS[s]}
</span>
<span className="mt-0.5 block text-lg font-semibold tabular text-ink">{n}</span>
<ProgressBar
className="mt-1"
height="h-1"
pct={tasks.length ? (n / tasks.length) * 100 : 0}
barClassName={TONE_DOT[TASK_STATUS_TONE[s]]}
/>
</button>
)
})}
</div>
</section>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* ------------------------------ Mes tâches ------------------------------ */}
<section className="card xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink">Mes tâches</h2>
<button
type="button"
onClick={() => setPage('kanban')}
className="flex items-center gap-1 text-xs text-accent hover:underline"
>
Voir le Kanban <ArrowRight size={12} />
</button>
</div>
{myTasks.length === 0 && myBugs.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<PartyPopper size={28} className="text-subtle" />
<p className="text-sm text-muted">
Rien ne t'est assigné. Pioche une tâche dans le Kanban.
</p>
</div>
) : (
<ul className="space-y-1.5">
{myTasks.slice(0, 6).map((t) => {
const late = t.due_date && isPast(new Date(`${t.due_date}T23:59:59`))
return (
<li key={t.id}>
<button
type="button"
onClick={() => openTask(t.id)}
className="flex w-full items-center gap-2.5 rounded-lg border border-border bg-panel2/50 px-3 py-2 text-left transition-colors hover:border-border-strong hover:bg-hover"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${taskStatusDot(t.status)}`} />
<span className="min-w-0 flex-1 truncate text-sm text-ink">{t.title}</span>
<Badge className={priorityBadge(t.priority)}>
{PRIORITY_LABELS[t.priority]}
</Badge>
{t.due_date && (
<span
className={`shrink-0 text-xs tabular ${
late ? 'font-medium text-rose-600 dark:text-rose-400' : 'text-subtle'
}`}
>
{format(new Date(t.due_date), 'd MMM', { locale: fr })}
</span>
)}
</button>
</li>
)
})}
{myBugs.slice(0, 3).map((b) => (
<li key={b.id}>
<button
type="button"
onClick={() => openBug(b.id)}
className="flex w-full items-center gap-2.5 rounded-lg border border-border bg-panel2/50 px-3 py-2 text-left transition-colors hover:border-border-strong hover:bg-hover"
>
<BugIcon size={13} className="shrink-0 text-rose-500" />
<span className="min-w-0 flex-1 truncate text-sm text-ink">{b.title}</span>
<Badge className={`${TONE_TEXT[SEVERITY_TONE[b.severity]]} bg-transparent`}>
{SEVERITY_LABELS[b.severity]}
</Badge>
</button>
</li>
))}
{myTasks.length > 6 && (
<li className="pt-1 text-center text-xs text-subtle">
+ {myTasks.length - 6} autre{myTasks.length - 6 > 1 ? 's' : ''}
</li>
)}
</ul>
)}
</section>
{/* ------------------------------ Bugs ------------------------------ */}
<section className="card">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink">Bugs par sévérité</h2>
{blockers > 0 && (
<span className="flex items-center gap-1 text-xs font-medium text-rose-600 dark:text-rose-400">
<AlertTriangle size={12} /> {blockers} bloquant{blockers > 1 ? 's' : ''}
</span>
)}
</div>
{activeBugs.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<CheckCircle2 size={28} className="text-emerald-500" />
<p className="text-sm text-muted">Aucun bug actif. 🎉</p>
</div>
) : (
<ul className="space-y-2.5">
{SEVERITY_ORDER.map((s) => {
const n = severityCounts[s]
return (
<li key={s}>
<button
type="button"
onClick={() => setPage('bugs')}
className="block w-full text-left"
disabled={n === 0}
>
<span className="mb-1 flex items-center justify-between text-xs">
<span className={n ? TONE_TEXT[SEVERITY_TONE[s]] : 'text-subtle'}>
{SEVERITY_LABELS[s]}
</span>
<span className="text-muted tabular">{n}</span>
</span>
<ProgressBar
height="h-1.5"
pct={activeBugs.length ? (n / activeBugs.length) * 100 : 0}
barClassName={TONE_DOT[SEVERITY_TONE[s]]}
/>
</button>
</li>
)
})}
</ul>
)}
</section>
{/* ------------------------------ Échéances ------------------------------ */}
<section className="card">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink">Échéances</h2>
{overdueCount > 0 && (
<span className="text-xs font-medium text-rose-600 dark:text-rose-400">
{overdueCount} en retard
</span>
)}
</div>
{deadlines.length === 0 ? (
<p className="py-6 text-center text-sm text-subtle">Aucune échéance fixée.</p>
) : (
<ul className="space-y-1.5">
{deadlines.map((t) => {
const late = isPast(new Date(`${t.due_date}T23:59:59`))
const days = differenceInCalendarDays(new Date(t.due_date as string), new Date())
return (
<li key={t.id}>
<button
type="button"
onClick={() => openTask(t.id)}
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="flex min-w-0 items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${taskStatusDot(t.status)}`} />
<span className="truncate text-sm text-ink">{t.title}</span>
</span>
<span
className={`flex shrink-0 items-center gap-1 text-xs tabular ${
late ? 'font-medium text-rose-600 dark:text-rose-400' : 'text-muted'
}`}
>
<CalendarClock size={12} />
{late
? `${-days} j de retard`
: days === 0
? "aujourd'hui"
: `dans ${days} j`}
</span>
</button>
</li>
)
})}
</ul>
)}
</section>
{/* ------------------------------ Temps équipe ------------------------------ */}
<section className="card">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-ink">Temps de l'équipe</h2>
<span className="text-xs text-subtle">
{format(weekStart, 'd', { locale: fr })}{format(weekEnd, 'd MMM', { locale: fr })}
</span>
</div>
{weekByMember.length === 0 ? (
<p className="py-6 text-center text-sm text-subtle">
Aucune heure saisie cette semaine.
</p>
) : (
<ul className="space-y-2">
{weekByMember.map(([uid, mins]) => (
<li key={uid || 'none'} className="flex items-center gap-2">
<Avatar profile={memberById(uid)} size={22} />
<span className="w-24 shrink-0 truncate text-xs text-ink">
{memberById(uid)?.full_name ?? 'Sans membre'}
</span>
<ProgressBar
className="flex-1"
height="h-1.5"
pct={topWeek ? (mins / topWeek) * 100 : 0}
barClassName="bg-accent2"
/>
<span className="w-16 shrink-0 text-right text-xs text-accent2 tabular">
{formatMinutes(mins)}
</span>
</li>
))}
</ul>
)}
{/* Rythme des 14 derniers jours */}
<div className="mt-4 border-t border-border pt-3">
<p className="mb-2 text-[11px] uppercase tracking-wide text-subtle">
14 derniers jours
</p>
<div className="flex h-10 items-end gap-1">
{sparkline.map((d) => (
<div
key={d.day}
title={`${format(new Date(d.day), 'EEEE d MMM', { locale: fr })} — ${formatMinutes(d.minutes)}`}
className="flex-1 rounded-sm bg-accent2/70 transition-all hover:bg-accent2"
style={{ height: `${Math.max(4, (d.minutes / sparkMax) * 100)}%` }}
/>
))}
</div>
</div>
</section>
{/* ------------------------------ Discussions ------------------------------ */}
<section className="card xl:col-span-2">
<h2 className="mb-3 text-sm font-medium text-ink">Derniers messages</h2>
{comments.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<MessageSquare size={26} className="text-subtle" />
<p className="text-sm text-muted">
Aucune discussion. Les échanges sur une tâche ou un bug apparaîtront ici.
</p>
</div>
) : (
<ul className="space-y-2.5">
{comments.map((c) => {
const target =
c.entity_kind === 'bug'
? bugs.find((b) => b.id === c.entity_id)?.title
: tasks.find((t) => t.id === c.entity_id)?.title
return (
<li key={c.id}>
<button
type="button"
onClick={() =>
c.entity_kind === 'bug' ? openBug(c.entity_id) : openTask(c.entity_id)
}
className="flex w-full gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<Avatar profile={memberById(c.author_id)} size={24} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-ink">
{memberById(c.author_id)?.full_name ?? 'Membre'}
</span>
<span className="truncate text-[11px] text-subtle">
sur {target ?? (c.entity_kind === 'bug' ? 'un bug' : 'une tâche')}
</span>
</span>
<span className="line-clamp-2 text-sm text-muted">{c.body}</span>
</span>
</button>
</li>
)
})}
</ul>
)}
</section>
</div>
</AsyncContent>
</div>
)
}
// ---------------------------------------------------------------------
const TONE_BG: Record<string, string> = {
accent: 'bg-accent/12 text-accent',
emerald: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400',
rose: 'bg-rose-500/12 text-rose-600 dark:text-rose-400',
teal: 'bg-teal-500/12 text-teal-600 dark:text-teal-400',
slate: 'bg-slate-500/12 text-slate-600 dark:text-slate-300'
}
function StatCard({
icon,
label,
value,
hint,
tone,
onClick
}: {
icon: JSX.Element
label: string
value: string | number
hint?: string
tone: keyof typeof TONE_BG
onClick?: () => void
}): JSX.Element {
const inner = (
<>
<div className={`shrink-0 rounded-lg p-2.5 ${TONE_BG[tone]}`}>{icon}</div>
<div className="min-w-0">
<div className="truncate text-2xl font-semibold leading-tight tabular text-ink">{value}</div>
<div className="truncate text-xs text-muted">{label}</div>
{hint && <div className="truncate text-[11px] text-subtle">{hint}</div>}
</div>
</>
)
if (!onClick) {
return (
<motion.div variants={staggerItem} className="card flex items-center gap-3">
{inner}
</motion.div>
)
}
return (
<motion.button
type="button"
variants={staggerItem}
whileHover={{ y: -2 }}
onClick={onClick}
className="card flex items-center gap-3 text-left transition-shadow hover:shadow-raised"
>
{inner}
</motion.button>
)
}
+642
View File
@@ -0,0 +1,642 @@
// =====================================================================
// Documentation — fiches créées dans l'app ou importées (.md, .docx).
//
// Deux volets : la liste à gauche, la fiche à droite. C'est la mise en
// page d'un wiki, celle qu'on attend quand on vient chercher une info
// précise dans un ensemble de documents.
// =====================================================================
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { format, formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale'
import {
BookOpen,
Check,
Download,
Eye,
FileText,
Pencil,
Plus,
Trash2,
List,
Upload,
X
} from 'lucide-react'
import { supa } from '../lib/supabase'
import { humanize, mutate, rows, run, useQuery, useRealtime } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { PageActions, usePageSubtitle } from '../components/Layout'
import { Doc, normalizeDocs, plural, tagBadge } from '../lib/types'
import { IMPORT_ACCEPT, excerpt, importFile, markdownToText } from '../lib/markdown'
import Markdown, { Heading } from '../components/Markdown'
import { Avatar, TagInput } from '../components/people'
import {
AsyncContent,
Badge,
Button,
EmptyState,
IconButton,
SearchInput,
SkeletonList
} from '../components/ui'
/** Contenu proposé à la création : une fiche vide décourage l'écriture. */
const TEMPLATE = `## Objectif
Décris ici à quoi sert ce document.
## Contenu
- Premier point
- Deuxième point
> Astuce : cette fiche s'écrit en Markdown. **gras**, *italique*,
> \`code\`, listes, tableaux et liens fonctionnent.
`
interface Draft {
title: string
content: string
tags: string[]
}
export default function Docs(): JSX.Element {
const { currentProject, profile, memberById } = useApp()
const { toast, confirm } = useFeedback()
const projectId = currentProject?.id
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<Draft | null>(null) // non-null = en édition
const [q, setQ] = useState('')
const [busy, setBusy] = useState(false)
const [importing, setImporting] = useState(false)
const [dragging, setDragging] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
// ------------------------------------------------------------------ data
const fetchDocs = useCallback(async () => {
if (!projectId) return [] as Doc[]
return rows<Doc>(
supa()
.from('documents')
.select('*')
.eq('project_id', projectId)
.order('updated_at', { ascending: false })
).then(normalizeDocs)
}, [projectId])
const docs = useQuery<Doc[]>(fetchDocs, [], { enabled: Boolean(projectId) })
const { reload } = docs
useRealtime(
projectId ? `docs-${projectId}` : null,
[{ table: 'documents', filter: `project_id=eq.${projectId}` }],
docs.reload
)
const list = docs.data
const selected = useMemo(() => list.find((d) => d.id === selectedId) ?? null, [list, selectedId])
// Sélection automatique de la première fiche, tant qu'on n'édite pas.
useEffect(() => {
if (!draft && !selectedId && list.length > 0) setSelectedId(list[0].id)
}, [list, selectedId, draft])
const shown = useMemo(() => {
const needle = q.trim().toLowerCase()
if (!needle) return list
return list.filter((d) =>
`${d.title} ${d.tags.join(' ')} ${markdownToText(d.content)}`.toLowerCase().includes(needle)
)
}, [list, q])
// ------------------------------------------------------------------ écriture
const create = (initial?: Partial<Draft>): void => {
setSelectedId(null)
setDraft({ title: '', content: TEMPLATE, tags: [], ...initial })
}
const save = async (): Promise<void> => {
if (!draft || !projectId) return
const title = draft.title.trim()
if (!title) {
toast('Donne un titre à la fiche.', 'error')
return
}
setBusy(true)
try {
if (selected) {
await mutate(
supa()
.from('documents')
.update({
title,
content: draft.content,
tags: draft.tags,
updated_by: profile?.id ?? null
})
.eq('id', selected.id)
)
} else {
const created = await run<{ id: string }>(
supa()
.from('documents')
.insert({
project_id: projectId,
title,
content: draft.content,
tags: draft.tags,
created_by: profile?.id ?? null,
updated_by: profile?.id ?? null
})
.select('id')
.single()
)
if (created) setSelectedId(created.id)
}
await docs.reload()
setDraft(null)
toast('Fiche enregistrée.', 'success')
} catch (e) {
toast(humanize(e), 'error')
} finally {
setBusy(false)
}
}
const remove = async (doc: Doc): Promise<void> => {
const ok = await confirm({
title: 'Supprimer cette fiche ?',
message: `« ${doc.title} » sera définitivement supprimée.`,
danger: true,
confirmLabel: 'Supprimer'
})
if (!ok) return
try {
await mutate(supa().from('documents').delete().eq('id', doc.id))
setSelectedId(null)
setDraft(null)
await docs.reload()
toast('Fiche supprimée.', 'success')
} catch (e) {
toast(humanize(e), 'error')
}
}
/** Réenregistre la fiche en .md — la doc n'est jamais prisonnière de l'app. */
const exportMd = (doc: Doc): void => {
const blob = new Blob([`# ${doc.title}\n\n${doc.content}`], {
type: 'text/markdown;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${doc.title.replace(/[^\w\s.-]/g, '').trim() || 'fiche'}.md`
a.click()
URL.revokeObjectURL(url)
}
// ------------------------------------------------------------------ import
const runImport = async (files: File[]): Promise<void> => {
if (!projectId || files.length === 0) return
setImporting(true)
const errors: string[] = []
let lastId: string | null = null
for (const file of files) {
try {
const doc = await importFile(file)
const created = await run<{ id: string }>(
supa()
.from('documents')
.insert({
project_id: projectId,
title: doc.title,
content: doc.content,
source_name: doc.sourceName,
created_by: profile?.id ?? null,
updated_by: profile?.id ?? null
})
.select('id')
.single()
)
if (created) lastId = created.id
} catch (e) {
errors.push(`${file.name}${humanize(e)}`)
}
}
await reload()
setImporting(false)
if (lastId) {
setDraft(null)
setSelectedId(lastId)
}
const ok = files.length - errors.length
if (ok > 0) toast(`${plural(ok, 'fiche')} importée${ok > 1 ? 's' : ''}.`, 'success')
// Chaque échec est signalé nommément : un import silencieusement
// partiel laisserait croire que tout est passé.
for (const err of errors) toast(err, 'error')
}
const onDrop = (e: DragEvent<HTMLDivElement>): void => {
e.preventDefault()
setDragging(false)
void runImport(Array.from(e.dataTransfer.files))
}
usePageSubtitle(
projectId ? `${currentProject?.name} · ${plural(list.length, 'fiche')}` : null
)
// ------------------------------------------------------------------ rendu
return (
<div
className="flex h-full flex-col"
onDragOver={(e) => {
if (!projectId) return
e.preventDefault()
setDragging(true)
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragging(false)
}}
onDrop={onDrop}
>
<PageActions>
<input
ref={fileRef}
type="file"
multiple
accept={IMPORT_ACCEPT}
className="hidden"
onChange={(e) => {
void runImport(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
<Button
icon={<Upload size={15} />}
loading={importing}
onClick={() => fileRef.current?.click()}
>
Importer
</Button>
<Button variant="primary" icon={<Plus size={16} />} onClick={() => create()}>
Nouvelle fiche
</Button>
</PageActions>
<div className="relative flex min-h-0 flex-1">
{/* ------------------------------ liste ------------------------------ */}
<aside className="flex w-72 shrink-0 flex-col border-r border-border">
<div className="border-b border-border p-3">
<SearchInput value={q} onChange={setQ} placeholder="Chercher dans la doc…" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{docs.loading ? (
<SkeletonList rows={4} />
) : shown.length === 0 ? (
<p className="px-2 py-8 text-center text-xs text-subtle">
{list.length === 0 ? 'Aucune fiche.' : 'Aucun résultat.'}
</p>
) : (
<ul className="space-y-1">
{shown.map((d) => {
const on = d.id === selectedId && !draft
return (
<li key={d.id}>
<button
type="button"
onClick={() => {
setDraft(null)
setSelectedId(d.id)
}}
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
on ? 'bg-accent/12 ring-1 ring-inset ring-accent/25' : 'hover:bg-hover'
}`}
>
<span className="flex items-center gap-1.5">
<FileText size={13} className={on ? 'text-accent' : 'text-subtle'} />
<span
className={`truncate text-sm font-medium ${on ? 'text-accent' : 'text-ink'}`}
>
{d.title}
</span>
</span>
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-snug text-subtle">
{excerpt(d.content, 90) || 'Fiche vide'}
</span>
<span className="mt-1 block text-[10px] text-subtle">
{formatDistanceToNow(new Date(d.updated_at), {
addSuffix: true,
locale: fr
})}
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
</aside>
{/* ------------------------------ fiche ------------------------------ */}
<section className="flex min-w-0 flex-1 flex-col overflow-hidden">
<AsyncContent loading={docs.loading} error={docs.error} onRetry={docs.reload}>
{draft ? (
<Editor
draft={draft}
setDraft={setDraft}
busy={busy}
onSave={save}
onCancel={() => setDraft(null)}
/>
) : selected ? (
<Viewer
doc={selected}
author={memberById(selected.updated_by ?? selected.created_by)}
onEdit={() =>
setDraft({
title: selected.title,
content: selected.content,
tags: selected.tags
})
}
onExport={() => exportMd(selected)}
onDelete={() => remove(selected)}
/>
) : (
<EmptyState
icon={<BookOpen size={40} />}
title={list.length === 0 ? 'Aucune documentation' : 'Choisis une fiche'}
text={
list.length === 0
? 'Crée une fiche, ou dépose un fichier .md ou .docx nimporte où sur cette page.'
: undefined
}
action={
list.length === 0 ? (
<div className="flex gap-2">
<Button icon={<Upload size={15} />} onClick={() => fileRef.current?.click()}>
Importer un fichier
</Button>
<Button variant="primary" icon={<Plus size={16} />} onClick={() => create()}>
Nouvelle fiche
</Button>
</div>
) : undefined
}
/>
)}
</AsyncContent>
</section>
{/* Dépôt de fichier : n'importe où sur la page. */}
{dragging && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center border-2 border-dashed border-accent bg-accent/10">
<p className="rounded-lg bg-panel px-4 py-2 text-sm font-medium text-accent shadow-pop">
Déposer pour importer ({IMPORT_ACCEPT})
</p>
</div>
)}
</div>
</div>
)
}
// =====================================================================
// Lecture
// =====================================================================
function Viewer({
doc,
author,
onEdit,
onExport,
onDelete
}: {
doc: Doc
author: ReturnType<ReturnType<typeof useApp>['memberById']>
onEdit: () => void
onExport: () => void
onDelete: () => void
}): JSX.Element {
const scrollRef = useRef<HTMLDivElement>(null)
const [headings, setHeadings] = useState<Heading[]>([])
const [active, setActive] = useState<string | null>(null)
// Le plan se reconstruit à chaque fiche : on repart du haut.
useEffect(() => {
setHeadings([])
setActive(null)
scrollRef.current?.scrollTo({ top: 0 })
}, [doc.id])
// Suivi de lecture : le titre surligné est le dernier franchi. On lit la
// position au défilement plutôt qu'avec un IntersectionObserver — une
// section plus haute que la fenêtre ne déclencherait aucune intersection
// et le plan resterait bloqué sur le titre précédent.
useEffect(() => {
const root = scrollRef.current
if (!root || headings.length === 0) return
const onScroll = (): void => {
const limit = root.scrollTop + 24
let current = headings[0].id
for (const h of headings) {
const el = root.querySelector<HTMLElement>(`#${CSS.escape(h.id)}`)
if (el && el.offsetTop <= limit) current = h.id
else break
}
setActive(current)
}
onScroll()
root.addEventListener('scroll', onScroll, { passive: true })
return () => root.removeEventListener('scroll', onScroll)
}, [headings])
const goTo = (id: string): void => {
const root = scrollRef.current
const el = root?.querySelector<HTMLElement>(`#${CSS.escape(id)}`)
if (!root || !el) return
root.scrollTo({ top: el.offsetTop - 12, behavior: 'smooth' })
setActive(id)
}
return (
<div className="flex min-h-0 flex-1">
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
<article className="mx-auto max-w-3xl px-8 py-6">
<header className="mb-5 border-b border-border pb-4">
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-semibold leading-tight text-ink">{doc.title}</h1>
<div className="flex shrink-0 items-center gap-1">
<IconButton size="sm" label="Modifier" icon={<Pencil size={16} />} onClick={onEdit} />
<IconButton
size="sm"
label="Exporter en Markdown"
icon={<Download size={16} />}
onClick={onExport}
/>
<IconButton
size="sm"
tone="danger"
label="Supprimer"
icon={<Trash2 size={16} />}
onClick={onDelete}
/>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-subtle">
{author && (
<span className="flex items-center gap-1.5">
<Avatar profile={author} size={18} />
{author.full_name}
</span>
)}
<span>
Modifiée le {format(new Date(doc.updated_at), 'd MMMM yyyy à HH:mm', { locale: fr })}
</span>
{doc.source_name && (
<span className="flex items-center gap-1">
<Upload size={11} /> importée de {doc.source_name}
</span>
)}
{doc.tags.map((t) => (
<Badge key={t} className={tagBadge(t)}>
{t}
</Badge>
))}
</div>
</header>
<Markdown content={doc.content} onHeadings={setHeadings} />
</article>
</div>
{/* Plan du document masqué s'il n'y a pas de quoi naviguer, ou si
la fenêtre est trop étroite pour l'afficher sans serrer le texte. */}
{headings.length > 1 && (
<nav
aria-label="Plan du document"
className="hidden w-56 shrink-0 overflow-y-auto border-l border-border px-3 py-6 xl:block"
>
<p className="mb-2 flex items-center gap-1.5 px-2 text-[10px] font-semibold uppercase tracking-wide text-subtle">
<List size={12} /> Plan
</p>
<ul>
{headings.map((h) => {
const on = h.id === active
return (
<li key={h.id}>
<button
type="button"
onClick={() => goTo(h.id)}
style={{ paddingLeft: `${(h.level - 1) * 12 + 8}px` }}
className={`block w-full border-l-2 py-1 pr-2 text-left text-xs leading-snug transition-colors ${
on
? 'border-accent font-medium text-accent'
: 'border-transparent text-muted hover:border-border-strong hover:text-ink'
}`}
>
{h.text}
</button>
</li>
)
})}
</ul>
</nav>
)}
</div>
)
}
// =====================================================================
// Écriture
// =====================================================================
function Editor({
draft,
setDraft,
busy,
onSave,
onCancel
}: {
draft: Draft
setDraft: (d: Draft) => void
busy: boolean
onSave: () => void
onCancel: () => void
}): JSX.Element {
const [preview, setPreview] = useState(false)
// Ctrl+S : le réflexe de toute personne qui écrit un document.
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
onSave()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onSave])
return (
<div className="mx-auto flex h-full max-w-3xl flex-col px-8 py-6">
<div className="mb-3 flex items-center gap-2">
<input
autoFocus
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
placeholder="Titre de la fiche"
aria-label="Titre de la fiche"
className="min-w-0 flex-1 border-0 bg-transparent p-0 text-2xl font-semibold text-ink outline-none placeholder:text-subtle"
/>
<IconButton
size="sm"
label={preview ? 'Revenir à l’édition' : 'Aperçu'}
active={preview}
icon={preview ? <Pencil size={16} /> : <Eye size={16} />}
onClick={() => setPreview((v) => !v)}
/>
<IconButton size="sm" label="Annuler" icon={<X size={16} />} onClick={onCancel} />
<Button variant="primary" icon={<Check size={15} />} loading={busy} onClick={onSave}>
Enregistrer
</Button>
</div>
<div className="mb-3">
<TagInput value={draft.tags} onChange={(tags) => setDraft({ ...draft, tags })} />
</div>
{preview ? (
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-border bg-panel p-4">
<Markdown content={draft.content} />
</div>
) : (
<textarea
value={draft.content}
onChange={(e) => setDraft({ ...draft, content: e.target.value })}
spellCheck
placeholder="Écris ici, en Markdown…"
className="min-h-0 flex-1 resize-none rounded-lg border border-border bg-panel2 p-4 font-mono text-[13px] leading-relaxed text-ink outline-none transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
)}
<p className="mt-2 shrink-0 text-[11px] text-subtle">
Markdown : <code className="text-muted">## Titre</code> ·{' '}
<code className="text-muted">**gras**</code> · <code className="text-muted">- liste</code> ·{' '}
<code className="text-muted">[lien](url)</code> <kbd className="kbd">Ctrl</kbd>{' '}
<kbd className="kbd">S</kbd> pour enregistrer
</p>
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
// =====================================================================
// Écran d'installation — fenêtre sans bordure, au design de l'application.
//
// Remplace l'assistant NSIS pour la PREMIÈRE installation. Les mises à
// jour, elles, restent silencieuses (aucune fenêtre du tout).
// =====================================================================
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { AlertTriangle, ArrowRight, Check, Minus, X } from 'lucide-react'
import {
InstallerStatus,
closeWindow,
install,
launchInstalled,
minimizeWindow,
startDragging
} from '../lib/installer'
import { Checkbox } from '../components/ui'
import Logo from '../components/Logo'
type Step = 'ready' | 'installing' | 'done' | 'error'
/** Étapes affichées pendant la copie purement indicatives, mais elles
* donnent à l'attente un déroulé lisible plutôt qu'une barre muette. */
const PHASES = [
'Copie des fichiers…',
'Création des raccourcis…',
'Enregistrement auprès de Windows…'
]
export default function Installer({ status }: { status: InstallerStatus | null }): JSX.Element {
const [step, setStep] = useState<Step>('ready')
const [desktopShortcut, setDesktopShortcut] = useState(true)
const [phase, setPhase] = useState(0)
const [error, setError] = useState('')
const [installDir, setInstallDir] = useState(status?.install_dir ?? '')
const reinstall = status?.already_installed ?? false
// Progression indicative : l'installation réelle est trop rapide pour
// qu'on puisse en suivre l'avancement, mais un écran figé inquiète.
useEffect(() => {
if (step !== 'installing') return
const t = setInterval(() => setPhase((p) => Math.min(p + 1, PHASES.length - 1)), 550)
return () => clearInterval(t)
}, [step])
const run = async (): Promise<void> => {
setStep('installing')
setPhase(0)
const started = Date.now()
try {
const dir = await install(desktopShortcut)
setInstallDir(dir)
// On laisse les étapes défiler au moins une seconde : une installation
// « instantanée » donne l'impression que rien ne s'est passé.
const remaining = Math.max(0, 1100 - (Date.now() - started))
setTimeout(() => setStep('done'), remaining)
} catch (e) {
setError(typeof e === 'string' ? e : ((e as Error)?.message ?? String(e)))
setStep('error')
}
}
const launch = async (): Promise<void> => {
try {
await launchInstalled()
} catch (e) {
setError(typeof e === 'string' ? e : ((e as Error)?.message ?? String(e)))
setStep('error')
}
}
return (
<div className="flex h-full flex-col overflow-hidden bg-bg">
{/* Barre de fenêtre maison : la fenêtre n'a pas de décoration Windows. */}
<header
onMouseDown={startDragging}
className="flex h-9 shrink-0 items-center justify-between pl-4 pr-1 select-none"
>
<span className="text-[11px] font-medium text-subtle">
Installation de {status?.product_name ?? 'GameDev Tracker'}
</span>
<div className="flex items-center">
<button
type="button"
aria-label="Réduire"
onClick={minimizeWindow}
className="flex h-8 w-10 items-center justify-center text-muted transition-colors hover:bg-hover hover:text-ink"
>
<Minus size={15} />
</button>
<button
type="button"
aria-label="Fermer"
onClick={closeWindow}
className="flex h-8 w-10 items-center justify-center text-muted transition-colors hover:bg-rose-600 hover:text-white"
>
<X size={15} />
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col items-center px-8 pb-7 text-center">
{/* Logo + halo, écho du panneau de l'ancien installeur */}
<div className="relative mt-4 shrink-0">
<div
aria-hidden
className="absolute inset-0 -z-10 rounded-full blur-2xl"
style={{ background: 'radial-gradient(circle, rgb(var(--c-accent)/0.45), transparent 70%)' }}
/>
<Logo size={76} className="rounded-2xl shadow-pop" />
</div>
<h1 className="mt-5 text-xl font-semibold text-ink">
{status?.product_name ?? 'GameDev Tracker'}
</h1>
<p className="mt-0.5 text-sm text-muted">
Gestion de projet de jeu vidéo
{status?.version && <span className="text-subtle"> · version {status.version}</span>}
</p>
<div className="mt-7 w-full flex-1">
<AnimatePresence mode="wait">
{/* ------------------------------------------------ prêt */}
{step === 'ready' && (
<motion.div
key="ready"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18 }}
className="flex h-full flex-col"
>
<p className="text-sm leading-relaxed text-muted">
{reinstall
? 'Une version est déjà installée. Elle sera remplacée par celle-ci.'
: "L'application va s'installer sur ton compte utilisateur. Aucun droit administrateur n'est nécessaire."}
</p>
<div className="mt-4 rounded-lg border border-border bg-panel2 px-3 py-2 text-left">
<span className="block text-[10px] font-medium uppercase tracking-wide text-subtle">
Dossier d'installation
</span>
<span className="mt-0.5 block break-all font-mono text-[11px] text-muted">
{installDir || '…'}
</span>
</div>
<Checkbox
className="mt-4 justify-center"
checked={desktopShortcut}
onChange={setDesktopShortcut}
label={<span className="text-sm text-muted">Créer un raccourci sur le bureau</span>}
/>
<button
type="button"
onClick={run}
className="btn-primary mt-auto w-full py-2.5 text-[15px]"
>
{reinstall ? 'Réinstaller' : 'Installer'}
<ArrowRight size={17} />
</button>
</motion.div>
)}
{/* ------------------------------------------- installation */}
{step === 'installing' && (
<motion.div
key="installing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col items-center justify-center gap-4"
>
<div className="h-1 w-56 overflow-hidden rounded-full bg-panel2">
<motion.div
className="h-full rounded-full bg-accent"
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ repeat: Infinity, duration: 1.1, ease: 'easeInOut' }}
style={{ width: '50%' }}
/>
</div>
<p className="text-sm text-muted">{PHASES[phase]}</p>
</motion.div>
)}
{/* ---------------------------------------------- terminé */}
{step === 'done' && (
<motion.div
key="done"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex h-full flex-col"
>
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<motion.span
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 320, damping: 18 }}
className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-500"
>
<Check size={26} strokeWidth={3} />
</motion.span>
<p className="text-sm font-medium text-ink">Installation terminée</p>
<p className="text-xs text-subtle">
{status?.product_name ?? 'GameDev Tracker'} est prêt à être utilisé.
</p>
</div>
<button
type="button"
onClick={launch}
className="btn-primary mt-auto w-full py-2.5 text-[15px]"
>
Démarrer l'application
<ArrowRight size={17} />
</button>
</motion.div>
)}
{/* ------------------------------------------------ erreur */}
{step === 'error' && (
<motion.div
key="error"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex h-full flex-col"
>
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<AlertTriangle size={30} className="text-rose-500" />
<p className="text-sm font-medium text-ink">L'installation a échoué</p>
<p className="max-w-full break-words px-2 text-xs text-muted">{error}</p>
</div>
<div className="mt-auto flex w-full gap-2">
<button type="button" onClick={closeWindow} className="btn-ghost flex-1 py-2.5">
Fermer
</button>
<button
type="button"
onClick={() => setStep('ready')}
className="btn-primary flex-1 py-2.5"
>
Réessayer
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
)
}
+752 -307
View File
File diff suppressed because it is too large Load Diff
+136 -39
View File
@@ -1,21 +1,63 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Loader2 } from 'lucide-react'
import { ArrowLeft, Mail } from 'lucide-react'
import { supa } from '../lib/supabase'
import { useFeedback } from '../lib/feedback'
import { Button, Field, Input } from '../components/ui'
import Logo from '../components/Logo'
/** Traduit les messages d'erreur de Supabase Auth. */
function translate(msg: string): string {
if (/invalid login/i.test(msg)) return 'Email ou mot de passe incorrect.'
if (/email not confirmed/i.test(msg)) return "Cette adresse email n'a pas encore été confirmée."
if (/rate limit|too many/i.test(msg)) return 'Trop de tentatives. Réessaie dans une minute.'
if (/failed to fetch|network/i.test(msg)) {
return 'Impossible de joindre le serveur. Vérifie ta connexion internet.'
}
return msg
}
export default function Login(): JSX.Element {
const { toast } = useFeedback()
const [mode, setMode] = useState<'signin' | 'reset'>('signin')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [sent, setSent] = useState(false)
const submit = async (): Promise<void> => {
const signIn = async (): Promise<void> => {
setError('')
if (!email.trim() || !password) {
setError('Renseigne ton email et ton mot de passe.')
return
}
setBusy(true)
try {
const { error } = await supa().auth.signInWithPassword({ email, password })
if (error) throw error
const { error: e } = await supa().auth.signInWithPassword({
email: email.trim(),
password
})
if (e) throw e
// La bascule vers l'application est pilotée par onAuthStateChange.
} catch (e) {
setError(translate((e as Error).message))
setBusy(false)
}
}
const sendReset = async (): Promise<void> => {
setError('')
if (!email.trim()) {
setError('Indique ton adresse email.')
return
}
setBusy(true)
try {
const { error: e } = await supa().auth.resetPasswordForEmail(email.trim())
if (e) throw e
setSent(true)
toast('Email de réinitialisation envoyé.', 'success')
} catch (e) {
setError(translate((e as Error).message))
} finally {
@@ -26,52 +68,107 @@ export default function Login(): JSX.Element {
return (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
initial={{ opacity: 0, y: 14, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
transition={{ type: 'spring', stiffness: 280, damping: 26 }}
className="card w-full max-w-sm"
>
<div className="mb-6 flex flex-col items-center gap-2 text-center">
<Logo size={56} className="rounded-xl shadow-sm" />
<Logo size={52} className="rounded-xl shadow-card" />
<h1 className="text-xl font-semibold text-ink">GameDev Tracker</h1>
<p className="text-sm text-muted">Connecte-toi à ton équipe</p>
<p className="text-sm text-muted">
{mode === 'signin' ? 'Connecte-toi à ton équipe' : 'Réinitialiser le mot de passe'}
</p>
</div>
<label className="label">Email</label>
<input
className="input mb-3"
type="email"
placeholder="toi@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<label className="label">Mot de passe</label>
<input
className="input mb-4"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
/>
{/* Un vrai <form> : les gestionnaires de mots de passe fonctionnent,
l'autocomplétion aussi, et Entrée valide naturellement. */}
<form
onSubmit={(e) => {
e.preventDefault()
void (mode === 'signin' ? signIn() : sendReset())
}}
className="space-y-3"
>
<Field label="Email">
{({ id }) => (
<Input
id={id}
type="email"
name="email"
autoComplete="username"
autoFocus
placeholder="toi@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
)}
</Field>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
{mode === 'signin' && (
<Field label="Mot de passe">
{({ id }) => (
<Input
id={id}
type="password"
name="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
)}
</Field>
)}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Se connecter
</button>
{error && (
<p role="alert" className="rounded-lg bg-rose-500/10 px-3 py-2 text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="mt-4 text-center text-xs text-subtle">
Pas de compte ? Demande à ton administrateur de t'en créer un.
</p>
{sent && mode === 'reset' && (
<p className="flex items-start gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
<Mail size={15} className="mt-0.5 shrink-0" />
Si un compte existe pour cette adresse, un lien de réinitialisation vient d'être envoyé.
</p>
)}
<Button type="submit" variant="primary" full loading={busy}>
{mode === 'signin' ? 'Se connecter' : 'Envoyer le lien'}
</Button>
</form>
{mode === 'signin' ? (
<div className="mt-4 space-y-1.5 text-center">
<button
type="button"
onClick={() => {
setMode('reset')
setError('')
}}
className="text-xs text-accent hover:underline"
>
Mot de passe oublié ?
</button>
<p className="text-xs text-subtle">
Pas de compte ? Demande à ton administrateur de t'en créer un.
</p>
</div>
) : (
<button
type="button"
onClick={() => {
setMode('signin')
setError('')
setSent(false)
}}
className="mx-auto mt-4 flex items-center gap-1 text-xs text-accent hover:underline"
>
<ArrowLeft size={12} /> Revenir à la connexion
</button>
)}
</motion.div>
</div>
)
}
function translate(msg: string): string {
if (msg.includes('Invalid login')) return 'Email ou mot de passe incorrect.'
if (msg.includes('Email not confirmed')) return "L'email n'a pas encore été confirmé."
return msg
}
+292 -189
View File
@@ -1,189 +1,292 @@
import { useEffect, useState, useRef } from 'react'
import { Users, Pencil, Camera, Loader2 } from 'lucide-react'
import { supa } from '../lib/supabase'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { uploadAvatar } from '../lib/avatar'
import { Profile, MemberRole, POLE_LABELS, poleColor, memberPoles } from '../lib/types'
import { Avatar, Modal, EmptyState, PoleChips } from '../components/ui'
import PageHeader from '../components/PageHeader'
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
export default function Members(): JSX.Element {
const { profile, refreshProfile } = useApp()
const [members, setMembers] = useState<Profile[]>([])
const [editing, setEditing] = useState(false)
const load = async (): Promise<void> => {
const { data } = await supa().from('profiles').select('*').order('full_name')
setMembers((data ?? []) as Profile[])
}
useEffect(() => {
load()
}, [])
return (
<div className="flex h-full flex-col">
<PageHeader title="Équipe" subtitle={`${members.length} membre(s)`}>
<button className="btn-ghost" onClick={() => setEditing(true)}>
<Pencil size={16} /> Modifier mon profil
</button>
</PageHeader>
<div className="flex-1 overflow-y-auto p-6">
{members.length === 0 ? (
<EmptyState icon={<Users size={40} />} text="Aucun membre." />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => (
<div key={m.id} className="card flex items-center gap-3">
<Avatar profile={m} size={44} />
<div className="min-w-0">
<div className="truncate font-medium text-ink">
{m.full_name}
{m.id === profile?.id && <span className="ml-1 text-xs text-accent2">(moi)</span>}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{memberPoles(m).length === 0 ? (
<span className="text-sm text-subtle">Aucun pôle</span>
) : (
memberPoles(m).map((p) => (
<span key={p} className={`badge ${poleColor(p)}`}>
{POLE_LABELS[p]}
</span>
))
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
{editing && (
<EditProfileModal
profile={profile!}
onClose={() => setEditing(false)}
onSaved={async () => {
await refreshProfile()
await load()
}}
/>
)}
</div>
)
}
function EditProfileModal({
profile,
onClose,
onSaved
}: {
profile: Profile
onClose: () => void
onSaved: () => Promise<void>
}): JSX.Element {
const { toast } = useFeedback()
const [name, setName] = useState(profile.full_name)
const [roles, setRoles] = useState<MemberRole[]>(memberPoles(profile))
const [color, setColor] = useState(profile.avatar_color)
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile.avatar_url ?? null)
const [uploading, setUploading] = useState(false)
const [busy, setBusy] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const toggleRole = (pole: MemberRole): void =>
setRoles((cur) => (cur.includes(pole) ? cur.filter((r) => r !== pole) : [...cur, pole]))
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast('Choisis un fichier image.', 'error')
return
}
setUploading(true)
try {
setAvatarUrl(await uploadAvatar(file, profile.id))
} catch (err) {
toast((err as Error).message, 'error')
} finally {
setUploading(false)
}
}
const save = async (): Promise<void> => {
setBusy(true)
await supa()
.from('profiles')
.update({
full_name: name.trim() || 'Membre',
roles,
role: roles[0] ?? 'other',
avatar_color: color,
avatar_url: avatarUrl
})
.eq('id', profile.id)
await onSaved()
setBusy(false)
onClose()
}
const preview = { full_name: name || '?', avatar_color: color, avatar_url: avatarUrl } as Profile
return (
<Modal title="Mon profil" onClose={onClose}>
<label className="label">Nom complet</label>
<input className="input mb-3" value={name} onChange={(e) => setName(e.target.value)} />
<label className="label">Photo de profil</label>
<div className="mb-3 flex items-center gap-3">
<div className="relative">
<Avatar profile={preview} size={56} />
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Loader2 className="animate-spin text-white" size={18} />
</div>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={pickPhoto} />
<button className="btn-ghost" onClick={() => fileRef.current?.click()} disabled={uploading}>
<Camera size={16} /> {avatarUrl ? 'Changer' : 'Ajouter'}
</button>
{avatarUrl && (
<button
className="text-xs text-muted hover:text-rose-600"
onClick={() => setAvatarUrl(null)}
>
Retirer
</button>
)}
</div>
<label className="label">Mes pôles (un ou plusieurs)</label>
<div className="mb-3">
<PoleChips selected={roles} onToggle={toggleRole} />
</div>
{!avatarUrl && (
<>
<label className="label">Couleur d'avatar (sans photo)</label>
<div className="mb-4 flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-8 w-8 rounded-full ${color === c ? 'ring-2 ring-ink ring-offset-2' : ''}`}
style={{ background: c }}
/>
))}
</div>
</>
)}
<button className="btn-primary w-full" onClick={save} disabled={busy || uploading}>
Enregistrer
</button>
</Modal>
)
}
import { useCallback, useMemo, useState } from 'react'
import { endOfWeek, format, startOfWeek } from 'date-fns'
import { Bug as BugIcon, Clock, KanbanSquare, Pencil, Users } from 'lucide-react'
import { supa } from '../lib/supabase'
import { rows, useQuery, useRealtime } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { PageActions, usePageSubtitle } from '../components/Layout'
import {
ACTIVE_BUG_STATUSES,
Bug,
Pole,
POLE_ORDER,
POLE_LABELS,
Profile,
Task,
TimeLog,
formatMinutes,
memberPoles,
normalizeBugs,
normalizeTasks,
plural
} from '../lib/types'
import { Avatar, PoleList } from '../components/people'
import { AsyncContent, Button, EmptyState, SearchInput, Skeleton } from '../components/ui'
import { Select } from '../components/Select'
import EditProfileModal from '../components/EditProfileModal'
const DAY = (d: Date): string => format(d, 'yyyy-MM-dd')
interface Stats {
active: number
done: number
bugs: number
weekMinutes: number
totalMinutes: number
}
export default function Members(): JSX.Element {
const { profile, members, membersLoading, currentProject, refreshProfile, setPage, setFocusEntity } =
useApp()
const projectId = currentProject?.id
const [editing, setEditing] = useState(false)
const [q, setQ] = useState('')
const [pole, setPole] = useState('')
const weekStart = useMemo(() => startOfWeek(new Date(), { weekStartsOn: 1 }), [])
const weekEnd = useMemo(() => endOfWeek(new Date(), { weekStartsOn: 1 }), [])
const fetchWork = useCallback(async () => {
if (!projectId) return { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[] }
const [tasks, bugs, logs] = await Promise.all([
rows<Task>(supa().from('tasks').select('*').eq('project_id', projectId)).then(normalizeTasks),
rows<Bug>(supa().from('bugs').select('*').eq('project_id', projectId)).then(normalizeBugs),
rows<TimeLog>(supa().from('time_logs').select('*').eq('project_id', projectId))
])
return { tasks, bugs, logs }
}, [projectId])
const work = useQuery(fetchWork, { tasks: [] as Task[], bugs: [] as Bug[], logs: [] as TimeLog[] }, {
enabled: Boolean(projectId)
})
useRealtime(
projectId ? `members-${projectId}` : null,
[
{ table: 'tasks', filter: `project_id=eq.${projectId}` },
{ table: 'bugs', filter: `project_id=eq.${projectId}` },
{ table: 'time_logs', filter: `project_id=eq.${projectId}` }
],
work.reload
)
/** Statistiques par membre — la page ne montrait qu'un nom et des pastilles. */
const statsOf = useCallback(
(id: string): Stats => {
const { tasks, bugs, logs } = work.data
const mine = logs.filter((l) => l.user_id === id)
return {
active: tasks.filter((t) => t.status !== 'done' && t.assignee_ids.includes(id)).length,
done: tasks.filter((t) => t.status === 'done' && t.assignee_ids.includes(id)).length,
bugs: bugs.filter((b) => ACTIVE_BUG_STATUSES.includes(b.status) && b.assignee_ids.includes(id))
.length,
weekMinutes: mine
.filter((l) => l.logged_at >= DAY(weekStart) && l.logged_at <= DAY(weekEnd))
.reduce((s, l) => s + l.minutes, 0),
totalMinutes: mine.reduce((s, l) => s + l.minutes, 0)
}
},
[work.data, weekStart, weekEnd]
)
const shown = useMemo(() => {
const needle = q.trim().toLowerCase()
return members.filter((m) => {
if (pole && !memberPoles(m).includes(pole as Pole)) return false
if (needle && !m.full_name.toLowerCase().includes(needle)) return false
return true
})
}, [members, q, pole])
usePageSubtitle(plural(members.length, 'membre'))
return (
<div className="flex h-full flex-col">
<PageActions>
<SearchInput
className="hidden w-48 md:block"
value={q}
onChange={setQ}
placeholder="Chercher un membre…"
/>
<Select
size="sm"
className="!w-40"
ariaLabel="Filtrer par pôle"
value={pole}
onChange={setPole}
options={[
{ value: '', label: 'Tous les pôles' },
...POLE_ORDER.map((p) => ({ value: p as string, label: POLE_LABELS[p] }))
]}
/>
<Button icon={<Pencil size={15} />} onClick={() => setEditing(true)}>
Mon profil
</Button>
</PageActions>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
{membersLoading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-[132px]" />
))}
</div>
) : shown.length === 0 ? (
<EmptyState
icon={<Users size={40} />}
title={members.length === 0 ? 'Aucun membre' : 'Aucun membre ne correspond'}
text={
members.length === 0
? "Les comptes se créent depuis l'onglet Administration."
: 'Change le filtre ou la recherche.'
}
/>
) : (
<AsyncContent loading={false} error={work.error} onRetry={work.reload}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{shown.map((m) => (
<MemberCard
key={m.id}
member={m}
isMe={m.id === profile?.id}
stats={statsOf(m.id)}
loading={work.loading && Boolean(projectId)}
hasProject={Boolean(projectId)}
onOpenTasks={() => {
setFocusEntity(null)
setPage('kanban')
}}
onEdit={m.id === profile?.id ? () => setEditing(true) : undefined}
/>
))}
</div>
</AsyncContent>
)}
</div>
{editing && profile && (
<EditProfileModal
profile={profile}
onClose={() => setEditing(false)}
onSaved={refreshProfile}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------
function MemberCard({
member,
isMe,
stats,
loading,
hasProject,
onOpenTasks,
onEdit
}: {
member: Profile
isMe: boolean
stats: Stats
loading: boolean
hasProject: boolean
onOpenTasks: () => void
onEdit?: () => void
}): JSX.Element {
return (
<article className="card flex flex-col gap-3">
<div className="flex items-start gap-3">
<Avatar profile={member} size={44} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<h3 className="truncate font-medium text-ink">{member.full_name}</h3>
{isMe && <span className="shrink-0 text-xs text-accent">(moi)</span>}
{member.is_admin && (
<span className="badge shrink-0 bg-amber-500/12 text-amber-700 dark:text-amber-300">
Admin
</span>
)}
</div>
<div className="mt-1.5">
<PoleList poles={memberPoles(member)} />
</div>
</div>
{onEdit && (
<button
type="button"
aria-label="Modifier mon profil"
onClick={onEdit}
className="shrink-0 text-subtle hover:text-accent"
>
<Pencil size={15} />
</button>
)}
</div>
{!hasProject ? (
<p className="text-xs text-subtle">Ouvre un projet pour voir l'activité de chacun.</p>
) : loading ? (
<Skeleton className="h-9 w-full" />
) : (
<div className="grid grid-cols-3 gap-1 border-t border-border pt-2.5">
<Stat
icon={<KanbanSquare size={13} />}
value={stats.active}
label={stats.active > 1 ? 'tâches' : 'tâche'}
hint={`${stats.done} terminée${stats.done > 1 ? 's' : ''}`}
onClick={onOpenTasks}
/>
<Stat
icon={<BugIcon size={13} />}
value={stats.bugs}
label={stats.bugs > 1 ? 'bugs' : 'bug'}
tone={stats.bugs > 0 ? 'text-rose-600 dark:text-rose-400' : undefined}
/>
<Stat
icon={<Clock size={13} />}
value={formatMinutes(stats.weekMinutes)}
label="cette semaine"
hint={stats.totalMinutes ? `${formatMinutes(stats.totalMinutes)} au total` : undefined}
tone="text-accent2"
/>
</div>
)}
</article>
)
}
function Stat({
icon,
value,
label,
hint,
tone = 'text-ink',
onClick
}: {
icon: JSX.Element
value: string | number
label: string
hint?: string
tone?: string
onClick?: () => void
}): JSX.Element {
const content = (
<>
<span className="flex items-center gap-1 text-[11px] text-subtle">
{icon}
{label}
</span>
<span className={`block text-sm font-semibold tabular ${tone}`}>{value}</span>
{hint && <span className="block truncate text-[10px] text-subtle">{hint}</span>}
</>
)
if (!onClick) return <div className="min-w-0">{content}</div>
return (
<button type="button" onClick={onClick} className="min-w-0 rounded-lg text-left hover:bg-hover">
{content}
</button>
)
}
+144 -190
View File
@@ -1,129 +1,102 @@
import { useState, useRef } from 'react'
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { ArrowRight, ArrowLeft, Check, Loader2, Sparkles, Camera } from 'lucide-react'
import { ArrowLeft, ArrowRight, Check, Sparkles } from 'lucide-react'
import { supa } from '../lib/supabase'
import { mutate, humanize } from '../lib/db'
import { useApp } from '../lib/AppContext'
import { useFeedback } from '../lib/feedback'
import { uploadAvatar } from '../lib/avatar'
import {
MemberRole,
Profile,
POLE_LABELS,
poleColor,
SELF_ASSIGNABLE_POLES,
CRITICAL_POLES,
memberPoles
Pole,
POLE_LABELS,
SELF_ASSIGNABLE_POLES,
memberPoles,
poleBadge
} from '../lib/types'
import { Avatar } from '../components/ui'
import { AVATAR_COLORS, AvatarUploader, ColorPicker } from '../components/people'
import { Button, Field, Input } from '../components/ui'
import Logo from '../components/Logo'
const COLORS = ['#7c5cff', '#00d2b8', '#ff5c8a', '#ffa53c', '#3ca8ff', '#9bdb4d', '#e15cff', '#ff6b6b']
const STEPS = ['Ton nom', 'Ta photo', 'Tes pôles'] as const
export default function Onboarding(): JSX.Element {
const { profile, refreshProfile } = useApp()
const { profile, refreshProfile, signOut } = useApp()
const { toast } = useFeedback()
// Rôles critiques déjà attribués par l'admin (ex : chef de projet) : on les
// Pôles critiques déjà attribués par un admin (ex : chef de projet) : on les
// conserve, mais le membre ne peut pas se les donner lui-même.
const keptCritical = memberPoles(profile ?? {}).filter((p) => CRITICAL_POLES.includes(p))
// Découpe le nom existant (« Prénom Nom ») s'il y en a un.
const existingName = profile && profile.full_name !== 'Membre' ? profile.full_name : ''
const [step, setStep] = useState(0)
const [firstName, setFirstName] = useState(existingName.split(' ')[0] ?? '')
const [lastName, setLastName] = useState(existingName.split(' ').slice(1).join(' '))
const [color, setColor] = useState(profile?.avatar_color ?? COLORS[0])
const [color, setColor] = useState(profile?.avatar_color ?? AVATAR_COLORS[0])
const [avatarUrl, setAvatarUrl] = useState<string | null>(profile?.avatar_url ?? null)
const [uploading, setUploading] = useState(false)
const [poles, setPoles] = useState<MemberRole[]>(
const [poles, setPoles] = useState<Pole[]>(
memberPoles(profile ?? {}).filter((p) => SELF_ASSIGNABLE_POLES.includes(p))
)
const [busy, setBusy] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const togglePole = (p: MemberRole): void =>
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
const fullName = `${firstName.trim()} ${lastName.trim()}`.trim()
const previewProfile = {
full_name: fullName || '?',
avatar_color: color,
avatar_url: avatarUrl
} as unknown as Profile
const pickPhoto = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = e.target.files?.[0]
if (!file || !profile) return
if (!file.type.startsWith('image/')) {
toast('Choisis un fichier image.', 'error')
return
}
setUploading(true)
try {
const url = await uploadAvatar(file, profile.id)
setAvatarUrl(url)
} catch (err) {
toast((err as Error).message, 'error')
} finally {
setUploading(false)
}
}
const steps = ['Ton nom', 'Ta photo', 'Tes pôles']
const canNext = step === 0 ? firstName.trim().length > 0 : true
const togglePole = (p: Pole): void =>
setPoles((cur) => (cur.includes(p) ? cur.filter((x) => x !== p) : [...cur, p]))
const finish = async (): Promise<void> => {
if (!profile) return
setBusy(true)
const roles = Array.from(new Set([...poles, ...keptCritical]))
const finalRoles = roles.length ? roles : (['other'] as MemberRole[])
const { error } = await supa()
.from('profiles')
.update({
full_name: fullName || 'Membre',
avatar_color: color,
avatar_url: avatarUrl,
roles: finalRoles,
role: finalRoles[0],
onboarded: true
})
.eq('id', profile.id)
if (error) {
const roles = [...new Set([...poles, ...keptCritical])]
try {
await mutate(
supa()
.from('profiles')
.update({
full_name: fullName || 'Membre',
avatar_color: color,
avatar_url: avatarUrl,
roles: roles.length ? roles : (['other'] as Pole[]),
onboarded: true
})
.eq('id', profile.id)
)
await refreshProfile()
// Le passage onboarded=true bascule automatiquement vers l'application.
} catch (e) {
// Sans ce reset, un échec laissait l'utilisateur bloqué sur un spinner.
setBusy(false)
toast(error.message, 'error')
return
toast(humanize(e), 'error')
}
await refreshProfile()
// Le passage onboarded=true bascule automatiquement vers l'app.
}
const next = (): void => {
if (step < steps.length - 1) setStep((s) => s + 1)
else finish()
if (step < STEPS.length - 1) setStep((s) => s + 1)
else void finish()
}
return (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
initial={{ opacity: 0, y: 14, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
transition={{ type: 'spring', stiffness: 280, damping: 26 }}
className="card w-full max-w-lg"
>
{/* En-tête */}
<div className="mb-5 flex items-center gap-3">
<Logo size={44} className="rounded-xl shadow-sm" />
<div>
<header className="mb-5 flex items-center gap-3">
<Logo size={42} className="rounded-xl shadow-card" />
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold text-ink">Bienvenue dans l'équipe 👋</h1>
<p className="text-sm text-muted">Configurons ton profil en 3 étapes.</p>
<p className="text-sm text-muted">
Étape {step + 1} sur {STEPS.length} {STEPS[step]}
</p>
</div>
</div>
</header>
{/* Barre de progression */}
<div className="mb-6 flex gap-1.5">
{steps.map((_, i) => (
<div className="mb-6 flex gap-1.5" role="progressbar" aria-valuenow={step + 1} aria-valuemin={1} aria-valuemax={STEPS.length}>
{STEPS.map((label, i) => (
<div
key={i}
key={label}
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= step ? 'bg-accent' : 'bg-panel2'
}`}
@@ -134,154 +107,135 @@ export default function Onboarding(): JSX.Element {
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, x: 24 }}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.2 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.18 }}
>
{step === 0 && (
<div>
<form
onSubmit={(e) => {
e.preventDefault()
if (canNext) next()
}}
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Prénom</label>
<input
className="input"
autoFocus
placeholder="Alex"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
/>
</div>
<div>
<label className="label">Nom</label>
<input
className="input"
placeholder="Martin"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && canNext && next()}
/>
</div>
<Field label="Prénom" required>
{({ id }) => (
<Input
id={id}
autoFocus
autoComplete="given-name"
placeholder="Alex"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
/>
)}
</Field>
<Field label="Nom">
{({ id }) => (
<Input
id={id}
autoComplete="family-name"
placeholder="Martin"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
/>
)}
</Field>
</div>
<p className="mt-2 text-xs text-subtle">
C'est le nom que tes coéquipiers verront sur les tâches.
C'est le nom que tes coéquipiers verront sur les tâches et les messages.
</p>
</div>
<button type="submit" className="hidden" aria-hidden tabIndex={-1} />
</form>
)}
{step === 1 && (
<div>
<label className="label">Ta photo de profil</label>
<div className="flex items-center gap-4">
<div className="relative">
<Avatar profile={previewProfile} size={72} />
{uploading && (
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50">
<Loader2 className="animate-spin text-white" size={22} />
</div>
)}
</div>
<div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={pickPhoto}
<Field label="Ta photo de profil">
{profile && (
<AvatarUploader
userId={profile.id}
name={fullName}
color={color}
avatarUrl={avatarUrl}
onChange={setAvatarUrl}
size={72}
/>
<button
className="btn-ghost"
onClick={() => fileRef.current?.click()}
disabled={uploading}
>
<Camera size={16} /> {avatarUrl ? 'Changer la photo' : 'Ajouter une photo'}
</button>
{avatarUrl && (
<button
className="ml-2 text-xs text-muted hover:text-rose-600"
onClick={() => setAvatarUrl(null)}
>
Retirer
</button>
)}
</div>
</div>
)}
</Field>
{/* Repli : couleur des initiales si aucune photo */}
{!avatarUrl && (
<div className="mt-4">
<p className="label">Ou choisis une couleur</p>
<div className="flex flex-wrap gap-2">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`h-8 w-8 rounded-full transition-transform hover:scale-110 ${
color === c ? 'ring-2 ring-ink ring-offset-2 ring-offset-panel' : ''
}`}
style={{ background: c }}
/>
))}
</div>
</div>
<Field label="Ou choisis une couleur pour tes initiales" className="mt-4">
<ColorPicker value={color} onChange={setColor} />
</Field>
)}
<p className="mt-3 text-xs text-subtle">
Optionnel tu pourras la changer plus tard depuis « Équipe ».
Optionnel tu pourras la changer plus tard depuis « Mon profil ».
</p>
</div>
)}
{step === 2 && (
<div>
<label className="label">Dans quel(s) pôle(s) travailles-tu ?</label>
<div className="flex flex-wrap gap-1.5">
{SELF_ASSIGNABLE_POLES.map((p) => {
const on = poles.includes(p)
return (
<button
key={p}
type="button"
onClick={() => togglePole(p)}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
on
? `${poleColor(p)} border-transparent ring-2 ring-accent/40`
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
{on && <Check size={13} />}
{POLE_LABELS[p]}
</button>
)
})}
</div>
<Field
label="Dans quel(s) pôle(s) travailles-tu ?"
hint="Tu recevras une notification quand une tâche ou un bug est confié à l'un de tes pôles."
>
<div className="flex flex-wrap gap-1.5">
{SELF_ASSIGNABLE_POLES.map((p) => {
const on = poles.includes(p)
return (
<button
key={p}
type="button"
aria-pressed={on}
onClick={() => togglePole(p)}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
on
? `${poleBadge(p)} border-transparent ring-2 ring-accent/40`
: 'border-border text-muted hover:border-accent hover:text-ink'
}`}
>
{on && <Check size={13} />}
{POLE_LABELS[p]}
</button>
)
})}
</div>
</Field>
{keptCritical.length > 0 && (
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent2">
<p className="mt-3 flex items-center gap-1.5 text-xs text-accent">
<Sparkles size={13} />
Ton rôle {keptCritical.map((p) => POLE_LABELS[p]).join(', ')} t'a é attribué par
l'administrateur.
</p>
)}
<p className="mt-2 text-xs text-subtle">
Tu recevras une notification quand une tâche est confiée à un de tes pôles.
</p>
{poles.length === 0 && keptCritical.length === 0 && (
<p className="mt-3 text-xs text-amber-600 dark:text-amber-400">
Sans pôle, tu ne recevras aucune notification d'équipe. Tu pourras en ajouter plus tard.
</p>
)}
</div>
)}
</motion.div>
</AnimatePresence>
{/* Navigation */}
<div className="mt-6 flex items-center justify-between">
{step > 0 ? (
<button className="btn-ghost" onClick={() => setStep((s) => s - 1)} disabled={busy}>
<ArrowLeft size={16} /> Retour
</button>
<Button icon={<ArrowLeft size={16} />} onClick={() => setStep((s) => s - 1)} disabled={busy}>
Retour
</Button>
) : (
<span />
<Button variant="quiet" onClick={signOut} disabled={busy}>
Se déconnecter
</Button>
)}
<button className="btn-primary" onClick={next} disabled={!canNext || busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
{step < steps.length - 1 ? (
<Button variant="primary" onClick={next} disabled={!canNext} loading={busy}>
{step < STEPS.length - 1 ? (
<>
Continuer <ArrowRight size={16} />
</>
@@ -290,7 +244,7 @@ export default function Onboarding(): JSX.Element {
<Check size={16} /> Terminer
</>
)}
</button>
</Button>
</div>
</motion.div>
</div>
+810 -448
View File
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Database, ExternalLink, Loader2 } from 'lucide-react'
import { useApp } from '../lib/AppContext'
export default function Setup(): JSX.Element {
const { saveConfig } = useApp()
const [url, setUrl] = useState('')
const [key, setKey] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const submit = async (): Promise<void> => {
setError('')
if (!url.includes('supabase') || key.length < 20) {
setError('URL ou clé invalide. Vérifie tes valeurs dans Supabase > Settings > API.')
return
}
setBusy(true)
try {
await saveConfig(url, key)
} catch (e) {
setError((e as Error).message)
setBusy(false)
}
}
return (
<div className="flex h-full items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 24 }}
className="card w-full max-w-lg"
>
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-accent/20 p-2 text-accent">
<Database size={24} />
</div>
<div>
<h1 className="text-xl font-semibold text-ink">Connexion à la base de données</h1>
<p className="text-sm text-muted">Configuration unique pour toute l'équipe</p>
</div>
</div>
<div className="mb-4 rounded-lg border border-border bg-panel2 p-3 text-sm text-gray-600">
<p className="mb-2 font-medium text-ink">Comment obtenir ces clés (gratuit) :</p>
<ol className="list-inside list-decimal space-y-1 text-muted">
<li>
Crée un compte sur{' '}
<a
href="https://supabase.com"
className="inline-flex items-center gap-1 text-accent2 hover:underline"
>
supabase.com <ExternalLink size={12} />
</a>
</li>
<li>Crée un nouveau projet (gratuit)</li>
<li>Onglet SQL Editor : colle le contenu de supabase/schema.sql et exécute-le</li>
<li>Onglet Settings API : copie l'URL et la clé « anon public »</li>
</ol>
</div>
<label className="label">URL du projet</label>
<input
className="input mb-3"
placeholder="https://xxxxx.supabase.co"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<label className="label">Clé anon public</label>
<input
className="input mb-4"
placeholder="eyJhbGciOi..."
value={key}
onChange={(e) => setKey(e.target.value)}
/>
{error && <p className="mb-3 text-sm text-rose-600">{error}</p>}
<button className="btn-primary w-full" onClick={submit} disabled={busy}>
{busy && <Loader2 className="animate-spin" size={16} />}
Connecter
</button>
<p className="mt-3 text-center text-xs text-subtle">
Les clés sont stockées localement sur ton ordinateur uniquement.
</p>
</motion.div>
</div>
)
}
+76 -57
View File
@@ -1,12 +1,14 @@
// =====================================================================
// Edge Function : admin-users
// Gère les comptes (lister / créer / supprimer / modifier rôle & admin).
// Gère les comptes (lister / créer / supprimer / modifier).
// Tourne sur les serveurs Supabase et garde la clé service_role en
// sécurité — elle n'est JAMAIS exposée dans l'appli.
// sécurité — elle n'est JAMAIS exposée dans l'application.
//
// Déploiement (voir le guide) :
// Déploiement :
// supabase functions deploy admin-users --no-verify-jwt
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous.)
// (la fonction vérifie elle-même l'identité de l'appelant ci-dessous)
//
// v10 : la colonne `profiles.role` a disparu ; seul `roles[]` fait foi.
// =====================================================================
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
@@ -20,6 +22,17 @@ const SUPABASE_URL = Deno.env.get('SUPABASE_URL')!
const ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!
const SERVICE_ROLE = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
const VALID_ROLES = [
'lead',
'programmer',
'artist',
'game_designer',
'sound',
'writer',
'qa',
'other'
]
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
@@ -27,8 +40,13 @@ function json(body: unknown, status = 200): Response {
})
}
/** Ne garde que des pôles valides (l'enum SQL rejetterait le reste). */
function cleanRoles(input: unknown): string[] {
if (!Array.isArray(input)) return []
return [...new Set(input.filter((r) => typeof r === 'string' && VALID_ROLES.includes(r)))]
}
Deno.serve(async (req) => {
// Pré-vol CORS (envoyé automatiquement par le navigateur/webview).
if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
try {
@@ -43,7 +61,7 @@ Deno.serve(async (req) => {
} = await caller.auth.getUser()
if (userErr || !user) return json({ error: 'Non authentifié.' }, 401)
// --- 2) Client "admin" (service_role) pour les opérations sensibles ---
// --- 2) Client « admin » (service_role) pour les opérations sensibles ---
const admin = createClient(SUPABASE_URL, SERVICE_ROLE, {
auth: { autoRefreshToken: false, persistSession: false }
})
@@ -61,63 +79,61 @@ Deno.serve(async (req) => {
switch (action) {
case 'list': {
// Emails dans auth.users + métadonnées dans profiles → on fusionne.
// Les emails sont dans auth.users, le reste dans profiles → fusion.
const { data: list, error } = await admin.auth.admin.listUsers({ perPage: 1000 })
if (error) throw error
const { data: profiles } = await admin.from('profiles').select('*')
const byId = new Map((profiles ?? []).map((pr: any) => [pr.id, pr]))
const users = list.users.map((u) => {
const pr: any = byId.get(u.id) ?? {}
const roles =
Array.isArray(pr.roles) && pr.roles.length
? pr.roles
: pr.role
? [pr.role]
: []
return {
id: u.id,
email: u.email ?? '',
full_name: pr.full_name ?? '',
role: pr.role ?? 'other',
roles,
is_admin: pr.is_admin ?? false,
avatar_color: pr.avatar_color ?? '#7c5cff',
avatar_url: pr.avatar_url ?? null,
created_at: pr.created_at ?? u.created_at
}
})
const byId = new Map((profiles ?? []).map((pr: Record<string, unknown>) => [pr.id, pr]))
const users = list.users
.map((u) => {
const pr = (byId.get(u.id) ?? {}) as Record<string, unknown>
return {
id: u.id,
email: u.email ?? '',
full_name: (pr.full_name as string) ?? '',
roles: Array.isArray(pr.roles) ? pr.roles : [],
is_admin: Boolean(pr.is_admin),
avatar_color: (pr.avatar_color as string) ?? '#7c5cff',
avatar_url: (pr.avatar_url as string) ?? null,
created_at: (pr.created_at as string) ?? u.created_at
}
})
.sort((a, b) => a.full_name.localeCompare(b.full_name, 'fr'))
return json(users)
}
case 'create': {
const { email, password, full_name, role, roles, is_admin } = p
const { email, password, full_name, roles, is_admin } = p
if (!email || !password) return json({ error: 'Email et mot de passe requis.' }, 400)
if (String(password).length < 6)
if (String(password).length < 6) {
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
}
// Liste de pôles (rôles) ; le premier fait office de rôle principal.
const roleList: string[] = Array.isArray(roles) && roles.length ? roles : role ? [role] : []
const roleList = cleanRoles(roles)
const primary = roleList[0] ?? 'other'
const name = full_name || String(email).split('@')[0]
// Crée le compte (email déjà confirmé : connexion immédiate possible).
// email_confirm : le membre peut se connecter immédiatement.
const { data: created, error } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: full_name || email.split('@')[0], role: primary }
user_metadata: { full_name: name, role: primary }
})
if (error) return json({ error: error.message }, 400)
// Le trigger handle_new_user a créé le profil ; on cale les champs.
await admin
const { error: profErr } = await admin
.from('profiles')
.update({
full_name: full_name || email.split('@')[0],
role: primary,
full_name: name,
roles: roleList.length ? roleList : [primary],
is_admin: !!is_admin
is_admin: Boolean(is_admin)
})
.eq('id', created.user.id)
if (profErr) return json({ error: profErr.message }, 400)
return json({ ok: true, id: created.user.id })
}
@@ -125,58 +141,61 @@ Deno.serve(async (req) => {
case 'delete': {
const { id } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id)
if (id === user.id) {
return json({ error: 'Tu ne peux pas supprimer ton propre compte.' }, 400)
}
const { error } = await admin.auth.admin.deleteUser(id) // profil supprimé en cascade
if (error) return json({ error: error.message }, 400)
return json({ ok: true })
}
case 'update': {
const { id, full_name, email, password, role, roles, is_admin, avatar_url } = p
const { id, full_name, email, password, roles, is_admin, avatar_url } = p
if (!id) return json({ error: 'Identifiant manquant.' }, 400)
if (id === user.id && is_admin === false)
if (id === user.id && is_admin === false) {
return json({ error: 'Tu ne peux pas retirer ton propre statut admin.' }, 400)
}
// --- a) Champs du compte auth (email / mot de passe / métadonnées) ---
// --- a) Compte auth (email / mot de passe / métadonnées) ---
const authPatch: Record<string, unknown> = {}
if (email !== undefined && String(email).trim()) {
authPatch.email = String(email).trim()
authPatch.email_confirm = true // évite le mail de confirmation
}
if (password !== undefined && String(password) !== '') {
if (String(password).length < 6)
if (String(password).length < 6) {
return json({ error: 'Le mot de passe doit faire au moins 6 caractères.' }, 400)
}
authPatch.password = String(password)
}
if (full_name !== undefined)
authPatch.user_metadata = { full_name, role: Array.isArray(roles) ? roles[0] : role }
if (full_name !== undefined) {
// On fusionne avec les métadonnées existantes plutôt que de les écraser.
const { data: existing } = await admin.auth.admin.getUserById(id)
authPatch.user_metadata = {
...(existing?.user?.user_metadata ?? {}),
full_name
}
}
if (Object.keys(authPatch).length > 0) {
const { error: authErr } = await admin.auth.admin.updateUserById(id, authPatch)
if (authErr) return json({ error: authErr.message }, 400)
}
// --- b) Champs du profil (nom, pôles, admin) ---
// --- b) Profil (nom, pôles, admin, avatar) ---
const patch: Record<string, unknown> = {}
if (full_name !== undefined) patch.full_name = full_name
if (is_admin !== undefined) patch.is_admin = !!is_admin
if (is_admin !== undefined) patch.is_admin = Boolean(is_admin)
if (avatar_url !== undefined) patch.avatar_url = avatar_url || null
// Gestion des pôles : roles = liste complète, role = principal (roles[0]).
if (Array.isArray(roles)) {
const primary = roles[0] ?? 'other'
patch.roles = roles.length ? roles : [primary]
patch.role = primary
} else if (role !== undefined) {
patch.role = role
patch.roles = [role]
}
if (roles !== undefined) patch.roles = cleanRoles(roles)
if (Object.keys(patch).length > 0) {
const { error } = await admin.from('profiles').update(patch).eq('id', id)
if (error) return json({ error: error.message }, 400)
}
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0)
if (Object.keys(authPatch).length === 0 && Object.keys(patch).length === 0) {
return json({ error: 'Rien à modifier.' }, 400)
}
return json({ ok: true })
}
+345
View File
@@ -0,0 +1,345 @@
-- =====================================================================
-- GameDev Tracker — Migration v10 « Refonte »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
--
-- Ce que fait cette migration :
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
-- 1. Commentaires sur les tâches et les bugs
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
-- 3. Parité tâches/bugs : plusieurs pôles par bug
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
-- (profiles.role, tasks.pole, bugs.assignee_id)
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
-- 7. Temps réel sur les projets
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
--
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
-- pas à pas, la colonne manquait et l'application plantait en tentant
-- d'afficher les étiquettes d'une tâche.
--
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
-- tableau : jamais NULL, toujours un tableau vide par défaut.
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists tags text[] not null default '{}';
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
do $$
declare
r record;
begin
for r in
select * from (values
('tasks', 'tags', '''{}''::text[]'),
('tasks', 'poles', '''{}''::member_role[]'),
('tasks', 'assignee_ids', '''{}''::uuid[]'),
('tasks', 'checklist', '''[]''::jsonb'),
('bugs', 'poles', '''{}''::member_role[]'),
('bugs', 'assignee_ids', '''{}''::uuid[]'),
('profiles', 'roles', '''{}''::member_role[]')
) as t(tbl, col, empty)
loop
if exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
) then
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
end if;
end loop;
end $$;
-- ---------------------------------------------------------------------
-- 1) Parité bugs / tâches : plusieurs pôles par bug
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists poles member_role[] not null default '{}';
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
-- ---------------------------------------------------------------------
create table if not exists public.comments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task', 'bug')),
entity_id uuid not null,
author_id uuid references public.profiles(id) on delete set null,
body text not null check (length(btrim(body)) > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
create index if not exists idx_comments_project on public.comments(project_id);
alter table public.comments enable row level security;
drop policy if exists comments_select on public.comments;
drop policy if exists comments_insert on public.comments;
drop policy if exists comments_update on public.comments;
drop policy if exists comments_delete on public.comments;
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
create policy comments_select on public.comments
for select to authenticated using (true);
create policy comments_insert on public.comments
for insert to authenticated with check (author_id = auth.uid());
create policy comments_update on public.comments
for update to authenticated
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy comments_delete on public.comments
for delete to authenticated
using (
author_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------------------------------------------------------------------
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
-- ---------------------------------------------------------------------
create table if not exists public.attachments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task', 'bug')),
entity_id uuid not null,
uploader_id uuid references public.profiles(id) on delete set null,
name text not null,
path text not null, -- chemin dans le bucket "attachments"
size bigint not null default 0,
mime text,
created_at timestamptz not null default now()
);
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
create index if not exists idx_attachments_project on public.attachments(project_id);
alter table public.attachments enable row level security;
drop policy if exists attachments_select on public.attachments;
drop policy if exists attachments_insert on public.attachments;
drop policy if exists attachments_delete on public.attachments;
create policy attachments_select on public.attachments
for select to authenticated using (true);
create policy attachments_insert on public.attachments
for insert to authenticated with check (uploader_id = auth.uid());
create policy attachments_delete on public.attachments
for delete to authenticated
using (
uploader_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- Bucket de stockage des fichiers joints.
insert into storage.buckets (id, name, public)
values ('attachments', 'attachments', true)
on conflict (id) do nothing;
drop policy if exists attachments_read on storage.objects;
drop policy if exists attachments_write on storage.objects;
drop policy if exists attachments_remove on storage.objects;
create policy attachments_read on storage.objects
for select using (bucket_id = 'attachments');
create policy attachments_write on storage.objects
for insert to authenticated with check (bucket_id = 'attachments');
create policy attachments_remove on storage.objects
for delete to authenticated using (bucket_id = 'attachments');
-- ---------------------------------------------------------------------
-- 4) Notifications : sur QUOI pointe la notification
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
-- ---------------------------------------------------------------------
alter table public.notifications
add column if not exists entity_kind text not null default 'task';
update public.notifications
set entity_kind = kind
where kind in ('task', 'bug') and entity_kind is distinct from kind;
create index if not exists idx_notifications_user_unread
on public.notifications(user_id, read, created_at desc);
-- ---------------------------------------------------------------------
-- 5) Réordonnancement du Kanban en UNE requête
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
-- allers-retours non transactionnels — un échec corrompait l'ordre).
-- ---------------------------------------------------------------------
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
returns void
language plpgsql
security invoker -- la RLS de l'appelant s'applique normalement
set search_path = public
as $$
begin
update public.tasks t
set status = p_status,
position = ordered.idx
from (
select id, (ord - 1)::double precision as idx
from unnest(p_ids) with ordinality as u(id, ord)
) ordered
where t.id = ordered.id;
end $$;
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
-- ---------------------------------------------------------------------
-- 6) Suppression d'un projet : créateur ou administrateur seulement
-- (avant : n'importe quel membre pouvait effacer un projet entier
-- et tout son contenu en cascade)
-- ---------------------------------------------------------------------
drop policy if exists projects_all on public.projects;
drop policy if exists projects_select on public.projects;
drop policy if exists projects_insert on public.projects;
drop policy if exists projects_update on public.projects;
drop policy if exists projects_delete on public.projects;
create policy projects_select on public.projects
for select to authenticated using (true);
create policy projects_insert on public.projects
for insert to authenticated with check (true);
create policy projects_update on public.projects
for update to authenticated using (true) with check (true);
create policy projects_delete on public.projects
for delete to authenticated
using (
created_by = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------------------------------------------------------------------
-- 7) Temps réel : projets, commentaires, pièces jointes
-- ---------------------------------------------------------------------
do $$ begin
alter publication supabase_realtime add table public.projects;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.comments;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.attachments;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 8) Création de profil à l'inscription : couleur d'avatar variée
-- (avant, tout le monde héritait du même violet #7c5cff)
-- ---------------------------------------------------------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
r member_role;
begin
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
insert into public.profiles (id, full_name, roles, avatar_color)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
array[r],
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
)
on conflict (id) do nothing;
return new;
end $$;
-- ---------------------------------------------------------------------
-- 9) Suppression des colonnes « de compatibilité »
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
-- utilise plus).
-- ---------------------------------------------------------------------
-- a) profiles.role → profiles.roles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
alter table public.profiles drop column role;
end if;
end $$;
-- b) tasks.pole → tasks.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
alter table public.tasks drop column pole;
end if;
end $$;
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
update public.bugs set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
alter table public.bugs drop column assignee_id;
end if;
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
alter table public.bugs drop column pole;
end if;
end $$;
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
-- perdre — mais plus personne ne la remplit.
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
alter table public.bugs alter column priority set default 'medium';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 10) Index manquants sur les colonnes les plus filtrées
-- ---------------------------------------------------------------------
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
-- ---------------------------------------------------------------------
-- 11) Temps passé sur un BUG (parité avec les tâches)
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
-- rien (activité libre : réunion, veille…). Jamais les deux.
-- ---------------------------------------------------------------------
alter table public.time_logs
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
do $$ begin
alter table public.time_logs
add constraint time_logs_one_target
check (not (task_id is not null and bug_id is not null));
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- Dernière ligne : forcer l'API à relire le schéma.
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
-- pendant plusieurs minutes après la migration.
-- ---------------------------------------------------------------------
notify pgrst, 'reload schema';
+46
View File
@@ -0,0 +1,46 @@
-- =====================================================================
-- GameDev Tracker — Migration v11 « Documentation »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée.
--
-- Ajoute les fiches de documentation : GDD, conventions de code, notes
-- de réunion, procédures… Créées dans l'app ou importées depuis un
-- fichier Markdown / Word.
-- =====================================================================
create table if not exists public.documents (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
-- cherchables et modifiables dans l'application.
content text not null default '',
tags text[] not null default '{}',
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
source_name text,
created_by uuid references public.profiles(id) on delete set null,
updated_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
drop trigger if exists trg_documents_touch on public.documents;
create trigger trg_documents_touch before update on public.documents
for each row execute function public.touch_updated_at();
alter table public.documents enable row level security;
drop policy if exists documents_all on public.documents;
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
create policy documents_all on public.documents
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.documents;
exception when duplicate_object then null; end $$;
notify pgrst, 'reload schema';
-70
View File
@@ -1,70 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v2
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (pour une base déjà créée avec schema.sql v1)
--
-- Contenu :
-- • Suppression des JALONS (milestones)
-- • Notion de PÔLE sur les tâches et les bugs
-- • Plusieurs personnes assignables sur une tâche (assignee_ids)
-- • Système de NOTIFICATIONS (assignation à un pôle)
-- =====================================================================
-- ---------- Pôle sur tâches & bugs (réutilise l'enum member_role) -----
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
-- ---------- Plusieurs assignés par tâche -----------------------------
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le nouveau tableau.
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null
and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column if exists assignee_id;
-- ---------- Suppression des jalons -----------------------------------
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
-- ---------- Notifications --------------------------------------------
create table if not exists public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
project_id uuid references public.projects(id) on delete cascade,
kind text not null, -- 'task' | 'bug'
title text not null, -- ex : « Nouvelle tâche »
body text, -- titre de la tâche / du bug
entity_id uuid, -- id de la tâche / du bug
pole member_role, -- pôle destinataire
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
-- membre connecté (afin de notifier les autres membres d'un pôle).
drop policy if exists notifications_select on public.notifications;
drop policy if exists notifications_insert on public.notifications;
drop policy if exists notifications_update on public.notifications;
drop policy if exists notifications_delete on public.notifications;
create policy notifications_select on public.notifications
for select to authenticated using (user_id = auth.uid());
create policy notifications_insert on public.notifications
for insert to authenticated with check (true);
create policy notifications_update on public.notifications
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy notifications_delete on public.notifications
for delete to authenticated using (user_id = auth.uid());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-25
View File
@@ -1,25 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v3
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (après migration_v2.sql)
--
-- Contenu :
-- • Temps « libre » dans la feuille de temps (réunion, etc.) :
-- une entrée peut ne pas être liée à une tâche du Kanban.
-- =====================================================================
-- La tâche devient optionnelle ; un libellé libre la remplace si absente.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
-- Rattachement direct au projet (pour retrouver aussi les entrées sans tâche).
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
-- Backfill : les entrées existantes héritent du projet de leur tâche.
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_project on public.time_logs(project_id);
-18
View File
@@ -1,18 +0,0 @@
-- =====================================================================
-- GameDev Tracker — Migration v4
-- À exécuter UNE FOIS dans : Supabase > SQL Editor > New query
-- (après migration_v3.sql)
--
-- Contenu :
-- • Un membre peut appartenir à PLUSIEURS pôles (rôles).
-- profiles.role reste le rôle « principal » ; profiles.roles
-- contient l'ensemble des pôles.
-- =====================================================================
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
-- Backfill : les profils existants héritent de leur rôle unique actuel.
update public.profiles
set roles = array[role]
where roles = '{}';
+29
View File
@@ -0,0 +1,29 @@
-- =====================================================================
-- Bucket « releases » — hébergement des mises à jour de l'application
-- À exécuter UNE SEULE FOIS : Supabase > SQL Editor > New query > Run
--
-- Ce bucket contient l'installeur signé et le fichier « latest.json »
-- que chaque application interroge au lancement.
--
-- Lecture PUBLIQUE : les collègues doivent pouvoir télécharger la mise
-- à jour avant même d'être connectés. Aucun risque : le paquet est
-- signé, et une mise à jour non signée par la clé privée du projet est
-- purement et simplement refusée par Tauri.
--
-- Écriture : réservée au script « npm run release », qui s'authentifie
-- avec la clé service_role depuis la machine de l'administrateur.
-- =====================================================================
insert into storage.buckets (id, name, public)
values ('releases', 'releases', true)
on conflict (id) do nothing;
drop policy if exists releases_read on storage.objects;
create policy releases_read on storage.objects
for select using (bucket_id = 'releases');
-- Personne d'autre que service_role n'écrit ici : aucune policy d'insert
-- n'est créée volontairement (service_role contourne la RLS).
notify pgrst, 'reload schema';
+463 -302
View File
@@ -1,302 +1,463 @@
-- =====================================================================
-- GameDev Tracker — Schéma de base de données (Supabase / PostgreSQL)
-- À exécuter UNE SEULE FOIS dans : Supabase > SQL Editor > New query
-- =====================================================================
-- ---------- Types énumérés -------------------------------------------
do $$ begin
create type member_role as enum
('lead','programmer','artist','game_designer','sound','writer','qa','other');
exception when duplicate_object then null; end $$;
do $$ begin
create type task_status as enum ('todo','in_progress','review','done');
exception when duplicate_object then null; end $$;
do $$ begin
create type priority_level as enum ('low','medium','high','urgent');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_severity as enum ('low','medium','high','critical');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_status as enum ('open','in_progress','resolved','closed');
exception when duplicate_object then null; end $$;
-- ---------- Profils (étend auth.users) -------------------------------
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text not null default 'Membre',
role member_role not null default 'other', -- rôle principal (= roles[0])
roles member_role[] not null default '{}', -- tous les pôles du membre
avatar_color text not null default '#7c5cff',
avatar_url text, -- photo de profil (optionnelle)
onboarded boolean not null default false, -- a terminé l'accueil
created_at timestamptz not null default now()
);
-- ---------- Projets --------------------------------------------------
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now()
);
-- ---------- Tâches ---------------------------------------------------
-- Un « pôle » (= member_role) reçoit la tâche ; une ou plusieurs
-- personnes de ce pôle peuvent y être assignées (assignee_ids).
create table if not exists public.tasks (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
status task_status not null default 'todo',
priority priority_level not null default 'medium',
pole member_role, -- pôle principal (= poles[0], compat)
poles member_role[] not null default '{}', -- tous les pôles concernés par la tâche
assignee_ids uuid[] not null default '{}',
due_date date,
position double precision not null default 0,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Bugs -----------------------------------------------------
create table if not exists public.bugs (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
severity bug_severity not null default 'medium',
status bug_status not null default 'open',
priority priority_level not null default 'medium',
pole member_role,
assignee_id uuid references public.profiles(id) on delete set null,
reporter_id uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_tasks_project on public.tasks(project_id);
create index if not exists idx_bugs_project on public.bugs(project_id);
-- ---------- Maj automatique de updated_at ----------------------------
create or replace function public.touch_updated_at()
returns trigger language plpgsql as $$
begin new.updated_at = now(); return new; end $$;
drop trigger if exists trg_tasks_touch on public.tasks;
create trigger trg_tasks_touch before update on public.tasks
for each row execute function public.touch_updated_at();
drop trigger if exists trg_bugs_touch on public.bugs;
create trigger trg_bugs_touch before update on public.bugs
for each row execute function public.touch_updated_at();
-- ---------- Création auto du profil à l'inscription ------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare r member_role;
begin
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
insert into public.profiles (id, full_name, role, roles)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email,'@',1)),
r,
array[r]
)
on conflict (id) do nothing;
return new;
end $$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------- Sécurité (RLS) -------------------------------------------
-- Équipe privée et de confiance : tout membre connecté a accès complet.
alter table public.profiles enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;
alter table public.bugs enable row level security;
do $$
declare t text;
begin
foreach t in array array['profiles','projects','tasks','bugs'] loop
execute format('drop policy if exists %I_all on public.%I', t, t);
execute format(
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
t, t);
end loop;
end $$;
-- ---------- Realtime (synchro live entre membres) --------------------
do $$ begin
alter publication supabase_realtime add table public.tasks;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.bugs;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.profiles;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- ADMINISTRATION : comptes & rôles gérés par un panel admin in-app
-- =====================================================================
-- ---------- Champ "administrateur" sur les profils -------------------
alter table public.profiles
add column if not exists is_admin boolean not null default false;
-- ---------- Sécurité fine sur les profils ----------------------------
-- On remplace la policy permissive "profiles_all" par des règles précises :
-- * tout le monde (connecté) PEUT LIRE les profils (affichage de l'équipe)
-- * chacun ne peut MODIFIER que SON propre profil
-- * la création/suppression de comptes passe uniquement par l'Edge
-- Function "admin-users" (clé service_role, côté serveur).
drop policy if exists profiles_all on public.profiles;
drop policy if exists profiles_select on public.profiles;
drop policy if exists profiles_update_self on public.profiles;
create policy profiles_select on public.profiles
for select to authenticated using (true);
create policy profiles_update_self on public.profiles
for update to authenticated
using (id = auth.uid()) with check (id = auth.uid());
-- ---------- Garde-fou : on ne se promeut pas admin soi-même ----------
-- Empêche un membre connecté (rôle "authenticated") de changer le champ
-- is_admin. Les opérations légitimes passent par l'Edge Function
-- (rôle "service_role") ou par le SQL Editor (rôle "postgres"), tous deux
-- autorisés ici. Un admin déjà en place peut aussi modifier le statut.
create or replace function public.guard_is_admin()
returns trigger language plpgsql security definer set search_path = public as $$
begin
if new.is_admin is distinct from old.is_admin
and current_user = 'authenticated'
and not exists (
select 1 from public.profiles p where p.id = auth.uid() and p.is_admin
) then
raise exception 'Seul un administrateur peut modifier le statut admin.';
end if;
return new;
end $$;
drop trigger if exists trg_profiles_guard_admin on public.profiles;
create trigger trg_profiles_guard_admin before update on public.profiles
for each row execute function public.guard_is_admin();
-- ---------- Amorçage du PREMIER admin (à faire UNE fois) -------------
-- Après ta première inscription dans l'appli, exécute cette ligne en
-- remplaçant l'email par le tien pour devenir administrateur :
--
-- update public.profiles set is_admin = true
-- where id = (select id from auth.users where email = 'TON_EMAIL@ exemple.com');
--
-- Ensuite, tu pourras créer/gérer tous les autres comptes depuis l'appli.
-- =====================================================================
-- TAGS (« jobs ») & SUIVI DU TEMPS
-- =====================================================================
-- ---------- Tags sur les tâches (ex: Programmation, Art, Audio) ------
alter table public.tasks
add column if not exists tags text[] not null default '{}';
-- ---------- Journal du temps passé (par tâche OU activité libre) -----
-- task_id null + label => temps « libre » (réunion, etc.).
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
task_id uuid references public.tasks(id) on delete cascade,
label text,
user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0),
note text,
logged_at date not null default current_date,
created_at timestamptz not null default now()
);
create index if not exists idx_time_logs_task on public.time_logs(task_id);
create index if not exists idx_time_logs_project on public.time_logs(project_id);
create index if not exists idx_time_logs_user on public.time_logs(user_id);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- NOTIFICATIONS (assignation d'une tâche / d'un bug à un pôle)
-- =====================================================================
create table if not exists public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
project_id uuid references public.projects(id) on delete cascade,
kind text not null, -- 'task' | 'bug'
title text not null, -- ex : « Nouvelle tâche »
body text, -- titre de la tâche / du bug
entity_id uuid, -- id de la tâche / du bug
pole member_role, -- pôle destinataire
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
-- Chacun lit/modifie SES notifications ; l'insertion est ouverte à tout
-- membre connecté (afin de notifier les autres membres d'un pôle).
drop policy if exists notifications_select on public.notifications;
drop policy if exists notifications_insert on public.notifications;
drop policy if exists notifications_update on public.notifications;
drop policy if exists notifications_delete on public.notifications;
create policy notifications_select on public.notifications
for select to authenticated using (user_id = auth.uid());
create policy notifications_insert on public.notifications
for insert to authenticated with check (true);
create policy notifications_update on public.notifications
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy notifications_delete on public.notifications
for delete to authenticated using (user_id = auth.uid());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- =====================================================================
-- STOCKAGE : photos de profil (bucket "avatars")
-- =====================================================================
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
drop policy if exists avatars_read on storage.objects;
drop policy if exists avatars_insert on storage.objects;
drop policy if exists avatars_update on storage.objects;
drop policy if exists avatars_delete on storage.objects;
create policy avatars_read on storage.objects
for select using (bucket_id = 'avatars');
create policy avatars_insert on storage.objects
for insert to authenticated with check (bucket_id = 'avatars');
create policy avatars_update on storage.objects
for update to authenticated using (bucket_id = 'avatars');
create policy avatars_delete on storage.objects
for delete to authenticated using (bucket_id = 'avatars');
-- =====================================================================
-- GameDev Tracker — Schéma complet (Supabase / PostgreSQL)
--
-- NOUVELLE base : exécute CE fichier, une seule fois.
-- Base EXISTANTE : exécute plutôt « update_all.sql » (ne perd rien).
--
-- Où ? Supabase > SQL Editor > New query > coller > Run.
-- =====================================================================
-- ---------- Types énumérés -------------------------------------------
do $$ begin
create type member_role as enum
('lead','programmer','artist','game_designer','sound','writer','qa','other');
exception when duplicate_object then null; end $$;
do $$ begin
create type task_status as enum ('todo','in_progress','review','done');
exception when duplicate_object then null; end $$;
do $$ begin
create type priority_level as enum ('low','medium','high','urgent');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_severity as enum ('low','medium','high','critical');
exception when duplicate_object then null; end $$;
do $$ begin
create type bug_status as enum ('open','in_progress','resolved','closed');
exception when duplicate_object then null; end $$;
-- =====================================================================
-- TABLES
-- =====================================================================
-- ---------- Profils (étend auth.users) -------------------------------
-- `roles` est la SEULE source de vérité pour les pôles d'un membre.
create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
full_name text not null default 'Membre',
roles member_role[] not null default '{}',
avatar_color text not null default '#7c5cff',
avatar_url text,
is_admin boolean not null default false,
onboarded boolean not null default false,
created_at timestamptz not null default now()
);
-- ---------- Projets --------------------------------------------------
create table if not exists public.projects (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now()
);
-- ---------- Tâches ---------------------------------------------------
-- Une tâche est confiée à un ou plusieurs PÔLES (poles) et assignée
-- nommément à une ou plusieurs PERSONNES (assignee_ids).
create table if not exists public.tasks (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
status task_status not null default 'todo',
priority priority_level not null default 'medium',
poles member_role[] not null default '{}',
assignee_ids uuid[] not null default '{}',
checklist jsonb not null default '[]'::jsonb, -- [{id, text, done}]
tags text[] not null default '{}',
due_date date,
position double precision not null default 0,
created_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Bugs -----------------------------------------------------
-- Même modèle que les tâches (pôles + assignés multiples). La SÉVÉRITÉ
-- est le seul axe d'importance : la colonne `priority` faisait doublon
-- et n'est plus exposée dans l'interface.
create table if not exists public.bugs (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
description text,
severity bug_severity not null default 'medium',
status bug_status not null default 'open',
priority priority_level not null default 'medium',
poles member_role[] not null default '{}',
assignee_ids uuid[] not null default '{}',
reporter_id uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Journal du temps (tâche OU activité libre) ---------------
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
task_id uuid references public.tasks(id) on delete cascade,
bug_id uuid references public.bugs(id) on delete cascade,
label text, -- si ni tâche ni bug
user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0),
note text,
logged_at date not null default current_date,
created_at timestamptz not null default now(),
-- Une entrée porte sur une tâche OU un bug OU rien : jamais les deux.
constraint time_logs_one_target check (not (task_id is not null and bug_id is not null))
);
-- ---------- Commentaires (tâches et bugs) ----------------------------
create table if not exists public.comments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task','bug')),
entity_id uuid not null,
author_id uuid references public.profiles(id) on delete set null,
body text not null check (length(btrim(body)) > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Pièces jointes (captures d'écran, logs) ------------------
create table if not exists public.attachments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task','bug')),
entity_id uuid not null,
uploader_id uuid references public.profiles(id) on delete set null,
name text not null,
path text not null,
size bigint not null default 0,
mime text,
created_at timestamptz not null default now()
);
-- ---------- Documentation (fiches créées ou importées) ----------------
-- Le contenu est du Markdown : format pivot, lisible et cherchable. Un
-- .docx importé est converti en Markdown au moment de l'import.
create table if not exists public.documents (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
content text not null default '',
tags text[] not null default '{}',
source_name text,
created_by uuid references public.profiles(id) on delete set null,
updated_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- ---------- Notifications --------------------------------------------
create table if not exists public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
project_id uuid references public.projects(id) on delete cascade,
kind text not null, -- 'task' | 'bug' | 'comment'
entity_kind text not null default 'task', -- ce qu'on ouvre au clic
entity_id uuid,
title text not null,
body text,
pole member_role,
read boolean not null default false,
created_at timestamptz not null default now()
);
-- =====================================================================
-- INDEX
-- =====================================================================
create index if not exists idx_tasks_project on public.tasks(project_id);
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
create index if not exists idx_bugs_project on public.bugs(project_id);
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
create index if not exists idx_time_logs_task on public.time_logs(task_id);
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
create index if not exists idx_time_logs_project on public.time_logs(project_id);
create index if not exists idx_time_logs_user on public.time_logs(user_id);
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
create index if not exists idx_comments_project on public.comments(project_id);
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
create index if not exists idx_attachments_project on public.attachments(project_id);
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
create index if not exists idx_notifications_user on public.notifications(user_id);
create index if not exists idx_notifications_user_unread
on public.notifications(user_id, read, created_at desc);
-- =====================================================================
-- FONCTIONS & TRIGGERS
-- =====================================================================
-- ---------- Maj automatique de updated_at ----------------------------
create or replace function public.touch_updated_at()
returns trigger language plpgsql as $$
begin new.updated_at = now(); return new; end $$;
drop trigger if exists trg_tasks_touch on public.tasks;
create trigger trg_tasks_touch before update on public.tasks
for each row execute function public.touch_updated_at();
drop trigger if exists trg_bugs_touch on public.bugs;
create trigger trg_bugs_touch before update on public.bugs
for each row execute function public.touch_updated_at();
drop trigger if exists trg_documents_touch on public.documents;
create trigger trg_documents_touch before update on public.documents
for each row execute function public.touch_updated_at();
drop trigger if exists trg_comments_touch on public.comments;
create trigger trg_comments_touch before update on public.comments
for each row execute function public.touch_updated_at();
-- ---------- Création auto du profil à l'inscription ------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
r member_role;
begin
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
insert into public.profiles (id, full_name, roles, avatar_color)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
array[r],
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
)
on conflict (id) do nothing;
return new;
end $$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- ---------- Réordonnancement du Kanban en une transaction ------------
-- L'appli envoie la liste ordonnée des tâches d'une colonne ; une seule
-- requête réécrit statut + position pour toute la colonne.
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
returns void
language plpgsql
security invoker
set search_path = public
as $$
begin
update public.tasks t
set status = p_status,
position = ordered.idx
from (
select id, (ord - 1)::double precision as idx
from unnest(p_ids) with ordinality as u(id, ord)
) ordered
where t.id = ordered.id;
end $$;
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
-- ---------- Garde-fou : on ne se promeut pas admin soi-même ----------
create or replace function public.guard_is_admin()
returns trigger language plpgsql security definer set search_path = public as $$
begin
if new.is_admin is distinct from old.is_admin
and current_user = 'authenticated'
and not exists (
select 1 from public.profiles p where p.id = auth.uid() and p.is_admin
) then
raise exception 'Seul un administrateur peut modifier le statut admin.';
end if;
return new;
end $$;
drop trigger if exists trg_profiles_guard_admin on public.profiles;
create trigger trg_profiles_guard_admin before update on public.profiles
for each row execute function public.guard_is_admin();
-- =====================================================================
-- SÉCURITÉ (RLS)
-- Équipe privée et de confiance : tout membre connecté lit et écrit le
-- contenu des projets. Les exceptions sont explicites ci-dessous :
-- · un profil ne se modifie que par son propriétaire
-- · un projet ne se supprime que par son créateur ou un admin
-- · un commentaire ne s'édite que par son auteur
-- =====================================================================
alter table public.profiles enable row level security;
alter table public.projects enable row level security;
alter table public.tasks enable row level security;
alter table public.bugs enable row level security;
alter table public.time_logs enable row level security;
alter table public.comments enable row level security;
alter table public.attachments enable row level security;
alter table public.documents enable row level security;
alter table public.notifications enable row level security;
-- ---------- Contenu partagé (tâches, bugs, temps) --------------------
do $$
declare t text;
begin
foreach t in array array['tasks','bugs','time_logs','documents'] loop
execute format('drop policy if exists %I_all on public.%I', t, t);
execute format(
'create policy %I_all on public.%I for all to authenticated using (true) with check (true)',
t, t);
end loop;
end $$;
-- ---------- Profils ---------------------------------------------------
drop policy if exists profiles_all on public.profiles;
drop policy if exists profiles_select on public.profiles;
drop policy if exists profiles_update_self on public.profiles;
create policy profiles_select on public.profiles
for select to authenticated using (true);
create policy profiles_update_self on public.profiles
for update to authenticated
using (id = auth.uid()) with check (id = auth.uid());
-- La création / suppression de comptes passe par l'Edge Function
-- « admin-users » (clé service_role, côté serveur).
-- ---------- Projets ---------------------------------------------------
drop policy if exists projects_all on public.projects;
drop policy if exists projects_select on public.projects;
drop policy if exists projects_insert on public.projects;
drop policy if exists projects_update on public.projects;
drop policy if exists projects_delete on public.projects;
create policy projects_select on public.projects
for select to authenticated using (true);
create policy projects_insert on public.projects
for insert to authenticated with check (true);
create policy projects_update on public.projects
for update to authenticated using (true) with check (true);
create policy projects_delete on public.projects
for delete to authenticated
using (
created_by = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------- Commentaires ----------------------------------------------
drop policy if exists comments_select on public.comments;
drop policy if exists comments_insert on public.comments;
drop policy if exists comments_update on public.comments;
drop policy if exists comments_delete on public.comments;
create policy comments_select on public.comments
for select to authenticated using (true);
create policy comments_insert on public.comments
for insert to authenticated with check (author_id = auth.uid());
create policy comments_update on public.comments
for update to authenticated
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy comments_delete on public.comments
for delete to authenticated
using (
author_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------- Pièces jointes --------------------------------------------
drop policy if exists attachments_select on public.attachments;
drop policy if exists attachments_insert on public.attachments;
drop policy if exists attachments_delete on public.attachments;
create policy attachments_select on public.attachments
for select to authenticated using (true);
create policy attachments_insert on public.attachments
for insert to authenticated with check (uploader_id = auth.uid());
create policy attachments_delete on public.attachments
for delete to authenticated
using (
uploader_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------- Notifications ---------------------------------------------
drop policy if exists notifications_select on public.notifications;
drop policy if exists notifications_insert on public.notifications;
drop policy if exists notifications_update on public.notifications;
drop policy if exists notifications_delete on public.notifications;
create policy notifications_select on public.notifications
for select to authenticated using (user_id = auth.uid());
-- Insertion ouverte : un membre doit pouvoir notifier ses coéquipiers.
create policy notifications_insert on public.notifications
for insert to authenticated with check (true);
create policy notifications_update on public.notifications
for update to authenticated
using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy notifications_delete on public.notifications
for delete to authenticated using (user_id = auth.uid());
-- =====================================================================
-- TEMPS RÉEL (synchro live entre membres)
-- =====================================================================
do $$
declare t text;
begin
foreach t in array array['tasks','bugs','profiles','projects','time_logs',
'notifications','comments','attachments','documents'] loop
begin
execute format('alter publication supabase_realtime add table public.%I', t);
exception when duplicate_object then null;
end;
end loop;
end $$;
-- =====================================================================
-- STOCKAGE
-- · avatars : photos de profil
-- · attachments : captures d'écran et fichiers joints aux bugs/tâches
-- =====================================================================
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true), ('attachments', 'attachments', true)
on conflict (id) do nothing;
drop policy if exists avatars_read on storage.objects;
drop policy if exists avatars_insert on storage.objects;
drop policy if exists avatars_update on storage.objects;
drop policy if exists avatars_delete on storage.objects;
create policy avatars_read on storage.objects
for select using (bucket_id = 'avatars');
create policy avatars_insert on storage.objects
for insert to authenticated with check (bucket_id = 'avatars');
create policy avatars_update on storage.objects
for update to authenticated using (bucket_id = 'avatars');
create policy avatars_delete on storage.objects
for delete to authenticated using (bucket_id = 'avatars');
drop policy if exists attachments_read on storage.objects;
drop policy if exists attachments_write on storage.objects;
drop policy if exists attachments_remove on storage.objects;
create policy attachments_read on storage.objects
for select using (bucket_id = 'attachments');
create policy attachments_write on storage.objects
for insert to authenticated with check (bucket_id = 'attachments');
create policy attachments_remove on storage.objects
for delete to authenticated using (bucket_id = 'attachments');
-- =====================================================================
-- DERNIÈRE ÉTAPE — devenir administrateur (à faire UNE fois)
-- Après avoir créé ton compte dans l'application, exécute :
--
-- update public.profiles set is_admin = true
-- where id = (select id from auth.users where email = 'TON_EMAIL@exemple.com');
--
-- Tu pourras ensuite gérer tous les comptes depuis l'onglet Administration.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Dernière ligne : forcer l'API à relire le schéma.
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
-- pendant plusieurs minutes après la migration.
-- ---------------------------------------------------------------------
notify pgrst, 'reload schema';
+563 -156
View File
@@ -1,156 +1,563 @@
-- =====================================================================
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
-- =====================================================================
-- ---------------------------------------------------------------------
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column assignee_id;
end if;
end $$;
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
create table if not exists public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
project_id uuid references public.projects(id) on delete cascade,
kind text not null,
title text not null,
body text,
entity_id uuid,
pole member_role,
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
drop policy if exists notifications_select on public.notifications;
drop policy if exists notifications_insert on public.notifications;
drop policy if exists notifications_update on public.notifications;
drop policy if exists notifications_delete on public.notifications;
create policy notifications_select on public.notifications
for select to authenticated using (user_id = auth.uid());
create policy notifications_insert on public.notifications
for insert to authenticated with check (true);
create policy notifications_update on public.notifications
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy notifications_delete on public.notifications
for delete to authenticated using (user_id = auth.uid());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
-- ---------------------------------------------------------------------
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
task_id uuid references public.tasks(id) on delete cascade,
label text,
user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0),
note text,
logged_at date not null default current_date,
created_at timestamptz not null default now()
);
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_task on public.time_logs(task_id);
create index if not exists idx_time_logs_project on public.time_logs(project_id);
create index if not exists idx_time_logs_user on public.time_logs(user_id);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v4 — Plusieurs pôles (rôles) par membre
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
update public.profiles
set roles = array[role]
where roles = '{}';
-- ---------------------------------------------------------------------
-- v5 — Accueil (onboarding) des nouveaux membres
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists onboarded boolean not null default false;
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
update public.profiles set onboarded = true where onboarded = false;
-- ---------------------------------------------------------------------
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
-- ---------------------------------------------------------------------
alter table public.profiles add column if not exists avatar_url text;
-- Bucket public "avatars" pour héberger les photos de profil.
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Lecture publique ; écriture réservée aux membres connectés.
drop policy if exists avatars_read on storage.objects;
drop policy if exists avatars_insert on storage.objects;
drop policy if exists avatars_update on storage.objects;
drop policy if exists avatars_delete on storage.objects;
create policy avatars_read on storage.objects
for select using (bucket_id = 'avatars');
create policy avatars_insert on storage.objects
for insert to authenticated with check (bucket_id = 'avatars');
create policy avatars_update on storage.objects
for update to authenticated using (bucket_id = 'avatars');
create policy avatars_delete on storage.objects
for delete to authenticated using (bucket_id = 'avatars');
-- ---------------------------------------------------------------------
-- v7 — Plusieurs pôles par tâche
-- ---------------------------------------------------------------------
alter table public.tasks
add column if not exists poles member_role[] not null default '{}';
-- Reprend le pôle unique existant dans le tableau (si présent).
update public.tasks
set poles = array[pole]
where pole is not null and poles = '{}';
-- =====================================================================
-- GameDev Tracker — Mise à jour complète (v2 + v3 + v4)
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Sans danger si une partie a déjà été appliquée (tout est idempotent).
-- =====================================================================
-- ---------------------------------------------------------------------
-- v2 — Pôles, assignation multiple, notifications, suppression jalons
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists pole member_role;
alter table public.bugs add column if not exists pole member_role;
alter table public.tasks
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si la colonne existe encore).
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema='public' and table_name='tasks' and column_name='assignee_id') then
update public.tasks
set assignee_ids = array[assignee_id]
where assignee_id is not null and (assignee_ids is null or assignee_ids = '{}');
alter table public.tasks drop column assignee_id;
end if;
end $$;
alter table public.tasks drop column if exists milestone_id;
drop table if exists public.milestones cascade;
drop type if exists milestone_status;
create table if not exists public.notifications (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
project_id uuid references public.projects(id) on delete cascade,
kind text not null,
title text not null,
body text,
entity_id uuid,
pole member_role,
read boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_notifications_user on public.notifications(user_id);
alter table public.notifications enable row level security;
drop policy if exists notifications_select on public.notifications;
drop policy if exists notifications_insert on public.notifications;
drop policy if exists notifications_update on public.notifications;
drop policy if exists notifications_delete on public.notifications;
create policy notifications_select on public.notifications
for select to authenticated using (user_id = auth.uid());
create policy notifications_insert on public.notifications
for insert to authenticated with check (true);
create policy notifications_update on public.notifications
for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());
create policy notifications_delete on public.notifications
for delete to authenticated using (user_id = auth.uid());
do $$ begin
alter publication supabase_realtime add table public.notifications;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v3 — Feuille de temps : entrées libres (réunion, etc.)
-- ---------------------------------------------------------------------
-- Crée la table si elle n'a jamais existé (ancienne base sans suivi du temps).
create table if not exists public.time_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid references public.projects(id) on delete cascade,
task_id uuid references public.tasks(id) on delete cascade,
label text,
user_id uuid references public.profiles(id) on delete set null,
minutes integer not null check (minutes > 0),
note text,
logged_at date not null default current_date,
created_at timestamptz not null default now()
);
-- Si la table existait déjà (ancienne version), on l'aligne sur le nouveau format.
alter table public.time_logs alter column task_id drop not null;
alter table public.time_logs add column if not exists label text;
alter table public.time_logs
add column if not exists project_id uuid references public.projects(id) on delete cascade;
update public.time_logs tl
set project_id = t.project_id
from public.tasks t
where tl.task_id = t.id and tl.project_id is null;
create index if not exists idx_time_logs_task on public.time_logs(task_id);
create index if not exists idx_time_logs_project on public.time_logs(project_id);
create index if not exists idx_time_logs_user on public.time_logs(user_id);
alter table public.time_logs enable row level security;
drop policy if exists time_logs_all on public.time_logs;
create policy time_logs_all on public.time_logs
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.time_logs;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- v4 — Plusieurs pôles (rôles) par membre
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists roles member_role[] not null default '{}';
update public.profiles
set roles = array[role]
where roles = '{}';
-- ---------------------------------------------------------------------
-- v5 — Accueil (onboarding) des nouveaux membres
-- ---------------------------------------------------------------------
alter table public.profiles
add column if not exists onboarded boolean not null default false;
-- Les membres déjà présents ne sont pas ré-embarqués : on les marque « fait ».
update public.profiles set onboarded = true where onboarded = false;
-- ---------------------------------------------------------------------
-- v6 — Photo de profil (avatar image) : colonne + bucket de stockage
-- ---------------------------------------------------------------------
alter table public.profiles add column if not exists avatar_url text;
-- Bucket public "avatars" pour héberger les photos de profil.
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true)
on conflict (id) do nothing;
-- Lecture publique ; écriture réservée aux membres connectés.
drop policy if exists avatars_read on storage.objects;
drop policy if exists avatars_insert on storage.objects;
drop policy if exists avatars_update on storage.objects;
drop policy if exists avatars_delete on storage.objects;
create policy avatars_read on storage.objects
for select using (bucket_id = 'avatars');
create policy avatars_insert on storage.objects
for insert to authenticated with check (bucket_id = 'avatars');
create policy avatars_update on storage.objects
for update to authenticated using (bucket_id = 'avatars');
create policy avatars_delete on storage.objects
for delete to authenticated using (bucket_id = 'avatars');
-- ---------------------------------------------------------------------
-- v7 — Plusieurs pôles par tâche
-- ---------------------------------------------------------------------
alter table public.tasks
add column if not exists poles member_role[] not null default '{}';
-- Reprend le pôle unique existant dans le tableau (si présent).
update public.tasks
set poles = array[pole]
where pole is not null and poles = '{}';
-- ---------------------------------------------------------------------
-- v8 — Checklists (cases à cocher) sur les tâches
-- ---------------------------------------------------------------------
-- Liste d'éléments [{ "id": "...", "text": "...", "done": false }, ...]
alter table public.tasks
add column if not exists checklist jsonb not null default '[]'::jsonb;
-- ---------------------------------------------------------------------
-- v9 — Assignation MULTIPLE des bugs (parité avec les tâches)
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists assignee_ids uuid[] not null default '{}';
-- Reprend l'assigné unique existant dans le tableau (si présent).
update public.bugs
set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
-- =====================================================================
-- GameDev Tracker — Migration v10 « Refonte »
-- À coller dans : Supabase > SQL Editor > New query > Run
-- Idempotent : sans danger si déjà appliquée en tout ou partie.
--
-- Ce que fait cette migration :
-- 0. Colonnes « tableau » manquantes (dont tasks.tags) et non-NULL
-- 1. Commentaires sur les tâches et les bugs
-- 2. Pièces jointes (captures d'écran) sur les tâches et les bugs
-- 3. Parité tâches/bugs : plusieurs pôles par bug
-- 4. Suppression des colonnes « de compatibilité » devenues des pièges
-- (profiles.role, tasks.pole, bugs.assignee_id)
-- 5. Réordonnancement du Kanban en UNE requête transactionnelle
-- 6. Suppression d'un projet réservée à son créateur ou à un admin
-- 7. Temps réel sur les projets
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0) Colonnes « tableau » garanties — À EXÉCUTER EN PREMIER
--
-- `tasks.tags` n'existait que dans schema.sql (installations neuves) et
-- n'a jamais été ajoutée par les migrations : sur une base mise à jour
-- pas à pas, la colonne manquait et l'application plantait en tentant
-- d'afficher les étiquettes d'une tâche.
--
-- Tant qu'on y est, on ré-affirme l'invariant sur TOUTES les colonnes
-- tableau : jamais NULL, toujours un tableau vide par défaut.
-- ---------------------------------------------------------------------
alter table public.tasks add column if not exists tags text[] not null default '{}';
alter table public.tasks add column if not exists poles member_role[] not null default '{}';
alter table public.tasks add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.tasks add column if not exists checklist jsonb not null default '[]'::jsonb;
alter table public.bugs add column if not exists assignee_ids uuid[] not null default '{}';
alter table public.profiles add column if not exists roles member_role[] not null default '{}';
-- Remet à vide les valeurs NULL héritées, puis verrouille la contrainte.
do $$
declare
r record;
begin
for r in
select * from (values
('tasks', 'tags', '''{}''::text[]'),
('tasks', 'poles', '''{}''::member_role[]'),
('tasks', 'assignee_ids', '''{}''::uuid[]'),
('tasks', 'checklist', '''[]''::jsonb'),
('bugs', 'poles', '''{}''::member_role[]'),
('bugs', 'assignee_ids', '''{}''::uuid[]'),
('profiles', 'roles', '''{}''::member_role[]')
) as t(tbl, col, empty)
loop
if exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = r.tbl and column_name = r.col
) then
execute format('update public.%I set %I = %s where %I is null', r.tbl, r.col, r.empty, r.col);
execute format('alter table public.%I alter column %I set default %s', r.tbl, r.col, r.empty);
execute format('alter table public.%I alter column %I set not null', r.tbl, r.col);
end if;
end loop;
end $$;
-- ---------------------------------------------------------------------
-- 1) Parité bugs / tâches : plusieurs pôles par bug
-- ---------------------------------------------------------------------
alter table public.bugs
add column if not exists poles member_role[] not null default '{}';
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
update public.bugs set poles = array[pole] where pole is not null and poles = '{}';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 2) Commentaires (fil de discussion sur une tâche ou un bug)
-- ---------------------------------------------------------------------
create table if not exists public.comments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task', 'bug')),
entity_id uuid not null,
author_id uuid references public.profiles(id) on delete set null,
body text not null check (length(btrim(body)) > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_comments_entity on public.comments(entity_kind, entity_id);
create index if not exists idx_comments_project on public.comments(project_id);
alter table public.comments enable row level security;
drop policy if exists comments_select on public.comments;
drop policy if exists comments_insert on public.comments;
drop policy if exists comments_update on public.comments;
drop policy if exists comments_delete on public.comments;
-- Toute l'équipe lit la discussion ; on n'écrit qu'en son propre nom ;
-- on ne modifie/supprime que ses propres messages (ou n'importe lequel si admin).
create policy comments_select on public.comments
for select to authenticated using (true);
create policy comments_insert on public.comments
for insert to authenticated with check (author_id = auth.uid());
create policy comments_update on public.comments
for update to authenticated
using (author_id = auth.uid()) with check (author_id = auth.uid());
create policy comments_delete on public.comments
for delete to authenticated
using (
author_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------------------------------------------------------------------
-- 3) Pièces jointes (captures d'écran, logs, fichiers)
-- ---------------------------------------------------------------------
create table if not exists public.attachments (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
entity_kind text not null check (entity_kind in ('task', 'bug')),
entity_id uuid not null,
uploader_id uuid references public.profiles(id) on delete set null,
name text not null,
path text not null, -- chemin dans le bucket "attachments"
size bigint not null default 0,
mime text,
created_at timestamptz not null default now()
);
create index if not exists idx_attachments_entity on public.attachments(entity_kind, entity_id);
create index if not exists idx_attachments_project on public.attachments(project_id);
alter table public.attachments enable row level security;
drop policy if exists attachments_select on public.attachments;
drop policy if exists attachments_insert on public.attachments;
drop policy if exists attachments_delete on public.attachments;
create policy attachments_select on public.attachments
for select to authenticated using (true);
create policy attachments_insert on public.attachments
for insert to authenticated with check (uploader_id = auth.uid());
create policy attachments_delete on public.attachments
for delete to authenticated
using (
uploader_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- Bucket de stockage des fichiers joints.
insert into storage.buckets (id, name, public)
values ('attachments', 'attachments', true)
on conflict (id) do nothing;
drop policy if exists attachments_read on storage.objects;
drop policy if exists attachments_write on storage.objects;
drop policy if exists attachments_remove on storage.objects;
create policy attachments_read on storage.objects
for select using (bucket_id = 'attachments');
create policy attachments_write on storage.objects
for insert to authenticated with check (bucket_id = 'attachments');
create policy attachments_remove on storage.objects
for delete to authenticated using (bucket_id = 'attachments');
-- ---------------------------------------------------------------------
-- 4) Notifications : sur QUOI pointe la notification
-- (une notif de commentaire ouvre la tâche ou le bug commenté)
-- ---------------------------------------------------------------------
alter table public.notifications
add column if not exists entity_kind text not null default 'task';
update public.notifications
set entity_kind = kind
where kind in ('task', 'bug') and entity_kind is distinct from kind;
create index if not exists idx_notifications_user_unread
on public.notifications(user_id, read, created_at desc);
-- ---------------------------------------------------------------------
-- 5) Réordonnancement du Kanban en UNE requête
-- Avant : une requête UPDATE par carte de la colonne (jusqu'à 30
-- allers-retours non transactionnels — un échec corrompait l'ordre).
-- ---------------------------------------------------------------------
create or replace function public.reorder_tasks(p_status task_status, p_ids uuid[])
returns void
language plpgsql
security invoker -- la RLS de l'appelant s'applique normalement
set search_path = public
as $$
begin
update public.tasks t
set status = p_status,
position = ordered.idx
from (
select id, (ord - 1)::double precision as idx
from unnest(p_ids) with ordinality as u(id, ord)
) ordered
where t.id = ordered.id;
end $$;
grant execute on function public.reorder_tasks(task_status, uuid[]) to authenticated;
-- ---------------------------------------------------------------------
-- 6) Suppression d'un projet : créateur ou administrateur seulement
-- (avant : n'importe quel membre pouvait effacer un projet entier
-- et tout son contenu en cascade)
-- ---------------------------------------------------------------------
drop policy if exists projects_all on public.projects;
drop policy if exists projects_select on public.projects;
drop policy if exists projects_insert on public.projects;
drop policy if exists projects_update on public.projects;
drop policy if exists projects_delete on public.projects;
create policy projects_select on public.projects
for select to authenticated using (true);
create policy projects_insert on public.projects
for insert to authenticated with check (true);
create policy projects_update on public.projects
for update to authenticated using (true) with check (true);
create policy projects_delete on public.projects
for delete to authenticated
using (
created_by = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ---------------------------------------------------------------------
-- 7) Temps réel : projets, commentaires, pièces jointes
-- ---------------------------------------------------------------------
do $$ begin
alter publication supabase_realtime add table public.projects;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.comments;
exception when duplicate_object then null; end $$;
do $$ begin
alter publication supabase_realtime add table public.attachments;
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- 8) Création de profil à l'inscription : couleur d'avatar variée
-- (avant, tout le monde héritait du même violet #7c5cff)
-- ---------------------------------------------------------------------
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
palette text[] := array['#7c5cff','#00d2b8','#ff5c8a','#ffa53c','#3ca8ff','#9bdb4d','#e15cff','#ff6b6b'];
r member_role;
begin
r := coalesce((new.raw_user_meta_data->>'role')::member_role, 'other');
insert into public.profiles (id, full_name, roles, avatar_color)
values (
new.id,
coalesce(new.raw_user_meta_data->>'full_name', split_part(new.email, '@', 1)),
array[r],
palette[1 + (abs(hashtext(new.id::text)) % array_length(palette, 1))]
)
on conflict (id) do nothing;
return new;
end $$;
-- ---------------------------------------------------------------------
-- 9) Suppression des colonnes « de compatibilité »
-- Elles doublonnaient les tableaux et devaient être re-synchronisées
-- à la main dans six endroits du code. On sauvegarde d'abord, puis
-- on supprime. À exécuter EN DERNIER (la fonction du point 8 ne les
-- utilise plus).
-- ---------------------------------------------------------------------
-- a) profiles.role → profiles.roles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') then
update public.profiles set roles = array[role] where roles = '{}' and role is not null;
alter table public.profiles drop column role;
end if;
end $$;
-- b) tasks.pole → tasks.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'tasks' and column_name = 'pole') then
update public.tasks set poles = array[pole] where pole is not null and poles = '{}';
alter table public.tasks drop column pole;
end if;
end $$;
-- c) bugs.assignee_id → bugs.assignee_ids[1] · bugs.pole → bugs.poles[1]
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'assignee_id') then
update public.bugs set assignee_ids = array[assignee_id]
where assignee_id is not null and assignee_ids = '{}';
alter table public.bugs drop column assignee_id;
end if;
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'pole') then
alter table public.bugs drop column pole;
end if;
end $$;
-- d) bugs.priority : plus exposée dans l'interface (la sévérité suffit).
-- La colonne reste en base, avec une valeur par défaut, pour ne rien
-- perdre — mais plus personne ne la remplit.
do $$ begin
if exists (select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'bugs' and column_name = 'priority') then
alter table public.bugs alter column priority set default 'medium';
end if;
end $$;
-- ---------------------------------------------------------------------
-- 10) Index manquants sur les colonnes les plus filtrées
-- ---------------------------------------------------------------------
create index if not exists idx_tasks_project_status on public.tasks(project_id, status, position);
create index if not exists idx_bugs_project_status on public.bugs(project_id, status);
create index if not exists idx_time_logs_date on public.time_logs(project_id, logged_at);
-- ---------------------------------------------------------------------
-- 11) Temps passé sur un BUG (parité avec les tâches)
-- Une entrée de temps porte sur une tâche, OU sur un bug, OU sur
-- rien (activité libre : réunion, veille…). Jamais les deux.
-- ---------------------------------------------------------------------
alter table public.time_logs
add column if not exists bug_id uuid references public.bugs(id) on delete cascade;
create index if not exists idx_time_logs_bug on public.time_logs(bug_id);
do $$ begin
alter table public.time_logs
add constraint time_logs_one_target
check (not (task_id is not null and bug_id is not null));
exception when duplicate_object then null; end $$;
-- ---------------------------------------------------------------------
-- Dernière ligne : forcer l'API à relire le schéma.
-- Sans ça, PostgREST peut continuer à servir l'ancienne structure et
-- renvoyer « Could not find the 'xxx' column ... in the schema cache »
-- pendant plusieurs minutes après la migration.
-- ---------------------------------------------------------------------
notify pgrst, 'reload schema';
-- =====================================================================
-- v11 — Documentation (fiches créées ou importées)
-- =====================================================================
create table if not exists public.documents (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
title text not null,
-- Contenu en Markdown : c'est le format pivot. Un .docx importé est
-- converti en Markdown à l'import, ce qui garde les fiches lisibles,
-- cherchables et modifiables dans l'application.
content text not null default '',
tags text[] not null default '{}',
-- Fichier d'origine, quand la fiche vient d'un import (informatif).
source_name text,
created_by uuid references public.profiles(id) on delete set null,
updated_by uuid references public.profiles(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_documents_project on public.documents(project_id, updated_at desc);
drop trigger if exists trg_documents_touch on public.documents;
create trigger trg_documents_touch before update on public.documents
for each row execute function public.touch_updated_at();
alter table public.documents enable row level security;
drop policy if exists documents_all on public.documents;
-- Équipe de confiance : tout membre connecté lit et écrit la documentation.
create policy documents_all on public.documents
for all to authenticated using (true) with check (true);
do $$ begin
alter publication supabase_realtime add table public.documents;
exception when duplicate_object then null; end $$;
notify pgrst, 'reload schema';
+18 -1
View File
@@ -7,16 +7,33 @@ export default {
colors: {
// Tokens pilotés par variables CSS (voir :root et .dark dans index.css).
bg: 'rgb(var(--c-bg) / <alpha-value>)',
sidebar: 'rgb(var(--c-sidebar) / <alpha-value>)',
panel: 'rgb(var(--c-panel) / <alpha-value>)',
panel2: 'rgb(var(--c-panel2) / <alpha-value>)',
sidebar: 'rgb(var(--c-sidebar) / <alpha-value>)',
hover: 'rgb(var(--c-hover) / <alpha-value>)',
border: 'rgb(var(--c-border) / <alpha-value>)',
'border-strong': 'rgb(var(--c-border-strong) / <alpha-value>)',
ink: 'rgb(var(--c-ink) / <alpha-value>)',
muted: 'rgb(var(--c-muted) / <alpha-value>)',
subtle: 'rgb(var(--c-subtle) / <alpha-value>)',
accent: 'rgb(var(--c-accent) / <alpha-value>)',
'accent-soft': 'rgb(var(--c-accent-soft) / <alpha-value>)',
accentfg: 'rgb(var(--c-accentfg) / <alpha-value>)',
accent2: 'rgb(var(--c-accent2) / <alpha-value>)'
},
boxShadow: {
// Élévation pilotée par le thème : en sombre, les ombres portées
// seules sont invisibles — les variables ajoutent un liseré interne.
card: 'var(--shadow-card)',
raised: 'var(--shadow-raised)',
pop: 'var(--shadow-pop)'
},
spacing: {
sidebar: '15rem',
'sidebar-sm': '3.75rem'
},
transitionTimingFunction: {
smooth: 'cubic-bezier(0.32, 0.72, 0, 1)'
}
}
},
+27 -23
View File
@@ -1,23 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src/**/*", "vite.config.ts"]
}
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src/**/*", "vite.config.ts"]
}
+13
View File
@@ -8,6 +8,19 @@ export default defineConfig({
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
build: {
rollupOptions: {
output: {
// Isole les grosses dépendances dans leurs propres chunks : elles
// changent rarement → meilleur cache navigateur entre deux versions.
manualChunks: {
react: ['react', 'react-dom'],
motion: ['framer-motion'],
supabase: ['@supabase/supabase-js']
}
}
}
},
clearScreen: false,
server: {
port: 5173,