Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4374392a | ||
|
|
ab97af0ea3 | ||
|
|
cbb8f93435 | ||
|
|
16bab06b71 | ||
|
|
969bd5c2bf | ||
|
|
e770ccf22b | ||
|
|
5828d1796d | ||
|
|
0db14560a5 | ||
|
|
7d62aa02be | ||
|
|
285273108c | ||
|
|
3516225e56 | ||
|
|
987ce43e52 | ||
|
|
8176fc4de0 | ||
|
|
1a9d5d5d18 | ||
|
|
0ca3eef7c3 | ||
|
|
7a697213d4 | ||
|
|
00db9dedfd | ||
|
|
f2b1c05356 | ||
|
|
237de1e529 | ||
|
|
811d186b46 | ||
|
|
b756517ab5 | ||
|
|
1ee0ec820c | ||
|
|
99e135ec74 | ||
|
|
7272772c9f | ||
|
|
9aa61c85df | ||
|
|
050d8eda8b | ||
|
|
3e58b00c1f | ||
|
|
f2c2a43d22 | ||
|
|
b2a3e34bbe | ||
|
|
f62f724046 | ||
|
|
3a2c8eb5cf | ||
|
|
9ca290c8ac | ||
|
|
e6de684f28 | ||
|
|
a2e0061dde | ||
|
|
d9d9587ded | ||
|
|
8cb9d37154 | ||
|
|
c62e6ef52d | ||
|
|
5b74c6da04 | ||
|
|
d660d2cc7a | ||
|
|
912734d2cc | ||
|
|
1c274289bf | ||
|
|
63833abbc7 | ||
|
|
b3f320975b | ||
|
|
fd53b33e9f | ||
|
|
f68c2b7259 | ||
|
|
cfc115665b | ||
|
|
401e98e9ee | ||
|
|
c3229cbf11 | ||
|
|
9ec78ba77e | ||
|
|
606dcbf3d7 | ||
|
|
5d5ccf406d | ||
|
|
c758337ff3 | ||
|
|
2016407a43 | ||
|
|
d913f3c0eb | ||
|
|
6ef3b26153 | ||
|
|
7790375c01 | ||
|
|
b97cba6e75 | ||
|
|
5a81c1e9f0 | ||
|
|
3e344f4c1c | ||
|
|
19ab75f7df | ||
|
|
35c3ef020d | ||
|
|
1ddd080c16 |
@@ -1,5 +0,0 @@
|
|||||||
# Copier ce fichier en .env et adapter les valeurs
|
|
||||||
DB_NAME=househub
|
|
||||||
DB_USER=househub
|
|
||||||
DB_PASS=changeme
|
|
||||||
DB_ROOT_PASS=rootchangeme
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
househub_db_data
|
househub_db_data
|
||||||
.env
|
.env
|
||||||
|
data/.hh_app_secret
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
🦙 HouseHub OS
|
# 🦙 HouseHub OS
|
||||||
Système de gestion familiale sur-mesure : Budget, Calendrier, Voyages, Cadeaux et Garage.
|
|
||||||
|
Système de gestion familiale sur-mesure — Budget, Calendrier, Voyages, Tâches, Courses, Notes, Impression 3D et plus.
|
||||||
|
|
||||||
|
Stack : PHP 8.2 natif · Vanilla JS · MariaDB 11 · Docker · Traefik
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Déploiement (Docker)
|
## 🚀 Déploiement (Docker)
|
||||||
|
|
||||||
@@ -24,95 +29,159 @@ La base de données meta (`househub_meta`) et les permissions sont **initialisé
|
|||||||
|
|
||||||
Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial.
|
Aucun compte par défaut — le premier utilisateur s'inscrit via `/register.php` et choisit de créer un espace familial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 👥 Gestion multi-tenant
|
## 👥 Gestion multi-tenant
|
||||||
|
|
||||||
HouseHub supporte plusieurs familles indépendantes sur la même instance :
|
HouseHub supporte plusieurs familles indépendantes sur la même instance.
|
||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
|-----|-------------|
|
| --------------- | ------------------------------------------------------------------- |
|
||||||
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre un espace existant via code |
|
| `/register.php` | Créer un compte + nouvel espace, ou rejoindre via code d'invitation |
|
||||||
| `/login.php` | Connexion |
|
| `/login.php` | Connexion |
|
||||||
| `/settings.php` | Paramètres : profil, mot de passe, langue, modules actifs, code d'invitation |
|
| `/settings.php` | Profil, mot de passe, langue, modules actifs, code d'invitation |
|
||||||
| `/admin/` | Panneau d'administration (admin uniquement) |
|
| `/admin/` | Panneau d'administration (admin uniquement) |
|
||||||
| `/garage.php` | Module Garage — véhicules, entretiens, pièces |
|
|
||||||
|
|
||||||
### Inviter quelqu'un dans son espace
|
### Inviter quelqu'un dans son espace
|
||||||
|
|
||||||
1. Aller sur `/settings.php` → copier le code d'invitation
|
1. Aller sur `/settings.php` → copier le code d'invitation
|
||||||
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
|
2. La personne va sur `/register.php` → onglet "Rejoindre" → coller le code
|
||||||
3. Les deux comptes partagent le même environnement de données
|
3. Les deux comptes partagent le même environnement de données
|
||||||
|
|
||||||
### Rôle admin
|
### Rôle admin
|
||||||
|
|
||||||
Le premier utilisateur inscrit doit être promu admin manuellement :
|
Le premier utilisateur inscrit doit être promu admin manuellement :
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
|
UPDATE househub_meta.users SET is_admin = 1 WHERE username = 'ton_username';
|
||||||
```
|
```
|
||||||
L'admin peut ensuite promouvoir/désactiver d'autres utilisateurs depuis `/admin/`.
|
|
||||||
|
|
||||||
## 🧩 Modules
|
|
||||||
|
|
||||||
| Module | Route | Description |
|
|
||||||
|--------|-------|-------------|
|
|
||||||
| Calendrier | `/family-calendar.php` | Congés, modes de garde, planning hebdo |
|
|
||||||
| Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne |
|
|
||||||
| Voyages | `/holidays.php` | Roadtrips, cartographie, météo |
|
|
||||||
| Cadeaux | `/gift-list.php` | Noël, anniversaires, Tricount |
|
|
||||||
| Garage | `/garage.php` | Véhicules, entretiens, pièces détachées |
|
|
||||||
|
|
||||||
Les modules sont activables/désactivables par famille depuis `/settings.php`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
📝 Présentation du projet
|
## 🧩 Modules
|
||||||
HouseHub est une application web privée conçue pour centraliser l'organisation de la tribu. Elle repose sur une stack légère (PHP natif / Vanilla JS) pour garantir rapidité et portabilité, avec un support complet de l'internationalisation (FR/CA).
|
|
||||||
|
|
||||||
🛠️ Fonctionnalités Principales
|
Tous les modules sont activables/désactivables par famille depuis `/settings.php`.
|
||||||
💰 Module Budget
|
|
||||||
Suivi Mensuel : Interface de gestion des dépenses réelles avec import CSV bancaire et catégorisation automatique.
|
|
||||||
|
|
||||||
Budget Prévisionnel : Planification des revenus et répartition automatique vers les différents comptes (Commune, Livrets, Épargne).
|
| Module | Route | Description |
|
||||||
|
| ----------------- | ---------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| 💰 Budget | `/budget.php` | Suivi mensuel, prévisionnel, épargne, import CSV bancaire |
|
||||||
|
| 📅 Calendrier | `/family-calendar.php` | Congés (CP/JRA/JA), modes de garde, planning hebdo, vacances scolaires |
|
||||||
|
| 🗓️ Calendrier iOS | `/calendar-ios.php` | Synchronisation CalDAV avec iPhone/iPad |
|
||||||
|
| 🏖️ Voyages | `/holidays.php` | Roadtrips, cartographie Leaflet, itinéraires OSRM, météo Open-Meteo |
|
||||||
|
| 🎁 Cadeaux | `/gift-list.php` | Listes Noël / anniversaires, intégration Tricount |
|
||||||
|
| 🚗 Garage | `/garage.php` | Véhicules, historique entretiens, pièces détachées |
|
||||||
|
| 🛒 Courses | `/groceries.php` | Liste de courses partagée en temps réel |
|
||||||
|
| ✅ Todo | `/todo.php` | Listes de tâches avec rappels quotidiens via Discord webhook |
|
||||||
|
| 📝 Mémo | `/memo.php` | Notes personnelles / familiales avec éditeur riche |
|
||||||
|
| 🖨️ PrintVault | `/printvault.php` | Gestionnaire de fichiers d'impression 3D (STL, 3MF, GCode) avec viewer Three.js |
|
||||||
|
| 📋 Planka | `/planka.php` | Kanban natif connecté à Planka — boards, listes, cartes, drag & drop, checklist |
|
||||||
|
|
||||||
Épargne : Visualisation des soldes bancaires et ventilation par postes de dépenses avec mode "Addition Rapide".
|
---
|
||||||
|
|
||||||
📅 Calendrier Familial
|
## 📋 Planka
|
||||||
Planning Hebdomadaire : Gestion visuelle des modes de garde, des congés enfants et de la maladie.
|
|
||||||
|
|
||||||
Gestion des Congés (CP/JRA/JA) : Calcul automatique des soldes restants avec snapshots de correction.
|
Module Kanban natif connecté à l'instance Planka du NAS — affichage et gestion directement dans HouseHub sans iframe.
|
||||||
|
|
||||||
Vacances Scolaires : Intégration automatique via l'API Data.Education (Zone C).
|
- **Boards** : sélecteur de boards du projet Planka lié à la famille
|
||||||
|
- **Colonnes** : listes Planka affichées en colonnes scrollables
|
||||||
|
- **Cartes** : labels colorés, date d'échéance, checklist
|
||||||
|
- **Actions** : créer, modifier, supprimer des cartes ; drag & drop entre colonnes ; cocher les tâches
|
||||||
|
- **Par famille** : chaque espace HouseHub peut pointer vers son propre projet Planka
|
||||||
|
- **Paramètres** : bouton ⚙️ pour configurer le `project_id` Planka
|
||||||
|
|
||||||
🏖️ Holidays & Roadtrips
|
---
|
||||||
Gestion de Voyages : Planification avec statuts (Brouillon, Confirmé, Terminé).
|
|
||||||
|
|
||||||
Moteur de Roadtrip : Cartographie interactive (Leaflet), itinéraires (OSRM) et gestion des étapes.
|
## 🖨️ PrintVault
|
||||||
|
|
||||||
Météo Intelligente : Prévisions réelles ou archives historiques (Open-Meteo).
|
Module de gestion de fichiers d'impression 3D entièrement intégré à HouseHub.
|
||||||
|
|
||||||
## 🚀 Roadmap d'Optimisation
|
- **Formats supportés** : STL (binaire/ASCII), 3MF, GCode
|
||||||
|
- **Viewer 3D** : Three.js (STL/3MF) et visualisation des trajectoires GCode avec dégradé de couleur par hauteur Z
|
||||||
|
- **Métadonnées GCode** : temps d'impression estimé, longueur filament, température buse/plateau
|
||||||
|
- **Stockage local** : fichiers sur le serveur, métadonnées en MariaDB (`pf_pv_*`)
|
||||||
|
- **Limite upload** : 300 Mo
|
||||||
|
|
||||||
- [x] **[Quickwin] Sécurisation AJAX (pachaFetch)**
|
---
|
||||||
- [x] **[Quickwin] Correction du piège "action"**
|
|
||||||
- [x] **[Structure] Centralisation des Constantes**
|
|
||||||
- [x] **[UX] Harmonisation Mobile**
|
|
||||||
- [x] **[Performance] Audit et Optimisation SQL**
|
|
||||||
- [ ] **[Backend] Système de Backup Automatique de la BDD**
|
|
||||||
|
|
||||||
Étape 3 : Performance & Backend
|
## ✅ Todo — Rappels Discord
|
||||||
[ ] Audit SQL : Remplacement des requêtes en boucle par des jointures.
|
|
||||||
|
|
||||||
[ ] Cache Cleanup : Script de nettoyage pour le cache de géocodage.
|
Le module Todo supporte des rappels quotidiens via un **webhook Discord** :
|
||||||
|
|
||||||
📌 Patch / Versioning
|
1. Aller sur `/todo.php` → ⚙️ → coller l'URL du webhook Discord
|
||||||
|
2. Configurer le cron sur l'hôte :
|
||||||
|
|
||||||
- **v1.3.0 :** Optimisation Performance SQL. Suppression des requêtes en boucle (N+1) dans les modules Budget et Holidays. Temps de réponse divisé par 3 sur les gros historiques.
|
```bash
|
||||||
|
* * * * * docker exec househub php /var/www/html/cron/todo_notify.php >> /home/perco/househub_todo_cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
* **v1.2.0 :** Modernisation de l'UI. Remplacement des alertes systèmes par des Toasts et des confirmations sur-mesure.
|
Le cron tourne chaque minute et envoie une notification Discord à l'heure exacte configurée sur chaque tâche.
|
||||||
* **v1.1.0 :** Refonte de l'expérience mobile (Bottom Sheets).
|
|
||||||
v1.0.4 : Centralisation des constantes (IDs, devises) dans includes/config.php et injection dans window.CONFIG.
|
|
||||||
|
|
||||||
v1.0.3 : Migration du module Calendrier vers pachaFetch.
|
---
|
||||||
|
|
||||||
v1.0.2 : Correction systématique du bug form.action dans les fichiers Budget.
|
## 🛠️ Stack technique
|
||||||
|
|
||||||
v1.0.1 : Création de l'utilitaire global pachaFetch dans le header.
|
| Composant | Technologie |
|
||||||
|
| -------------------- | ---------------------------------------- |
|
||||||
|
| Backend | PHP 8.2 (Apache) |
|
||||||
|
| Base de données | MariaDB 11 |
|
||||||
|
| Frontend | Vanilla JS, CSS custom (dark mode natif) |
|
||||||
|
| Viewer 3D | Three.js (ES modules via importmap CDN) |
|
||||||
|
| Cartographie | Leaflet + OSRM |
|
||||||
|
| Conteneurisation | Docker + Docker Compose |
|
||||||
|
| Reverse proxy | Traefik |
|
||||||
|
| Internationalisation | FR / CA |
|
||||||
|
|
||||||
v1.0.0 : Version stable initiale.
|
---
|
||||||
|
|
||||||
|
## 🔄 Mises à jour & Migrations SQL (Multi-Tenant)
|
||||||
|
|
||||||
|
L'architecture de HouseHub isole chaque foyer dans sa propre base de données (`househub_f1`, `househub_f2`, etc.). Lors de l'ajout de nouvelles fonctionnalités impliquant des changements de structure SQL, il faut suivre une double procédure :
|
||||||
|
|
||||||
|
1. **Pour les futures familles (Initialisation) :**
|
||||||
|
Mettre à jour le fichier `docker/schema_family.sql`. Ce fichier est lu par `register.php` à la création d'un nouvel espace.
|
||||||
|
2. **Pour les familles existantes (Migration) :**
|
||||||
|
Créer ou mettre à jour le script `migrate.php` à la racine du projet. Ce script boucle sur toutes les bases existantes référencées dans `househub_meta.families` pour y injecter les requêtes `ALTER TABLE` ou `CREATE TABLE`.
|
||||||
|
- **Exécution** : Accéder à `https://votre-domaine.com/migrate.php` avec un navigateur.
|
||||||
|
- ⚠️ **Sécurité** : Pensez à supprimer ou sécuriser l'accès à ce fichier après exécution en production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap de Variabilisation (Migration Multi-Tenant)
|
||||||
|
|
||||||
|
Afin de transformer complètement l'application héritée de Pacha Family en un produit générique et autonome pour chaque espace familial, les chantiers suivants doivent être menés.
|
||||||
|
|
||||||
|
🟢 Niveau 1 : Facile (Constantes et configurations globales)
|
||||||
|
[x] Configuration de base : Extraire la devise (CURRENCY) et la zone de vacances scolaires (ZONE_SCOLAIRE) du fichier config.php pour les stocker dans les paramètres d'espace en BDD.
|
||||||
|
[ ] Filtres API Éducation Nationale : Rendre dynamique la zone de l'API (zones LIKE '%Zone C%' dans family-calendar.js) pour s'adapter à la région de chaque foyer.
|
||||||
|
[ ] Identifiant Planka : Variabiliser le project_id Planka par défaut écrit en dur dans planka/api.php.
|
||||||
|
[ ] Suppression des IDs statiques : Supprimer les IDs parents fixes (ID_ALEX = 2, ID_LAIA = 3) dans config.php au profit d'une lecture dynamique basée sur les utilisateurs réels de la famille connectée.
|
||||||
|
|
||||||
|
🟡 Niveau 2 : Intermédiaire (Listes et dictionnaires en dur)
|
||||||
|
[ ] Gestion des membres (Cadeaux) : Dans gift-list.php, remplacer la liste d'enfants ($children) et d'adultes ($baseAdults, $extraAdults) codée en dur par une lecture dynamique des membres du foyer déclarés en BDD.
|
||||||
|
[ ] Occasions festives (Cadeaux) : Rendre configurables les occasions spéciales (TIO, NOEL, ROIS, ANNIV, SANT).
|
||||||
|
[ ] Configuration des catégories (Budget) : Dans budget/views/suivi.php, migrer le tableau $categoriesConfig (FMCG, Essence, École, etc.), leurs couleurs et leurs suggestions de magasins associés vers un stockage paramétrable.
|
||||||
|
[ ] Déductions du Reste à venir (Budget) : Rendre dynamiques les libellés de catégories ciblés pour le calcul automatique du reste à venir (Estimacio escola, Estimation gasolina, etc.).
|
||||||
|
[ ] Types de carburants (Garage) : Variabiliser la liste statique des carburants dans garage.php.
|
||||||
|
|
||||||
|
🔴 Niveau 3 : Complexe (Logique métier et Schéma SQL)
|
||||||
|
[ ] Refonte relationnelle de la répartition (Budget) : Modifier la structure de la table pf_alloc_values pour supprimer les colonnes figées amount_alex et amount_laia et basculer sur un modèle relationnel (alloc_id, user_id, amount) capable de gérer n'importe quel nombre d'adultes (1, 2, 3 ou plus).
|
||||||
|
[ ] Destinations de transferts (Budget) : Rendre dynamiques les cibles de virements prévisionnels (vers L.Pol, vers L.Pep, etc.) en fonction des comptes épargne créés par la famille.
|
||||||
|
[ ] Onglets d'Épargne : Générer dynamiquement les sous-onglets de la vue epargne.php à partir des membres réels au lieu des blocs statiques Alex, Laia et Nens.
|
||||||
|
[ ] Types de Garde (Calendrier) : Supprimer les types d'événements et compteurs codés en dur (OFF_CAROLE, EXTRA_OFF_CAROLE, CENTRE, AVIS, PEP_SICK) pour permettre à chaque foyer de définir ses propres modalités de garde et compteurs de congés associés.
|
||||||
|
[ ] Cycles de Congés : Rendre configurables les mois de début de calcul des droits aux congés (Août pour les CP, date d'anniversaire personnalisée pour les JA) par profil utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 Versioning
|
||||||
|
|
||||||
|
| Version | Changements |
|
||||||
|
| ------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| v2.2.0 | Multi-tenant (p.1) : Paramètres dynamiques du foyer (€, Zone) via BDD + script migration |
|
||||||
|
| v2.1.0 | Module Planka : Kanban natif connecté à Planka via API proxy PHP |
|
||||||
|
| v2.0.0 | Module PrintVault : viewer 3D STL/3MF/GCode, stockage local, trajectoires GCode illimitées |
|
||||||
|
| v1.5.0 | Modules Todo (rappels Discord), Mémo, Courses (liste partagée) |
|
||||||
|
| v1.4.0 | Synchronisation CalDAV iOS |
|
||||||
|
| v1.3.0 | Optimisation SQL — suppression des requêtes N+1, temps de réponse ÷3 |
|
||||||
|
| v1.2.0 | Modernisation UI — Toasts, confirmations sur-mesure |
|
||||||
|
| v1.1.0 | Refonte expérience mobile (Bottom Sheets) |
|
||||||
|
| v1.0.0 | Version stable initiale — Budget, Calendrier, Voyages, Cadeaux, Garage |
|
||||||
|
|||||||
+2
-2
@@ -76,7 +76,7 @@ require __DIR__ . '/../header.php';
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($families as $f): ?>
|
<?php foreach ($families as $f): ?>
|
||||||
<tr style="border-bottom:1px solid #f1f5f9">
|
<tr style="border-bottom:1px solid var(--border-light)">
|
||||||
<td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td>
|
<td style="padding:10px 14px;color:#94a3b8"><?= $f['id'] ?></td>
|
||||||
<td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td>
|
<td style="padding:10px 14px;font-weight:600"><?= htmlspecialchars($f['name']) ?></td>
|
||||||
<td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td>
|
<td style="padding:10px 14px"><code style="font-size:0.8rem;background:#f1f5f9;padding:2px 6px;border-radius:4px"><?= $f['db_name'] ?></code></td>
|
||||||
@@ -135,7 +135,7 @@ require __DIR__ . '/../header.php';
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?>
|
<?php foreach ($users as $u): $isSelf = $u['id'] == $_SESSION['user']['id']; ?>
|
||||||
<tr style="border-bottom:1px solid #f1f5f9<?= $isSelf ? ';background:#fafff4' : '' ?>">
|
<tr style="border-bottom:1px solid var(--border-light)<?= $isSelf ? ';background:#fafff4' : '' ?>">
|
||||||
<td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td>
|
<td style="padding:10px 14px;color:#94a3b8"><?= $u['id'] ?></td>
|
||||||
<td style="padding:10px 14px">
|
<td style="padding:10px 14px">
|
||||||
<strong><?= htmlspecialchars($u['display_name']) ?></strong>
|
<strong><?= htmlspecialchars($u['display_name']) ?></strong>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Calendrier iOS — HouseHub';
|
||||||
|
$activePage = 'calendar-ios';
|
||||||
|
$mainClass = 'pf-calendar-ios-page';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/calendar-ios/assets/calendar-ios.css">
|
||||||
|
|
||||||
|
<div class="pf-container ios-calendar-wrap">
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<div class="ios-calendar-head">
|
||||||
|
<h1>📱 <?= tr('menu_calendar_ios') ?></h1>
|
||||||
|
<div class="ios-calendar-head-actions">
|
||||||
|
<button type="button" class="ios-agenda-btn ios-agenda-btn--ghost" id="ios-sync-btn">Synchroniser</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ios-agenda-intro">Les événements viennent de HouseHub après synchro iCloud. <strong>Synchroniser</strong> met à jour tous les calendriers du compte ; les changements locaux partent au prochain envoi.</p>
|
||||||
|
|
||||||
|
<details class="ios-cal-prefs-details" id="ios-cal-prefs-details">
|
||||||
|
<summary class="ios-cal-prefs-summary">Calendriers affichés et couleurs</summary>
|
||||||
|
<p class="ios-cal-prefs-help">Décoche un calendrier pour le masquer dans l’agenda. La couleur est libre (repère la teinte iOS sur ton téléphone puis copie-la avec le sélecteur).</p>
|
||||||
|
<div id="ios-calendar-prefs-rows" class="ios-cal-prefs-rows"></div>
|
||||||
|
<p id="ios-calendar-prefs-msg" class="ios-cal-prefs-msg" role="status"></p>
|
||||||
|
<button type="button" class="pf-btn" id="ios-calendar-prefs-save">Enregistrer affichage & couleurs</button>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card ios-agenda-card">
|
||||||
|
<div class="ios-agenda-toolbar">
|
||||||
|
<div class="ios-agenda-title" id="ios-agenda-title" aria-live="polite">—</div>
|
||||||
|
<div class="ios-agenda-segments" role="tablist">
|
||||||
|
<button type="button" class="ios-seg" data-ios-view="day" role="tab">Jour</button>
|
||||||
|
<button type="button" class="ios-seg ios-seg--active" data-ios-view="week" role="tab">Semaine</button>
|
||||||
|
<button type="button" class="ios-seg" data-ios-view="month" role="tab">Mois</button>
|
||||||
|
</div>
|
||||||
|
<div class="ios-agenda-nav">
|
||||||
|
<button type="button" class="ios-agenda-btn" id="ios-agenda-prev" aria-label="Période précédente">‹</button>
|
||||||
|
<button type="button" class="ios-agenda-btn" id="ios-agenda-next" aria-label="Période suivante">›</button>
|
||||||
|
<button type="button" class="ios-agenda-today" id="ios-agenda-today">Aujourd'hui</button>
|
||||||
|
<button type="button" class="ios-agenda-add" id="ios-agenda-add" title="Nouvel événement">+</button>
|
||||||
|
<button type="button" class="ios-agenda-listlink" id="ios-view-list">Liste</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ios-agenda-mount" class="ios-agenda-mount"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card" id="ios-edit-panel">
|
||||||
|
<h2 class="pf-card-h2">Ajouter / Modifier un événement</h2>
|
||||||
|
<form id="ios-event-form" class="ios-form-grid">
|
||||||
|
<input type="hidden" id="ios-event-id">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Titre</label>
|
||||||
|
<input class="pf-input" id="ios-title" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Lieu</label>
|
||||||
|
<input class="pf-input" id="ios-location">
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Début</label>
|
||||||
|
<input class="pf-input" id="ios-start" type="datetime-local" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Fin</label>
|
||||||
|
<input class="pf-input" id="ios-end" type="datetime-local" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group ios-form-full">
|
||||||
|
<label class="pf-label">Description</label>
|
||||||
|
<textarea class="pf-input" id="ios-description"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="ios-form-full ios-form-actions">
|
||||||
|
<button class="pf-btn" type="submit">Enregistrer</button>
|
||||||
|
<button class="pf-btn btn-secondary" type="button" id="ios-form-reset">Réinitialiser</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pf-panel-card" id="ios-list-section" style="display:none">
|
||||||
|
<h2 class="pf-card-h2">Événements (liste)</h2>
|
||||||
|
<div id="ios-sync-status" class="ios-agenda-intro"></div>
|
||||||
|
<div id="ios-events-list" class="ios-events-list"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/calendar-ios/assets/calendar-ios.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__) . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
$rows = $meta_pdo->query("SELECT user_id FROM user_calendar_integrations WHERE provider='icloud_caldav' AND status='connected'")->fetchAll();
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$userId = (int)$row['user_id'];
|
||||||
|
$url = 'http://localhost/modules/calendar-ios/api.php?action=sync';
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'X-Requested-With: XMLHttpRequest',
|
||||||
|
// Le cron réel devra utiliser un mécanisme d'auth/session technique.
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Cron: Todo daily reminder notifications via Discord webhook
|
||||||
|
* Runs every minute — sends Discord reminder when due_time is reached.
|
||||||
|
* Repeats every day at the same time (resets at midnight via notified_date).
|
||||||
|
* Usage: php /opt/container/househub/cron/todo_notify.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
date_default_timezone_set('Europe/Paris');
|
||||||
|
|
||||||
|
$DB_HOST = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$DB_USER = getenv('DB_USER') ?: 'househub';
|
||||||
|
$DB_PASS = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
$DB_ROOT = getenv('DB_ROOT_PASS') ?: 'rootchangeme';
|
||||||
|
|
||||||
|
// Load .env if running from CLI outside container
|
||||||
|
$env_file = dirname(__DIR__) . '/.env';
|
||||||
|
if (file_exists($env_file)) {
|
||||||
|
foreach (file($env_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
if (strpos($line, '=') !== false && $line[0] !== '#') {
|
||||||
|
[$k, $v] = explode('=', $line, 2);
|
||||||
|
$k = trim($k); $v = trim($v);
|
||||||
|
if (!getenv($k)) putenv("$k=$v");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$DB_HOST = getenv('DB_HOST') ?: $DB_HOST;
|
||||||
|
$DB_USER = getenv('DB_USER') ?: $DB_USER;
|
||||||
|
$DB_PASS = getenv('DB_PASS') ?: $DB_PASS;
|
||||||
|
$DB_ROOT = getenv('DB_ROOT_PASS') ?: $DB_ROOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of families from meta DB
|
||||||
|
try {
|
||||||
|
$meta = new PDO(
|
||||||
|
"mysql:host=$DB_HOST;dbname=househub_meta;charset=utf8mb4",
|
||||||
|
'root', $DB_ROOT,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
$families = $meta->query("SELECT id FROM families")->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
exit("Cannot connect to meta DB: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($families as $family_id) {
|
||||||
|
$db_name = 'househub_f' . $family_id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO(
|
||||||
|
"mysql:host=$DB_HOST;dbname=$db_name;charset=utf8mb4",
|
||||||
|
$DB_USER, $DB_PASS,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
||||||
|
);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$pdo->query("SHOW TABLES LIKE 'pf_todos'")->fetchColumn()) continue;
|
||||||
|
|
||||||
|
$ws = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
|
||||||
|
$ws->execute();
|
||||||
|
$webhook = $ws->fetchColumn();
|
||||||
|
if (!$webhook) continue;
|
||||||
|
|
||||||
|
// Daily reminder: exact HH:MM match (same logic as the original todo app)
|
||||||
|
$now = date('H:i');
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.id, t.title, t.notes, t.due_time, t.priority,
|
||||||
|
l.name AS list_name, l.icon AS list_icon, l.color AS list_color
|
||||||
|
FROM pf_todos t
|
||||||
|
LEFT JOIN pf_todo_lists l ON l.id = t.list_id
|
||||||
|
WHERE t.due_time IS NOT NULL
|
||||||
|
AND TIME_FORMAT(t.due_time, '%H:%i') = ?
|
||||||
|
AND t.done = 0
|
||||||
|
");
|
||||||
|
$stmt->execute([$now]);
|
||||||
|
|
||||||
|
$priColors = ['high' => 0xef4444, 'medium' => 0xf59e0b, 'low' => 0x22c55e, 'none' => 0x3b82f6];
|
||||||
|
$priLabels = ['high' => '🔴 Haute', 'medium' => '🟡 Moyenne', 'low' => '🟢 Basse', 'none' => null];
|
||||||
|
|
||||||
|
foreach ($stmt->fetchAll() as $t) {
|
||||||
|
$time = substr($t['due_time'], 0, 5);
|
||||||
|
$priority = $t['priority'] ?? 'none';
|
||||||
|
$color = $priColors[$priority] ?? 0x3b82f6;
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
if ($t['list_name']) {
|
||||||
|
$fields[] = ['name' => 'Liste', 'value' => $t['list_icon'] . ' ' . $t['list_name'], 'inline' => true];
|
||||||
|
}
|
||||||
|
if ($priLabels[$priority]) {
|
||||||
|
$fields[] = ['name' => 'Priorité', 'value' => $priLabels[$priority], 'inline' => true];
|
||||||
|
}
|
||||||
|
$fields[] = ['name' => 'Heure', 'value' => '⏰ ' . $time, 'inline' => true];
|
||||||
|
|
||||||
|
$embed = [
|
||||||
|
'title' => '🔔 Rappel : ' . $t['title'],
|
||||||
|
'color' => $color,
|
||||||
|
'fields' => $fields,
|
||||||
|
'footer' => ['text' => 'HouseHub Todo'],
|
||||||
|
];
|
||||||
|
if (!empty($t['notes'])) {
|
||||||
|
$embed['description'] = $t['notes'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = ['username' => 'HouseHub Todo', 'embeds' => [$embed]];
|
||||||
|
|
||||||
|
$ch = curl_init($webhook);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,6 @@
|
|||||||
════════════════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
|
/* ─── HOME PAGE ─────────────────────────────────────────────────────────────── */
|
||||||
/* Le header home.css a plus de spécificité que global.css → on le force ici */
|
|
||||||
[data-theme="dark"] body[data-page="home"] .pf-header {
|
|
||||||
background: rgba(13,17,23,.92) !important;
|
|
||||||
border-color: rgba(48,54,61,.6) !important;
|
|
||||||
backdrop-filter: blur(8px) !important;
|
|
||||||
}
|
|
||||||
[data-theme="dark"] body[data-page="home"] .pf-module-card {
|
[data-theme="dark"] body[data-page="home"] .pf-module-card {
|
||||||
background: rgba(22,27,34,.92) !important;
|
background: rgba(22,27,34,.92) !important;
|
||||||
border: 1px solid var(--border-light) !important;
|
border: 1px solid var(--border-light) !important;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
DB_NAME: ${DB_NAME:-househub}
|
DB_NAME: ${DB_NAME:-househub}
|
||||||
DB_USER: ${DB_USER:-househub}
|
DB_USER: ${DB_USER:-househub}
|
||||||
DB_PASS: ${DB_PASS:-changeme}
|
DB_PASS: ${DB_PASS:-changeme}
|
||||||
|
# Clé applicative pour chiffrer les secrets (iCloud, etc.). Optionnel si absent : fichier data/.hh_app_secret ou dérivation DB.
|
||||||
|
APP_SECRET_KEY: ${APP_SECRET_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- househub_uploads:/uploads
|
- househub_uploads:/uploads
|
||||||
- /opt/container/househub:/var/www/html
|
- /opt/container/househub:/var/www/html
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
FOREIGN KEY (family_id) REFERENCES families(id)
|
FOREIGN KEY (family_id) REFERENCES families(id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
dav_principal_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
status VARCHAR(30) DEFAULT 'connected',
|
||||||
|
last_sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_provider (user_id, provider),
|
||||||
|
CONSTRAINT fk_calendar_integration_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Donner au user applicatif le droit de créer des DBs famille
|
-- Donner au user applicatif le droit de créer des DBs famille
|
||||||
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
|
GRANT ALL PRIVILEGES ON `househub_f%`.* TO 'househub'@'%';
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
|
|||||||
+5
-3
@@ -1,3 +1,5 @@
|
|||||||
upload_max_filesize = 20M
|
upload_max_filesize = 300M
|
||||||
post_max_size = 25M
|
post_max_size = 310M
|
||||||
memory_limit = 256M
|
memory_limit = 512M
|
||||||
|
max_execution_time = 120
|
||||||
|
max_input_time = 120
|
||||||
|
|||||||
+206
-3
@@ -12,6 +12,17 @@ CREATE TABLE IF NOT EXISTS pf_users (
|
|||||||
display_name VARCHAR(100) NOT NULL
|
display_name VARCHAR(100) NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Familles ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT '€',
|
||||||
|
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Initialisation de la ligne unique par défaut
|
||||||
|
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C');
|
||||||
|
|
||||||
-- ─── Personnes ────────────────────────────────────────────────────────────────
|
-- ─── Personnes ────────────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS pf_people (
|
CREATE TABLE IF NOT EXISTS pf_people (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -91,7 +102,7 @@ CREATE TABLE IF NOT EXISTS pf_budget_items (
|
|||||||
CREATE TABLE IF NOT EXISTS pf_expenses (
|
CREATE TABLE IF NOT EXISTS pf_expenses (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
date_exp DATE NOT NULL,
|
date_exp DATE NOT NULL,
|
||||||
gestion_month VARCHAR(7) NOT NULL,
|
gestion_month VARCHAR(10) NOT NULL,
|
||||||
category VARCHAR(100),
|
category VARCHAR(100),
|
||||||
label VARCHAR(255),
|
label VARCHAR(255),
|
||||||
amount DECIMAL(10,2) DEFAULT 0,
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
@@ -110,7 +121,7 @@ CREATE TABLE IF NOT EXISTS pf_alloc_categories (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
month_date VARCHAR(7) NOT NULL,
|
month_date VARCHAR(10) NOT NULL,
|
||||||
cat_id INT NOT NULL,
|
cat_id INT NOT NULL,
|
||||||
amount_alex DECIMAL(10,2) DEFAULT 0,
|
amount_alex DECIMAL(10,2) DEFAULT 0,
|
||||||
amount_laia DECIMAL(10,2) DEFAULT 0,
|
amount_laia DECIMAL(10,2) DEFAULT 0,
|
||||||
@@ -120,7 +131,7 @@ CREATE TABLE IF NOT EXISTS pf_alloc_values (
|
|||||||
CREATE TABLE IF NOT EXISTS pf_savings (
|
CREATE TABLE IF NOT EXISTS pf_savings (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
owner VARCHAR(50) NOT NULL,
|
owner VARCHAR(50) NOT NULL,
|
||||||
month_date VARCHAR(7) NOT NULL,
|
month_date VARCHAR(10) NOT NULL,
|
||||||
category VARCHAR(100) NOT NULL,
|
category VARCHAR(100) NOT NULL,
|
||||||
amount DECIMAL(10,2) DEFAULT 0,
|
amount DECIMAL(10,2) DEFAULT 0,
|
||||||
holiday_id INT DEFAULT NULL
|
holiday_id INT DEFAULT NULL
|
||||||
@@ -293,3 +304,195 @@ CREATE TABLE IF NOT EXISTS pf_parts (
|
|||||||
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
|
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
|
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_garage_documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
maintenance_id INT DEFAULT NULL,
|
||||||
|
label VARCHAR(255) DEFAULT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
mime VARCHAR(120) DEFAULT NULL,
|
||||||
|
size INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_gd_vehicle (vehicle_id),
|
||||||
|
KEY idx_gd_maint (maintenance_id),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_memo_notes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
content LONGTEXT DEFAULT '',
|
||||||
|
tags VARCHAR(1000) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FULLTEXT KEY ft_notes (title, content, tags)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_memo_attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
note_id INT NOT NULL,
|
||||||
|
type ENUM('image','file','url') NOT NULL DEFAULT 'file',
|
||||||
|
filename VARCHAR(255) DEFAULT NULL,
|
||||||
|
original_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
url TEXT DEFAULT NULL,
|
||||||
|
label VARCHAR(255) DEFAULT NULL,
|
||||||
|
size INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Todo ─────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_todo_lists (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
color VARCHAR(20) DEFAULT '#3b82f6',
|
||||||
|
icon VARCHAR(10) DEFAULT '📋',
|
||||||
|
position INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_todos (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
list_id INT DEFAULT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
due_date DATE DEFAULT NULL,
|
||||||
|
due_time TIME DEFAULT NULL,
|
||||||
|
notified TINYINT(1) DEFAULT 0,
|
||||||
|
notified_date DATE DEFAULT NULL,
|
||||||
|
priority ENUM('none','low','medium','high') DEFAULT 'none',
|
||||||
|
done TINYINT(1) DEFAULT 0,
|
||||||
|
done_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Module Liste (multi-listes) ──────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_lists (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO pf_lists (id, name, position) VALUES (1, 'Ma liste', 0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
list_id INT NOT NULL DEFAULT 1,
|
||||||
|
category_id INT DEFAULT NULL,
|
||||||
|
label VARCHAR(500) NOT NULL,
|
||||||
|
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_items_list (list_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||||
|
KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_list_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
position INT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_item_category_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
keyword VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
UNIQUE KEY uq_keyword (keyword)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
family_id INT NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
is_all_day TINYINT(1) DEFAULT 0,
|
||||||
|
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
|
||||||
|
rrule VARCHAR(500) DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'confirmed',
|
||||||
|
external_uid VARCHAR(255) DEFAULT NULL,
|
||||||
|
sync_state VARCHAR(30) DEFAULT 'pending_push',
|
||||||
|
deleted_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_external_uid (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
calendar_event_id INT NOT NULL,
|
||||||
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── PrintVault ───────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_models (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
category VARCHAR(100) DEFAULT 'Non classé',
|
||||||
|
tags VARCHAR(500) DEFAULT '',
|
||||||
|
file_type VARCHAR(20) NOT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
file_size INT DEFAULT 0,
|
||||||
|
dim_x DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_y DECIMAL(10,3) DEFAULT 0,
|
||||||
|
dim_z DECIMAL(10,3) DEFAULT 0,
|
||||||
|
volume DECIMAL(10,3) DEFAULT 0,
|
||||||
|
gcode_time VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_filament VARCHAR(50) DEFAULT '',
|
||||||
|
gcode_nozzle VARCHAR(20) DEFAULT '',
|
||||||
|
gcode_bed VARCHAR(20) DEFAULT '',
|
||||||
|
thumb VARCHAR(255) DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_pv_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(20) DEFAULT '#8b5cf6'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_pv_categories (name, color) VALUES
|
||||||
|
('Non classé','#64748b'),('Déco','#ec4899'),('Fonctionnel','#3b82f6'),
|
||||||
|
('Mécanique','#f59e0b'),('Jouets','#10b981'),('Outils','#ef4444'),
|
||||||
|
('Architecture','#8b5cf6'),('Art','#06b6d4');
|
||||||
|
|
||||||
|
-- ─── Planka ───────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_planka_config (
|
||||||
|
id INT NOT NULL DEFAULT 1,
|
||||||
|
project_id VARCHAR(32),
|
||||||
|
admin_token TEXT,
|
||||||
|
token_expires_at DATETIME,
|
||||||
|
active_board_id VARCHAR(32),
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* HouseHub OS - Source Extractor 🦙🚀
|
||||||
|
* Génère un fichier Markdown contenant tout le code source du projet.
|
||||||
|
* Idéal pour alimenter une GEM ou un LLM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1. 🚨 SÉCURITÉ : Ne jamais laisser ce fichier accessible publiquement sans protection !
|
||||||
|
// Appelle le script via : https://househub.nas.../extract_source.php?key=TON_MOT_DE_PASSE
|
||||||
|
$secretKey = 'mR83s7MmXbP$jer$9C4HnGkry6xhGL6';
|
||||||
|
|
||||||
|
if (!isset($_GET['key']) || $_GET['key'] !== $secretKey) {
|
||||||
|
http_response_code(403);
|
||||||
|
die("⛔ Accès refusé. Veuillez fournir la clé de sécurité.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forcer l'affichage en texte brut dans le navigateur pour un copier-coller facile
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
|
||||||
|
$root = __DIR__;
|
||||||
|
|
||||||
|
// 2. CONFIGURATION DU FILTRAGE
|
||||||
|
// Dossiers à exclure (ne mets pas de slash au début)
|
||||||
|
$excludeDirs = [
|
||||||
|
'.git',
|
||||||
|
'.gitea',
|
||||||
|
'.devtools',
|
||||||
|
'vendor',
|
||||||
|
'node_modules',
|
||||||
|
'assets/img',
|
||||||
|
'modules/holidays/assets/img',
|
||||||
|
'modules/family-calendar/assets/img',
|
||||||
|
'modules/gift-list/assets/img'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fichiers spécifiques à exclure (Sécurité & Bruit)
|
||||||
|
$excludeFiles = [
|
||||||
|
'.env',
|
||||||
|
'.env.example',
|
||||||
|
'extract_source.php', // On s'exclut soi-même
|
||||||
|
'pachafamily_source.md',
|
||||||
|
'docker-compose.override.yml'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extensions autorisées (on ignore les images, pdf, zip, etc.)
|
||||||
|
$allowedExtensions = ['php', 'js', 'css', 'sql', 'json', 'yml', 'yaml', 'md', 'sh'];
|
||||||
|
|
||||||
|
// 3. INITIALISATION DU PARCOURS
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
$report = "# 🦙 Source Code HouseHub OS\n\n";
|
||||||
|
$report .= "> *Généré le " . date('Y-m-d H:i:s') . "*\n\n";
|
||||||
|
$report .= "---\n\n";
|
||||||
|
|
||||||
|
// 4. BOUCLE D'EXTRACTION
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isDir()) continue;
|
||||||
|
|
||||||
|
$filePath = $file->getPathname();
|
||||||
|
$relativePath = str_replace($root . DIRECTORY_SEPARATOR, '', $filePath);
|
||||||
|
$relativePath = str_replace('\\', '/', $relativePath); // Uniformisation Windows/Linux
|
||||||
|
|
||||||
|
// Extraction de l'extension
|
||||||
|
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// A. Filtrage par dossier
|
||||||
|
$skip = false;
|
||||||
|
foreach ($excludeDirs as $dir) {
|
||||||
|
// Si le chemin commence par ce dossier, ou contient /ce_dossier/
|
||||||
|
if (strpos($relativePath, $dir . '/') === 0 || strpos($relativePath, '/' . $dir . '/') !== false) {
|
||||||
|
$skip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($skip) continue;
|
||||||
|
|
||||||
|
// B. Filtrage par nom de fichier
|
||||||
|
if (in_array(basename($filePath), $excludeFiles)) continue;
|
||||||
|
|
||||||
|
// C. Filtrage par extension
|
||||||
|
if (!in_array($ext, $allowedExtensions)) continue;
|
||||||
|
|
||||||
|
// 5. LECTURE ET FORMATAGE
|
||||||
|
$content = file_get_contents($filePath);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
$content = "// Erreur lors de la lecture de ce fichier.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajustement du tag de langage pour le Markdown
|
||||||
|
$lang = $ext;
|
||||||
|
if ($ext === 'js') $lang = 'javascript';
|
||||||
|
if ($ext === 'yml') $lang = 'yaml';
|
||||||
|
|
||||||
|
$report .= "### 📄 Fichier : `$relativePath`\n";
|
||||||
|
$report .= "```$lang\n";
|
||||||
|
$report .= trim($content) . "\n";
|
||||||
|
$report .= "```\n\n---\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. SORTIE
|
||||||
|
echo $report;
|
||||||
+5
-2
@@ -35,8 +35,11 @@ require __DIR__ . '/header.php';
|
|||||||
<div id="modalHolidays" class="pf-modal">
|
<div id="modalHolidays" class="pf-modal">
|
||||||
<div class="pf-modal-content">
|
<div class="pf-modal-content">
|
||||||
<div class="pf-modal-header">
|
<div class="pf-modal-header">
|
||||||
<h3 class="pf-modal-title"><?= tr('fc_modal_holidays_title') ?></h3>
|
<h3 class="pf-modal-title">
|
||||||
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('open'); document.body.classList.remove('no-scroll');">×</button>
|
<?= tr('fc_modal_holidays_title') ?>
|
||||||
|
<?php if (defined('ZONE_SCOLAIRE') && ZONE_SCOLAIRE !== 'Autre') echo '(Zone ' . htmlspecialchars(ZONE_SCOLAIRE) . ')'; ?>
|
||||||
|
</h3>
|
||||||
|
<button id="btnCloseHolidays" class="pf-modal-close" onclick="document.getElementById('modalHolidays').classList.remove('active')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-modal-body" style="padding:0;">
|
<div class="pf-modal-body" style="padding:0;">
|
||||||
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
<div style="width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
||||||
|
|||||||
+48
-10
@@ -28,15 +28,15 @@ require __DIR__ . '/header.php';
|
|||||||
<div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div>
|
<div class="stat-pill amber"><div class="label">Pièces</div><div class="value" id="stat-parts">--</div></div>
|
||||||
<div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
|
<div class="stat-pill red"><div class="label">Coût total</div><div class="value" id="stat-cost">--</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 380px;gap:1.5rem;align-items:start">
|
<div class="garage-dash-layout">
|
||||||
<div>
|
<div class="garage-dash-main">
|
||||||
<div class="card-header" style="margin-bottom:.75rem">
|
<div class="card-header garage-card-header" style="margin-bottom:.75rem">
|
||||||
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
|
<h2 style="font-size:1rem;font-weight:600">Mes véhicules</h2>
|
||||||
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
|
<button class="btn btn-primary btn-sm" onclick="openAddVehicle()">+ Ajouter</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="dashboard-vehicles" class="vehicles-grid"></div>
|
<div id="dashboard-vehicles" class="vehicles-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card garage-dash-aside">
|
||||||
<div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div>
|
<div class="card-header"><div class="card-title">🔔 Rappels & prochains entretiens</div></div>
|
||||||
<div id="reminders-list"></div>
|
<div id="reminders-list"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@ require __DIR__ . '/header.php';
|
|||||||
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
|
<!-- ── VÉHICULES ──────────────────────────────────────────────────────────── -->
|
||||||
<div id="page-vehicles" class="page">
|
<div id="page-vehicles" class="page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card-header" style="margin-bottom:1rem">
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
<h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1>
|
<h1 style="font-size:1.2rem;font-weight:700">🚗 Mes véhicules</h1>
|
||||||
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
|
<button class="btn btn-primary" onclick="openAddVehicle()">+ Ajouter un véhicule</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,22 +62,38 @@ require __DIR__ . '/header.php';
|
|||||||
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
|
<button class="btn btn-secondary btn-sm" onclick="navigate('vehicles')">← Retour</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="vehicle-header"></div>
|
<div id="vehicle-header"></div>
|
||||||
<div class="tabs">
|
<div class="tabs garage-tabs">
|
||||||
<button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button>
|
<button class="tab-btn active" data-tab="maintenances" onclick="switchTab('maintenances')">🔧 Entretiens <span id="maintenance-total" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
<button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button>
|
<button class="tab-btn" data-tab="parts" onclick="switchTab('parts')">🔩 Pièces <span id="parts-total-vehicle" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
|
<button class="tab-btn" data-tab="documents" onclick="switchTab('documents')">📎 Documents <span id="vehicle-docs-tab-count" style="color:var(--muted);font-size:.78rem"></span></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-maintenances" class="tab-pane active">
|
<div id="tab-maintenances" class="tab-pane active">
|
||||||
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end">
|
<div class="garage-tab-actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
|
<button class="btn btn-primary btn-sm" onclick="openAddMaintenance()">+ Ajouter un entretien</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="maintenance-list"></div>
|
<div id="maintenance-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-parts" class="tab-pane">
|
<div id="tab-parts" class="tab-pane">
|
||||||
<div style="margin-bottom:1rem;display:flex;justify-content:flex-end">
|
<div class="garage-tab-actions">
|
||||||
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
|
<button class="btn btn-primary btn-sm" onclick="openAddPart()">+ Ajouter une pièce</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="parts-list-vehicle"></div>
|
<div id="parts-list-vehicle"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-documents" class="tab-pane">
|
||||||
|
<div class="garage-doc-upload-bar">
|
||||||
|
<div class="form-group" style="flex:1;min-width:0;margin:0">
|
||||||
|
<label class="form-label" for="vehicle-doc-label">Libellé (optionnel)</label>
|
||||||
|
<input type="text" class="form-control" id="vehicle-doc-label" placeholder="Carte grise, assurance…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||||
|
<label class="form-label" for="vehicle-doc-file">Fichier</label>
|
||||||
|
<input type="file" class="form-control" id="vehicle-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadVehicleDocument()">Envoyer</button>
|
||||||
|
</div>
|
||||||
|
<p class="garage-doc-hint">PDF, images, Office… · max 12 Mo</p>
|
||||||
|
<div id="vehicle-documents-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +104,28 @@ require __DIR__ . '/header.php';
|
|||||||
<button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button>
|
<button class="btn btn-secondary btn-sm" id="maintenance-back-btn" onclick="navigate('vehicle',{id:currentVehicleId})">← Retour au véhicule</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="maintenance-detail-header"></div>
|
<div id="maintenance-detail-header"></div>
|
||||||
|
|
||||||
|
<div class="card garage-docs-card" style="margin-top:1rem">
|
||||||
|
<div class="card-header garage-card-header">
|
||||||
|
<span class="card-title">📎 Documents & factures (cette révision)</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 1.25rem 1rem">
|
||||||
|
<div class="garage-doc-upload-bar">
|
||||||
|
<div class="form-group" style="flex:1;min-width:0;margin:0">
|
||||||
|
<label class="form-label" for="maint-doc-label">Libellé (ex. facture garage)</label>
|
||||||
|
<input type="text" class="form-control" id="maint-doc-label" placeholder="Facture, devis…">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;flex:1;min-width:140px">
|
||||||
|
<label class="form-label" for="maint-doc-file">Fichier</label>
|
||||||
|
<input type="file" class="form-control" id="maint-doc-file" accept=".pdf,.png,.jpg,.jpeg,.webp,.doc,.docx,.xls,.xlsx,.txt">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm garage-doc-upload-btn" onclick="uploadMaintenanceDocument()">Envoyer</button>
|
||||||
|
</div>
|
||||||
|
<p class="garage-doc-hint">PDF, images… · max 12 Mo</p>
|
||||||
|
<div id="maintenance-documents-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="padding:0;margin-top:1.5rem">
|
<div class="card" style="padding:0;margin-top:1.5rem">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span>
|
<span class="card-title">🔩 Pièces utilisées <span id="maint-parts-count" class="badge badge-gray" style="margin-left:.5rem"></span></span>
|
||||||
@@ -127,7 +165,7 @@ require __DIR__ . '/header.php';
|
|||||||
<!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── -->
|
<!-- ── TOUS LES ENTRETIENS ─────────────────────────────────────────────────── -->
|
||||||
<div id="page-maintenances-all" class="page">
|
<div id="page-maintenances-all" class="page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card-header" style="margin-bottom:1rem">
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1>
|
<h1 style="font-size:1.2rem;font-weight:700">🔧 Tous les entretiens</h1>
|
||||||
<p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p>
|
<p style="color:var(--muted);font-size:.85rem"><span id="all-maint-count">0</span> entretiens · Total : <span id="all-maint-total">--</span></p>
|
||||||
@@ -140,7 +178,7 @@ require __DIR__ . '/header.php';
|
|||||||
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
|
<!-- ── TOUTES LES PIÈCES ──────────────────────────────────────────────────── -->
|
||||||
<div id="page-parts" class="page">
|
<div id="page-parts" class="page">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card-header" style="margin-bottom:1rem">
|
<div class="card-header garage-card-header" style="margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1>
|
<h1 style="font-size:1.2rem;font-weight:700">🔩 Toutes les pièces</h1>
|
||||||
<p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p>
|
<p style="color:var(--muted);font-size:.85rem"><span id="all-parts-count">0</span> pièces · Total : <span id="all-parts-total">--</span></p>
|
||||||
|
|||||||
+322
-29
@@ -13,6 +13,15 @@
|
|||||||
--text-muted: #64748b;
|
--text-muted: #64748b;
|
||||||
--bg-page: #f8fafc;
|
--bg-page: #f8fafc;
|
||||||
--bg-panel: #ffffff;
|
--bg-panel: #ffffff;
|
||||||
|
--bg-subtle: #f1f5f9;
|
||||||
|
--bg-soft: #eff6ff;
|
||||||
|
--bg-danger-soft: #fef2f2;
|
||||||
|
--bg-overlay: rgba(15, 23, 42, 0.6);
|
||||||
|
--text-on-primary: #ffffff;
|
||||||
|
--text-danger-soft: #f87171;
|
||||||
|
--border-strong: #cbd5e1;
|
||||||
|
--border-danger-soft: #fecaca;
|
||||||
|
--focus-ring: rgba(37, 99, 235, 0.1);
|
||||||
|
|
||||||
/* Bordures, Arrondis & Ombres */
|
/* Bordures, Arrondis & Ombres */
|
||||||
--border-light: #e2e8f0;
|
--border-light: #e2e8f0;
|
||||||
@@ -20,6 +29,17 @@
|
|||||||
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg:
|
--shadow-lg:
|
||||||
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Alertes / badges (clair) — paramètres & blocs similaires */
|
||||||
|
--alert-success-bg: #dcfce7;
|
||||||
|
--alert-success-border: #86efac;
|
||||||
|
--alert-success-text: #166534;
|
||||||
|
--alert-error-bg: #fee2e2;
|
||||||
|
--alert-error-border: #fca5a5;
|
||||||
|
--alert-error-text: #991b1b;
|
||||||
|
--invite-code-color: #1d4ed8;
|
||||||
|
--badge-admin-bg: #dbeafe;
|
||||||
|
--badge-admin-text: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 🛡️ RESET MOBILE UNIVERSEL */
|
/* 🛡️ RESET MOBILE UNIVERSEL */
|
||||||
@@ -64,7 +84,7 @@ body.no-scroll {
|
|||||||
|
|
||||||
/* === HEADER & NAVIGATION === */
|
/* === HEADER & NAVIGATION === */
|
||||||
.pf-header {
|
.pf-header {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
@@ -96,11 +116,11 @@ body.no-scroll {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.pf-nav-link:hover {
|
.pf-nav-link:hover {
|
||||||
background: #f1f5f9;
|
background: var(--bg-subtle);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
.pf-nav-link--active {
|
.pf-nav-link--active {
|
||||||
background: #eff6ff;
|
background: var(--bg-soft);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
.pf-header-actions {
|
.pf-header-actions {
|
||||||
@@ -125,8 +145,8 @@ body.no-scroll {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
.pf-logout-btn:hover {
|
.pf-logout-btn:hover {
|
||||||
background: #fef2f2;
|
background: var(--bg-danger-soft);
|
||||||
border-color: #fecaca;
|
border-color: var(--border-danger-soft);
|
||||||
}
|
}
|
||||||
.pf-burger-btn {
|
.pf-burger-btn {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -142,7 +162,7 @@ body.no-scroll {
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: var(--bg-overlay);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
z-index: 998;
|
z-index: 998;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -216,12 +236,12 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pf-mobile-nav-link:active {
|
.pf-mobile-nav-link:active {
|
||||||
background: #f1f5f9;
|
background: var(--bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pf-mobile-logout {
|
.pf-mobile-logout {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
background: #fef2f2;
|
background: var(--bg-danger-soft);
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +273,262 @@ body.pf-home .pf-mobile-close-btn {
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bloc type « carte » (ex. paramètres) — suit le thème light/dark */
|
||||||
|
.pf-panel-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Paramètres & petits blocs réutilisables ═══ */
|
||||||
|
.pf-settings-page {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 32px auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.pf-settings-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-card-h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-card-h2--tight {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.pf-muted-note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.pf-muted-note.pf-muted-note--tight {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pf-muted-tiny {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.pf-muted-inline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-alert {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.pf-alert--success {
|
||||||
|
background: var(--alert-success-bg);
|
||||||
|
border: 1px solid var(--alert-success-border);
|
||||||
|
color: var(--alert-success-text);
|
||||||
|
}
|
||||||
|
.pf-alert--error {
|
||||||
|
background: var(--alert-error-bg);
|
||||||
|
border: 1px solid var(--alert-error-border);
|
||||||
|
color: var(--alert-error-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-lang-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pf-btn--current-lang {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-stack-md {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.pf-stack-sm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tuile module (checkbox) — état coché via :has(), compatible thème */
|
||||||
|
.pf-module-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-page);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
.pf-module-tile:has(input:checked) {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-checkbox-lg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pf-tile-icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pf-tile-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Encadré pointillé (code d’invitation, etc.) */
|
||||||
|
.pf-dashed-panel {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 2px dashed var(--border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-home-bg-preview {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 160px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pf-home-bg-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.pf-badge-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-on-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-inline-form-end {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pf-inline-form-end .pf-form-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.pf-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.pf-mt-sm {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pf-btn-sm-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.pf-mb-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-invite-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pf-invite-code {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
color: var(--invite-code-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pf-copy-msg {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.pf-copy-msg.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.pf-ml-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.pf-invite-footnote {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-member-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pf-admin-badge {
|
||||||
|
background: var(--badge-admin-bg);
|
||||||
|
color: var(--badge-admin-text);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.pf-members-heading {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.pf-settings-admin-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.pf-settings-admin-link a {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-input--disabled-muted:disabled {
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-flag-inline {
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-flex-gap-8 {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -274,7 +550,7 @@ p {
|
|||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
}
|
}
|
||||||
.pf-module-card {
|
.pf-module-card {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -289,7 +565,7 @@ p {
|
|||||||
.pf-module-card:hover {
|
.pf-module-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border-color: #cbd5e1;
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
.pf-card-icon {
|
.pf-card-icon {
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
@@ -322,7 +598,7 @@ p {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--text-on-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -336,14 +612,14 @@ p {
|
|||||||
background: var(--primary-dark);
|
background: var(--primary-dark);
|
||||||
}
|
}
|
||||||
.pf-btn.btn-secondary {
|
.pf-btn.btn-secondary {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
.pf-btn.btn-secondary:hover {
|
.pf-btn.btn-secondary:hover {
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
border-color: #94a3b8;
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Formulaires & Inputs Standards */
|
/* Formulaires & Inputs Standards */
|
||||||
@@ -363,17 +639,17 @@ p {
|
|||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.pf-input:focus {
|
.pf-input:focus {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tableaux Standards */
|
/* Tableaux Standards */
|
||||||
@@ -387,7 +663,7 @@ p {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.pf-table th {
|
.pf-table th {
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -407,7 +683,7 @@ p {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.pf-table tbody tr:hover {
|
.pf-table tbody tr:hover {
|
||||||
background-color: #f8fafc;
|
background-color: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 🪟 LE DESIGN SYSTEM DES MODALES (Applicable partout) === */
|
/* === 🪟 LE DESIGN SYSTEM DES MODALES (Applicable partout) === */
|
||||||
@@ -416,7 +692,7 @@ p {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
background: rgba(15, 23, 42, 0.6);
|
background: var(--bg-overlay);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -480,7 +756,7 @@ p {
|
|||||||
/* === FOOTER & TOASTS === */
|
/* === FOOTER & TOASTS === */
|
||||||
.pf-footer {
|
.pf-footer {
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -492,7 +768,7 @@ p {
|
|||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
border-left: 5px solid var(--primary);
|
border-left: 5px solid var(--primary);
|
||||||
@@ -548,7 +824,7 @@ p {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
background: #cbd5e1;
|
background: var(--border-strong);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin: -10px auto 20px auto;
|
margin: -10px auto 20px auto;
|
||||||
}
|
}
|
||||||
@@ -588,13 +864,13 @@ p {
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
.btn-icon-action.edit:hover {
|
.btn-icon-action.edit:hover {
|
||||||
background: #eff6ff; /* Fond bleu très clair au survol */
|
background: var(--bg-soft); /* Fond bleu très clair au survol */
|
||||||
}
|
}
|
||||||
.btn-icon-action.delete {
|
.btn-icon-action.delete {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.btn-icon-action.delete:hover {
|
.btn-icon-action.delete:hover {
|
||||||
background: #fef2f2; /* Fond rouge très clair au survol */
|
background: var(--bg-danger-soft); /* Fond rouge très clair au survol */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 13. PAGE DE CONNEXION (LOGIN) === */
|
/* === 13. PAGE DE CONNEXION (LOGIN) === */
|
||||||
@@ -610,7 +886,7 @@ p {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
@@ -641,11 +917,11 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pf-login-error {
|
.pf-login-error {
|
||||||
background: #fef2f2;
|
background: var(--bg-danger-soft);
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #fca5a5;
|
border: 1px solid var(--border-danger-soft);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -699,6 +975,23 @@ p {
|
|||||||
--border-light: #30363d;
|
--border-light: #30363d;
|
||||||
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
|
--shadow: 0 4px 6px -1px rgba(0,0,0,.4), 0 2px 4px -2px rgba(0,0,0,.3);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4);
|
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.5), 0 4px 6px -4px rgba(0,0,0,.4);
|
||||||
|
--bg-subtle: rgba(255,255,255,.06);
|
||||||
|
--bg-soft: rgba(59,130,246,.15);
|
||||||
|
--bg-danger-soft: rgba(239,68,68,.1);
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.55);
|
||||||
|
--text-danger-soft: #f87171;
|
||||||
|
--border-strong: #484f58;
|
||||||
|
--border-danger-soft: rgba(239,68,68,.3);
|
||||||
|
--focus-ring: rgba(59,130,246,.25);
|
||||||
|
--alert-success-bg: rgba(34, 197, 94, 0.12);
|
||||||
|
--alert-success-border: rgba(34, 197, 94, 0.45);
|
||||||
|
--alert-success-text: #86efac;
|
||||||
|
--alert-error-bg: rgba(239, 68, 68, 0.12);
|
||||||
|
--alert-error-border: rgba(248, 113, 113, 0.45);
|
||||||
|
--alert-error-text: #fca5a5;
|
||||||
|
--invite-code-color: #93c5fd;
|
||||||
|
--badge-admin-bg: rgba(59, 130, 246, 0.2);
|
||||||
|
--badge-admin-text: #93c5fd;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); }
|
[data-theme="dark"] body { background: var(--bg-page); color: var(--text-main); }
|
||||||
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); }
|
[data-theme="dark"] .pf-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('mod_groceries_name') . ' — HouseHub';
|
||||||
|
$activePage = 'groceries';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/groceries/assets/groceries.css">
|
||||||
|
<div id="groceries-toasts" class="groceries-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="groceries-layout">
|
||||||
|
<div class="groceries-hero">
|
||||||
|
<h1>🛒 <?= htmlspecialchars(tr('mod_groceries_name')) ?></h1>
|
||||||
|
<p><?= htmlspecialchars(tr('mod_groceries_desc')) ?></p>
|
||||||
|
<p id="groceries-subtitle" class="pf-muted-note" style="margin-top:0.5rem;font-size:0.85rem"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groceries-add-card">
|
||||||
|
<div class="groceries-add-card-inner">
|
||||||
|
<div class="groceries-quick-add">
|
||||||
|
<input type="text" id="groceries-new" class="pf-input groceries-add-input" placeholder="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>" autocomplete="off" maxlength="500" aria-label="<?= htmlspecialchars(tr('groceries_placeholder_add')) ?>">
|
||||||
|
<button type="button" class="pf-btn groceries-add-submit" onclick="addFromInput()"><?= htmlspecialchars(tr('groceries_btn_add')) ?></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="groceries-toolbar">
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn" onclick="uncheckAll()"><?= htmlspecialchars(tr('groceries_btn_uncheck_all')) ?></button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--danger" onclick="deletePicked()"><?= htmlspecialchars(tr('groceries_btn_delete_picked')) ?></button>
|
||||||
|
<button type="button" class="pf-btn btn-secondary groceries-toolbar-btn groceries-toolbar-btn--outline" onclick="clearWholeList()"><?= htmlspecialchars(tr('groceries_btn_clear_list')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="groceries-lists"></div>
|
||||||
|
|
||||||
|
<div class="groceries-history-card" id="groceries-history-root" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/groceries/assets/groceries.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
+19
-3
@@ -45,7 +45,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<a href="/index.php" class="pf-logo">HouseHub</a>
|
<a href="/index.php" class="pf-logo">HouseHub</a>
|
||||||
|
|
||||||
<?php if (isset($_SESSION['user'])): ?>
|
<?php if (isset($_SESSION['user'])): ?>
|
||||||
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts']; ?>
|
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios']; ?>
|
||||||
<nav class="pf-nav">
|
<nav class="pf-nav">
|
||||||
<a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a>
|
<a href="/index.php" class="pf-nav-link <?= $activePage === 'home' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_home') ?></a>
|
||||||
<?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?>
|
<?php if (in_array('calendar', $mods)): ?><a href="/family-calendar.php" class="pf-nav-link <?= $activePage === 'family-calendar' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar') ?></a><?php endif; ?>
|
||||||
@@ -53,6 +53,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
|
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('liste', $mods)): ?><a href="/liste.php" class="pf-nav-link <?= $activePage === 'liste' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_liste') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-nav-link <?= $activePage === 'calendar-ios' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-nav-link <?= $activePage === 'printvault' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-nav-link <?= $activePage === 'planka' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -98,6 +104,12 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
|
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('liste',$mods)): ?><a href="/liste.php" class="pf-mobile-nav-link">🛒 <?= tr('menu_liste') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?><a href="/calendar-ios.php" class="pf-mobile-nav-link">📱 <?= tr('menu_calendar_ios') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('printvault', $mods)): ?><a href="/printvault.php" class="pf-mobile-nav-link">🖨️ <?= tr('menu_printvault') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('planka', $mods)): ?><a href="/planka.php" class="pf-mobile-nav-link">📋 <?= tr('menu_planka') ?></a><?php endif; ?>
|
||||||
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
|
||||||
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
<?php if (!empty($_SESSION['user']['is_admin'])): ?>
|
||||||
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
|
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
|
||||||
@@ -110,16 +122,20 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
|
<main class="pf-main<?= ($mainClass ?? '') ? ' '.htmlspecialchars($mainClass) : '' ?>">
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// CSRF avant I18N : le parseur HTML coupe le script dès la séquence de fin de balise « script » (même dans un commentaire JS).
|
||||||
|
window.CSRF_TOKEN = "<?= htmlspecialchars(function_exists('csrf_token') ? csrf_token() : '', ENT_QUOTES, 'UTF-8') ?>";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pont d'Internationalisation et Configuration
|
* Pont d'Internationalisation et Configuration
|
||||||
*/
|
*/
|
||||||
window.I18N = <?php echo json_encode($current_translations_array ?? []); ?>;
|
window.I18N = <?php echo json_encode($current_translations_array ?? [], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
|
||||||
// ✅ Injection sécurisée (évite le crash si config.php n'est pas chargé)
|
// ✅ Injection sécurisée (évite le crash si config.php n'est pas chargé)
|
||||||
window.CONFIG = {
|
window.CONFIG = {
|
||||||
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>,
|
ID_ALEX: <?php echo defined('ID_ALEX') ? ID_ALEX : 2; ?>,
|
||||||
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>,
|
ID_LAIA: <?php echo defined('ID_LAIA') ? ID_LAIA : 3; ?>,
|
||||||
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>'
|
CURRENCY: '<?php echo defined('CURRENCY') ? CURRENCY : "€"; ?>',
|
||||||
|
ZONE_SCOLAIRE: '<?php echo defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : "C"; ?>'
|
||||||
};
|
};
|
||||||
|
|
||||||
function tr(key) {
|
function tr(key) {
|
||||||
|
|||||||
@@ -32,3 +32,25 @@ function require_login(?string $loginPage = '/login.php'): void
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csrf_token(): string
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
return $_SESSION['csrf_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_csrf(?string $token): bool
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (empty($_SESSION['csrf_token']) || !$token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hash_equals($_SESSION['csrf_token'], $token);
|
||||||
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,8 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// HouseHub Config - Version sans erreur
|
// includes/config.php
|
||||||
|
// HouseHub Config - Version dynamique
|
||||||
if (!defined('ID_ALEX')) {
|
if (!defined('ID_ALEX')) {
|
||||||
define('ID_ALEX', 2);
|
define('ID_ALEX', 2);
|
||||||
define('ID_LAIA', 3);
|
define('ID_LAIA', 3);
|
||||||
define('CURRENCY', '€');
|
|
||||||
define('ZONE_SCOLAIRE', 'C');
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matériau de clé pour chiffrer les secrets (mot de passe d’app iCloud, etc.).
|
||||||
|
* Ordre de priorité :
|
||||||
|
* 1. Variable d’environnement APP_SECRET_KEY (recommandé en production)
|
||||||
|
* 2. Fichier data/.hh_app_secret (généré automatiquement au premier besoin si le dossier est inscriptible)
|
||||||
|
* 3. Dérivation stable depuis DB_PASS + DB_HOST (évite l’erreur si rien n’est configuré ; change si le mot de passe DB change)
|
||||||
|
*/
|
||||||
|
function hh_secret_key_material(): string
|
||||||
|
{
|
||||||
|
$fromEnv = trim((string) getenv('APP_SECRET_KEY'));
|
||||||
|
if ($fromEnv !== '') {
|
||||||
|
return $fromEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyFile = dirname(__DIR__) . '/data/.hh_app_secret';
|
||||||
|
if (is_readable($keyFile)) {
|
||||||
|
$content = trim((string) file_get_contents($keyFile));
|
||||||
|
if ($content !== '' && strlen($content) >= 32) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname($keyFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0700, true);
|
||||||
|
}
|
||||||
|
if (is_dir($dir) && is_writable($dir)) {
|
||||||
|
try {
|
||||||
|
$material = bin2hex(random_bytes(32));
|
||||||
|
if (@file_put_contents($keyFile, $material, LOCK_EX) !== false) {
|
||||||
|
@chmod($keyFile, 0600);
|
||||||
|
return $material;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// continuer vers la dérivation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbPass = (string) (getenv('DB_PASS') ?: '');
|
||||||
|
$dbHost = (string) (getenv('DB_HOST') ?: '');
|
||||||
|
return 'hh-derived|' . $dbPass . '|' . $dbHost . '|HouseHub-calendar-ios-v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_encryption_key(): string
|
||||||
|
{
|
||||||
|
return hash('sha256', hh_secret_key_material(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_encrypt_secret(string $plain): string
|
||||||
|
{
|
||||||
|
$key = hh_encryption_key();
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$cipher = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($cipher === false) {
|
||||||
|
throw new RuntimeException('Chiffrement impossible');
|
||||||
|
}
|
||||||
|
return base64_encode($iv . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_decrypt_secret(string $encrypted): string
|
||||||
|
{
|
||||||
|
$key = hh_encryption_key();
|
||||||
|
$raw = base64_decode($encrypted, true);
|
||||||
|
if ($raw === false || strlen($raw) <= 16) {
|
||||||
|
throw new RuntimeException('Secret invalide');
|
||||||
|
}
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$cipher = substr($raw, 16);
|
||||||
|
$plain = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($plain === false) {
|
||||||
|
throw new RuntimeException('Déchiffrement impossible');
|
||||||
|
}
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
@@ -34,6 +34,21 @@ try {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
$pdo->exec("SET collation_connection = utf8mb4_general_ci");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
|
||||||
|
if (!defined('CURRENCY')) {
|
||||||
|
define('CURRENCY', $foyer['currency'] ?? '€');
|
||||||
|
}
|
||||||
|
if (!defined('ZONE_SCOLAIRE')) {
|
||||||
|
define('ZONE_SCOLAIRE', $foyer['zone_scolaire'] ?? 'C');
|
||||||
|
}
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
if (!defined('CURRENCY')) define('CURRENCY', '€');
|
||||||
|
if (!defined('ZONE_SCOLAIRE')) define('ZONE_SCOLAIRE', 'C');
|
||||||
|
}
|
||||||
|
// ------------------------------------------------
|
||||||
|
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
|
die(json_encode(['ok' => false, 'error' => 'Erreur BDD : ' . $e->getMessage()]));
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ if (!function_exists('tr')) {
|
|||||||
return $GLOBALS['translations'][$key] ?? $key;
|
return $GLOBALS['translations'][$key] ?? $key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export pour le client (header.php → window.I18N) : pachaConfirm, modules JS, etc.
|
||||||
|
$current_translations_array = $GLOBALS['translations'] ?? [];
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise le mot de passe d’application Apple (supprime les espaces, garde les tirets).
|
||||||
|
*/
|
||||||
|
function hh_normalize_apple_app_password(string $password): string
|
||||||
|
{
|
||||||
|
return preg_replace('/\s+/', '', trim($password));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_url_is_icloud(string $url): bool
|
||||||
|
{
|
||||||
|
$h = strtolower((string) (parse_url(trim($url), PHP_URL_HOST) ?: ''));
|
||||||
|
return str_contains($h, 'icloud.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_is_icloud_well_known_root(string $url): bool
|
||||||
|
{
|
||||||
|
$parts = parse_url(rtrim(trim($url), '/'));
|
||||||
|
if (empty($parts['scheme']) || empty($parts['host'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$host = strtolower($parts['host']);
|
||||||
|
if ($host !== 'caldav.icloud.com') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$path = $parts['path'] ?? '';
|
||||||
|
return $path === '' || $path === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_resolve_href(string $againstUrl, string $href): string
|
||||||
|
{
|
||||||
|
$href = trim($href);
|
||||||
|
if ($href === '') {
|
||||||
|
return $againstUrl;
|
||||||
|
}
|
||||||
|
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
|
||||||
|
return $href;
|
||||||
|
}
|
||||||
|
$parts = parse_url($againstUrl);
|
||||||
|
$scheme = $parts['scheme'] ?? 'https';
|
||||||
|
$host = $parts['host'] ?? '';
|
||||||
|
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
|
||||||
|
if ($href[0] === '/') {
|
||||||
|
return $scheme . '://' . $host . $port . $href;
|
||||||
|
}
|
||||||
|
$base = preg_replace('#/[^/]*$#', '/', $againstUrl);
|
||||||
|
return rtrim($base, '/') . '/' . ltrim($href, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{code:int, body:string}
|
||||||
|
*/
|
||||||
|
function hh_caldav_propfind(string $url, string $username, string $password, string $xml, string $depth = '0'): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 25,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'PROPFIND',
|
||||||
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||||
|
CURLOPT_USERPWD => $username . ':' . $password,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/xml; charset=utf-8',
|
||||||
|
'Depth: ' . $depth,
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => $xml,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($body === false) {
|
||||||
|
return ['code' => 0, 'body' => ''];
|
||||||
|
}
|
||||||
|
return ['code' => $code, 'body' => $body];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh_caldav_first_href_by_element(string $xml, string $parentLocalName): ?string
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xml)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$nodes = $xpath->query("//*[local-name()='{$parentLocalName}']//*[local-name()='href']");
|
||||||
|
if ($nodes->length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return trim($nodes->item(0)->textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{href:string, display:string}>
|
||||||
|
*/
|
||||||
|
function hh_caldav_parse_calendar_collections(string $xml): array
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xml)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$responses = $xpath->query("//*[local-name()='response']");
|
||||||
|
$out = [];
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
|
||||||
|
if ($hrefNodes->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$href = trim($hrefNodes->item(0)->textContent);
|
||||||
|
$cal = $xpath->query(".//*[local-name()='resourcetype']/*[local-name()='calendar']", $resp);
|
||||||
|
if ($cal->length === 0 || $href === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$display = '';
|
||||||
|
$dn = $xpath->query(".//*[local-name()='displayname']", $resp);
|
||||||
|
if ($dn->length > 0) {
|
||||||
|
$display = trim($dn->item(0)->textContent);
|
||||||
|
}
|
||||||
|
$out[] = ['href' => $href, 'display' => $display];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les collections calendrier iCloud (URL complète avec / final + nom affiché).
|
||||||
|
*
|
||||||
|
* @return list<array{display:string, url:string}>
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_discover_calendar_entries(string $username, string $password): array
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password($password);
|
||||||
|
$root = 'https://caldav.icloud.com/';
|
||||||
|
|
||||||
|
$xml1 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:">'
|
||||||
|
. '<prop><current-user-principal/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r1 = hh_caldav_propfind($root, $username, $password, $xml1, '0');
|
||||||
|
if ($r1['code'] === 401 || $r1['code'] === 403) {
|
||||||
|
throw new RuntimeException('Identifiant Apple ou mot de passe d’app refusé (HTTP ' . $r1['code'] . ').');
|
||||||
|
}
|
||||||
|
if (!in_array($r1['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('CalDAV racine inaccessible (HTTP ' . $r1['code'] . ').');
|
||||||
|
}
|
||||||
|
$principalHref = hh_caldav_first_href_by_element($r1['body'], 'current-user-principal');
|
||||||
|
if (!$principalHref) {
|
||||||
|
throw new RuntimeException('Réponse CalDAV inattendue : principal introuvable.');
|
||||||
|
}
|
||||||
|
$principalUrl = hh_caldav_resolve_href($root, $principalHref);
|
||||||
|
|
||||||
|
$xml2 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<prop><C:calendar-home-set/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r2 = hh_caldav_propfind($principalUrl, $username, $password, $xml2, '0');
|
||||||
|
if (!in_array($r2['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Lecture du principal CalDAV impossible (HTTP ' . $r2['code'] . ').');
|
||||||
|
}
|
||||||
|
$homeHref = hh_caldav_first_href_by_element($r2['body'], 'calendar-home-set');
|
||||||
|
if (!$homeHref) {
|
||||||
|
throw new RuntimeException('Aucun dossier de calendriers (calendar-home-set) trouvé.');
|
||||||
|
}
|
||||||
|
$homeUrl = hh_caldav_resolve_href($principalUrl, $homeHref);
|
||||||
|
|
||||||
|
$xml3 = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:">'
|
||||||
|
. '<prop><resourcetype/><displayname/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r3 = hh_caldav_propfind($homeUrl, $username, $password, $xml3, '1');
|
||||||
|
if (!in_array($r3['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Liste des calendriers impossible (HTTP ' . $r3['code'] . ').');
|
||||||
|
}
|
||||||
|
$raw = hh_caldav_parse_calendar_collections($r3['body']);
|
||||||
|
if ($raw === []) {
|
||||||
|
throw new RuntimeException('Aucun calendrier trouvé sur ce compte iCloud.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($raw as $e) {
|
||||||
|
$url = rtrim(hh_caldav_resolve_href($homeUrl, $e['href']), '/') . '/';
|
||||||
|
$out[] = ['display' => $e['display'], 'url' => $url];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Depuis la racine iCloud (https://caldav.icloud.com), découvre l’URL d’un calendrier (par défaut « Calendar » / « Calendrier », sinon le premier).
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_discover_default_calendar_url(string $username, string $password): string
|
||||||
|
{
|
||||||
|
$entries = hh_icloud_discover_calendar_entries($username, $password);
|
||||||
|
$preferred = ['calendar', 'calendrier', 'home', 'par défaut', 'default'];
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$lower = mb_strtolower($e['display']);
|
||||||
|
foreach ($preferred as $p) {
|
||||||
|
if ($lower !== '' && str_contains($lower, $p)) {
|
||||||
|
return $e['url'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $entries[0]['url'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu’une URL de collection calendrier répond en CalDAV (PROPFIND).
|
||||||
|
*/
|
||||||
|
function hh_caldav_test_calendar_collection(string $username, string $password, string $calendarUrl): int
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password($password);
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>'
|
||||||
|
. '</propfind>';
|
||||||
|
$r = hh_caldav_propfind(rtrim($calendarUrl, '/') . '/', $username, $password, $xml, '0');
|
||||||
|
return $r['code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si URL = racine iCloud bien connue, découvre et retourne l’URL d’un calendrier ; sinon retourne l’URL telle quelle (trim).
|
||||||
|
*
|
||||||
|
* @throws RuntimeException
|
||||||
|
*/
|
||||||
|
function hh_icloud_resolve_calendar_url_if_needed(string $username, string $password, string $calendarUrl): string
|
||||||
|
{
|
||||||
|
$calendarUrl = trim($calendarUrl);
|
||||||
|
if (hh_caldav_is_icloud_well_known_root($calendarUrl)) {
|
||||||
|
return hh_icloud_discover_default_calendar_url($username, $password);
|
||||||
|
}
|
||||||
|
return $calendarUrl;
|
||||||
|
}
|
||||||
+63
-2
@@ -22,6 +22,7 @@ return [
|
|||||||
'menu_budget' => 'Pressupost',
|
'menu_budget' => 'Pressupost',
|
||||||
'menu_holidays' => 'Viatges',
|
'menu_holidays' => 'Viatges',
|
||||||
'menu_gifts' => 'Regals',
|
'menu_gifts' => 'Regals',
|
||||||
|
'menu_calendar_ios' => 'Calendari iOS',
|
||||||
'nav_home' => 'Inici',
|
'nav_home' => 'Inici',
|
||||||
'nav_calendar' => 'Calendari',
|
'nav_calendar' => 'Calendari',
|
||||||
'nav_budget' => 'Pressupost',
|
'nav_budget' => 'Pressupost',
|
||||||
@@ -90,10 +91,70 @@ return [
|
|||||||
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
|
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
|
||||||
'mod_garage_name' => 'Garatge',
|
'mod_garage_name' => 'Garatge',
|
||||||
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
|
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
|
||||||
|
'mod_calendar_ios_name' => 'Calendari iOS',
|
||||||
|
'mod_calendar_ios_desc' => 'Sincronitzeu els esdeveniments de HouseHub amb el calendari de l\'iPhone (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Compra',
|
||||||
|
'mod_groceries_desc' => 'Llista compartida: afegiu productes i marqueu-los quan siguin al carretó.',
|
||||||
|
'menu_groceries' => 'Compra',
|
||||||
|
'groceries_placeholder_add' => 'Ex : llet, pa, tomàquets…',
|
||||||
|
'groceries_btn_add' => 'Afegir',
|
||||||
|
'groceries_btn_uncheck_all' => 'Tornar a marcar com a pendents',
|
||||||
|
'groceries_btn_delete_picked' => 'Eliminar els marcats',
|
||||||
|
'groceries_subtitle' => '{pending} pendents · {cart} al carretó',
|
||||||
|
'groceries_added' => 'Afegit',
|
||||||
|
'groceries_updated' => 'Actualitzat',
|
||||||
|
'groceries_deleted' => 'Eliminat',
|
||||||
|
'groceries_reset_next' => 'Llista llesta per a la propera compra',
|
||||||
|
'groceries_picked_removed' => 'Articles eliminats',
|
||||||
|
'groceries_label_empty' => 'El text no pot ser buit',
|
||||||
|
'groceries_confirm_del_title' => 'Eliminar?',
|
||||||
|
'groceries_confirm_del_body' => 'Voleu treure aquest producte de la llista?',
|
||||||
|
'groceries_confirm_uncheck_title' => 'Tornar a marcar com a pendents',
|
||||||
|
'groceries_confirm_uncheck_body' => 'Es netejaran les marques del carretó. Els productes es mantindran a la llista.',
|
||||||
|
'groceries_confirm_clear_title' => 'Eliminar els marcats',
|
||||||
|
'groceries_confirm_clear_body' => 'Tots els articles marcats se suprimiran de la llista.',
|
||||||
|
'groceries_empty' => 'Encara no hi ha res. Afegiu un producte a dalt.',
|
||||||
|
'groceries_aria_toggle' => 'Marcar com al carretó o pendent',
|
||||||
|
'groceries_section_pending' => 'Pendents',
|
||||||
|
'groceries_section_cart' => 'Al carretó',
|
||||||
|
'groceries_prompt_edit' => 'Editar el producte',
|
||||||
|
'groceries_btn_clear_list' => 'Buidar la llista',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Buidar tota la llista?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'Es trauran tots els productes. Romandran a l’historial per tornar-los a afegir ràpidament.',
|
||||||
|
'groceries_list_cleared' => 'Llista buidada',
|
||||||
|
'groceries_history_title' => 'Historial — un clic per afegir',
|
||||||
|
'groceries_history_empty' => 'L’historial es va omplint amb els productes que afegiu (fins al límit definit als paràmetres).',
|
||||||
|
'groceries_history_meta' => 'Fins a {max} productes · ',
|
||||||
|
'groceries_settings_link' => 'Paràmetres',
|
||||||
|
'groceries_settings_title' => 'Compra — historial',
|
||||||
|
'groceries_settings_intro' => 'Nombre màxim de productes recordats a l’historial (sense duplicats, sense distingir majúscules).',
|
||||||
|
'groceries_settings_history_max' => 'Mida de l’historial',
|
||||||
|
'groceries_settings_history_hint' => 'Entre 1 i 50. Les entrades més antigues se suprimeixen quan es supera el límit.',
|
||||||
|
'groceries_settings_saved' => 'Preferències de la compra desades.',
|
||||||
|
'groceries_settings_error' => 'No s’ha pogut desar:',
|
||||||
|
'groceries_err_duplicate' => 'Aquest producte ja és a la llista.',
|
||||||
'cta_open' => 'Obrir',
|
'cta_open' => 'Obrir',
|
||||||
'cta_explore' => 'Explorar',
|
'cta_explore' => 'Explorar',
|
||||||
'cta_view_lists' => 'Veure les llistes',
|
'cta_view_lists' => 'Desembolicar',
|
||||||
'cta_manage' => 'Gestionar',
|
'cta_manage' => 'Gestionar',
|
||||||
|
'cta_service' => 'Revisar',
|
||||||
|
'cta_jot' => 'Apuntar',
|
||||||
|
'cta_check' => 'Marcar',
|
||||||
|
'cta_cart' => 'Anar a comprar',
|
||||||
|
'cta_sync' => 'Sincronitzar',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Gestioneu els vostres fitxers d\'impressió 3D — STL, 3MF, GCode.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Imprimir',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gestioneu els vostres projectes en mode Kanban amb Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
@@ -102,7 +163,7 @@ return [
|
|||||||
'fc_main_header' => 'Calendari Familiar',
|
'fc_main_header' => 'Calendari Familiar',
|
||||||
'fc_btn_correct_balances' => 'Corregir els saldos',
|
'fc_btn_correct_balances' => 'Corregir els saldos',
|
||||||
'fc_btn_school_holidays' => 'Vacances escolars',
|
'fc_btn_school_holidays' => 'Vacances escolars',
|
||||||
'fc_modal_holidays_title' => 'Vacances Escolars (Zona C)',
|
'fc_modal_holidays_title' => 'Vacances Escolars',
|
||||||
'fc_modal_snap_title' => 'Ajustar un saldo',
|
'fc_modal_snap_title' => 'Ajustar un saldo',
|
||||||
'fc_col_period' => 'Període',
|
'fc_col_period' => 'Període',
|
||||||
'fc_col_from' => 'Des del',
|
'fc_col_from' => 'Des del',
|
||||||
|
|||||||
+63
-2
@@ -23,6 +23,7 @@ return [
|
|||||||
'menu_budget' => 'Budget',
|
'menu_budget' => 'Budget',
|
||||||
'menu_holidays' => 'Trips',
|
'menu_holidays' => 'Trips',
|
||||||
'menu_gifts' => 'Gifts',
|
'menu_gifts' => 'Gifts',
|
||||||
|
'menu_calendar_ios' => 'iOS Calendar',
|
||||||
'nav_home' => 'Home',
|
'nav_home' => 'Home',
|
||||||
'nav_calendar' => 'Calendar',
|
'nav_calendar' => 'Calendar',
|
||||||
'nav_budget' => 'Budget',
|
'nav_budget' => 'Budget',
|
||||||
@@ -91,10 +92,70 @@ return [
|
|||||||
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
|
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
|
||||||
'mod_garage_name' => 'Garage',
|
'mod_garage_name' => 'Garage',
|
||||||
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
|
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
|
||||||
|
'mod_calendar_ios_name' => 'iOS Calendar',
|
||||||
|
'mod_calendar_ios_desc' => 'Sync HouseHub events with your iPhone calendar (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Groceries',
|
||||||
|
'mod_groceries_desc' => 'Shared list: add items and check them off when they are in your cart.',
|
||||||
|
'menu_groceries' => 'Groceries',
|
||||||
|
'groceries_placeholder_add' => 'e.g. milk, bread, tomatoes…',
|
||||||
|
'groceries_btn_add' => 'Add',
|
||||||
|
'groceries_btn_uncheck_all' => 'Reset cart checks',
|
||||||
|
'groceries_btn_delete_picked' => 'Remove checked items',
|
||||||
|
'groceries_subtitle' => '{pending} to pick up · {cart} in cart',
|
||||||
|
'groceries_added' => 'Added',
|
||||||
|
'groceries_updated' => 'Updated',
|
||||||
|
'groceries_deleted' => 'Removed',
|
||||||
|
'groceries_reset_next' => 'List ready for your next trip',
|
||||||
|
'groceries_picked_removed' => 'Checked items removed',
|
||||||
|
'groceries_label_empty' => 'Label cannot be empty',
|
||||||
|
'groceries_confirm_del_title' => 'Delete?',
|
||||||
|
'groceries_confirm_del_body' => 'Remove this product from the list?',
|
||||||
|
'groceries_confirm_uncheck_title' => 'Reset cart checks',
|
||||||
|
'groceries_confirm_uncheck_body' => 'All “in cart” checkmarks will be cleared. Items stay on the list.',
|
||||||
|
'groceries_confirm_clear_title' => 'Remove checked items',
|
||||||
|
'groceries_confirm_clear_body' => 'Every checked item will be deleted from the list.',
|
||||||
|
'groceries_empty' => 'Nothing yet. Add a product above.',
|
||||||
|
'groceries_aria_toggle' => 'Mark as in cart or still to pick up',
|
||||||
|
'groceries_section_pending' => 'To pick up',
|
||||||
|
'groceries_section_cart' => 'In cart',
|
||||||
|
'groceries_prompt_edit' => 'Edit product',
|
||||||
|
'groceries_btn_clear_list' => 'Clear list',
|
||||||
|
'groceries_confirm_empty_list_title' => 'Clear the entire list?',
|
||||||
|
'groceries_confirm_empty_list_body' => 'All products will be removed. They stay in history so you can add them again quickly.',
|
||||||
|
'groceries_list_cleared' => 'List cleared',
|
||||||
|
'groceries_history_title' => 'History — click to add',
|
||||||
|
'groceries_history_empty' => 'History fills as you add products (up to the limit set in Settings).',
|
||||||
|
'groceries_history_meta' => 'Up to {max} products · ',
|
||||||
|
'groceries_settings_link' => 'Settings',
|
||||||
|
'groceries_settings_title' => 'Groceries — history',
|
||||||
|
'groceries_settings_intro' => 'Maximum number of products remembered in history (no duplicates, case-insensitive).',
|
||||||
|
'groceries_settings_history_max' => 'History size',
|
||||||
|
'groceries_settings_history_hint' => 'Between 1 and 50. Oldest entries are removed when the limit is exceeded.',
|
||||||
|
'groceries_settings_saved' => 'Grocery preferences saved.',
|
||||||
|
'groceries_settings_error' => 'Could not save:',
|
||||||
|
'groceries_err_duplicate' => 'This product is already on the list.',
|
||||||
'cta_open' => 'Open',
|
'cta_open' => 'Open',
|
||||||
'cta_explore' => 'Explore',
|
'cta_explore' => 'Explore',
|
||||||
'cta_view_lists' => 'View lists',
|
'cta_view_lists' => 'Unwrap',
|
||||||
'cta_manage' => 'Manage',
|
'cta_manage' => 'Manage',
|
||||||
|
'cta_service' => 'Service',
|
||||||
|
'cta_jot' => 'Jot down',
|
||||||
|
'cta_check' => 'Check off',
|
||||||
|
'cta_cart' => 'Go shopping',
|
||||||
|
'cta_sync' => 'Sync',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Manage your 3D print files — STL, 3MF, GCode with interactive viewer.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Print',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Manage your projects in Kanban mode with Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
@@ -103,7 +164,7 @@ return [
|
|||||||
'fc_main_header' => 'Family Calendar',
|
'fc_main_header' => 'Family Calendar',
|
||||||
'fc_btn_correct_balances' => 'Adjust balances',
|
'fc_btn_correct_balances' => 'Adjust balances',
|
||||||
'fc_btn_school_holidays' => 'School holidays',
|
'fc_btn_school_holidays' => 'School holidays',
|
||||||
'fc_modal_holidays_title' => 'School Holidays (Zone C)',
|
'fc_modal_holidays_title' => 'School Holidays',
|
||||||
'fc_modal_snap_title' => 'Adjust a balance',
|
'fc_modal_snap_title' => 'Adjust a balance',
|
||||||
'fc_col_period' => 'Period',
|
'fc_col_period' => 'Period',
|
||||||
'fc_col_from' => 'From',
|
'fc_col_from' => 'From',
|
||||||
|
|||||||
+60
-2
@@ -24,6 +24,7 @@ return [
|
|||||||
'menu_budget' => 'Budget',
|
'menu_budget' => 'Budget',
|
||||||
'menu_holidays' => 'Voyages',
|
'menu_holidays' => 'Voyages',
|
||||||
'menu_gifts' => 'Cadeaux',
|
'menu_gifts' => 'Cadeaux',
|
||||||
|
'menu_calendar_ios' => 'Calendrier iOS',
|
||||||
'nav_home' => 'Accueil',
|
'nav_home' => 'Accueil',
|
||||||
'nav_calendar' => 'Calendrier',
|
'nav_calendar' => 'Calendrier',
|
||||||
'nav_budget' => 'Budget',
|
'nav_budget' => 'Budget',
|
||||||
@@ -93,10 +94,67 @@ return [
|
|||||||
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
|
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
|
||||||
'mod_garage_name' => 'Garage',
|
'mod_garage_name' => 'Garage',
|
||||||
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
|
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
|
||||||
|
'mod_memo_name' => 'Notes',
|
||||||
|
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
|
||||||
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
|
||||||
|
'mod_calendar_ios_name' => 'Calendrier iOS',
|
||||||
|
'mod_calendar_ios_desc' => 'Synchronisez vos événements HouseHub avec votre calendrier iPhone (iCloud CalDAV).',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
|
'mod_groceries_name' => 'Courses',
|
||||||
|
'mod_groceries_desc' => 'Liste partagée : ajoutez des produits et cochez-les quand ils sont dans le caddie.',
|
||||||
|
'menu_groceries' => 'Courses',
|
||||||
|
// Module Liste (remplace Courses)
|
||||||
|
'mod_liste_name' => 'Listes',
|
||||||
|
'mod_liste_desc' => 'Listes partagées nommées — courses, bricolage, pharmacie… Autant de listes que nécessaire.',
|
||||||
|
'menu_liste' => 'Listes',
|
||||||
|
'liste_placeholder_add' => 'Ajouter un article…',
|
||||||
|
'liste_btn_add' => 'Ajouter',
|
||||||
|
'liste_btn_uncheck_all' => 'Tout décocher',
|
||||||
|
'liste_btn_delete_picked' => 'Supprimer les cochés',
|
||||||
|
'liste_btn_clear_all' => 'Vider la liste',
|
||||||
|
'liste_subtitle' => '{pending} à faire · {cart} cochés',
|
||||||
|
'liste_added' => 'Ajouté',
|
||||||
|
'liste_updated' => 'Modifié',
|
||||||
|
'liste_deleted' => 'Supprimé',
|
||||||
|
'liste_already_in' => 'Déjà dans la liste',
|
||||||
|
'liste_confirm_clear' => 'Vider toute la liste ?',
|
||||||
|
'liste_confirm_delete_list' => 'Supprimer cette liste et tous ses articles ?',
|
||||||
|
'liste_history_title' => 'Récemment utilisés — clic pour rajouter',
|
||||||
|
'liste_new_list' => 'Nouvelle liste',
|
||||||
|
'liste_list_name_placeholder'=> 'Nom de la liste…',
|
||||||
|
'liste_create' => 'Créer',
|
||||||
|
'liste_rename_list' => 'Renommer',
|
||||||
|
'liste_new_name' => 'Nouveau nom :',
|
||||||
|
'liste_list_deleted' => 'Liste supprimée',
|
||||||
|
'liste_list_created' => 'Liste créée',
|
||||||
|
'liste_list_renamed' => 'Liste renommée',
|
||||||
|
'liste_edit_placeholder' => 'Modifier l\'article :',
|
||||||
|
'liste_empty_state' => 'Créez votre première liste avec le bouton + ci-dessus.',
|
||||||
|
'liste_settings_title' => 'Listes — historique',
|
||||||
|
'liste_settings_intro' => 'Nombre maximum d\'articles mémorisés dans l\'historique (commun à toutes les listes).',
|
||||||
|
'liste_settings_history_max' => 'Taille de l\'historique',
|
||||||
|
'liste_settings_history_hint'=> 'Entre 1 et 50.',
|
||||||
|
'groceries_settings_saved' => 'Préférences enregistrées.',
|
||||||
|
'groceries_settings_error' => 'Impossible d\'enregistrer :',
|
||||||
|
'groceries_err_duplicate' => 'Ce produit est déjà dans la liste.',
|
||||||
'cta_open' => 'Ouvrir',
|
'cta_open' => 'Ouvrir',
|
||||||
'cta_explore' => 'Explorer',
|
'cta_explore' => 'Explorer',
|
||||||
'cta_view_lists' => 'Voir les listes',
|
'cta_view_lists' => 'Déballer',
|
||||||
'cta_manage' => 'Gérer',
|
'cta_manage' => 'Gérer',
|
||||||
|
'cta_service' => 'Réviser',
|
||||||
|
'cta_jot' => 'Griffonner',
|
||||||
|
'cta_check' => 'Cocher',
|
||||||
|
'cta_cart' => 'Faire les courses',
|
||||||
|
'cta_sync' => 'Synchroniser',
|
||||||
|
'mod_printvault_name' => 'PrintVault',
|
||||||
|
'mod_printvault_desc' => 'Gérez vos fichiers d\'impression 3D — STL, 3MF, GCode avec viewer interactif.',
|
||||||
|
'menu_printvault' => 'PrintVault',
|
||||||
|
'cta_print' => 'Imprimer',
|
||||||
|
'mod_planka_name' => 'Planka',
|
||||||
|
'mod_planka_desc' => 'Gérez vos projets en mode Kanban avec Planka.',
|
||||||
|
'menu_planka' => 'Planka',
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// FAMILY CALENDAR
|
// FAMILY CALENDAR
|
||||||
@@ -105,7 +163,7 @@ return [
|
|||||||
'fc_main_header' => 'Calendrier Familial',
|
'fc_main_header' => 'Calendrier Familial',
|
||||||
'fc_btn_correct_balances' => 'Corriger les soldes',
|
'fc_btn_correct_balances' => 'Corriger les soldes',
|
||||||
'fc_btn_school_holidays' => 'Vacances scolaires',
|
'fc_btn_school_holidays' => 'Vacances scolaires',
|
||||||
'fc_modal_holidays_title' => 'Vacances Scolaires (Zone C)',
|
'fc_modal_holidays_title' => 'Vacances Scolaires',
|
||||||
'fc_modal_snap_title' => 'Ajuster un solde',
|
'fc_modal_snap_title' => 'Ajuster un solde',
|
||||||
'fc_col_period' => 'Période',
|
'fc_col_period' => 'Période',
|
||||||
'fc_col_from' => 'Du',
|
'fc_col_from' => 'Du',
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ if ($_has_custom_bg): ?>
|
|||||||
<section class="pf-section">
|
<section class="pf-section">
|
||||||
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
|
<h2 style="color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,0.6);"><?= tr('home_modules_title') ?></h2>
|
||||||
|
|
||||||
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage']; ?>
|
<?php $mods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','garage','calendar_ios']; ?>
|
||||||
<div class="pf-modules-grid">
|
<div class="pf-modules-grid">
|
||||||
|
|
||||||
<?php if (in_array('calendar', $mods)): ?>
|
<?php if (in_array('calendar', $mods)): ?>
|
||||||
@@ -87,7 +87,52 @@ if ($_has_custom_bg): ?>
|
|||||||
<div class="pf-card-icon">🚗</div>
|
<div class="pf-card-icon">🚗</div>
|
||||||
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
|
<h3 class="pf-card-title"><?= tr('mod_garage_name') ?></h3>
|
||||||
<div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div>
|
<div class="pf-card-desc"><?= tr('mod_garage_desc') ?></div>
|
||||||
<span class="pf-card-cta"><?= tr('cta_open') ?></span>
|
<span class="pf-card-cta"><?= tr('cta_service') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('memo', $mods)): ?>
|
||||||
|
<a href="/memo.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">📝</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_memo_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_memo_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_jot') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('todo', $mods)): ?>
|
||||||
|
<a href="/todo.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">✅</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_todo_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_todo_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_check') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('liste', $mods)): ?>
|
||||||
|
<a href="/liste.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🛒</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_liste_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_liste_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_cart') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('calendar_ios', $mods)): ?>
|
||||||
|
<a href="/calendar-ios.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">📱</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_calendar_ios_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_calendar_ios_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_sync') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('printvault', $mods)): ?>
|
||||||
|
<a href="/printvault.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">🖨️</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_printvault_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_printvault_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_print') ?></span>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = tr('mod_liste_name') . ' — HouseHub';
|
||||||
|
$activePage = 'liste';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/liste/assets/liste.css">
|
||||||
|
|
||||||
|
<main class="liste-main">
|
||||||
|
|
||||||
|
<!-- Tabs des listes -->
|
||||||
|
<div class="liste-tabs-bar">
|
||||||
|
<div class="liste-tabs" id="liste-tabs"></div>
|
||||||
|
<button class="liste-tab-add" id="btn-add-list" title="<?= htmlspecialchars(tr('liste_new_list')) ?>">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire de création de liste inline -->
|
||||||
|
<div class="liste-new-form hidden" id="liste-new-form">
|
||||||
|
<input type="text" id="new-list-name" maxlength="100"
|
||||||
|
placeholder="<?= htmlspecialchars(tr('liste_list_name_placeholder')) ?>" autocomplete="off">
|
||||||
|
<button id="btn-confirm-new-list"><?= htmlspecialchars(tr('liste_create')) ?></button>
|
||||||
|
<button id="btn-cancel-new-list"><?= htmlspecialchars(tr('cancel')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps de la liste active -->
|
||||||
|
<div class="liste-body" id="liste-body">
|
||||||
|
|
||||||
|
<div class="liste-add-card">
|
||||||
|
<input type="text" id="liste-input" maxlength="500"
|
||||||
|
placeholder="<?= htmlspecialchars(tr('liste_placeholder_add')) ?>" autocomplete="off">
|
||||||
|
<button id="btn-liste-add"><?= htmlspecialchars(tr('liste_btn_add')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="liste-toolbar" id="liste-toolbar">
|
||||||
|
<button class="btn-tool" id="btn-uncheck-all"><?= htmlspecialchars(tr('liste_btn_uncheck_all')) ?></button>
|
||||||
|
<button class="btn-tool btn-tool-danger" id="btn-delete-picked"><?= htmlspecialchars(tr('liste_btn_delete_picked')) ?></button>
|
||||||
|
<button class="btn-tool btn-tool-danger" id="btn-clear-all"><?= htmlspecialchars(tr('liste_btn_clear_all')) ?></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="liste-items-root"></div>
|
||||||
|
<div id="liste-history-root"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const LISTE_TRANSLATIONS = {
|
||||||
|
already_in: <?= json_encode(tr('liste_already_in')) ?>,
|
||||||
|
confirm_clear: <?= json_encode(tr('liste_confirm_clear')) ?>,
|
||||||
|
confirm_delete_list: <?= json_encode(tr('liste_confirm_delete_list')) ?>,
|
||||||
|
rename_list: <?= json_encode(tr('liste_rename_list')) ?>,
|
||||||
|
new_name: <?= json_encode(tr('liste_new_name')) ?>,
|
||||||
|
list_deleted: <?= json_encode(tr('liste_list_deleted')) ?>,
|
||||||
|
list_created: <?= json_encode(tr('liste_list_created')) ?>,
|
||||||
|
list_renamed: <?= json_encode(tr('liste_list_renamed')) ?>,
|
||||||
|
edit_placeholder: <?= json_encode(tr('liste_edit_placeholder')) ?>,
|
||||||
|
history_title: <?= json_encode(tr('liste_history_title')) ?>,
|
||||||
|
added: <?= json_encode(tr('liste_added')) ?>,
|
||||||
|
updated: <?= json_encode(tr('liste_updated')) ?>,
|
||||||
|
deleted: <?= json_encode(tr('liste_deleted')) ?>,
|
||||||
|
cancel: <?= json_encode(tr('cancel')) ?>,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/modules/liste/assets/liste.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
session_start();
|
session_start();
|
||||||
require_once __DIR__ . '/includes/i18n.php';
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
require_once __DIR__ . '/includes/meta_db.php';
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
|
||||||
$pageTitle = tr('login_title');
|
$pageTitle = tr('login_title');
|
||||||
$activePage = "login";
|
$activePage = "login";
|
||||||
@@ -14,6 +15,9 @@ if (isset($_SESSION['user'])) {
|
|||||||
$error = null;
|
$error = null;
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||||
|
$error = "Session invalide (CSRF). Rechargez la page.";
|
||||||
|
} else {
|
||||||
$username = trim($_POST['username'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
@@ -36,6 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
} elseif ($user['family_id'] && !$user['family_active']) {
|
} elseif ($user['family_id'] && !$user['family_active']) {
|
||||||
$error = "Espace familial désactivé. Contactez l'administrateur.";
|
$error = "Espace familial désactivé. Contactez l'administrateur.";
|
||||||
} else {
|
} else {
|
||||||
|
session_regenerate_id(true);
|
||||||
$_SESSION['user'] = [
|
$_SESSION['user'] = [
|
||||||
'id' => (int)$user['id'],
|
'id' => (int)$user['id'],
|
||||||
'username' => $user['username'],
|
'username' => $user['username'],
|
||||||
@@ -45,7 +50,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
];
|
];
|
||||||
$_SESSION['family_db'] = $user['db_name'];
|
$_SESSION['family_db'] = $user['db_name'];
|
||||||
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
|
$_SESSION['app_lang'] = $user['lang'] ?? 'fr';
|
||||||
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts"]', true);
|
$_SESSION['enabled_modules'] = json_decode($user['enabled_modules'] ?? '["calendar","budget","holidays","gifts","calendar_ios"]', true);
|
||||||
$redirectTo = $_GET['redirect'] ?? '/index.php';
|
$redirectTo = $_GET['redirect'] ?? '/index.php';
|
||||||
header('Location: ' . $redirectTo);
|
header('Location: ' . $redirectTo);
|
||||||
exit;
|
exit;
|
||||||
@@ -54,6 +59,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$error = tr('error_invalid_credentials');
|
$error = tr('error_invalid_credentials');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require __DIR__ . '/header.php';
|
require __DIR__ . '/header.php';
|
||||||
@@ -75,6 +81,7 @@ require __DIR__ . '/header.php';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>">
|
<form method="post" action="/login.php<?= isset($_GET['redirect']) ? '?redirect=' . urlencode($_GET['redirect']) : '' ?>">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label" for="username"><?= tr('label_username') ?></label>
|
<label class="pf-label" for="username"><?= tr('label_username') ?></label>
|
||||||
<input type="text" id="username" name="username" class="pf-input"
|
<input type="text" id="username" name="username" class="pf-input"
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Notes — HouseHub';
|
||||||
|
$activePage = 'memo';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/memo/assets/memo.css">
|
||||||
|
<div id="memo-toasts" class="memo-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="memo-layout">
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="memo-sidebar" id="memo-sidebar">
|
||||||
|
<div class="memo-sidebar-top">
|
||||||
|
<button class="btn btn-primary" onclick="openCreate()" style="width:100%;justify-content:center">+ Nouvelle note</button>
|
||||||
|
<div class="memo-search">
|
||||||
|
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
|
||||||
|
<input type="text" id="memo-search-input" placeholder="Rechercher…">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-tags-list" id="memo-tags-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="memo-main">
|
||||||
|
|
||||||
|
<!-- Page : liste ─────────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-list" class="memo-page active">
|
||||||
|
<div class="memo-list-header">
|
||||||
|
<div>
|
||||||
|
<div id="memo-list-title" style="font-size:1rem;font-weight:700">📝 Notes</div>
|
||||||
|
<div id="memo-list-subtitle" class="memo-list-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreate()">+ Nouvelle note</button>
|
||||||
|
</div>
|
||||||
|
<div id="memo-notes-grid" class="notes-grid"></div>
|
||||||
|
<div id="memo-pagination" style="display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem 1.5rem;gap:.5rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page : vue note ──────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-view" class="memo-page">
|
||||||
|
<div class="memo-view-header">
|
||||||
|
<div class="memo-view-title" id="view-title"></div>
|
||||||
|
<div class="memo-view-meta">
|
||||||
|
<div id="view-tags" style="display:flex;gap:.35rem;flex-wrap:wrap"></div>
|
||||||
|
<span id="view-date" style="margin-left:auto"></span>
|
||||||
|
<div class="memo-view-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="loadList(currentQ,currentTag)">← Retour</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="openEdit(currentNoteId)">✏️ Modifier</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteNote(currentNoteId)">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-content-area">
|
||||||
|
<div class="md-body" id="view-md"></div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-attachments" id="view-attachments" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page : édition ───────────────────────────────────────────────────── -->
|
||||||
|
<div id="memo-page-edit" class="memo-page">
|
||||||
|
<div class="memo-edit-area">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem;flex-wrap:wrap;gap:.5rem">
|
||||||
|
<h2 id="edit-page-title" style="font-size:1rem;font-weight:700;margin:0">Nouvelle note</h2>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<button class="btn btn-secondary" onclick="currentNoteId?viewNote(currentNoteId):loadList()">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveNote()">💾 Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="memo-edit-form">
|
||||||
|
|
||||||
|
<!-- Titre -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titre *</label>
|
||||||
|
<input type="text" id="edit-title" class="form-control" placeholder="Titre de la note…" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu + preview -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Contenu <span style="font-size:.72rem;color:var(--text-muted)">(Markdown supporté)</span></label>
|
||||||
|
<div class="edit-split">
|
||||||
|
<textarea id="edit-content" class="form-control edit-textarea" placeholder="Écrivez ici en Markdown…"></textarea>
|
||||||
|
<div class="md-preview-box md-body" id="md-live-preview" style="min-height:320px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<div class="tags-input-wrap" onclick="document.getElementById('edit-tags-input').focus()">
|
||||||
|
<div id="edit-tags-chips" style="display:contents"></div>
|
||||||
|
<input type="text" id="edit-tags-input" placeholder="Ajouter un tag…" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">Appuyez sur Entrée ou virgule pour valider</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fichiers -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Fichiers & Images</label>
|
||||||
|
<div id="edit-drop-zone" class="attach-drop">
|
||||||
|
📎 Glissez-déposez des fichiers ou cliquez pour choisir (images, PDF…)
|
||||||
|
<input type="file" id="edit-file-input" multiple accept="image/*,.pdf,.doc,.docx,.txt,.csv,.zip" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="edit-attach-preview" class="attach-preview-list"></div>
|
||||||
|
<div id="edit-existing-attachments" style="margin-top:.75rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URLs -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Liens / URLs</label>
|
||||||
|
<div id="edit-url-rows"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- memo-main -->
|
||||||
|
</div><!-- memo-layout -->
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<div id="memo-lightbox" class="lightbox">
|
||||||
|
<img src="" alt="" onclick="event.stopPropagation()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- marked.js pour le rendu Markdown -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script>
|
||||||
|
if(window.marked){
|
||||||
|
marked.setOptions({breaks:true,gfm:true});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/modules/memo/assets/memo.js"></script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
// Script de migration pour mettre à jour TOUTES les bases de données familiales
|
||||||
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
|
||||||
|
echo "<h1>🚀 Début de la migration...</h1>";
|
||||||
|
|
||||||
|
// 1. Récupérer toutes les bases de données des familles
|
||||||
|
$stmt = $meta_pdo->query("SELECT name, db_name FROM families WHERE db_name != ''");
|
||||||
|
$families = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$host = getenv('DB_HOST') ?: 'househub-db';
|
||||||
|
$user = getenv('DB_USER') ?: 'househub';
|
||||||
|
$pass = getenv('DB_PASS') ?: 'changeme';
|
||||||
|
|
||||||
|
// 2. Boucler sur chaque famille
|
||||||
|
foreach ($families as $f) {
|
||||||
|
$db_name = $f['db_name'];
|
||||||
|
echo "Mise à jour de la famille <strong>{$f['name']}</strong> (Base : $db_name) ... ";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fam_pdo = new PDO("mysql:host=$host;dbname=$db_name;charset=utf8mb4", $user, $pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Injecter la nouvelle table
|
||||||
|
$sql = "
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_foyer_settings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
currency VARCHAR(10) NOT NULL DEFAULT '€',
|
||||||
|
zone_scolaire VARCHAR(5) NOT NULL DEFAULT 'C',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO pf_foyer_settings (id, currency, zone_scolaire) VALUES (1, '€', 'C');
|
||||||
|
";
|
||||||
|
|
||||||
|
$fam_pdo->exec($sql);
|
||||||
|
echo "<span style='color:green'>✅ OK</span><br>";
|
||||||
|
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
echo "<span style='color:red'>❌ Erreur : " . $e->getMessage() . "</span><br>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "<h2>🎉 Migration terminée ! Tu peux supprimer ce fichier.</h2>";
|
||||||
|
?>
|
||||||
@@ -424,7 +424,7 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
.prev-salary-table td {
|
.prev-salary-table td {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid var(--border-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,15 +444,15 @@ input[type="number"] {
|
|||||||
}
|
}
|
||||||
.prev-alloc-table th {
|
.prev-alloc-table th {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-right: 1px solid #f1f5f9;
|
border-right: 1px solid var(--border-light);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.prev-alloc-table td {
|
.prev-alloc-table td {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid var(--border-light);
|
||||||
border-right: 1px solid #f1f5f9;
|
border-right: 1px solid var(--border-light);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
@@ -601,7 +601,7 @@ tr:hover .row-actions {
|
|||||||
.recap-table th,
|
.recap-table th,
|
||||||
.recap-table td {
|
.recap-table td {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid var(--border-light);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,18 +666,18 @@ function updateSummaryTable() {
|
|||||||
const laiaSum = dataByTarget[targetName] ? dataByTarget[targetName].laia : 0;
|
const laiaSum = dataByTarget[targetName] ? dataByTarget[targetName].laia : 0;
|
||||||
const globalSum = alexSum + laiaSum;
|
const globalSum = alexSum + laiaSum;
|
||||||
|
|
||||||
row.cells[1].innerText = Math.round(alexSum).toLocaleString(window.appLang) + ' €';
|
row.cells[1].innerText = Math.round(alexSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
row.cells[2].innerText = Math.round(laiaSum).toLocaleString(window.appLang) + ' €';
|
row.cells[2].innerText = Math.round(laiaSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
row.cells[3].innerText = Math.round(globalSum).toLocaleString(window.appLang) + ' €';
|
row.cells[3].innerText = Math.round(globalSum).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
|
|
||||||
grandTotalAlex += alexSum;
|
grandTotalAlex += alexSum;
|
||||||
grandTotalLaia += laiaSum;
|
grandTotalLaia += laiaSum;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('grand_total_alex').innerText = Math.round(grandTotalAlex).toLocaleString(window.appLang) + ' €';
|
document.getElementById('grand_total_alex').innerText = Math.round(grandTotalAlex).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
document.getElementById('grand_total_laia').innerText = Math.round(grandTotalLaia).toLocaleString(window.appLang) + ' €';
|
document.getElementById('grand_total_laia').innerText = Math.round(grandTotalLaia).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
document.getElementById('grand_total_global').innerText = Math.round(grandTotalAlex + grandTotalLaia).toLocaleString(window.appLang) + ' €';
|
document.getElementById('grand_total_global').innerText = Math.round(grandTotalAlex + grandTotalLaia).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveData(action, data) {
|
function saveData(action, data) {
|
||||||
@@ -788,7 +788,7 @@ function updateSumResult() {
|
|||||||
total += val;
|
total += val;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('sumResultValue').innerText = new Intl.NumberFormat(window.appLang, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(total);
|
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory(id) {
|
async function deleteCategory(id) {
|
||||||
|
|||||||
@@ -528,7 +528,7 @@ function updateSumResult() {
|
|||||||
total += val;
|
total += val;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('sumResultValue').innerText = new Intl.NumberFormat(window.appLang, { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }).format(total);
|
document.getElementById('sumResultValue').innerText = Math.round(total).toLocaleString(window.appLang) + ' ' + window.CONFIG.CURRENCY;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ $totalRevenus = 0;
|
|||||||
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
|
$rowClass = ($item['category'] === 'income') ? 'row-income' : 'row-expense';
|
||||||
if ($item['is_estimate']) $rowClass .= ' row-estimate';
|
if ($item['is_estimate']) $rowClass .= ' row-estimate';
|
||||||
?>
|
?>
|
||||||
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid #f1f5f9;">
|
<tr class="<?= $rowClass ?>" style="border-bottom:1px solid var(--border-light);">
|
||||||
<td style="padding:15px;">
|
<td style="padding:15px;">
|
||||||
<strong><?= htmlspecialchars($item['name']) ?></strong>
|
<strong><?= htmlspecialchars($item['name']) ?></strong>
|
||||||
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">('.tr('bud_est_short').')</small>' : '' ?>
|
<?= $item['is_estimate'] ? ' <small style="color:#64748b;">('.tr('bud_est_short').')</small>' : '' ?>
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/caldav_sync.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$userId = (int)($_SESSION['user']['id'] ?? 0);
|
||||||
|
$familyId = (int)($_SESSION['user']['family_id'] ?? 0);
|
||||||
|
|
||||||
|
$meta_pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
dav_principal_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
status VARCHAR(30) DEFAULT 'connected',
|
||||||
|
last_sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_provider (user_id, provider)
|
||||||
|
)");
|
||||||
|
try {
|
||||||
|
$meta_pdo->exec('ALTER TABLE user_calendar_integrations ADD COLUMN calendar_prefs_json TEXT NULL');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// colonne déjà présente
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
family_id INT NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
is_all_day TINYINT(1) DEFAULT 0,
|
||||||
|
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
|
||||||
|
rrule VARCHAR(500) DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'confirmed',
|
||||||
|
external_uid VARCHAR(255) DEFAULT NULL,
|
||||||
|
sync_state VARCHAR(30) DEFAULT 'pending_push',
|
||||||
|
deleted_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_external_uid (external_uid)
|
||||||
|
)");
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
calendar_event_id INT NOT NULL,
|
||||||
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
external_href VARCHAR(2048) DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
|
)");
|
||||||
|
try {
|
||||||
|
$pdo->exec('ALTER TABLE pf_calendar_event_links ADD COLUMN external_href VARCHAR(2048) DEFAULT NULL');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// colonne déjà présente
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_ok($data): void { echo json_encode(['ok' => true, 'data' => $data], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function ios_err(string $message, int $status = 400): void { http_response_code($status); echo json_encode(['ok' => false, 'error' => $message]); exit; }
|
||||||
|
function ios_body(): array { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
function ios_require_csrf(): void {
|
||||||
|
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
|
||||||
|
if (!verify_csrf($token)) {
|
||||||
|
ios_err('Token CSRF invalide', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_normalize_datetime(?string $s): ?string
|
||||||
|
{
|
||||||
|
if ($s === null || $s === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$s = str_replace('T', ' ', trim($s));
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $s)) {
|
||||||
|
$s .= ':00';
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_calendar_url_key(?string $url): string
|
||||||
|
{
|
||||||
|
if ($url === null || $url === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return rtrim(trim($url), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{visible:bool, color:?string}> */
|
||||||
|
function ios_calendar_prefs_decode(?string $json): array
|
||||||
|
{
|
||||||
|
if ($json === null || $json === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$d = json_decode($json, true);
|
||||||
|
if (!is_array($d)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($d as $k => $v) {
|
||||||
|
if (!is_string($k) || $k === '' || !is_array($v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$key = ios_calendar_url_key($k);
|
||||||
|
if ($key === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = [
|
||||||
|
'visible' => !array_key_exists('visible', $v) ? true : (bool) $v['visible'],
|
||||||
|
'color' => (isset($v['color']) && is_string($v['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $v['color'])) ? $v['color'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{url:string,url_key:string,display:string,visible:bool,color:?string}>
|
||||||
|
*/
|
||||||
|
function ios_list_calendar_sources_for_ui(array $integration, string $password, PDO $pdo): array
|
||||||
|
{
|
||||||
|
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
$urlsMeta = [];
|
||||||
|
$primary = $integration['calendar_url'] ?? '';
|
||||||
|
if (hh_caldav_url_is_icloud($primary)) {
|
||||||
|
try {
|
||||||
|
foreach (hh_icloud_discover_calendar_entries($integration['username'], $password) as $e) {
|
||||||
|
$k = ios_calendar_url_key($e['url']);
|
||||||
|
if ($k === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$disp = trim((string) ($e['display'] ?? ''));
|
||||||
|
if ($disp === '') {
|
||||||
|
$disp = basename(parse_url($e['url'], PHP_URL_PATH) ?: '') ?: $k;
|
||||||
|
}
|
||||||
|
$urlsMeta[$k] = ['url' => rtrim($e['url'], '/') . '/', 'display' => $disp];
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$k = ios_calendar_url_key($primary);
|
||||||
|
if ($k !== '') {
|
||||||
|
$urlsMeta[$k] = ['url' => rtrim($primary, '/') . '/', 'display' => 'Calendrier'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$stmt = $pdo->query("
|
||||||
|
SELECT DISTINCT l.calendar_url AS u
|
||||||
|
FROM pf_calendar_event_links l
|
||||||
|
INNER JOIN pf_calendar_events e ON e.id = l.calendar_event_id
|
||||||
|
WHERE e.deleted_at IS NULL AND l.calendar_url IS NOT NULL AND TRIM(l.calendar_url) != ''
|
||||||
|
");
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $u) {
|
||||||
|
$k = ios_calendar_url_key((string) $u);
|
||||||
|
if ($k === '' || isset($urlsMeta[$k])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urlsMeta[$k] = [
|
||||||
|
'url' => rtrim((string) $u, '/') . '/',
|
||||||
|
'display' => basename(parse_url((string) $u, PHP_URL_PATH) ?: '') ?: $k,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
foreach ($urlsMeta as $key => $meta) {
|
||||||
|
$p = $prefs[$key] ?? [];
|
||||||
|
$visible = !array_key_exists('visible', $p) ? true : (bool) $p['visible'];
|
||||||
|
$color = (isset($p['color']) && is_string($p['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $p['color'])) ? $p['color'] : null;
|
||||||
|
$out[] = [
|
||||||
|
'url' => $meta['url'],
|
||||||
|
'url_key' => $key,
|
||||||
|
'display' => $meta['display'],
|
||||||
|
'visible' => $visible,
|
||||||
|
'color' => $color,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($out, static fn ($a, $b) => strcasecmp($a['display'], $b['display']));
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
$integrationStmt = $meta_pdo->prepare("SELECT * FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
|
||||||
|
$integrationStmt->execute([$userId]);
|
||||||
|
$integration = $integrationStmt->fetch();
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'GET') {
|
||||||
|
$prefs = [];
|
||||||
|
if ($integration) {
|
||||||
|
$prefs = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
}
|
||||||
|
$rows = $pdo->query("
|
||||||
|
SELECT e.*, l.calendar_url AS calendar_source_url
|
||||||
|
FROM pf_calendar_events e
|
||||||
|
LEFT JOIN pf_calendar_event_links l ON l.calendar_event_id = e.id
|
||||||
|
WHERE e.deleted_at IS NULL
|
||||||
|
ORDER BY e.start_at ASC
|
||||||
|
")->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$key = ios_calendar_url_key($r['calendar_source_url'] ?? '');
|
||||||
|
if ($key !== '' && isset($prefs[$key]['visible']) && $prefs[$key]['visible'] === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$r['display_color'] = null;
|
||||||
|
if ($key !== '' && !empty($prefs[$key]['color']) && is_string($prefs[$key]['color']) && preg_match('/^#[0-9A-Fa-f]{6}$/', $prefs[$key]['color'])) {
|
||||||
|
$r['display_color'] = $prefs[$key]['color'];
|
||||||
|
}
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
ios_ok($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_sources' && $method === 'GET') {
|
||||||
|
if (!$integration) {
|
||||||
|
ios_ok(['calendars' => []]);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
$integrationStmt->execute([$userId]);
|
||||||
|
$integrationFresh = $integrationStmt->fetch();
|
||||||
|
if (!$integrationFresh) {
|
||||||
|
ios_ok(['calendars' => []]);
|
||||||
|
}
|
||||||
|
ios_ok(['calendars' => ios_list_calendar_sources_for_ui($integrationFresh, $ctx['password'], $pdo)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_prefs' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
if (!$integration) {
|
||||||
|
ios_err('Connexion iCloud non configurée.', 400);
|
||||||
|
}
|
||||||
|
$body = ios_body();
|
||||||
|
$raw = $body['prefs'] ?? null;
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
ios_err('Format prefs invalide', 400);
|
||||||
|
}
|
||||||
|
$clean = [];
|
||||||
|
foreach ($raw as $key => $p) {
|
||||||
|
if (!is_array($p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$urlKey = ios_calendar_url_key(is_string($key) ? $key : '');
|
||||||
|
if ($urlKey === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$visible = array_key_exists('visible', $p) ? (bool) $p['visible'] : true;
|
||||||
|
$col = $p['color'] ?? null;
|
||||||
|
if ($col !== null && $col !== '' && is_string($col) && preg_match('/^#[0-9A-Fa-f]{6}$/', $col)) {
|
||||||
|
$col = '#' . strtolower(substr($col, 1));
|
||||||
|
} else {
|
||||||
|
$col = null;
|
||||||
|
}
|
||||||
|
$clean[$urlKey] = ['visible' => $visible, 'color' => $col];
|
||||||
|
}
|
||||||
|
$existing = ios_calendar_prefs_decode($integration['calendar_prefs_json'] ?? null);
|
||||||
|
foreach ($clean as $urlKey => $v) {
|
||||||
|
$existing[$urlKey] = $v;
|
||||||
|
}
|
||||||
|
$json = json_encode($existing, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$meta_pdo->prepare('UPDATE user_calendar_integrations SET calendar_prefs_json = ? WHERE id = ?')->execute([$json, $integration['id']]);
|
||||||
|
ios_ok(['saved' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
if (empty($d['title']) || empty($d['start_at']) || empty($d['end_at'])) {
|
||||||
|
ios_err('Champs requis manquants');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, sync_state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', 'pending_push')
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$familyId, $userId, trim($d['title']), $d['description'] ?? null, $d['location'] ?? null,
|
||||||
|
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null),
|
||||||
|
]);
|
||||||
|
ios_ok(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'PUT') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
if ($id <= 0) ios_err('ID invalide');
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE pf_calendar_events
|
||||||
|
SET title=?, description=?, location=?, start_at=?, end_at=?, updated_at=NOW(), sync_state='pending_push'
|
||||||
|
WHERE id=?
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
trim($d['title'] ?? ''), $d['description'] ?? null, $d['location'] ?? null,
|
||||||
|
ios_normalize_datetime($d['start_at'] ?? null), ios_normalize_datetime($d['end_at'] ?? null), $id,
|
||||||
|
]);
|
||||||
|
ios_ok(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'events' && $method === 'DELETE') {
|
||||||
|
ios_require_csrf();
|
||||||
|
$d = ios_body();
|
||||||
|
$id = (int)($d['id'] ?? 0);
|
||||||
|
if ($id <= 0) ios_err('ID invalide');
|
||||||
|
$row = $pdo->prepare("SELECT id FROM pf_calendar_events WHERE id=?");
|
||||||
|
$row->execute([$id]);
|
||||||
|
if (!$row->fetch()) ios_err('Événement introuvable', 404);
|
||||||
|
$pdo->prepare("UPDATE pf_calendar_events SET deleted_at=NOW(), sync_state='pending_delete' WHERE id=?")->execute([$id]);
|
||||||
|
ios_ok(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'sync_status' && $method === 'GET') {
|
||||||
|
if (!$integration) {
|
||||||
|
ios_ok(['message' => 'Aucune intégration iCloud configurée.']);
|
||||||
|
}
|
||||||
|
$msg = 'Connecté iCloud. Dernière synchro: ' . ($integration['last_sync_at'] ?? 'jamais');
|
||||||
|
ios_ok(['message' => $msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'sync' && $method === 'POST') {
|
||||||
|
ios_require_csrf();
|
||||||
|
if (!$integration) ios_err('Connexion iCloud non configurée.', 400);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctx = ios_caldav_prepare($integration, $meta_pdo);
|
||||||
|
$integration = $ctx['integration'];
|
||||||
|
$davPassword = $ctx['password'];
|
||||||
|
$syncUrls = ios_sync_calendar_collection_urls($integration, $davPassword);
|
||||||
|
$remoteEvents = ios_fetch_remote_events_all_calendars($integration, $davPassword);
|
||||||
|
|
||||||
|
$localStmt = $pdo->query("SELECT * FROM pf_calendar_events WHERE deleted_at IS NULL");
|
||||||
|
$localEvents = $localStmt->fetchAll();
|
||||||
|
|
||||||
|
$linkStmt = $pdo->prepare('SELECT external_href, calendar_url FROM pf_calendar_event_links WHERE calendar_event_id = ?');
|
||||||
|
|
||||||
|
foreach ($localEvents as $evt) {
|
||||||
|
if ($evt['sync_state'] === 'pending_push' || empty($evt['external_uid'])) {
|
||||||
|
$linkStmt->execute([$evt['id']]);
|
||||||
|
$lr = $linkStmt->fetch() ?: [];
|
||||||
|
$push = ios_push_event_to_remote(
|
||||||
|
$integration,
|
||||||
|
$evt,
|
||||||
|
$davPassword,
|
||||||
|
$lr['external_href'] ?? null,
|
||||||
|
$lr['calendar_url'] ?? null
|
||||||
|
);
|
||||||
|
if ($push['code'] >= 200 && $push['code'] < 300) {
|
||||||
|
$uid = $evt['external_uid'] ?: ('hh-' . $evt['id'] . '@househub');
|
||||||
|
$pdo->prepare("UPDATE pf_calendar_events SET external_uid=?, sync_state='synced', updated_at=NOW() WHERE id=?")->execute([$uid, $evt['id']]);
|
||||||
|
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE external_etag=VALUES(external_etag), calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
|
||||||
|
->execute([$evt['id'], $uid, $integration['calendar_url'], null, $lr['external_href'] ?? null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingDelete = $pdo->query("SELECT id, external_uid FROM pf_calendar_events WHERE deleted_at IS NOT NULL AND sync_state='pending_delete'")->fetchAll();
|
||||||
|
foreach ($pendingDelete as $evt) {
|
||||||
|
if (!empty($evt['external_uid'])) {
|
||||||
|
$linkStmt->execute([$evt['id']]);
|
||||||
|
$lr = $linkStmt->fetch() ?: [];
|
||||||
|
ios_delete_remote_event(
|
||||||
|
$integration,
|
||||||
|
$evt['external_uid'],
|
||||||
|
$davPassword,
|
||||||
|
$lr['external_href'] ?? null,
|
||||||
|
$lr['calendar_url'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_calendar_event_links WHERE calendar_event_id=?")->execute([$evt['id']]);
|
||||||
|
$pdo->prepare("DELETE FROM pf_calendar_events WHERE id=?")->execute([$evt['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($remoteEvents as $remote) {
|
||||||
|
$href = $remote['_resource_url'] ?? null;
|
||||||
|
$colUrl = $remote['_calendar_collection_url'] ?? $integration['calendar_url'];
|
||||||
|
$existing = $pdo->prepare("SELECT id, updated_at FROM pf_calendar_events WHERE external_uid=? LIMIT 1");
|
||||||
|
$existing->execute([$remote['external_uid']]);
|
||||||
|
$row = $existing->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
$pdo->prepare("
|
||||||
|
INSERT INTO pf_calendar_events (family_id, created_by_user_id, title, description, location, start_at, end_at, timezone, external_uid, sync_state)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'Europe/Paris', ?, 'synced')
|
||||||
|
")->execute([$familyId, $userId, $remote['title'], $remote['description'], $remote['location'], $remote['start_at'], $remote['end_at'], $remote['external_uid']]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
$pdo->prepare("INSERT INTO pf_calendar_event_links (calendar_event_id, external_uid, calendar_url, external_etag, external_href) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE calendar_url=VALUES(calendar_url), external_href=COALESCE(VALUES(external_href), external_href), updated_at=NOW()")
|
||||||
|
->execute([$newId, $remote['external_uid'], $colUrl, null, $href]);
|
||||||
|
} elseif ($href) {
|
||||||
|
$pdo->prepare('UPDATE pf_calendar_event_links SET external_href = COALESCE(external_href, ?), calendar_url = COALESCE(calendar_url, ?) WHERE calendar_event_id = ?')
|
||||||
|
->execute([$href, $colUrl, $row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta_pdo->prepare("UPDATE user_calendar_integrations SET last_sync_at=NOW(), status='connected' WHERE id=?")->execute([$integration['id']]);
|
||||||
|
$n = count($remoteEvents);
|
||||||
|
$nc = count($syncUrls);
|
||||||
|
ios_ok(['message' => 'Synchronisation terminée. ' . $n . ' événement(s) depuis ' . $nc . ' calendrier(s) distant(s).']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
ios_err('CalDAV: ' . $e->getMessage(), 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ios_err('Action inconnue', 404);
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
/* ─── Page layout (cartes claires hors agenda) ─────────────────────────── */
|
||||||
|
.ios-calendar-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-calendar-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-calendar-head h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-details {
|
||||||
|
margin-top: 14px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-help {
|
||||||
|
margin: 10px 0 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--pf-bg-lighter, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-row input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-label {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-color {
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-reset {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-pref-reset:hover {
|
||||||
|
color: var(--pf-text-main, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-prefs-msg {
|
||||||
|
min-height: 1.25em;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ios-cal-pref-row {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
.ios-cal-pref-color {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
.ios-cal-pref-reset {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-events-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card {
|
||||||
|
border: 1px solid var(--pf-border, #e2e8f0);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--pf-bg-page, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-meta {
|
||||||
|
color: var(--pf-text-muted, #64748b);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Agenda sombre (style type app calendrier) ─────────────────────────── */
|
||||||
|
.ios-agenda-card {
|
||||||
|
--ios-bg: #0a0a0c;
|
||||||
|
--ios-elev: #12121a;
|
||||||
|
--ios-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--ios-text: #f1f5f9;
|
||||||
|
--ios-muted: #94a3b8;
|
||||||
|
--ios-now: #ef4444;
|
||||||
|
--ios-seg-bg: #1a1a22;
|
||||||
|
background: var(--ios-bg);
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
color: var(--ios-text);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: capitalize;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-segments {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--ios-seg-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg:hover {
|
||||||
|
color: var(--ios-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-seg--active {
|
||||||
|
background: #2d2d38;
|
||||||
|
color: var(--ios-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn {
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn:hover {
|
||||||
|
background: #1e1e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-today {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-now);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-add {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-add:hover {
|
||||||
|
background: #1e1e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-listlink {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-listlink:hover {
|
||||||
|
color: var(--ios-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-mount {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-list-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grille semaine / jour */
|
||||||
|
.ios-week-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px repeat(7, minmax(0, 1fr));
|
||||||
|
border-bottom: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-elev);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head--day {
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-corner {
|
||||||
|
border-right: 1px solid var(--ios-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-day {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-left: 1px solid var(--ios-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ios-text);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-head-day--today .ios-week-head-num {
|
||||||
|
background: var(--ios-now);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-scroll {
|
||||||
|
max-height: min(70vh, 720px);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-week-track {
|
||||||
|
display: flex;
|
||||||
|
min-width: min(100%, 720px);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-gutter {
|
||||||
|
width: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--ios-border);
|
||||||
|
background: var(--ios-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-gutter-cell {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
line-height: 44px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 704px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 704px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-cols--day {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col {
|
||||||
|
border-left: 1px solid var(--ios-border);
|
||||||
|
background-color: var(--ios-bg);
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
transparent 43px,
|
||||||
|
var(--ios-border) 43px,
|
||||||
|
var(--ios-border) 44px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col:first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-agenda-col-inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block {
|
||||||
|
position: absolute;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block--custom {
|
||||||
|
border: none !important;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: inset 3px 0 0 rgba(255, 255, 255, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-event-block-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-cal-c0 {
|
||||||
|
background: linear-gradient(135deg, #166534, #15803d);
|
||||||
|
}
|
||||||
|
.ios-cal-c1 {
|
||||||
|
background: linear-gradient(135deg, #1e40af, #2563eb);
|
||||||
|
}
|
||||||
|
.ios-cal-c2 {
|
||||||
|
background: linear-gradient(135deg, #6b21a8, #7c3aed);
|
||||||
|
}
|
||||||
|
.ios-cal-c3 {
|
||||||
|
background: linear-gradient(135deg, #a16207, #ca8a04);
|
||||||
|
}
|
||||||
|
.ios-cal-c4 {
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||||
|
}
|
||||||
|
.ios-cal-c5 {
|
||||||
|
background: linear-gradient(135deg, #9f1239, #e11d48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-line-span {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ios-now);
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-now-lbl-span {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ios-now);
|
||||||
|
text-shadow: 0 0 6px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mois (dans carte sombre) */
|
||||||
|
.ios-month-agenda {
|
||||||
|
padding: 12px 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-agenda-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ios-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-agenda-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--ios-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ios-elev);
|
||||||
|
color: var(--ios-text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 4px 2px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--pad {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--today {
|
||||||
|
border-color: var(--ios-now);
|
||||||
|
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-cell--has {
|
||||||
|
background: #1a1a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-num {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-month-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ios-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.ios-calendar-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ios-event-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ios-agenda-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.ios-agenda-segments {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ios-agenda-nav {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
const iosEventsList = document.getElementById("ios-events-list");
|
||||||
|
const iosSyncStatus = document.getElementById("ios-sync-status");
|
||||||
|
const iosForm = document.getElementById("ios-event-form");
|
||||||
|
const iosAgendaMount = document.getElementById("ios-agenda-mount");
|
||||||
|
const iosAgendaTitle = document.getElementById("ios-agenda-title");
|
||||||
|
|
||||||
|
const H_START = 6;
|
||||||
|
const H_END = 22;
|
||||||
|
const PX_PER_H = 44;
|
||||||
|
const TRACK_H = (H_END - H_START) * PX_PER_H;
|
||||||
|
|
||||||
|
let iosEventsCache = [];
|
||||||
|
/** @type {'day'|'week'|'month'} */
|
||||||
|
let iosViewMode = "week";
|
||||||
|
/** Ancre de navigation (jour courant dans la période affichée) */
|
||||||
|
let iosCursor = new Date();
|
||||||
|
let iosNowTimer = null;
|
||||||
|
let iosListVisible = false;
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
"ios-cal-c0",
|
||||||
|
"ios-cal-c1",
|
||||||
|
"ios-cal-c2",
|
||||||
|
"ios-cal-c3",
|
||||||
|
"ios-cal-c4",
|
||||||
|
"ios-cal-c5",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUGGEST_HEX = ["#22c55e", "#3b82f6", "#8b5cf6", "#eab308", "#14b8a6", "#f43f5e"];
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s ?? "";
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sqlToDatetimeLocal(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})[\sT](\d{2}):(\d{2})/.exec(String(s).trim());
|
||||||
|
if (!m) return "";
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDatetimeToSql(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const t = s.includes("T") ? s.replace("T", " ") : s;
|
||||||
|
return t.length === 16 ? `${t}:00` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvtDate(iso) {
|
||||||
|
const d = new Date(String(iso).replace(" ", "T"));
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function darkenHex(hex, amount) {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.max(0, (n >> 16) - amount);
|
||||||
|
const g = Math.max(0, ((n >> 8) & 0xff) - amount);
|
||||||
|
const b = Math.max(0, (n & 0xff) - amount);
|
||||||
|
return `#${[r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestedColorForKey(key) {
|
||||||
|
const k = String(key || "");
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) >>> 0;
|
||||||
|
return SUGGEST_HEX[h % SUGGEST_HEX.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorClassForEvent(evt) {
|
||||||
|
const key = String(evt.calendar_source_url || evt.external_uid || evt.id || "");
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
|
||||||
|
return PALETTE[h % PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique couleur perso (API) ou palette par défaut */
|
||||||
|
function styleEventBlock(el, evt) {
|
||||||
|
el.className = "ios-event-block";
|
||||||
|
el.style.background = "";
|
||||||
|
el.style.boxShadow = "";
|
||||||
|
const hex = evt.display_color;
|
||||||
|
if (hex && /^#[0-9A-Fa-f]{6}$/i.test(hex)) {
|
||||||
|
const c = hex.startsWith("#") ? hex : `#${hex.slice(-6)}`;
|
||||||
|
el.classList.add("ios-event-block--custom");
|
||||||
|
el.style.background = `linear-gradient(135deg, ${darkenHex(c, 40)} 0%, ${c} 100%)`;
|
||||||
|
el.style.boxShadow = "inset 3px 0 0 rgba(255,255,255,0.35)";
|
||||||
|
} else {
|
||||||
|
el.classList.add(colorClassForEvent(evt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mondayOf(date) {
|
||||||
|
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
const dow = d.getDay();
|
||||||
|
const diff = dow === 0 ? -6 : 1 - dow;
|
||||||
|
d.setDate(d.getDate() + diff);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setDate(x.getDate() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(d, n) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setMonth(x.getMonth() + n);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLocalDay(a, b) {
|
||||||
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segment d’un événement visible dans [dayMidnight, dayMidnight+1j[ */
|
||||||
|
function segmentInLocalDay(evt, dayMidnight) {
|
||||||
|
const s = parseEvtDate(evt.start_at);
|
||||||
|
const e = parseEvtDate(evt.end_at);
|
||||||
|
if (!s || !e) return null;
|
||||||
|
const dayEnd = addDays(dayMidnight, 1);
|
||||||
|
if (e <= dayMidnight || s >= dayEnd) return null;
|
||||||
|
const segStart = s > dayMidnight ? s : dayMidnight;
|
||||||
|
const segEnd = e < dayEnd ? e : dayEnd;
|
||||||
|
return { segStart, segEnd, evt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pxInTrackFromHourFraction(dayMidnight, t) {
|
||||||
|
const grid0 = new Date(dayMidnight);
|
||||||
|
grid0.setHours(H_START, 0, 0, 0);
|
||||||
|
const grid1 = new Date(dayMidnight);
|
||||||
|
grid1.setHours(H_END, 0, 0, 0);
|
||||||
|
const tt = Math.min(Math.max(t.getTime(), grid0.getTime()), grid1.getTime());
|
||||||
|
return ((tt - grid0.getTime()) / 3600000) * PX_PER_H;
|
||||||
|
}
|
||||||
|
|
||||||
|
function heightPxForSegment(dayMidnight, segStart, segEnd) {
|
||||||
|
const a = pxInTrackFromHourFraction(dayMidnight, segStart);
|
||||||
|
const b = pxInTrackFromHourFraction(dayMidnight, segEnd);
|
||||||
|
return Math.max(18, b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowLineTopPx(colDay) {
|
||||||
|
const now = new Date();
|
||||||
|
if (!sameLocalDay(now, colDay)) return null;
|
||||||
|
return pxInTrackFromHourFraction(colDay, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignLanes(segments) {
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
segments.sort((a, b) => a.segStart - b.segStart);
|
||||||
|
const laneEnds = [];
|
||||||
|
segments.forEach((seg) => {
|
||||||
|
let L = 0;
|
||||||
|
while (laneEnds[L] && seg.segStart < laneEnds[L]) L++;
|
||||||
|
laneEnds[L] = seg.segEnd;
|
||||||
|
seg._lane = L;
|
||||||
|
});
|
||||||
|
const maxL = segments.reduce((m, s) => Math.max(m, s._lane), 0);
|
||||||
|
segments.forEach((s) => {
|
||||||
|
s._laneCount = maxL + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function iosApi(action, options = {}) {
|
||||||
|
const method = options.method || "GET";
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
if (method !== "GET") {
|
||||||
|
headers["X-CSRF-Token"] = window.CSRF_TOKEN || "";
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
const response = await fetch(`/modules/calendar-ios/api.php?action=${encodeURIComponent(action)}`, {
|
||||||
|
method,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.ok) throw new Error(json.error || "Erreur inconnue");
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = parseEvtDate(iso);
|
||||||
|
return d ? d.toLocaleString("fr-FR") : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(evt) {
|
||||||
|
document.getElementById("ios-event-id").value = evt.id;
|
||||||
|
document.getElementById("ios-title").value = evt.title || "";
|
||||||
|
document.getElementById("ios-location").value = evt.location || "";
|
||||||
|
document.getElementById("ios-description").value = evt.description || "";
|
||||||
|
document.getElementById("ios-start").value = sqlToDatetimeLocal(evt.start_at);
|
||||||
|
document.getElementById("ios-end").value = sqlToDatetimeLocal(evt.end_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
iosForm.reset();
|
||||||
|
document.getElementById("ios-event-id").value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventsForLocalDay(y, m, d) {
|
||||||
|
return iosEventsCache.filter((evt) => {
|
||||||
|
const t = parseEvtDate(evt.start_at);
|
||||||
|
return t && t.getFullYear() === y && t.getMonth() === m && t.getDate() === d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToolbarTitle() {
|
||||||
|
if (!iosAgendaTitle) return;
|
||||||
|
if (iosViewMode === "day") {
|
||||||
|
iosAgendaTitle.textContent = iosCursor.toLocaleDateString("fr-FR", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (iosViewMode === "week") {
|
||||||
|
const mon = mondayOf(iosCursor);
|
||||||
|
const sun = addDays(mon, 6);
|
||||||
|
if (mon.getMonth() === sun.getMonth() && mon.getFullYear() === sun.getFullYear()) {
|
||||||
|
iosAgendaTitle.textContent = `${mon.getDate()} – ${sun.getDate()} ${sun.toLocaleDateString("fr-FR", { month: "long", year: "numeric" })}`;
|
||||||
|
} else {
|
||||||
|
iosAgendaTitle.textContent = `${mon.toLocaleDateString("fr-FR", { day: "numeric", month: "short" })} – ${sun.toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric" })}`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
iosAgendaTitle.textContent = new Date(iosCursor.getFullYear(), iosCursor.getMonth(), 1).toLocaleDateString("fr-FR", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveSegments() {
|
||||||
|
document.querySelectorAll(".ios-seg").forEach((b) => {
|
||||||
|
b.classList.toggle("ios-seg--active", b.getAttribute("data-ios-view") === iosViewMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeGutter() {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (let h = H_START; h < H_END; h++) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ios-agenda-gutter-cell";
|
||||||
|
row.textContent = `${h} h`;
|
||||||
|
frag.appendChild(row);
|
||||||
|
}
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDayColumn(dayMidnight) {
|
||||||
|
const col = document.createElement("div");
|
||||||
|
col.className = "ios-agenda-col";
|
||||||
|
const inner = document.createElement("div");
|
||||||
|
inner.className = "ios-agenda-col-inner";
|
||||||
|
inner.style.height = `${TRACK_H}px`;
|
||||||
|
|
||||||
|
const segs = [];
|
||||||
|
iosEventsCache.forEach((evt) => {
|
||||||
|
const seg = segmentInLocalDay(evt, dayMidnight);
|
||||||
|
if (seg) segs.push(seg);
|
||||||
|
});
|
||||||
|
assignLanes(segs);
|
||||||
|
|
||||||
|
segs.forEach((seg) => {
|
||||||
|
const top = pxInTrackFromHourFraction(dayMidnight, seg.segStart);
|
||||||
|
const h = heightPxForSegment(dayMidnight, seg.segStart, seg.segEnd);
|
||||||
|
const w = 100 / seg._laneCount;
|
||||||
|
const left = (100 * seg._lane) / seg._laneCount;
|
||||||
|
const blk = document.createElement("button");
|
||||||
|
blk.type = "button";
|
||||||
|
styleEventBlock(blk, seg.evt);
|
||||||
|
blk.style.top = `${top}px`;
|
||||||
|
blk.style.height = `${h}px`;
|
||||||
|
blk.style.left = `calc(${left}% + 2px)`;
|
||||||
|
blk.style.width = `calc(${w}% - 4px)`;
|
||||||
|
blk.innerHTML = `<span class="ios-event-block-title">${escHtml(seg.evt.title || "(Sans titre)")}</span>`;
|
||||||
|
blk.title = [seg.evt.title, seg.evt.location, seg.evt.calendar_source_url].filter(Boolean).join("\n");
|
||||||
|
blk.addEventListener("click", () => {
|
||||||
|
fillForm(seg.evt);
|
||||||
|
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
inner.appendChild(blk);
|
||||||
|
});
|
||||||
|
|
||||||
|
col.appendChild(inner);
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWeekOrDay() {
|
||||||
|
if (!iosAgendaMount) return;
|
||||||
|
const isDay = iosViewMode === "day";
|
||||||
|
const weekStart = isDay ? new Date(iosCursor.getFullYear(), iosCursor.getMonth(), iosCursor.getDate()) : mondayOf(iosCursor);
|
||||||
|
const days = isDay ? [weekStart] : Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "ios-week-wrap";
|
||||||
|
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "ios-week-head" + (isDay ? " ios-week-head--day" : "");
|
||||||
|
const corner = document.createElement("div");
|
||||||
|
corner.className = "ios-week-corner";
|
||||||
|
head.appendChild(corner);
|
||||||
|
const today = new Date();
|
||||||
|
days.forEach((d) => {
|
||||||
|
const th = document.createElement("div");
|
||||||
|
th.className = "ios-week-head-day";
|
||||||
|
if (sameLocalDay(d, today)) th.classList.add("ios-week-head-day--today");
|
||||||
|
const num = document.createElement("span");
|
||||||
|
num.className = "ios-week-head-num";
|
||||||
|
num.textContent = String(d.getDate());
|
||||||
|
const wd = d.toLocaleDateString("fr-FR", { weekday: "short" }).replace(/\.$/, "");
|
||||||
|
th.appendChild(num);
|
||||||
|
th.appendChild(document.createTextNode(` ${wd}.`));
|
||||||
|
head.appendChild(th);
|
||||||
|
});
|
||||||
|
wrap.appendChild(head);
|
||||||
|
|
||||||
|
const scroll = document.createElement("div");
|
||||||
|
scroll.className = "ios-week-scroll";
|
||||||
|
|
||||||
|
const track = document.createElement("div");
|
||||||
|
track.className = "ios-week-track";
|
||||||
|
|
||||||
|
const gutter = document.createElement("div");
|
||||||
|
gutter.className = "ios-agenda-gutter";
|
||||||
|
gutter.appendChild(renderTimeGutter());
|
||||||
|
track.appendChild(gutter);
|
||||||
|
|
||||||
|
const colsWrap = document.createElement("div");
|
||||||
|
colsWrap.className = "ios-agenda-cols-wrap";
|
||||||
|
|
||||||
|
const cols = document.createElement("div");
|
||||||
|
cols.className = `ios-agenda-cols${isDay ? " ios-agenda-cols--day" : ""}`;
|
||||||
|
days.forEach((d0) => {
|
||||||
|
const dm = new Date(d0.getFullYear(), d0.getMonth(), d0.getDate());
|
||||||
|
cols.appendChild(buildDayColumn(dm));
|
||||||
|
});
|
||||||
|
colsWrap.appendChild(cols);
|
||||||
|
|
||||||
|
const todayCol = days.findIndex((d) => sameLocalDay(d, new Date()));
|
||||||
|
if (todayCol >= 0) {
|
||||||
|
const dm = new Date(days[todayCol].getFullYear(), days[todayCol].getMonth(), days[todayCol].getDate());
|
||||||
|
const nowTop = nowLineTopPx(dm);
|
||||||
|
if (nowTop !== null) {
|
||||||
|
const ov = document.createElement("div");
|
||||||
|
ov.className = "ios-now-overlay";
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "ios-now-line-span";
|
||||||
|
line.style.top = `${nowTop}px`;
|
||||||
|
const lbl = document.createElement("div");
|
||||||
|
lbl.className = "ios-now-lbl-span";
|
||||||
|
lbl.textContent = new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
lbl.style.top = `${Math.max(2, nowTop - 10)}px`;
|
||||||
|
ov.appendChild(line);
|
||||||
|
ov.appendChild(lbl);
|
||||||
|
colsWrap.appendChild(ov);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
track.appendChild(colsWrap);
|
||||||
|
scroll.appendChild(track);
|
||||||
|
wrap.appendChild(scroll);
|
||||||
|
iosAgendaMount.innerHTML = "";
|
||||||
|
iosAgendaMount.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthInAgenda() {
|
||||||
|
if (!iosAgendaMount) return;
|
||||||
|
const y = iosCursor.getFullYear();
|
||||||
|
const m = iosCursor.getMonth();
|
||||||
|
const first = new Date(y, m, 1);
|
||||||
|
const last = new Date(y, m + 1, 0);
|
||||||
|
let startWeekday = first.getDay() - 1;
|
||||||
|
if (startWeekday < 0) startWeekday = 6;
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "ios-month-agenda";
|
||||||
|
const wk = document.createElement("div");
|
||||||
|
wk.className = "ios-month-agenda-weekdays";
|
||||||
|
["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"].forEach((t) => {
|
||||||
|
const s = document.createElement("span");
|
||||||
|
s.textContent = t;
|
||||||
|
wk.appendChild(s);
|
||||||
|
});
|
||||||
|
wrap.appendChild(wk);
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "ios-month-agenda-grid";
|
||||||
|
|
||||||
|
for (let i = 0; i < startWeekday; i++) {
|
||||||
|
const c = document.createElement("div");
|
||||||
|
c.className = "ios-month-cell ios-month-cell--pad";
|
||||||
|
grid.appendChild(c);
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
for (let d = 1; d <= last.getDate(); d++) {
|
||||||
|
const cell = document.createElement("button");
|
||||||
|
cell.type = "button";
|
||||||
|
cell.className = "ios-month-cell";
|
||||||
|
const list = eventsForLocalDay(y, m, d);
|
||||||
|
if (list.length) cell.classList.add("ios-month-cell--has");
|
||||||
|
if (y === today.getFullYear() && m === today.getMonth() && d === today.getDate()) {
|
||||||
|
cell.classList.add("ios-month-cell--today");
|
||||||
|
}
|
||||||
|
cell.innerHTML = `<span class="ios-month-num">${d}</span>`;
|
||||||
|
if (list.length) {
|
||||||
|
const dots = document.createElement("div");
|
||||||
|
dots.className = "ios-month-dots";
|
||||||
|
list.slice(0, 4).forEach(() => {
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "ios-month-dot";
|
||||||
|
dots.appendChild(dot);
|
||||||
|
});
|
||||||
|
cell.appendChild(dots);
|
||||||
|
}
|
||||||
|
cell.addEventListener("click", () => {
|
||||||
|
iosViewMode = "day";
|
||||||
|
iosCursor = new Date(y, m, d);
|
||||||
|
setActiveSegments();
|
||||||
|
updateToolbarTitle();
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
wrap.appendChild(grid);
|
||||||
|
iosAgendaMount.innerHTML = "";
|
||||||
|
iosAgendaMount.appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgenda() {
|
||||||
|
updateToolbarTitle();
|
||||||
|
setActiveSegments();
|
||||||
|
if (iosViewMode === "month") {
|
||||||
|
renderMonthInAgenda();
|
||||||
|
} else {
|
||||||
|
renderWeekOrDay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNowTimer() {
|
||||||
|
if (iosNowTimer) clearInterval(iosNowTimer);
|
||||||
|
iosNowTimer = setInterval(() => {
|
||||||
|
if (iosViewMode === "week" || iosViewMode === "day") renderAgenda();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showListSection(show) {
|
||||||
|
iosListVisible = !!show;
|
||||||
|
const sec = document.getElementById("ios-list-section");
|
||||||
|
if (!sec) return;
|
||||||
|
sec.style.display = show ? "block" : "none";
|
||||||
|
if (show && iosAgendaMount) {
|
||||||
|
iosAgendaMount.innerHTML =
|
||||||
|
'<p class="ios-agenda-list-hint">Vue liste ci-dessous. Utilise <strong>Jour</strong>, <strong>Semaine</strong> ou <strong>Mois</strong> pour retrouver l’agenda.</p>';
|
||||||
|
} else {
|
||||||
|
renderAgenda();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
const events = await iosApi("events");
|
||||||
|
iosEventsCache = events;
|
||||||
|
if (iosEventsList) {
|
||||||
|
iosEventsList.innerHTML = "";
|
||||||
|
if (!events.length) {
|
||||||
|
iosEventsList.innerHTML = "<div class='ios-agenda-intro'>Aucun événement.</div>";
|
||||||
|
} else {
|
||||||
|
events.forEach((evt) => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "ios-event-card";
|
||||||
|
if (evt.display_color && /^#[0-9A-Fa-f]{6}$/i.test(evt.display_color)) {
|
||||||
|
card.style.borderLeft = `4px solid ${evt.display_color}`;
|
||||||
|
}
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="ios-event-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="ios-event-card-title">${escHtml(evt.title || "(Sans titre)")}</div>
|
||||||
|
<div class="ios-event-card-meta">${escHtml(formatDate(evt.start_at))} → ${escHtml(formatDate(evt.end_at))}</div>
|
||||||
|
<div class="ios-event-card-meta">${escHtml(evt.location || "")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ios-event-card-actions">
|
||||||
|
<button type="button" class="ios-agenda-btn" data-edit="${evt.id}">Modifier</button>
|
||||||
|
<button type="button" class="ios-agenda-btn" data-delete="${evt.id}">Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.querySelector("[data-edit]").addEventListener("click", () => fillForm(evt));
|
||||||
|
card.querySelector("[data-delete]").addEventListener("click", async () => {
|
||||||
|
if (!confirm("Supprimer cet événement ?")) return;
|
||||||
|
await iosApi("events", { method: "DELETE", body: { id: evt.id } });
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
iosEventsList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!iosListVisible) {
|
||||||
|
renderAgenda();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncStatus() {
|
||||||
|
if (!iosSyncStatus) return;
|
||||||
|
const status = await iosApi("sync_status");
|
||||||
|
iosSyncStatus.textContent = status.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarPrefs() {
|
||||||
|
const wrap = document.getElementById("ios-calendar-prefs-rows");
|
||||||
|
const msg = document.getElementById("ios-calendar-prefs-msg");
|
||||||
|
if (!wrap) return;
|
||||||
|
if (msg) msg.textContent = "";
|
||||||
|
wrap.innerHTML = "<span class=\"ios-agenda-intro\">Chargement des calendriers…</span>";
|
||||||
|
try {
|
||||||
|
const data = await iosApi("calendar_sources");
|
||||||
|
const rows = data.calendars || [];
|
||||||
|
wrap.innerHTML = "";
|
||||||
|
if (!rows.length) {
|
||||||
|
wrap.innerHTML =
|
||||||
|
"<span class=\"ios-agenda-intro\">Aucun calendrier détecté. Configure iCloud dans Paramètres puis synchronise.</span>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const id = `ios-cal-cb-${idx}`;
|
||||||
|
const rowEl = document.createElement("div");
|
||||||
|
rowEl.className = "ios-cal-pref-row";
|
||||||
|
rowEl.dataset.urlKey = row.url_key;
|
||||||
|
|
||||||
|
const cb = document.createElement("input");
|
||||||
|
cb.type = "checkbox";
|
||||||
|
cb.id = id;
|
||||||
|
cb.checked = row.visible !== false;
|
||||||
|
|
||||||
|
const lab = document.createElement("label");
|
||||||
|
lab.className = "ios-cal-pref-label";
|
||||||
|
lab.htmlFor = id;
|
||||||
|
lab.textContent = row.display || row.url_key;
|
||||||
|
|
||||||
|
const colInp = document.createElement("input");
|
||||||
|
colInp.type = "color";
|
||||||
|
colInp.className = "ios-cal-pref-color";
|
||||||
|
colInp.value = row.color || suggestedColorForKey(row.url_key);
|
||||||
|
colInp.title = "Couleur dans l’agenda";
|
||||||
|
|
||||||
|
const autoBtn = document.createElement("button");
|
||||||
|
autoBtn.type = "button";
|
||||||
|
autoBtn.className = "ios-cal-pref-reset";
|
||||||
|
autoBtn.textContent = "Palette auto";
|
||||||
|
autoBtn.title = "Couleurs HouseHub par défaut (sans teinte iOS)";
|
||||||
|
autoBtn.addEventListener("click", () => {
|
||||||
|
rowEl.dataset.paletteAuto = "1";
|
||||||
|
colInp.disabled = true;
|
||||||
|
colInp.style.opacity = "0.35";
|
||||||
|
});
|
||||||
|
|
||||||
|
rowEl.appendChild(cb);
|
||||||
|
rowEl.appendChild(lab);
|
||||||
|
rowEl.appendChild(colInp);
|
||||||
|
rowEl.appendChild(autoBtn);
|
||||||
|
wrap.appendChild(rowEl);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
wrap.innerHTML = `<span class="ios-agenda-intro">${escHtml(e.message)}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCalendarPrefs() {
|
||||||
|
const msg = document.getElementById("ios-calendar-prefs-msg");
|
||||||
|
const wrap = document.getElementById("ios-calendar-prefs-rows");
|
||||||
|
if (!wrap) return;
|
||||||
|
const prefs = {};
|
||||||
|
wrap.querySelectorAll(".ios-cal-pref-row").forEach((rowEl) => {
|
||||||
|
const key = rowEl.dataset.urlKey;
|
||||||
|
if (!key) return;
|
||||||
|
const vis = rowEl.querySelector('input[type="checkbox"]')?.checked === true;
|
||||||
|
const colInp = rowEl.querySelector(".ios-cal-pref-color");
|
||||||
|
let color = null;
|
||||||
|
if (!rowEl.dataset.paletteAuto && colInp && !colInp.disabled) {
|
||||||
|
const v = colInp.value;
|
||||||
|
if (v && /^#[0-9A-Fa-f]{6}$/i.test(v)) {
|
||||||
|
color = v.startsWith("#") ? v.toLowerCase() : `#${v}`.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs[key] = { visible: vis, color };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await iosApi("calendar_prefs", { method: "POST", body: { prefs } });
|
||||||
|
if (msg) msg.textContent = "Préférences enregistrées.";
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
} catch (e) {
|
||||||
|
if (msg) msg.textContent = e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iosForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idStr = document.getElementById("ios-event-id").value.trim();
|
||||||
|
const payload = {
|
||||||
|
title: document.getElementById("ios-title").value.trim(),
|
||||||
|
location: document.getElementById("ios-location").value.trim(),
|
||||||
|
description: document.getElementById("ios-description").value.trim(),
|
||||||
|
start_at: localDatetimeToSql(document.getElementById("ios-start").value),
|
||||||
|
end_at: localDatetimeToSql(document.getElementById("ios-end").value),
|
||||||
|
};
|
||||||
|
if (idStr) {
|
||||||
|
payload.id = parseInt(idStr, 10);
|
||||||
|
await iosApi("events", { method: "PUT", body: payload });
|
||||||
|
} else {
|
||||||
|
await iosApi("events", { method: "POST", body: payload });
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
await loadEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-form-reset")?.addEventListener("click", resetForm);
|
||||||
|
|
||||||
|
document.getElementById("ios-sync-btn")?.addEventListener("click", async () => {
|
||||||
|
const btn = document.getElementById("ios-sync-btn");
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const data = await iosApi("sync", { method: "POST", body: {} });
|
||||||
|
if (iosSyncStatus) iosSyncStatus.textContent = data.message;
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".ios-seg").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
iosViewMode = /** @type {'day'|'week'|'month'} */ (btn.getAttribute("data-ios-view"));
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-view-list")?.addEventListener("click", () => {
|
||||||
|
showListSection(true);
|
||||||
|
if (iosNowTimer) clearInterval(iosNowTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-prev")?.addEventListener("click", () => {
|
||||||
|
if (iosViewMode === "day") iosCursor = addDays(iosCursor, -1);
|
||||||
|
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, -7);
|
||||||
|
else iosCursor = addMonths(iosCursor, -1);
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-next")?.addEventListener("click", () => {
|
||||||
|
if (iosViewMode === "day") iosCursor = addDays(iosCursor, 1);
|
||||||
|
else if (iosViewMode === "week") iosCursor = addDays(iosCursor, 7);
|
||||||
|
else iosCursor = addMonths(iosCursor, 1);
|
||||||
|
renderAgenda();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-today")?.addEventListener("click", () => {
|
||||||
|
iosCursor = new Date();
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-agenda-add")?.addEventListener("click", () => {
|
||||||
|
resetForm();
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15, 0, 0);
|
||||||
|
const end = new Date(now.getTime() + 3600000);
|
||||||
|
const f = (d) => {
|
||||||
|
const p = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||||
|
};
|
||||||
|
document.getElementById("ios-start").value = f(now);
|
||||||
|
document.getElementById("ios-end").value = f(end);
|
||||||
|
document.getElementById("ios-edit-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
document.getElementById("ios-title")?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("ios-calendar-prefs-save")?.addEventListener("click", () => saveCalendarPrefs());
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
try {
|
||||||
|
iosCursor = new Date();
|
||||||
|
await loadEvents();
|
||||||
|
await loadCalendarPrefs();
|
||||||
|
await loadSyncStatus();
|
||||||
|
showListSection(false);
|
||||||
|
startNowTimer();
|
||||||
|
} catch (e) {
|
||||||
|
if (iosSyncStatus) iosSyncStatus.textContent = e.message;
|
||||||
|
if (iosAgendaMount) iosAgendaMount.innerHTML = `<p class="ios-agenda-intro">${escHtml(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/crypto.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/icloud_caldav.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mot de passe normalisé + URL calendrier (découverte si racine iCloud).
|
||||||
|
*
|
||||||
|
* @return array{integration: array, password: string}
|
||||||
|
*/
|
||||||
|
function ios_caldav_prepare(array $integration, ?PDO $metaPdo = null): array
|
||||||
|
{
|
||||||
|
$password = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
|
||||||
|
$url = trim($integration['calendar_url'] ?? '');
|
||||||
|
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $password, $url);
|
||||||
|
$out = $integration;
|
||||||
|
$out['calendar_url'] = $resolvedUrl;
|
||||||
|
if ($metaPdo && !empty($integration['id']) && rtrim($url, '/') !== rtrim($resolvedUrl, '/')) {
|
||||||
|
$metaPdo->prepare('UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?')
|
||||||
|
->execute([$resolvedUrl, $integration['id']]);
|
||||||
|
}
|
||||||
|
return ['integration' => $out, 'password' => $password];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_make_ics(array $event): string
|
||||||
|
{
|
||||||
|
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
||||||
|
$start = gmdate('Ymd\THis\Z', strtotime($event['start_at']));
|
||||||
|
$end = gmdate('Ymd\THis\Z', strtotime($event['end_at']));
|
||||||
|
$summary = addcslashes($event['title'] ?? '', ",;");
|
||||||
|
$description = addcslashes($event['description'] ?? '', ",;");
|
||||||
|
$location = addcslashes($event['location'] ?? '', ",;");
|
||||||
|
$updated = gmdate('Ymd\THis\Z');
|
||||||
|
|
||||||
|
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//HouseHub//Calendar iOS//FR\r\nBEGIN:VEVENT\r\nUID:$uid\r\nDTSTAMP:$updated\r\nDTSTART:$start\r\nDTEND:$end\r\nSUMMARY:$summary\r\nDESCRIPTION:$description\r\nLOCATION:$location\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_caldav_request(string $url, string $username, string $password, string $method = 'GET', ?string $body = null, array $headers = []): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$defaultCt = ($method === 'REPORT' || $method === 'PROPFIND')
|
||||||
|
? 'application/xml; charset=utf-8'
|
||||||
|
: 'text/calendar; charset=utf-8';
|
||||||
|
$requestHeaders = array_merge([
|
||||||
|
'Content-Type: ' . $defaultCt,
|
||||||
|
], $headers);
|
||||||
|
$timeout = ($method === 'REPORT' || $method === 'PROPFIND') ? 60 : 20;
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
|
||||||
|
CURLOPT_USERPWD => $username . ':' . $password,
|
||||||
|
CURLOPT_HTTPHEADER => $requestHeaders,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 10,
|
||||||
|
]);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($raw === false) {
|
||||||
|
return ['code' => 0, 'headers' => '', 'body' => ''];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'code' => $code,
|
||||||
|
'headers' => substr($raw, 0, $headerSize),
|
||||||
|
'body' => substr($raw, $headerSize),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déplie les lignes ICS pliées (RFC 5545).
|
||||||
|
*/
|
||||||
|
function ios_ics_unfold(string $ics): string
|
||||||
|
{
|
||||||
|
$ics = str_replace("\r\n", "\n", $ics);
|
||||||
|
return preg_replace("/\n[ \t]/", '', $ics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{external_uid:string,title:string,description:string,location:string,start_at:string,end_at:string}>
|
||||||
|
*/
|
||||||
|
function ios_parse_vevents_from_ics(string $ics): array
|
||||||
|
{
|
||||||
|
$ics = ios_ics_unfold($ics);
|
||||||
|
$events = [];
|
||||||
|
if (!preg_match_all('/BEGIN:VEVENT\s*(.*?)\s*END:VEVENT/s', $ics, $matches)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $chunk) {
|
||||||
|
if (!preg_match('/^UID:([^\r\n]+)/m', $chunk, $uidM)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$uid = trim($uidM[1]);
|
||||||
|
$summary = '';
|
||||||
|
if (preg_match('/^SUMMARY:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$summary = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
|
||||||
|
}
|
||||||
|
$description = '';
|
||||||
|
if (preg_match('/^DESCRIPTION:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$description = str_replace('\\,', ',', str_replace('\\;', ';', str_replace('\\n', "\n", trim($m[1]))));
|
||||||
|
}
|
||||||
|
$location = '';
|
||||||
|
if (preg_match('/^LOCATION:([^\r\n]*)/m', $chunk, $m)) {
|
||||||
|
$location = str_replace('\\,', ',', str_replace('\\;', ';', trim($m[1])));
|
||||||
|
}
|
||||||
|
$startRaw = null;
|
||||||
|
if (preg_match('/^DTSTART[^:]*:([^\r\n]+)/m', $chunk, $m)) {
|
||||||
|
$startRaw = trim($m[1]);
|
||||||
|
}
|
||||||
|
if ($startRaw === null || $startRaw === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$endRaw = null;
|
||||||
|
if (preg_match('/^DTEND[^:]*:([^\r\n]+)/m', $chunk, $m)) {
|
||||||
|
$endRaw = trim($m[1]);
|
||||||
|
}
|
||||||
|
if (preg_match('/^\d{8}$/', $startRaw)) {
|
||||||
|
$startTs = strtotime($startRaw . 'T000000 UTC');
|
||||||
|
} else {
|
||||||
|
$norm = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $startRaw);
|
||||||
|
$startTs = strtotime($norm ?: $startRaw);
|
||||||
|
if ($startTs === false) {
|
||||||
|
$startTs = strtotime($startRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($startTs === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($endRaw !== null && $endRaw !== '') {
|
||||||
|
if (preg_match('/^\d{8}$/', $endRaw)) {
|
||||||
|
$endTs = strtotime($endRaw . 'T000000 UTC');
|
||||||
|
} else {
|
||||||
|
$normE = preg_replace('/^(\d{8})T(\d{6})Z?$/', '$1T$2 UTC', $endRaw);
|
||||||
|
$endTs = strtotime($normE ?: $endRaw);
|
||||||
|
if ($endTs === false) {
|
||||||
|
$endTs = strtotime($endRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$endTs = $startTs;
|
||||||
|
}
|
||||||
|
if ($endTs === false) {
|
||||||
|
$endTs = $startTs;
|
||||||
|
}
|
||||||
|
$events[] = [
|
||||||
|
'external_uid' => $uid,
|
||||||
|
'title' => $summary,
|
||||||
|
'description' => $description,
|
||||||
|
'location' => $location,
|
||||||
|
'start_at' => date('Y-m-d H:i:s', $startTs),
|
||||||
|
'end_at' => date('Y-m-d H:i:s', $endTs),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associe chaque fragment iCalendar du REPORT à l’URL de ressource CalDAV (nécessaire pour PUT/DELETE sur iCloud).
|
||||||
|
*
|
||||||
|
* @return list<array{resource_url:string,ical:string}>
|
||||||
|
*/
|
||||||
|
function ios_caldav_parse_report_calendar_fragments(string $xmlBody, string $calendarBaseUrl): array
|
||||||
|
{
|
||||||
|
$dom = new DOMDocument();
|
||||||
|
if (!@$dom->loadXML($xmlBody)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
$responses = $xpath->query("//*[local-name()='response']");
|
||||||
|
$out = [];
|
||||||
|
foreach ($responses as $resp) {
|
||||||
|
$hrefNodes = $xpath->query(".//*[local-name()='href']", $resp);
|
||||||
|
if ($hrefNodes->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$href = trim($hrefNodes->item(0)->textContent);
|
||||||
|
if ($href === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cd = $xpath->query(".//*[local-name()='calendar-data']", $resp);
|
||||||
|
if ($cd->length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ical = trim($cd->item(0)->textContent);
|
||||||
|
if ($ical === '' || stripos($ical, 'BEGIN:VEVENT') === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$resourceUrl = (str_starts_with($href, 'http://') || str_starts_with($href, 'https://'))
|
||||||
|
? $href
|
||||||
|
: hh_caldav_resolve_href(rtrim($calendarBaseUrl, '/') . '/', $href);
|
||||||
|
$out[] = ['resource_url' => $resourceUrl, 'ical' => $ical];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URLs des collections calendrier à interroger (tous les calendriers iCloud du compte, sinon l’URL configurée seule).
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function ios_sync_calendar_collection_urls(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$primary = rtrim($integration['calendar_url'] ?? '', '/') . '/';
|
||||||
|
if (hh_caldav_url_is_icloud($primary)) {
|
||||||
|
try {
|
||||||
|
$entries = hh_icloud_discover_calendar_entries($integration['username'], $password);
|
||||||
|
|
||||||
|
return array_values(array_unique(array_map(static function (array $e): string {
|
||||||
|
return rtrim($e['url'], '/') . '/';
|
||||||
|
}, $entries)));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// compte partiellement lisible : au moins le calendrier principal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [$primary];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string,mixed>> champs événement + _resource_url + _calendar_collection_url
|
||||||
|
*/
|
||||||
|
function ios_fetch_remote_events(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$calUrl = rtrim($integration['calendar_url'], '/') . '/';
|
||||||
|
$tStart = gmdate('Ymd\THis\Z', strtotime('-3 years'));
|
||||||
|
$tEnd = gmdate('Ymd\THis\Z', strtotime('+4 years'));
|
||||||
|
$reportXml = '<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
. '<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">'
|
||||||
|
. '<D:prop><C:calendar-data/></D:prop>'
|
||||||
|
. '<C:filter>'
|
||||||
|
. '<C:comp-filter name="VCALENDAR">'
|
||||||
|
. '<C:comp-filter name="VEVENT">'
|
||||||
|
. '<C:time-range start="' . htmlspecialchars($tStart, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '" end="' . htmlspecialchars($tEnd, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '"/>'
|
||||||
|
. '</C:comp-filter></C:comp-filter></C:filter>'
|
||||||
|
. '</C:calendar-query>';
|
||||||
|
|
||||||
|
$res = ios_caldav_request($calUrl, $integration['username'], $password, 'REPORT', $reportXml, [
|
||||||
|
'Accept: application/xml, text/xml',
|
||||||
|
'Depth: 1',
|
||||||
|
]);
|
||||||
|
if (!in_array($res['code'], [200, 207], true)) {
|
||||||
|
throw new RuntimeException('Lecture CalDAV (REPORT) impossible (HTTP ' . $res['code'] . ').');
|
||||||
|
}
|
||||||
|
$fragments = ios_caldav_parse_report_calendar_fragments($res['body'], $calUrl);
|
||||||
|
$byUid = [];
|
||||||
|
foreach ($fragments as $frag) {
|
||||||
|
foreach (ios_parse_vevents_from_ics($frag['ical']) as $ev) {
|
||||||
|
$ev['_resource_url'] = $frag['resource_url'];
|
||||||
|
$ev['_calendar_collection_url'] = $calUrl;
|
||||||
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($byUid === [] && strpos($res['body'], 'multistatus') === false) {
|
||||||
|
$get = ios_caldav_request($calUrl, $integration['username'], $password, 'GET', null, ['Accept: text/calendar']);
|
||||||
|
if ($get['code'] >= 200 && $get['code'] < 400 && str_contains($get['body'], 'BEGIN:VEVENT')) {
|
||||||
|
foreach (ios_parse_vevents_from_ics($get['body']) as $ev) {
|
||||||
|
$ev['_resource_url'] = null;
|
||||||
|
$ev['_calendar_collection_url'] = $calUrl;
|
||||||
|
$byUid[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($byUid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fusionne tous les calendriers du compte (iCloud) ou une seule collection.
|
||||||
|
*
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
function ios_fetch_remote_events_all_calendars(array $integration, string $password): array
|
||||||
|
{
|
||||||
|
$urls = ios_sync_calendar_collection_urls($integration, $password);
|
||||||
|
$merged = [];
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
$sub = $integration;
|
||||||
|
$sub['calendar_url'] = $url;
|
||||||
|
foreach (ios_fetch_remote_events($sub, $password) as $ev) {
|
||||||
|
$merged[$ev['external_uid']] = $ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_push_event_to_remote(array $integration, array $event, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
|
||||||
|
{
|
||||||
|
$uid = $event['external_uid'] ?: ('hh-' . $event['id'] . '@househub');
|
||||||
|
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
|
||||||
|
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($uid) . '.ics');
|
||||||
|
$ics = ios_make_ics($event);
|
||||||
|
return ios_caldav_request($url, $integration['username'], $password, 'PUT', $ics);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ios_delete_remote_event(array $integration, string $externalUid, string $password, ?string $resourceUrl = null, ?string $collectionUrlOverride = null): array
|
||||||
|
{
|
||||||
|
$collection = rtrim($collectionUrlOverride ?? $integration['calendar_url'], '/');
|
||||||
|
$url = $resourceUrl ?: ($collection . '/' . rawurlencode($externalUid) . '.ics');
|
||||||
|
return ios_caldav_request($url, $integration['username'], $password, 'DELETE', null, ['Accept: */*']);
|
||||||
|
}
|
||||||
@@ -119,7 +119,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
options += `<option value="${y}" ${selected}>${y} - ${y + 1}</option>`;
|
options += `<option value="${y}" ${selected}>${y} - ${y + 1}</option>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
headerTitle.innerHTML = `🏖️ ${tr("fc_modal_holidays_title")}
|
// --- NOUVEAU : Récupération de la zone dynamique ---
|
||||||
|
const zoneText =
|
||||||
|
window.CONFIG &&
|
||||||
|
window.CONFIG.ZONE_SCOLAIRE &&
|
||||||
|
window.CONFIG.ZONE_SCOLAIRE !== "Autre"
|
||||||
|
? `(Zone ${window.CONFIG.ZONE_SCOLAIRE})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Injection du texte de la zone dans le header
|
||||||
|
headerTitle.innerHTML = `🏖️ ${window.I18N["fc_modal_holidays_title"]} ${zoneText}
|
||||||
<select id="holidayYearSelect" class="pf-input" style="width:auto; display:inline-block; margin-left:10px; padding:4px 10px; height:auto;">
|
<select id="holidayYearSelect" class="pf-input" style="width:auto; display:inline-block; margin-left:10px; padding:4px 10px; height:auto;">
|
||||||
${options}
|
${options}
|
||||||
</select>`;
|
</select>`;
|
||||||
@@ -332,8 +341,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
async fetchAndSaveGovHolidays(yearStart) {
|
async fetchAndSaveGovHolidays(yearStart) {
|
||||||
try {
|
try {
|
||||||
const yearStr = `${yearStart}-${yearStart + 1}`;
|
const yearStr = `${yearStart}-${yearStart + 1}`;
|
||||||
const url = `https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='${yearStr}' AND zones LIKE '%Zone C%'&limit=100`;
|
const zone = window.CONFIG?.ZONE_SCOLAIRE || "C";
|
||||||
|
|
||||||
|
if (zone === "Autre") {
|
||||||
|
alert(
|
||||||
|
"L'importation automatique n'est disponible que pour les zones A, B ou C (France).",
|
||||||
|
);
|
||||||
|
const btn = document.getElementById("btnFetchGovHolidays");
|
||||||
|
if (btn) {
|
||||||
|
btn.innerText = "Non disponible";
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records?where=annee_scolaire='${yearStr}' AND zones LIKE '%Zone ${zone}%'&limit=100`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const rawRecords = data.results || [];
|
const rawRecords = data.results || [];
|
||||||
|
|||||||
+165
-1
@@ -17,6 +17,24 @@ require_once dirname(__DIR__, 2) . '/includes/db.php';
|
|||||||
$UPLOAD_DIR = '/uploads/garage/';
|
$UPLOAD_DIR = '/uploads/garage/';
|
||||||
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
|
if (!is_dir($UPLOAD_DIR)) { @mkdir($UPLOAD_DIR, 0755, true); }
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_garage_documents (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
maintenance_id INT DEFAULT NULL,
|
||||||
|
label VARCHAR(255) DEFAULT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
mime VARCHAR(120) DEFAULT NULL,
|
||||||
|
size INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_gd_vehicle (vehicle_id),
|
||||||
|
KEY idx_gd_maint (maintenance_id),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
@@ -38,6 +56,37 @@ function handleUpload(string $field, string $dir): ?string {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGarageDocumentUpload(string $field, string $dir): ?array {
|
||||||
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($_FILES[$field]['size'] > 12 * 1024 * 1024) {
|
||||||
|
gErr('Fichier trop volumineux (max 12 Mo).', 400);
|
||||||
|
}
|
||||||
|
$ext = strtolower(pathinfo($_FILES[$field]['name'], PATHINFO_EXTENSION));
|
||||||
|
$allowed = ['pdf','jpg','jpeg','png','gif','webp','doc','docx','xls','xlsx','odt','txt'];
|
||||||
|
if (!in_array($ext, $allowed, true)) {
|
||||||
|
gErr('Format non autorisé.', 400);
|
||||||
|
}
|
||||||
|
$fname = 'd_' . uniqid('', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mimeMap = [
|
||||||
|
'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp',
|
||||||
|
'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'xls' => 'application/vnd.ms-excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'odt' => 'application/vnd.oasis.opendocument.text', 'txt' => 'text/plain',
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
'filename' => $fname,
|
||||||
|
'original_name' => $_FILES[$field]['name'],
|
||||||
|
'mime' => $mimeMap[$ext] ?? 'application/octet-stream',
|
||||||
|
'size' => (int) $_FILES[$field]['size'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
||||||
if ($action === 'vehicles') {
|
if ($action === 'vehicles') {
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
@@ -47,6 +96,12 @@ if ($action === 'vehicles') {
|
|||||||
$vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
|
$vehicle = $v->fetch(); if (!$vehicle) gErr('Véhicule introuvable', 404);
|
||||||
$s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
|
$s = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as total FROM pf_maintenances WHERE vehicle_id = ?"); $s->execute([$id]); $vehicle['stats'] = $s->fetch();
|
||||||
$p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
|
$p = $pdo->prepare("SELECT COUNT(*) as cnt, COALESCE(SUM(price*quantity),0) as total FROM pf_parts WHERE vehicle_id = ?"); $p->execute([$id]); $vehicle['parts_stats'] = $p->fetch();
|
||||||
|
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL");
|
||||||
|
$dc->execute([$id]);
|
||||||
|
$vehicle['vehicle_docs_count'] = (int) $dc->fetchColumn();
|
||||||
|
$dm = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NOT NULL");
|
||||||
|
$dm->execute([$id]);
|
||||||
|
$vehicle['maint_docs_count'] = (int) $dm->fetchColumn();
|
||||||
gOk($vehicle);
|
gOk($vehicle);
|
||||||
}
|
}
|
||||||
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
|
$stmt = $pdo->query("SELECT v.*, (SELECT COUNT(*) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as maintenance_count, (SELECT COALESCE(SUM(cost),0) FROM pf_maintenances m WHERE m.vehicle_id = v.id) as total_cost, (SELECT date FROM pf_maintenances m WHERE m.vehicle_id = v.id ORDER BY date DESC LIMIT 1) as last_maintenance FROM pf_vehicles v ORDER BY v.created_at DESC");
|
||||||
@@ -76,11 +131,110 @@ if ($action === 'vehicles') {
|
|||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE vehicle_id = ?');
|
||||||
|
$docs->execute([$id]);
|
||||||
|
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
|
||||||
|
if ($fn) {
|
||||||
|
@unlink($UPLOAD_DIR . basename($fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
$v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
|
$v = $pdo->prepare("SELECT photo FROM pf_vehicles WHERE id=?"); $v->execute([$id]); $row = $v->fetch(); if ($row['photo']) @unlink($UPLOAD_DIR . $row['photo']);
|
||||||
$pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
$pdo->prepare("DELETE FROM pf_vehicles WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Documents (véhicule / entretien — factures, PDF, etc.) ───────────────────
|
||||||
|
if ($action === 'garage_documents') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$vid = $_GET['vehicle_id'] ?? null;
|
||||||
|
$mid = $_GET['maintenance_id'] ?? null;
|
||||||
|
$vehicleOnly = ($_GET['vehicle_only'] ?? '') === '1';
|
||||||
|
if ($mid) {
|
||||||
|
$stmt = $pdo->prepare("SELECT d.*, v.name as vehicle_name FROM pf_garage_documents d JOIN pf_vehicles v ON v.id = d.vehicle_id WHERE d.maintenance_id = ? ORDER BY d.created_at DESC");
|
||||||
|
$stmt->execute([$mid]);
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if (!$vid) {
|
||||||
|
gErr('vehicle_id ou maintenance_id requis');
|
||||||
|
}
|
||||||
|
if ($vehicleOnly) {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? AND maintenance_id IS NULL ORDER BY created_at DESC");
|
||||||
|
$stmt->execute([$vid]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM pf_garage_documents WHERE vehicle_id = ? ORDER BY created_at DESC");
|
||||||
|
$stmt->execute([$vid]);
|
||||||
|
}
|
||||||
|
gOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = $_POST;
|
||||||
|
$vid = (int) ($d['vehicle_id'] ?? 0);
|
||||||
|
if ($vid < 1) {
|
||||||
|
gErr('vehicle_id manquant');
|
||||||
|
}
|
||||||
|
$mid = isset($d['maintenance_id']) && $d['maintenance_id'] !== '' ? (int) $d['maintenance_id'] : null;
|
||||||
|
if ($mid) {
|
||||||
|
$chk = $pdo->prepare("SELECT id FROM pf_maintenances WHERE id = ? AND vehicle_id = ?");
|
||||||
|
$chk->execute([$mid, $vid]);
|
||||||
|
if (!$chk->fetch()) {
|
||||||
|
gErr('Entretien invalide pour ce véhicule');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$up = handleGarageDocumentUpload('file', $UPLOAD_DIR);
|
||||||
|
if (!$up) {
|
||||||
|
gErr('Upload échoué ou fichier manquant');
|
||||||
|
}
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
$pdo->prepare("INSERT INTO pf_garage_documents (vehicle_id, maintenance_id, label, filename, original_name, mime, size) VALUES (?,?,?,?,?,?,?)")
|
||||||
|
->execute([$vid, $mid, $label ?: null, $up['filename'], $up['original_name'], $up['mime'], $up['size']]);
|
||||||
|
gOk(['id' => (int) $pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$r = $pdo->prepare("SELECT filename FROM pf_garage_documents WHERE id = ?");
|
||||||
|
$r->execute([$id]);
|
||||||
|
$row = $r->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
gErr('Document introuvable', 404);
|
||||||
|
}
|
||||||
|
if (!empty($row['filename'])) {
|
||||||
|
@unlink($UPLOAD_DIR . $row['filename']);
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_garage_documents WHERE id = ?")->execute([$id]);
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'garage_document') {
|
||||||
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM pf_garage_documents WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$doc = $stmt->fetch();
|
||||||
|
if (!$doc || empty($doc['filename'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$f = basename($doc['filename']);
|
||||||
|
$path = $UPLOAD_DIR . $f;
|
||||||
|
if (!preg_match('/^d_[a-zA-Z0-9._-]+$/', $f) || !is_file($path)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$mime = $doc['mime'] ?: 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
$disp = $doc['original_name'] ?: $f;
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($disp) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Maintenances ─────────────────────────────────────────────────────────────
|
// ─── Maintenances ─────────────────────────────────────────────────────────────
|
||||||
if ($action === 'maintenances') {
|
if ($action === 'maintenances') {
|
||||||
$vid = $_GET['vehicle_id'] ?? null;
|
$vid = $_GET['vehicle_id'] ?? null;
|
||||||
@@ -89,10 +243,13 @@ if ($action === 'maintenances') {
|
|||||||
if ($id) {
|
if ($id) {
|
||||||
$stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?");
|
$stmt = $pdo->prepare("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.id = ?");
|
||||||
$stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404);
|
$stmt->execute([$id]); $m = $stmt->fetch(); if (!$m) gErr('Entretien introuvable', 404);
|
||||||
|
$dc = $pdo->prepare("SELECT COUNT(*) FROM pf_garage_documents WHERE maintenance_id = ?");
|
||||||
|
$dc->execute([$id]);
|
||||||
|
$m['documents_count'] = (int) $dc->fetchColumn();
|
||||||
gOk($m);
|
gOk($m);
|
||||||
}
|
}
|
||||||
if ($vid) {
|
if ($vid) {
|
||||||
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
|
$stmt = $pdo->prepare("SELECT m.*, GROUP_CONCAT(p.name SEPARATOR ', ') as parts_names, COUNT(p.id) as parts_count, COALESCE(SUM(p.price*p.quantity),0) as parts_cost, (SELECT COUNT(*) FROM pf_garage_documents d WHERE d.maintenance_id = m.id) as documents_count FROM pf_maintenances m LEFT JOIN pf_parts p ON p.maintenance_id = m.id WHERE m.vehicle_id = ? GROUP BY m.id ORDER BY m.date DESC, m.created_at DESC");
|
||||||
$stmt->execute([$vid]); gOk($stmt->fetchAll());
|
$stmt->execute([$vid]); gOk($stmt->fetchAll());
|
||||||
}
|
}
|
||||||
$stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
|
$stmt = $pdo->query("SELECT m.*, v.name as vehicle_name, v.license_plate, v.current_km FROM pf_maintenances m JOIN pf_vehicles v ON v.id = m.vehicle_id WHERE m.next_date IS NOT NULL OR m.next_km IS NOT NULL ORDER BY m.next_date ASC");
|
||||||
@@ -126,6 +283,13 @@ if ($action === 'maintenances') {
|
|||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
$id = $_GET['id'] ?? null; if (!$id) gErr('ID manquant');
|
||||||
|
$docs = $pdo->prepare('SELECT filename FROM pf_garage_documents WHERE maintenance_id = ?');
|
||||||
|
$docs->execute([$id]);
|
||||||
|
foreach ($docs->fetchAll(PDO::FETCH_COLUMN) as $fn) {
|
||||||
|
if ($fn) {
|
||||||
|
@unlink($UPLOAD_DIR . basename($fn));
|
||||||
|
}
|
||||||
|
}
|
||||||
$pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
$pdo->prepare("DELETE FROM pf_maintenances WHERE id = ?")->execute([$id]); gOk(['deleted' => true]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap;
|
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light); white-space: nowrap;
|
||||||
}
|
}
|
||||||
.table-wrap table td {
|
.table-wrap table td {
|
||||||
padding: .65rem .9rem; border-bottom: 1px solid #f1f5f9;
|
padding: .65rem .9rem; border-bottom: 1px solid var(--border-light);
|
||||||
vertical-align: middle; color: var(--text-main);
|
vertical-align: middle; color: var(--text-main);
|
||||||
}
|
}
|
||||||
.table-wrap table tr:last-child td { border-bottom: none; }
|
.table-wrap table tr:last-child td { border-bottom: none; }
|
||||||
@@ -268,7 +268,8 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
|||||||
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
|
/* ─── TOASTS ─────────────────────────────────────────────────────────────────── */
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
|
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
|
||||||
display: flex; flex-direction: column; gap: .5rem; pointer-events: none;
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
gap: .5rem; pointer-events: none;
|
||||||
}
|
}
|
||||||
.toast {
|
.toast {
|
||||||
background: var(--bg-panel); border: 1px solid var(--border-light);
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
@@ -292,7 +293,7 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
|||||||
.scroll-list-item {
|
.scroll-list-item {
|
||||||
display: flex; align-items: center; gap: .75rem;
|
display: flex; align-items: center; gap: .75rem;
|
||||||
padding: .45rem .75rem; cursor: pointer;
|
padding: .45rem .75rem; cursor: pointer;
|
||||||
border-bottom: 1px solid #f1f5f9; transition: background .12s;
|
border-bottom: 1px solid var(--border-light); transition: background .12s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.scroll-list-item:last-child { border-bottom: none; }
|
.scroll-list-item:last-child { border-bottom: none; }
|
||||||
@@ -303,18 +304,169 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
|||||||
.item-brand { font-size: .75rem; color: var(--text-muted); }
|
.item-brand { font-size: .75rem; color: var(--text-muted); }
|
||||||
.item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; }
|
.item-price { font-size: .875rem; font-weight: 600; color: var(--primary); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ─── DASHBOARD LAYOUT ─────────────────────────────────────────────────────── */
|
||||||
|
.garage-dash-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr minmax(260px, 380px);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.garage-dash-main { min-width: 0; }
|
||||||
|
.garage-dash-aside { padding: 1rem 1.25rem; }
|
||||||
|
.garage-card-header { flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
||||||
|
|
||||||
|
.garage-tab-actions {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TABS (scroll mobile) ─────────────────────────────────────────────────── */
|
||||||
|
.garage-tabs {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
.garage-tabs .tab-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── VEHICLE HEADER ACTIONS ───────────────────────────────────────────────── */
|
||||||
|
.vehicle-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.garage-docs-recap {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.garage-docs-recap strong { color: var(--text-main); }
|
||||||
|
.btn-link-sm {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.btn-link-sm:hover { color: var(--primary-dark); }
|
||||||
|
|
||||||
|
.garage-maint-doc-badge { font-size: 0.65rem; vertical-align: middle; margin-left: 0.15rem; }
|
||||||
|
|
||||||
|
/* ─── DOCUMENTS ───────────────────────────────────────────────────────────── */
|
||||||
|
.garage-docs-card .card-header { padding: 1rem 1.25rem; }
|
||||||
|
.garage-doc-upload-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.garage-doc-upload-btn { align-self: flex-end; flex-shrink: 0; }
|
||||||
|
.garage-doc-hint {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0.35rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
.garage-doc-empty { padding: 1.25rem !important; }
|
||||||
|
.garage-doc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.garage-doc-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.garage-doc-item:last-child { border-bottom: none; }
|
||||||
|
.garage-doc-link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.garage-doc-link:hover { text-decoration: underline; }
|
||||||
|
.garage-doc-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.garage-doc-item .btn-icon { flex-shrink: 0; margin-left: auto; }
|
||||||
|
|
||||||
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
/* ─── RESPONSIVE ─────────────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container { padding: 1rem; }
|
.container { padding: 1rem; }
|
||||||
.vehicle-detail-header { flex-direction: column; }
|
.vehicle-detail-header { flex-direction: column; }
|
||||||
.vehicle-detail-img { width: 100%; height: 200px; }
|
.vehicle-detail-img { width: 100%; height: 200px; }
|
||||||
|
.vehicle-detail-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.vehicle-detail-actions .btn { width: 100%; justify-content: center; }
|
||||||
.form-row, .form-row-3 { grid-template-columns: 1fr; }
|
.form-row, .form-row-3 { grid-template-columns: 1fr; }
|
||||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.modal { max-height: 95vh; }
|
.modal { max-height: 95vh; }
|
||||||
.vehicles-grid { grid-template-columns: 1fr; }
|
.vehicles-grid { grid-template-columns: 1fr; }
|
||||||
.meta-grid { gap: .75rem; }
|
.meta-grid { gap: 0.75rem; }
|
||||||
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
|
.garage-subnav { padding: 8px 12px; overflow-x: auto; flex-wrap: nowrap; }
|
||||||
.garage-subnav .nav-link { white-space: nowrap; }
|
.garage-subnav .nav-link { white-space: nowrap; }
|
||||||
|
.garage-dash-layout { grid-template-columns: 1fr; }
|
||||||
|
.garage-dash-aside { order: -1; }
|
||||||
|
.garage-doc-upload-bar { flex-direction: column; align-items: stretch; }
|
||||||
|
.garage-doc-upload-btn { width: 100%; align-self: stretch; }
|
||||||
|
.garage-doc-item { flex-direction: column; align-items: stretch; }
|
||||||
|
.garage-doc-item .btn-icon { margin-left: 0; align-self: flex-end; }
|
||||||
|
.garage-doc-meta { white-space: normal; }
|
||||||
|
.toast-container {
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: max(1rem, env(safe-area-inset-bottom, 0px));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.toast { max-width: none; min-width: 0; width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.table-wrap {
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
margin-right: -0.25rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.table-wrap table { min-width: 640px; }
|
||||||
|
#inline-part-form .form-row,
|
||||||
|
#inline-part-form .form-row-3 { grid-template-columns: 1fr !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ══ DARK MODE — Garage module ══════════════════════════════════ */
|
/* ══ DARK MODE — Garage module ══════════════════════════════════ */
|
||||||
@@ -364,3 +516,10 @@ textarea.form-control { resize: vertical; min-height: 80px; }
|
|||||||
[data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); }
|
[data-theme="dark"] .scroll-list-item:hover { background: rgba(255,255,255,.04); }
|
||||||
[data-theme="dark"] .item-name { color: var(--text-main); }
|
[data-theme="dark"] .item-name { color: var(--text-main); }
|
||||||
[data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; }
|
[data-theme="dark"] #inline-part-form { background: #1c2128 !important; border-color: var(--border-light) !important; }
|
||||||
|
[data-theme="dark"] .garage-docs-recap { background: rgba(255,255,255,.04); border-color: var(--border-light); color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .garage-docs-recap strong { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .btn-link-sm { color: var(--primary); }
|
||||||
|
[data-theme="dark"] .garage-doc-list { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .garage-doc-item { background: #1c2128; border-color: rgba(48,54,61,.5); }
|
||||||
|
[data-theme="dark"] .garage-doc-link { color: #93c5fd; }
|
||||||
|
[data-theme="dark"] .garage-doc-meta { color: var(--text-muted); }
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ function $$(s,ctx=document){return [...ctx.querySelectorAll(s)];}
|
|||||||
function fmt(n){return n!=null?Number(n).toLocaleString('fr-FR'):'--';}
|
function fmt(n){return n!=null?Number(n).toLocaleString('fr-FR'):'--';}
|
||||||
function fmtPrice(n){return n!=null?Number(n).toFixed(2)+' €':'--';}
|
function fmtPrice(n){return n!=null?Number(n).toFixed(2)+' €':'--';}
|
||||||
function fmtDate(d){if(!d)return '--';return new Date(d).toLocaleDateString('fr-FR');}
|
function fmtDate(d){if(!d)return '--';return new Date(d).toLocaleDateString('fr-FR');}
|
||||||
|
function formatBytes(n){
|
||||||
|
n=Number(n)||0;
|
||||||
|
if(n<1024)return n+' o';
|
||||||
|
if(n<1048576)return (n/1024).toFixed(1)+' Ko';
|
||||||
|
return (n/1048576).toFixed(1)+' Mo';
|
||||||
|
}
|
||||||
function ago(d){if(!d)return '';const diff=Math.floor((Date.now()-new Date(d))/86400000);if(diff===0)return "aujourd'hui";if(diff===1)return 'hier';return 'il y a '+diff+'j';}
|
function ago(d){if(!d)return '';const diff=Math.floor((Date.now()-new Date(d))/86400000);if(diff===0)return "aujourd'hui";if(diff===1)return 'hier';return 'il y a '+diff+'j';}
|
||||||
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
|
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
|
||||||
function toast(msg,type='success'){
|
function toast(msg,type='success'){
|
||||||
@@ -25,7 +31,7 @@ function navigate(page,params={}){
|
|||||||
$$('.page').forEach(p=>p.classList.remove('active'));
|
$$('.page').forEach(p=>p.classList.remove('active'));
|
||||||
$$('.nav-link').forEach(n=>n.classList.remove('active'));
|
$$('.nav-link').forEach(n=>n.classList.remove('active'));
|
||||||
document.getElementById('page-'+page)?.classList.add('active');
|
document.getElementById('page-'+page)?.classList.add('active');
|
||||||
const navTarget=page==='maintenance'?'maintenances-all':page;
|
const navTarget=page==='maintenance'?'maintenances-all':page==='vehicle'?'vehicles':page;
|
||||||
document.querySelector('.nav-link[data-page="'+navTarget+'"]')?.classList.add('active');
|
document.querySelector('.nav-link[data-page="'+navTarget+'"]')?.classList.add('active');
|
||||||
if(page==='dashboard')loadDashboard();
|
if(page==='dashboard')loadDashboard();
|
||||||
else if(page==='vehicles')loadVehicles();
|
else if(page==='vehicles')loadVehicles();
|
||||||
@@ -127,6 +133,8 @@ async function loadVehicleDetail(id){
|
|||||||
renderVehicleHeader(v);
|
renderVehicleHeader(v);
|
||||||
renderMaintenances(maintenances);
|
renderMaintenances(maintenances);
|
||||||
renderVehicleParts(parts);
|
renderVehicleParts(parts);
|
||||||
|
setVehicleDocTabBadge(v);
|
||||||
|
await loadVehicleDocuments();
|
||||||
document.title='GarageManager · '+v.name;
|
document.title='GarageManager · '+v.name;
|
||||||
}catch(e){toast(e.message,'error');}
|
}catch(e){toast(e.message,'error');}
|
||||||
}
|
}
|
||||||
@@ -152,8 +160,13 @@ function renderVehicleHeader(v){
|
|||||||
(v.purchase_date?'<div class="meta-item"><span class="k">Achat</span><br><span class="v">'+fmtDate(v.purchase_date)+'</span></div>':'')+
|
(v.purchase_date?'<div class="meta-item"><span class="k">Achat</span><br><span class="v">'+fmtDate(v.purchase_date)+'</span></div>':'')+
|
||||||
(v.vin?'<div class="meta-item"><span class="k">VIN</span><br><span class="v" style="font-size:.75rem;font-family:monospace">'+escHtml(v.vin)+'</span></div>':'')+
|
(v.vin?'<div class="meta-item"><span class="k">VIN</span><br><span class="v" style="font-size:.75rem;font-family:monospace">'+escHtml(v.vin)+'</span></div>':'')+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
|
'<div class="garage-docs-recap">'+
|
||||||
|
'📎 <strong>'+(Number(v.vehicle_docs_count)||0)+'</strong> '+(Number(v.vehicle_docs_count)===1?'document':'documents')+' véhicule · '+
|
||||||
|
'<strong>'+(Number(v.maint_docs_count)||0)+'</strong> sur entretiens (factures, etc.) · '+
|
||||||
|
'<button type="button" class="btn-link-sm" onclick="switchTab(\'documents\')">Voir les documents véhicule</button>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-self:flex-start">'+
|
'</div>'+
|
||||||
|
'<div class="vehicle-detail-actions">'+
|
||||||
'<button class="btn btn-secondary btn-sm" onclick="openEditVehicle('+v.id+')">✏️ Modifier</button>'+
|
'<button class="btn btn-secondary btn-sm" onclick="openEditVehicle('+v.id+')">✏️ Modifier</button>'+
|
||||||
'<button class="btn btn-secondary btn-sm" onclick="openUploadPhoto('+v.id+')">📷 Photo</button>'+
|
'<button class="btn btn-secondary btn-sm" onclick="openUploadPhoto('+v.id+')">📷 Photo</button>'+
|
||||||
'<button class="btn btn-danger btn-sm" onclick="deleteVehicle('+v.id+')">🗑️ Supprimer</button>'+
|
'<button class="btn btn-danger btn-sm" onclick="deleteVehicle('+v.id+')">🗑️ Supprimer</button>'+
|
||||||
@@ -168,7 +181,9 @@ function renderMaintenances(list){
|
|||||||
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien enregistré</p></div>';return;}
|
if(!list.length){el.innerHTML='<div class="empty-state"><div class="icon">🔧</div><p>Aucun entretien enregistré</p></div>';return;}
|
||||||
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Kilométrage</th><th>Coût MO</th><th>Pièces</th><th>Prochain</th><th></th></tr></thead><tbody>'+
|
el.innerHTML='<div class="table-wrap"><table><thead><tr><th>Date</th><th>Type</th><th>Description</th><th>Kilométrage</th><th>Coût MO</th><th>Pièces</th><th>Prochain</th><th></th></tr></thead><tbody>'+
|
||||||
list.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
|
list.map(m=>'<tr style="cursor:pointer" onclick="navigate(\'maintenance\',{id:'+m.id+'})">'+
|
||||||
'<td><strong>'+fmtDate(m.date)+'</strong><br><span style="color:var(--muted);font-size:.75rem">'+ago(m.date)+'</span></td>'+
|
'<td><strong>'+fmtDate(m.date)+'</strong><br><span style="color:var(--muted);font-size:.75rem">'+ago(m.date)+'</span>'+
|
||||||
|
(m.documents_count>0?' <span class="badge badge-gray garage-maint-doc-badge" title="Documents / factures">📎 '+m.documents_count+'</span>':'')+
|
||||||
|
'</td>'+
|
||||||
'<td><span class="badge badge-blue">'+escHtml(m.type)+'</span></td>'+
|
'<td><span class="badge badge-blue">'+escHtml(m.type)+'</span></td>'+
|
||||||
'<td style="max-width:200px;color:var(--muted)">'+(m.description?escHtml(m.description):'--')+'</td>'+
|
'<td style="max-width:200px;color:var(--muted)">'+(m.description?escHtml(m.description):'--')+'</td>'+
|
||||||
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
|
'<td>'+(m.km?fmt(m.km)+' km':'--')+'</td>'+
|
||||||
@@ -218,6 +233,7 @@ async function loadMaintenanceDetail(id){
|
|||||||
currentVehicleId=m.vehicle_id;
|
currentVehicleId=m.vehicle_id;
|
||||||
renderMaintenanceDetailHeader(m);
|
renderMaintenanceDetailHeader(m);
|
||||||
renderMaintenanceParts(parts,id,m.vehicle_id);
|
renderMaintenanceParts(parts,id,m.vehicle_id);
|
||||||
|
await loadMaintenanceDocuments();
|
||||||
loadKnownParts(m.vehicle_id);
|
loadKnownParts(m.vehicle_id);
|
||||||
const f=document.getElementById('inline-part-form');
|
const f=document.getElementById('inline-part-form');
|
||||||
if(f)f.style.display='none';
|
if(f)f.style.display='none';
|
||||||
@@ -231,6 +247,7 @@ function renderMaintenanceDetailHeader(m){
|
|||||||
'<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:.5rem">'+
|
'<div style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;margin-bottom:.5rem">'+
|
||||||
'<span class="badge badge-blue" style="font-size:.95rem;padding:.35rem .8rem">'+escHtml(m.type)+'</span>'+
|
'<span class="badge badge-blue" style="font-size:.95rem;padding:.35rem .8rem">'+escHtml(m.type)+'</span>'+
|
||||||
'<span style="color:var(--muted)">'+fmtDate(m.date)+' · '+ago(m.date)+'</span>'+
|
'<span style="color:var(--muted)">'+fmtDate(m.date)+' · '+ago(m.date)+'</span>'+
|
||||||
|
(m.documents_count>0?'<span class="badge badge-gray" title="Documents attachés à cette révision">📎 '+m.documents_count+' fichier'+(m.documents_count>1?'s':'')+'</span>':'')+
|
||||||
(m.vehicle_name?'<span style="color:var(--muted);font-size:.85rem">🚗 <a href="#" onclick="navigate(\'vehicle\',{id:'+m.vehicle_id+'});return false" style="color:inherit;text-decoration:underline">'+escHtml(m.vehicle_name)+'</a></span>':'')+
|
(m.vehicle_name?'<span style="color:var(--muted);font-size:.85rem">🚗 <a href="#" onclick="navigate(\'vehicle\',{id:'+m.vehicle_id+'});return false" style="color:inherit;text-decoration:underline">'+escHtml(m.vehicle_name)+'</a></span>':'')+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
(m.description?'<p style="margin-bottom:.75rem">'+escHtml(m.description)+'</p>':'')+
|
(m.description?'<p style="margin-bottom:.75rem">'+escHtml(m.description)+'</p>':'')+
|
||||||
@@ -244,7 +261,7 @@ function renderMaintenanceDetailHeader(m){
|
|||||||
'</div>'+
|
'</div>'+
|
||||||
(m.notes?'<p style="margin-top:.75rem;color:var(--muted);font-size:.85rem;font-style:italic">'+escHtml(m.notes)+'</p>':'')+
|
(m.notes?'<p style="margin-top:.75rem;color:var(--muted);font-size:.85rem;font-style:italic">'+escHtml(m.notes)+'</p>':'')+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-self:flex-start">'+
|
'<div class="vehicle-detail-actions">'+
|
||||||
'<button class="btn btn-secondary btn-sm" onclick="openEditMaintenance('+m.id+')">✏️ Modifier</button>'+
|
'<button class="btn btn-secondary btn-sm" onclick="openEditMaintenance('+m.id+')">✏️ Modifier</button>'+
|
||||||
'<button class="btn btn-danger btn-sm" onclick="deleteMaintenance('+m.id+')">🗑️ Supprimer</button>'+
|
'<button class="btn btn-danger btn-sm" onclick="deleteMaintenance('+m.id+')">🗑️ Supprimer</button>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
@@ -635,12 +652,120 @@ async function doUploadPhoto(){
|
|||||||
}catch(e){toast(e.message,'error');}
|
}catch(e){toast(e.message,'error');}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setVehicleDocTabBadge(v){
|
||||||
|
const el=document.getElementById('vehicle-docs-tab-count');
|
||||||
|
if(!el)return;
|
||||||
|
const n=Number(v.vehicle_docs_count)||0;
|
||||||
|
el.textContent=n>0?'('+n+')':'';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVehicleDocuments(){
|
||||||
|
if(!currentVehicleId)return;
|
||||||
|
try{
|
||||||
|
const list=await api('garage_documents','GET',null,'&vehicle_id='+currentVehicleId+'&vehicle_only=1');
|
||||||
|
renderGarageDocList('vehicle-documents-list',list);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMaintenanceDocuments(){
|
||||||
|
if(!currentMaintenanceId)return;
|
||||||
|
try{
|
||||||
|
const list=await api('garage_documents','GET',null,'&maintenance_id='+currentMaintenanceId);
|
||||||
|
renderGarageDocList('maintenance-documents-list',list);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGarageDocList(elId,list){
|
||||||
|
const el=document.getElementById(elId);
|
||||||
|
if(!el)return;
|
||||||
|
if(!list||!list.length){
|
||||||
|
el.innerHTML='<div class="empty-state garage-doc-empty"><p>Aucun document pour l’instant</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML='<ul class="garage-doc-list">'+
|
||||||
|
list.map(d=>{
|
||||||
|
const href=API+'?action=garage_document&id='+encodeURIComponent(d.id);
|
||||||
|
const title=escHtml(d.label||d.original_name||d.filename||'Document');
|
||||||
|
const meta=fmtDate(d.created_at)+' · '+formatBytes(d.size||0);
|
||||||
|
return '<li class="garage-doc-item">'+
|
||||||
|
'<a class="garage-doc-link" href="'+href+'" target="_blank" rel="noopener noreferrer">📄 '+title+'</a>'+
|
||||||
|
'<span class="garage-doc-meta">'+meta+'</span>'+
|
||||||
|
'<button type="button" class="btn btn-danger btn-sm btn-icon" onclick="deleteGarageDocument('+parseInt(d.id,10)+')" title="Supprimer">🗑️</button>'+
|
||||||
|
'</li>';
|
||||||
|
}).join('')+
|
||||||
|
'</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadVehicleDocument(){
|
||||||
|
const inp=document.getElementById('vehicle-doc-file');
|
||||||
|
if(!inp||!inp.files[0]){toast('Choisissez un fichier','error');return;}
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('vehicle_id',String(currentVehicleId));
|
||||||
|
fd.append('file',inp.files[0]);
|
||||||
|
const lab=document.getElementById('vehicle-doc-label')?.value.trim();
|
||||||
|
if(lab)fd.append('label',lab);
|
||||||
|
try{
|
||||||
|
const r=await fetch(API+'?action=garage_documents',{method:'POST',body:fd});
|
||||||
|
const j=await r.json();
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur');
|
||||||
|
toast('Document ajouté');
|
||||||
|
inp.value='';
|
||||||
|
const ll=document.getElementById('vehicle-doc-label');if(ll)ll.value='';
|
||||||
|
const v=await api('vehicles','GET',null,'&id='+currentVehicleId);
|
||||||
|
renderVehicleHeader(v);
|
||||||
|
setVehicleDocTabBadge(v);
|
||||||
|
await loadVehicleDocuments();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMaintenanceDocument(){
|
||||||
|
const inp=document.getElementById('maint-doc-file');
|
||||||
|
if(!inp||!inp.files[0]){toast('Choisissez un fichier','error');return;}
|
||||||
|
if(!currentVehicleId||!currentMaintenanceId){toast('Contexte manquant','error');return;}
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('vehicle_id',String(currentVehicleId));
|
||||||
|
fd.append('maintenance_id',String(currentMaintenanceId));
|
||||||
|
fd.append('file',inp.files[0]);
|
||||||
|
const lab=document.getElementById('maint-doc-label')?.value.trim();
|
||||||
|
if(lab)fd.append('label',lab);
|
||||||
|
try{
|
||||||
|
const r=await fetch(API+'?action=garage_documents',{method:'POST',body:fd});
|
||||||
|
const j=await r.json();
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur');
|
||||||
|
toast('Document ajouté');
|
||||||
|
inp.value='';
|
||||||
|
const ll=document.getElementById('maint-doc-label');if(ll)ll.value='';
|
||||||
|
const m=await api('maintenances','GET',null,'&id='+currentMaintenanceId);
|
||||||
|
renderMaintenanceDetailHeader(m);
|
||||||
|
await loadMaintenanceDocuments();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGarageDocument(id){
|
||||||
|
if(!confirm('Supprimer ce document ?'))return;
|
||||||
|
try{
|
||||||
|
await api('garage_documents','DELETE',null,'&id='+id);
|
||||||
|
toast('Document supprimé');
|
||||||
|
if(currentPage==='vehicle'){
|
||||||
|
const v=await api('vehicles','GET',null,'&id='+currentVehicleId);
|
||||||
|
renderVehicleHeader(v);
|
||||||
|
setVehicleDocTabBadge(v);
|
||||||
|
await loadVehicleDocuments();
|
||||||
|
}else if(currentPage==='maintenance'){
|
||||||
|
const m=await api('maintenances','GET',null,'&id='+currentMaintenanceId);
|
||||||
|
renderMaintenanceDetailHeader(m);
|
||||||
|
await loadMaintenanceDocuments();
|
||||||
|
}
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── TABS ────────────────────────────────────────────────────────────────────
|
// ─── TABS ────────────────────────────────────────────────────────────────────
|
||||||
function switchTab(tab){
|
function switchTab(tab){
|
||||||
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
|
$$('.tab-btn').forEach(b=>b.classList.remove('active'));
|
||||||
$$('.tab-pane').forEach(p=>p.classList.remove('active'));
|
$$('.tab-pane').forEach(p=>p.classList.remove('active'));
|
||||||
document.querySelector('.tab-btn[data-tab="'+tab+'"]')?.classList.add('active');
|
document.querySelector('.tab-btn[data-tab="'+tab+'"]')?.classList.add('active');
|
||||||
document.getElementById('tab-'+tab)?.classList.add('active');
|
document.getElementById('tab-'+tab)?.classList.add('active');
|
||||||
|
if(tab==='documents'&¤tVehicleId)loadVehicleDocuments();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── PHOTO PREVIEW ────────────────────────────────────────────────────────────
|
// ─── PHOTO PREVIEW ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cl-view-btn.is-active {
|
.cl-view-btn.is-active {
|
||||||
background: #334155;
|
background: var(--text-main);
|
||||||
color: white;
|
color: var(--bg-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === FILTRES STICKY === */
|
/* === FILTRES STICKY === */
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
/* === SECTIONS ENFANTS === */
|
/* === SECTIONS ENFANTS === */
|
||||||
.pf-child-section {
|
.pf-child-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid var(--border-light);
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
.pf-child-totals-bar::-webkit-scrollbar {
|
.pf-child-totals-bar::-webkit-scrollbar {
|
||||||
@@ -128,13 +128,13 @@
|
|||||||
}
|
}
|
||||||
.pf-summary-pill {
|
.pf-summary-pill {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #475569;
|
color: var(--text-muted);
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--border-strong);
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,9 +145,9 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
}
|
}
|
||||||
.pf-gift-card-compact {
|
.pf-gift-card-compact {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
@@ -156,12 +156,12 @@
|
|||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
}
|
}
|
||||||
.pf-gift-card-compact:hover {
|
.pf-gift-card-compact:hover {
|
||||||
border-color: #cbd5e1;
|
border-color: var(--border-strong);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
.pf-gift-title {
|
.pf-gift-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1e293b;
|
color: var(--text-main);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.pf-tricount-item {
|
.pf-tricount-item {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -317,7 +317,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
left: 0;
|
left: 0;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
.pf-ms-option:hover {
|
.pf-ms-option:hover {
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
.pf-ms-option input[type="checkbox"] {
|
.pf-ms-option input[type="checkbox"] {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -359,7 +359,7 @@
|
|||||||
}
|
}
|
||||||
.pf-ms-option.is-all {
|
.pf-ms-option.is-all {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-light);
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_exception_handler(function (\Throwable $e) {
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label VARCHAR(500) NOT NULL,
|
||||||
|
in_cart TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash),
|
||||||
|
KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function gOk($d)
|
||||||
|
{
|
||||||
|
echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
function gErr($m, $c = 400)
|
||||||
|
{
|
||||||
|
http_response_code($c);
|
||||||
|
echo json_encode(['ok' => false, 'error' => $m]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
function gBody()
|
||||||
|
{
|
||||||
|
return json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryNormalize(string $label): string
|
||||||
|
{
|
||||||
|
$t = trim($label);
|
||||||
|
if ($t === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strtolower')) {
|
||||||
|
$t = mb_strtolower($t, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
$t = strtolower($t);
|
||||||
|
}
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($t, 'UTF-8') > 500) {
|
||||||
|
$t = mb_substr($t, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($t) > 500) {
|
||||||
|
$t = substr($t, 0, 500);
|
||||||
|
}
|
||||||
|
return $t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryHistoryMax(PDO $pdo): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$s = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'");
|
||||||
|
$v = $s ? $s->fetchColumn() : false;
|
||||||
|
$n = ($v !== false && $v !== null && $v !== '') ? (int) $v : 20;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$n = 20;
|
||||||
|
}
|
||||||
|
return max(1, min(50, $n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryTouchHistory(PDO $pdo, string $labelDisplay): void
|
||||||
|
{
|
||||||
|
$norm = groceryNormalize($labelDisplay);
|
||||||
|
if ($norm === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$disp = trim($labelDisplay);
|
||||||
|
if (function_exists('mb_strlen') && mb_strlen($disp, 'UTF-8') > 500) {
|
||||||
|
$disp = mb_substr($disp, 0, 500, 'UTF-8');
|
||||||
|
} elseif (strlen($disp) > 500) {
|
||||||
|
$disp = substr($disp, 0, 500);
|
||||||
|
}
|
||||||
|
$hash = hash('sha256', $norm, false);
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()'
|
||||||
|
)->execute([$hash, $disp]);
|
||||||
|
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$cnt = (int) $pdo->query('SELECT COUNT(*) FROM pf_grocery_history')->fetchColumn();
|
||||||
|
if ($cnt <= $max) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$toDel = $cnt - $max;
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM pf_grocery_history ORDER BY last_used_at ASC, id ASC LIMIT ?');
|
||||||
|
$stmt->execute([$toDel]);
|
||||||
|
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
if (!$ids) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_history WHERE id IN ($placeholders)")->execute($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryCurrentNormSet(PDO $pdo): array
|
||||||
|
{
|
||||||
|
$rows = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$set = [];
|
||||||
|
foreach ($rows as $lab) {
|
||||||
|
$n = groceryNormalize((string) $lab);
|
||||||
|
if ($n !== '') {
|
||||||
|
$set[$n] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'items') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT id, label, in_cart, position, created_at, updated_at
|
||||||
|
FROM pf_grocery_items
|
||||||
|
ORDER BY in_cart ASC, position ASC, id ASC"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['in_cart'] = (int) $r['in_cart'];
|
||||||
|
$r['id'] = (int) $r['id'];
|
||||||
|
$r['position'] = (int) $r['position'];
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
gOk($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = gBody();
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
if ($label === '') {
|
||||||
|
gErr('Libellé requis');
|
||||||
|
}
|
||||||
|
$norm = groceryNormalize($label);
|
||||||
|
$chk = $pdo->query('SELECT label FROM pf_grocery_items');
|
||||||
|
while ($existing = $chk->fetchColumn()) {
|
||||||
|
if (groceryNormalize((string) $existing) === $norm) {
|
||||||
|
gErr(function_exists('tr') ? tr('groceries_err_duplicate') : 'Ce produit est déjà dans la liste.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$max = (int) $pdo->query('SELECT COALESCE(MAX(position),0) FROM pf_grocery_items')->fetchColumn();
|
||||||
|
$pdo->prepare('INSERT INTO pf_grocery_items (label, position) VALUES (?,?)')->execute([$label, $max + 1]);
|
||||||
|
$id = (int) $pdo->lastInsertId();
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
|
$s = $pdo->prepare('SELECT id, label, in_cart, position, created_at, updated_at FROM pf_grocery_items WHERE id=?');
|
||||||
|
$s->execute([$id]);
|
||||||
|
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$row['in_cart'] = (int) $row['in_cart'];
|
||||||
|
$row['id'] = (int) $row['id'];
|
||||||
|
$row['position'] = (int) $row['position'];
|
||||||
|
gOk($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$d = gBody();
|
||||||
|
if (array_key_exists('in_cart', $d)) {
|
||||||
|
$in = !empty($d['in_cart']) ? 1 : 0;
|
||||||
|
$pdo->prepare('UPDATE pf_grocery_items SET in_cart=?, updated_at=NOW() WHERE id=?')->execute([$in, $id]);
|
||||||
|
gOk(['in_cart' => $in]);
|
||||||
|
}
|
||||||
|
$label = trim($d['label'] ?? '');
|
||||||
|
if ($label === '') {
|
||||||
|
gErr('Libellé requis');
|
||||||
|
}
|
||||||
|
$pdo->prepare('UPDATE pf_grocery_items SET label=?, updated_at=NOW() WHERE id=?')->execute([$label, $id]);
|
||||||
|
groceryTouchHistory($pdo, $label);
|
||||||
|
gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
|
||||||
|
if ($id < 1) {
|
||||||
|
gErr('ID manquant');
|
||||||
|
}
|
||||||
|
$pdo->prepare('DELETE FROM pf_grocery_items WHERE id=?')->execute([$id]);
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'history' && $method === 'GET') {
|
||||||
|
$max = groceryHistoryMax($pdo);
|
||||||
|
$rows = $pdo->query(
|
||||||
|
'SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT 100'
|
||||||
|
)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$onList = groceryCurrentNormSet($pdo);
|
||||||
|
$labels = [];
|
||||||
|
foreach ($rows as $disp) {
|
||||||
|
$disp = (string) $disp;
|
||||||
|
if (groceryNormalize($disp) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($onList[groceryNormalize($disp)])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$labels[] = $disp;
|
||||||
|
if (count($labels) >= $max) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gOk(['labels' => $labels, 'history_max' => $max]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'uncheck_all' && $method === 'POST') {
|
||||||
|
$pdo->exec('UPDATE pf_grocery_items SET in_cart=0');
|
||||||
|
gOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_picked' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items WHERE in_cart=1')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM pf_grocery_items WHERE in_cart=1');
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'clear_all' && $method === 'POST') {
|
||||||
|
$labs = $pdo->query('SELECT label FROM pf_grocery_items')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
foreach ($labs as $lab) {
|
||||||
|
groceryTouchHistory($pdo, (string) $lab);
|
||||||
|
}
|
||||||
|
$pdo->exec('DELETE FROM pf_grocery_items');
|
||||||
|
gOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
gErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/* HouseHub — Courses (liste de courses) */
|
||||||
|
.groceries-layout {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-hero {
|
||||||
|
padding: 1.25rem 0 1rem;
|
||||||
|
}
|
||||||
|
.groceries-hero h1 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.groceries-hero p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Encart ajout — plus visible, aligné sur les cartes HouseHub */
|
||||||
|
.groceries-add-card {
|
||||||
|
margin-bottom: 1.35rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.groceries-add-card-inner {
|
||||||
|
padding: 1.15rem 1.25rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-quick-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.groceries-quick-add .groceries-add-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.groceries-add-submit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 14px 22px !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.groceries-quick-add {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.groceries-add-submit {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions secondaires — même famille que pf-btn du reste du site */
|
||||||
|
.groceries-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 1.15rem;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn {
|
||||||
|
padding: 10px 18px !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--danger {
|
||||||
|
color: var(--danger) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.35) !important;
|
||||||
|
background: var(--bg-panel) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.08) !important;
|
||||||
|
border-color: rgba(239, 68, 68, 0.5) !important;
|
||||||
|
color: var(--danger) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--outline {
|
||||||
|
border: 1px dashed var(--border-strong) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.groceries-toolbar-btn--outline:hover {
|
||||||
|
background: var(--bg-page) !important;
|
||||||
|
color: var(--text-main) !important;
|
||||||
|
border-color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-section-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
.groceries-section-title:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.groceries-row:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.08) 0%, var(--bg-panel) 48%);
|
||||||
|
border-color: rgba(34, 197, 94, 0.28);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart:hover {
|
||||||
|
border-color: rgba(34, 197, 94, 0.45);
|
||||||
|
}
|
||||||
|
.groceries-row.in-cart .groceries-label {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-check {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
background: var(--bg-page);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.groceries-check:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
.groceries-check:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
/* Produit validé / dans le caddie — vert franc */
|
||||||
|
.groceries-check.checked {
|
||||||
|
background: linear-gradient(145deg, #22c55e 0%, #16a34a 100%);
|
||||||
|
border-color: #15803d;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.28), 0 2px 6px rgba(22, 163, 74, 0.35);
|
||||||
|
}
|
||||||
|
.groceries-check.checked:hover {
|
||||||
|
background: linear-gradient(145deg, #4ade80 0%, #22c55e 100%);
|
||||||
|
border-color: #166534;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.35), 0 3px 10px rgba(22, 163, 74, 0.4);
|
||||||
|
}
|
||||||
|
.groceries-check.checked::after {
|
||||||
|
content: '✓';
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.groceries-empty .groceries-empty-icon {
|
||||||
|
font-size: 2.75rem;
|
||||||
|
opacity: 0.35;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groceries-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.groceries-toast {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 200px;
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
}
|
||||||
|
.groceries-toast.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .groceries-add-card {
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row:hover {
|
||||||
|
border-color: #484f58;
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-row.in-cart {
|
||||||
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.12) 0%, var(--bg-panel) 50%);
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check {
|
||||||
|
border-color: #484f58;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-check.checked {
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22), 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Historique produits */
|
||||||
|
.groceries-history-card {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
padding: 1.15rem 1.2rem 1.2rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
.groceries-history-h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.groceries-history-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.groceries-history-settings-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.groceries-history-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.groceries-history-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.45rem 0.75rem 0.45rem 0.55rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.groceries-history-chip:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
|
box-shadow: 0 1px 4px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
.groceries-history-chip-plus {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary, #fff);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.groceries-history-empty {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .groceries-history-card {
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme='dark'] .groceries-history-chip:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// HouseHub — Liste de courses
|
||||||
|
const API = '/modules/groceries/api.php';
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = String(s ?? '');
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function T(key, fallback) {
|
||||||
|
if (typeof tr === 'function') {
|
||||||
|
const v = tr(key);
|
||||||
|
if (v && v !== key) return v;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, type = 'success') {
|
||||||
|
const c = document.getElementById('groceries-toasts');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'groceries-toast' + (type === 'error' ? ' error' : '');
|
||||||
|
t.textContent = msg;
|
||||||
|
c.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(action, method = 'GET', data = null, extra = '') {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
};
|
||||||
|
if (data) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
const r = await fetch(API + '?action=' + action + extra, opts);
|
||||||
|
const text = await r.text();
|
||||||
|
let j;
|
||||||
|
try {
|
||||||
|
j = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bad JSON from API:', text.slice(0, 200));
|
||||||
|
throw new Error(T('error_occured', 'Erreur serveur'));
|
||||||
|
}
|
||||||
|
if (!j.ok) throw new Error(j.error || T('error_occured', 'Erreur'));
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [items, hist] = await Promise.all([api('items', 'GET'), api('history', 'GET')]);
|
||||||
|
render(items);
|
||||||
|
updateSubtitle(items);
|
||||||
|
renderHistory(hist);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubtitle(items) {
|
||||||
|
const el = document.getElementById('groceries-subtitle');
|
||||||
|
if (!el) return;
|
||||||
|
const pending = items.filter((i) => !parseInt(i.in_cart, 10)).length;
|
||||||
|
const cart = items.filter((i) => parseInt(i.in_cart, 10)).length;
|
||||||
|
if (!items.length) {
|
||||||
|
el.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tpl = typeof tr === 'function' ? tr('groceries_subtitle') : '';
|
||||||
|
el.textContent = (tpl && tpl !== 'groceries_subtitle' ? tpl : '{pending} à prendre · {cart} dans le caddie')
|
||||||
|
.replace('{pending}', String(pending))
|
||||||
|
.replace('{cart}', String(cart));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(hist) {
|
||||||
|
const root = document.getElementById('groceries-history-root');
|
||||||
|
if (!root) return;
|
||||||
|
const max = hist && typeof hist.history_max === 'number' ? hist.history_max : 20;
|
||||||
|
const labels = (hist && hist.labels) || [];
|
||||||
|
const title = escHtml(T('groceries_history_title', 'Historique'));
|
||||||
|
const metaTpl = T('groceries_history_meta', 'Jusqu’à {max} produits · ');
|
||||||
|
const meta = escHtml(metaTpl.replace('{max}', String(max)));
|
||||||
|
const linkLabel = escHtml(T('groceries_settings_link', 'Paramètres'));
|
||||||
|
let chips = '';
|
||||||
|
labels.forEach((lab) => {
|
||||||
|
const l = String(lab);
|
||||||
|
chips +=
|
||||||
|
'<button type="button" class="groceries-history-chip" data-label="' +
|
||||||
|
escAttr(l) +
|
||||||
|
'"><span class="groceries-history-chip-plus">+</span>' +
|
||||||
|
escHtml(l) +
|
||||||
|
'</button>';
|
||||||
|
});
|
||||||
|
const emptyHint = labels.length
|
||||||
|
? ''
|
||||||
|
: '<p class="groceries-history-empty">' + escHtml(T('groceries_history_empty', '')) + '</p>';
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="groceries-history-inner">' +
|
||||||
|
'<div class="groceries-history-head">' +
|
||||||
|
'<h2 class="groceries-history-h2">' +
|
||||||
|
title +
|
||||||
|
'</h2>' +
|
||||||
|
'<p class="groceries-history-meta">' +
|
||||||
|
meta +
|
||||||
|
'<a href="/settings.php" class="groceries-history-settings-link">' +
|
||||||
|
linkLabel +
|
||||||
|
'</a></p></div>' +
|
||||||
|
'<div class="groceries-history-chips">' +
|
||||||
|
chips +
|
||||||
|
'</div>' +
|
||||||
|
emptyHint +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(items) {
|
||||||
|
const wrap = document.getElementById('groceries-lists');
|
||||||
|
if (!items.length) {
|
||||||
|
const msg = T('groceries_empty', 'Rien pour le moment. Ajoutez un produit ci-dessus.');
|
||||||
|
wrap.innerHTML =
|
||||||
|
'<div class="groceries-empty"><div class="groceries-empty-icon">🛒</div><p>' + escHtml(msg) + '</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending = items.filter((i) => !parseInt(i.in_cart, 10));
|
||||||
|
const cart = items.filter((i) => parseInt(i.in_cart, 10));
|
||||||
|
const labPending = escHtml(T('groceries_section_pending', 'À prendre'));
|
||||||
|
const labCart = escHtml(T('groceries_section_cart', 'Dans le caddie'));
|
||||||
|
let html = '';
|
||||||
|
if (pending.length) {
|
||||||
|
html += '<div class="groceries-section-title">' + labPending + '</div><div class="groceries-list">';
|
||||||
|
pending.forEach((i) => {
|
||||||
|
html += rowHtml(i, false);
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
if (cart.length) {
|
||||||
|
html += '<div class="groceries-section-title">' + labCart + '</div><div class="groceries-list">';
|
||||||
|
cart.forEach((i) => {
|
||||||
|
html += rowHtml(i, true);
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(i, inCart) {
|
||||||
|
const id = i.id;
|
||||||
|
const checkClass = 'groceries-check' + (inCart ? ' checked' : '');
|
||||||
|
const rowClass = 'groceries-row' + (inCart ? ' in-cart' : '');
|
||||||
|
const aria = escAttr(T('groceries_aria_toggle', 'Marquer dans le caddie'));
|
||||||
|
return (
|
||||||
|
'<div class="' +
|
||||||
|
rowClass +
|
||||||
|
'" data-id="' +
|
||||||
|
id +
|
||||||
|
'">' +
|
||||||
|
'<button type="button" class="' +
|
||||||
|
checkClass +
|
||||||
|
'" onclick="toggleCart(' +
|
||||||
|
id +
|
||||||
|
',' +
|
||||||
|
(inCart ? '0' : '1') +
|
||||||
|
')" aria-label="' +
|
||||||
|
aria +
|
||||||
|
'"></button>' +
|
||||||
|
'<div class="groceries-label">' +
|
||||||
|
escHtml(i.label) +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="groceries-row-actions">' +
|
||||||
|
'<button type="button" class="btn-icon-action edit" onclick="editItem(' +
|
||||||
|
id +
|
||||||
|
')" title="' +
|
||||||
|
escAttr(T('edit', 'Modifier')) +
|
||||||
|
'" aria-label="' +
|
||||||
|
escAttr(T('edit', 'Modifier')) +
|
||||||
|
'">✏️</button>' +
|
||||||
|
'<button type="button" class="btn-icon-action delete" onclick="deleteItem(' +
|
||||||
|
id +
|
||||||
|
')" title="' +
|
||||||
|
escAttr(T('delete', 'Supprimer')) +
|
||||||
|
'" aria-label="' +
|
||||||
|
escAttr(T('delete', 'Supprimer')) +
|
||||||
|
'">🗑️</button>' +
|
||||||
|
'</div></div>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFromInput() {
|
||||||
|
const input = document.getElementById('groceries-new');
|
||||||
|
const label = (input.value || '').trim();
|
||||||
|
if (!label) return;
|
||||||
|
try {
|
||||||
|
await api('items', 'POST', { label: label });
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
toast(T('groceries_added', 'Ajouté'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFromHistory(label) {
|
||||||
|
const t = String(label || '').trim();
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
await api('items', 'POST', { label: t });
|
||||||
|
toast(T('groceries_added', 'Ajouté'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCart(id, next) {
|
||||||
|
try {
|
||||||
|
await api('items', 'PUT', { in_cart: !!parseInt(next, 10) }, '&id=' + id);
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editItem(id) {
|
||||||
|
const row = document.querySelector('.groceries-row[data-id="' + id + '"]');
|
||||||
|
const current = row ? row.querySelector('.groceries-label').textContent : '';
|
||||||
|
const label = window.prompt(T('groceries_prompt_edit', 'Modifier le produit'), current);
|
||||||
|
if (label === null) return;
|
||||||
|
const t = label.trim();
|
||||||
|
if (!t) {
|
||||||
|
toast(T('groceries_label_empty', 'Libellé vide'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api('items', 'PUT', { label: t }, '&id=' + id);
|
||||||
|
toast(T('groceries_updated', 'Mis à jour'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(id) {
|
||||||
|
const ok = await pachaConfirm(
|
||||||
|
T('groceries_confirm_del_title', 'Supprimer ?'),
|
||||||
|
T('groceries_confirm_del_body', 'Retirer ce produit de la liste ?')
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('items', 'DELETE', null, '&id=' + id);
|
||||||
|
toast(T('groceries_deleted', 'Supprimé'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uncheckAll() {
|
||||||
|
const ok = await pachaConfirm(
|
||||||
|
T('groceries_confirm_uncheck_title', 'Tout remettre à prendre'),
|
||||||
|
T('groceries_confirm_uncheck_body', '')
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('uncheck_all', 'POST', {});
|
||||||
|
toast(T('groceries_reset_next', 'Liste prête pour la prochaine sortie'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePicked() {
|
||||||
|
const ok = await pachaConfirm(
|
||||||
|
T('groceries_confirm_clear_title', 'Retirer les produits du caddie'),
|
||||||
|
T('groceries_confirm_clear_body', '')
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('delete_picked', 'POST', {});
|
||||||
|
toast(T('groceries_picked_removed', 'Articles retirés'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearWholeList() {
|
||||||
|
const ok = await pachaConfirm(
|
||||||
|
T('groceries_confirm_empty_list_title', 'Vider toute la liste ?'),
|
||||||
|
T('groceries_confirm_empty_list_body', '')
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api('clear_all', 'POST', {});
|
||||||
|
toast(T('groceries_list_cleared', 'Liste vidée'));
|
||||||
|
loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || T('error_occured', 'Erreur'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const input = document.getElementById('groceries-new');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addFromInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const histRoot = document.getElementById('groceries-history-root');
|
||||||
|
if (histRoot) {
|
||||||
|
histRoot.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.groceries-history-chip');
|
||||||
|
if (!btn || !histRoot.contains(btn)) return;
|
||||||
|
const raw = btn.getAttribute('data-label');
|
||||||
|
if (raw) addFromHistory(raw);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
loadAll();
|
||||||
|
});
|
||||||
+113
-70
@@ -9,11 +9,14 @@ body.no-scroll {
|
|||||||
:root {
|
:root {
|
||||||
--primary: #2563eb;
|
--primary: #2563eb;
|
||||||
--primary-hover: #1d4ed8;
|
--primary-hover: #1d4ed8;
|
||||||
--bg-page: #f1f5f9;
|
--bg-card: var(--bg-panel);
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-main: #1e293b;
|
|
||||||
--text-muted: #64748b;
|
--text-muted: #64748b;
|
||||||
|
--hol-accent-transport: #2563eb;
|
||||||
|
--hol-accent-accommodation: #059669;
|
||||||
|
--hol-accent-activity: #d97706;
|
||||||
|
--hol-info-bg: #e0f2fe;
|
||||||
|
--hol-info-text: #0369a1;
|
||||||
|
--hol-warning-bg: #fffbeb;
|
||||||
--radius-l: 16px;
|
--radius-l: 16px;
|
||||||
--radius-m: 10px;
|
--radius-m: 10px;
|
||||||
--radius-s: 6px;
|
--radius-s: 6px;
|
||||||
@@ -42,7 +45,7 @@ body.no-scroll {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -60,7 +63,7 @@ body.no-scroll {
|
|||||||
.hol-main-title {
|
.hol-main-title {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #0f172a;
|
color: var(--text-main);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,12 +82,10 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hol-badge-left-to-pay {
|
.hol-badge-left-to-pay {
|
||||||
background: #fff1f2;
|
|
||||||
color: #be123c;
|
color: #be123c;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border: 1px solid #fecdd3;
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -129,7 +130,7 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hol-map-toggle {
|
.hol-map-toggle {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
border: 1px solid #ccfbf1;
|
border: 1px solid #ccfbf1;
|
||||||
}
|
}
|
||||||
@@ -140,20 +141,20 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pf-btn.btn-secondary {
|
.pf-btn.btn-secondary {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--border-strong);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.pf-btn.btn-secondary:hover {
|
.pf-btn.btn-secondary:hover {
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Petit bouton + pour les colonnes */
|
/* Petit bouton + pour les colonnes */
|
||||||
.btn-icon-small {
|
.btn-icon-small {
|
||||||
background: white !important;
|
background: var(--bg-panel) !important;
|
||||||
border: 1px solid #e2e8f0 !important;
|
border: 1px solid var(--border-light) !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
width: 32px !important;
|
width: 32px !important;
|
||||||
height: 32px !important;
|
height: 32px !important;
|
||||||
@@ -170,8 +171,8 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-small:hover {
|
.btn-icon-small:hover {
|
||||||
background: #f1f5f9 !important;
|
background: var(--bg-subtle) !important;
|
||||||
border-color: #cbd5e1 !important;
|
border-color: var(--border-strong) !important;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ body.no-scroll {
|
|||||||
.hol-idea-card:hover {
|
.hol-idea-card:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
box-shadow: var(--shadow-hover);
|
box-shadow: var(--shadow-hover);
|
||||||
border-color: #e2e8f0;
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hol-idea-card__head {
|
.hol-idea-card__head {
|
||||||
@@ -231,7 +232,7 @@ body.no-scroll {
|
|||||||
|
|
||||||
.hol-notes {
|
.hol-notes {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
line-clamp: 2;
|
line-clamp: 2;
|
||||||
@@ -284,7 +285,7 @@ body.no-scroll {
|
|||||||
|
|
||||||
.pf-modal-content {
|
.pf-modal-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
width: 95%; /* Responsive */
|
width: 95%; /* Responsive */
|
||||||
max-width: 1100px; /* Largeur augmentée pour 3 colonnes */
|
max-width: 1100px; /* Largeur augmentée pour 3 colonnes */
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
@@ -297,13 +298,56 @@ body.no-scroll {
|
|||||||
margin: auto; /* Sécurité centrage */
|
margin: auto; /* Sécurité centrage */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hol-modal-content {
|
||||||
|
max-width: 800px;
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-flex-2 {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-date-range-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-divider {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-cat-transport {
|
||||||
|
color: var(--hol-accent-transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-cat-accommodation {
|
||||||
|
color: var(--hol-accent-accommodation);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-cat-activity {
|
||||||
|
color: var(--hol-accent-activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hol-btn-delete {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--border-danger-soft);
|
||||||
|
margin-right: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cas spécifique modale carte */
|
/* Cas spécifique modale carte */
|
||||||
.hol-dialog--map {
|
.hol-dialog--map {
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
height: 90vh;
|
height: 90vh;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -321,20 +365,20 @@ body.no-scroll {
|
|||||||
.pf-input {
|
.pf-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid #cbd5e1;
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: var(--radius-m);
|
border-radius: var(--radius-m);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: #ffffff; /* Fond blanc forcé */
|
background: var(--bg-panel);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pf-input:focus {
|
.pf-input:focus {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
@@ -353,10 +397,9 @@ body.no-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hol-col {
|
.hol-col {
|
||||||
background: #f8fafc;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
width: 100%; /* Prend toute la largeur disponible */
|
width: 100%; /* Prend toute la largeur disponible */
|
||||||
box-sizing: border-box; /* Empêche le padding de casser la largeur */
|
box-sizing: border-box; /* Empêche le padding de casser la largeur */
|
||||||
}
|
}
|
||||||
@@ -365,7 +408,7 @@ body.no-scroll {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between; /* Titre à gauche, bouton à droite */
|
justify-content: space-between; /* Titre à gauche, bouton à droite */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-light);
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -424,10 +467,10 @@ body.no-scroll {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px; /* Espace réduit */
|
gap: 8px; /* Espace réduit */
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #f1f5f9;
|
border: 1px solid var(--border-light);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +488,7 @@ body.no-scroll {
|
|||||||
padding: 6px 8px !important;
|
padding: 6px 8px !important;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #059669;
|
color: var(--hol-accent-accommodation);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Checkbox Payé */
|
/* Checkbox Payé */
|
||||||
@@ -453,7 +496,7 @@ body.no-scroll {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -476,7 +519,7 @@ body.no-scroll {
|
|||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.btn-remove:hover {
|
.btn-remove:hover {
|
||||||
background: #fef2f2;
|
background: var(--bg-danger-soft);
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +530,7 @@ body.no-scroll {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 10. MOBILE --- */
|
/* --- 10. MOBILE --- */
|
||||||
@@ -649,17 +692,17 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background: #e0f2fe;
|
background: var(--hol-info-bg);
|
||||||
color: #0369a1;
|
color: var(--hol-info-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bloc Résumé Financier */
|
/* Bloc Résumé Financier */
|
||||||
.hol-summary-card {
|
.hol-summary-card {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,7 +733,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
.hol-summary-value.total {
|
.hol-summary-value.total {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #0f172a;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grille principale : Carte + Timeline */
|
/* Grille principale : Carte + Timeline */
|
||||||
@@ -703,10 +746,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
/* Panneaux (Carte et Timeline) */
|
/* Panneaux (Carte et Timeline) */
|
||||||
.hol-panel {
|
.hol-panel {
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
border-radius: var(--radius-l);
|
border-radius: var(--radius-l);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
@@ -715,11 +758,11 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
.hol-panel-header {
|
.hol-panel-header {
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hol-panel-header h3 {
|
.hol-panel-header h3 {
|
||||||
@@ -736,20 +779,20 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
/* Styles des Checkpoints (Timeline) */
|
/* Styles des Checkpoints (Timeline) */
|
||||||
.hol-checkpoint {
|
.hol-checkpoint {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hol-cp-header {
|
.hol-cp-header {
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-light);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +805,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hol-cp-title {
|
.hol-cp-title {
|
||||||
color: #0f172a;
|
color: var(--text-main);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -798,7 +841,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
border-bottom: 1px dashed #f1f5f9;
|
border-bottom: 1px dashed var(--bg-subtle);
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,8 +853,8 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
.hol-notes-panel {
|
.hol-notes-panel {
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid var(--border-light);
|
||||||
background: #fffbeb;
|
background: var(--hol-warning-bg);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -886,11 +929,11 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
background: white;
|
background: var(--bg-panel);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
.hol-legend-item {
|
.hol-legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -925,7 +968,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-bottom: 1px dashed #f1f5f9;
|
border-bottom: 1px dashed var(--bg-subtle);
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
.hol-expense-wrapper:last-child {
|
.hol-expense-wrapper:last-child {
|
||||||
@@ -941,7 +984,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.hol-expense-name {
|
.hol-expense-name {
|
||||||
color: #475569;
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.hol-expense-amount {
|
.hol-expense-amount {
|
||||||
@@ -959,7 +1002,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
.hol-expense-note {
|
.hol-expense-note {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #94a3b8;
|
color: var(--border-strong);
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -993,9 +1036,9 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
.hol-form-inner {
|
.hol-form-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1030,13 +1073,13 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.hol-form-notes-input {
|
.hol-form-notes-input {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
color: #475569;
|
color: var(--text-muted);
|
||||||
width: calc(100% - 58px);
|
width: calc(100% - 58px);
|
||||||
margin-left: 58px;
|
margin-left: 58px;
|
||||||
}
|
}
|
||||||
@@ -1103,10 +1146,10 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
/* Zone des activités non planifiées */
|
/* Zone des activités non planifiées */
|
||||||
.hol-unmapped-zone {
|
.hol-unmapped-zone {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
background: #f8fafc;
|
background: var(--bg-page);
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 2px dashed #cbd5e1;
|
border: 2px dashed var(--border-strong);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -1116,7 +1159,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #64748b;
|
color: var(--text-muted);
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1136,16 +1179,16 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
}
|
}
|
||||||
.hol-calendar-zone::-webkit-scrollbar-track,
|
.hol-calendar-zone::-webkit-scrollbar-track,
|
||||||
.hol-unmapped-zone::-webkit-scrollbar-track {
|
.hol-unmapped-zone::-webkit-scrollbar-track {
|
||||||
background: #f1f5f9;
|
background: var(--bg-subtle);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
.hol-calendar-zone::-webkit-scrollbar-thumb,
|
.hol-calendar-zone::-webkit-scrollbar-thumb,
|
||||||
.hol-unmapped-zone::-webkit-scrollbar-thumb {
|
.hol-unmapped-zone::-webkit-scrollbar-thumb {
|
||||||
background: #cbd5e1;
|
background: var(--border-strong);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
.hol-calendar-zone::-webkit-scrollbar-thumb:hover {
|
.hol-calendar-zone::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hol-calendar-zone .hol-drag-item {
|
.hol-calendar-zone .hol-drag-item {
|
||||||
@@ -1186,7 +1229,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: max-content; /* Laisse la colonne grandir jusqu'à 22h */
|
height: max-content; /* Laisse la colonne grandir jusqu'à 22h */
|
||||||
@@ -1211,7 +1254,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
|
|
||||||
.hol-time-slot {
|
.hol-time-slot {
|
||||||
height: 75px;
|
height: 75px;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid var(--border-light);
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -1238,7 +1281,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid var(--border-light);
|
||||||
border-left: 4px solid var(--primary);
|
border-left: 4px solid var(--primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1318,7 +1361,7 @@ input[type="date"]::-webkit-calendar-picker-indicator:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
background: #f8fafc; /* Slate 50 */
|
background: #f8fafc; /* Slate 50 */
|
||||||
border: 1px solid #e2e8f0; /* Slate 200 */
|
border: 1px solid var(--border-light); /* Slate 200 */
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #475569; /* Slate 600 */
|
color: #475569; /* Slate 600 */
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function renderHolidayCard($h, $pdo) {
|
|||||||
<span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span>
|
<span>🗓️ ".($dateDisplay ?: tr('hdl_dates_to_define'))."</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style='margin-top:auto; padding-top:15px; border-top:1px solid #f1f5f9;'>
|
<div style='margin-top:auto; padding-top:15px; border-top:1px solid var(--border-light);'>
|
||||||
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'>
|
<div style='display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;'>
|
||||||
<span style='font-size:0.85rem; color:#64748b;'>".tr('hdl_total_budget')."</span>
|
<span style='font-size:0.85rem; color:#64748b;'>".tr('hdl_total_budget')."</span>
|
||||||
<span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
|
<span style='font-size:1.1rem; font-weight:bold; color:#1e293b;'>".number_format($cost, 0, ',', ' ')." €</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div id="holidayModal" class="pf-modal">
|
<div id="holidayModal" class="pf-modal">
|
||||||
<div class="pf-modal-content" style="max-width: 800px; width: 95%;">
|
<div class="pf-modal-content hol-modal-content">
|
||||||
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
|
<h3 id="modalTitle" class="pf-modal-title"><?= tr('hdl_modal_title') ?></h3>
|
||||||
|
|
||||||
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
|
<form action="/modules/holidays/includes/api/save_holiday.php" method="POST" id="holidayForm">
|
||||||
@@ -7,11 +7,11 @@
|
|||||||
<input type="hidden" name="action" value="save">
|
<input type="hidden" name="action" value="save">
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div style="flex: 2;">
|
<div class="hol-flex-2">
|
||||||
<label class="pf-label"><?= tr('hdl_label_name') ?></label>
|
<label class="pf-label"><?= tr('hdl_label_name') ?></label>
|
||||||
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="<?= tr('hdl_ph_name') ?>" required>
|
<input type="text" name="title" id="inp_title" class="pf-input" placeholder="<?= tr('hdl_ph_name') ?>" required>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1;">
|
<div class="hol-flex-1">
|
||||||
<label class="pf-label"><?= tr('hdl_label_status') ?></label>
|
<label class="pf-label"><?= tr('hdl_label_status') ?></label>
|
||||||
<select name="status" id="inp_status" class="pf-input">
|
<select name="status" id="inp_status" class="pf-input">
|
||||||
<option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option>
|
<option value="draft"><?= tr('hdl_status_draft') ?> ✏️</option>
|
||||||
@@ -28,24 +28,24 @@
|
|||||||
<label class="pf-label"><?= tr('hdl_label_period') ?></label>
|
<label class="pf-label"><?= tr('hdl_label_period') ?></label>
|
||||||
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
|
<input type="text" name="period_hint" id="inp_period" class="pf-input" placeholder="<?= tr('hdl_ph_period') ?>">
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:10px;">
|
<div class="hol-date-range-group">
|
||||||
<div style="flex:1">
|
<div class="hol-flex-1">
|
||||||
<label class="pf-label"><?= tr('hdl_label_from') ?></label>
|
<label class="pf-label"><?= tr('hdl_label_from') ?></label>
|
||||||
<input type="date" name="start_date" id="inp_start" class="pf-input">
|
<input type="date" name="start_date" id="inp_start" class="pf-input">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1">
|
<div class="hol-flex-1">
|
||||||
<label class="pf-label"><?= tr('hdl_label_to') ?></label>
|
<label class="pf-label"><?= tr('hdl_label_to') ?></label>
|
||||||
<input type="date" name="end_date" id="inp_end" class="pf-input">
|
<input type="date" name="end_date" id="inp_end" class="pf-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
|
<hr class="hol-divider">
|
||||||
|
|
||||||
<div class="hol-columns-wrapper">
|
<div class="hol-columns-wrapper">
|
||||||
<div class="hol-col">
|
<div class="hol-col">
|
||||||
<div class="hol-col-header">
|
<div class="hol-col-header">
|
||||||
<h4 style="color:#2563eb;">🚗 <?= tr('hdl_cat_transport') ?></h4>
|
<h4 class="hol-cat-transport">🚗 <?= tr('hdl_cat_transport') ?></h4>
|
||||||
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="<?= tr('hdl_add_transport') ?>">+</button>
|
<button type="button" class="btn-add-item" onclick="addItem('transport')" title="<?= tr('hdl_add_transport') ?>">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list_transport" class="dynamic-list"></div>
|
<div id="list_transport" class="dynamic-list"></div>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
<div class="hol-col">
|
<div class="hol-col">
|
||||||
<div class="hol-col-header">
|
<div class="hol-col-header">
|
||||||
<h4 style="color:#059669;">🏨 <?= tr('hdl_cat_accommodation') ?></h4>
|
<h4 class="hol-cat-accommodation">🏨 <?= tr('hdl_cat_accommodation') ?></h4>
|
||||||
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="<?= tr('hdl_add_accommodation') ?>">+</button>
|
<button type="button" class="btn-add-item" onclick="addItem('accommodation')" title="<?= tr('hdl_add_accommodation') ?>">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list_accommodation" class="dynamic-list"></div>
|
<div id="list_accommodation" class="dynamic-list"></div>
|
||||||
@@ -61,14 +61,14 @@
|
|||||||
|
|
||||||
<div class="hol-col">
|
<div class="hol-col">
|
||||||
<div class="hol-col-header">
|
<div class="hol-col-header">
|
||||||
<h4 style="color:#d97706;">🎫 <?= tr('hdl_cat_activity') ?></h4>
|
<h4 class="hol-cat-activity">🎫 <?= tr('hdl_cat_activity') ?></h4>
|
||||||
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="<?= tr('hdl_add_activity') ?>">+</button>
|
<button type="button" class="btn-add-item" onclick="addItem('activity')" title="<?= tr('hdl_add_activity') ?>">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="list_activity" class="dynamic-list"></div>
|
<div id="list_activity" class="dynamic-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;">
|
<hr class="hol-divider">
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary" style="color:#ef4444; border-color:#fca5a5; margin-right:auto; display:none;"><?= tr('btn_delete') ?></button>
|
<button type="button" onclick="deleteHoliday()" id="btn_delete" class="pf-btn btn-secondary hol-btn-delete"><?= tr('btn_delete') ?></button>
|
||||||
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
<button type="button" onclick="closeHolidayModal()" class="pf-btn btn-secondary"><?= tr('btn_cancel') ?></button>
|
||||||
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
<button type="submit" class="pf-btn"><?= tr('btn_save') ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-51
@@ -10,7 +10,7 @@ body[data-page="home"] {
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
/* Couleur de texte par défaut sur la home (Blanc) */
|
/* Couleur de texte par défaut sur la home (Blanc) */
|
||||||
color: #ffffff;
|
color: var(--text-on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Voile sombre pour garantir la lisibilité du texte blanc */
|
/* Voile sombre pour garantir la lisibilité du texte blanc */
|
||||||
@@ -18,51 +18,13 @@ body[data-page="home"]::before {
|
|||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(15, 23, 42, 0.5); /* Bleu nuit semi-transparent */
|
background: var(--bg-overlay); /* Bleu nuit semi-transparent */
|
||||||
z-index: -1; /* Derrière le contenu */
|
z-index: -1; /* Derrière le contenu */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 2. HEADER SPÉCIFIQUE (Translucide) === */
|
/* Header : identique au reste du site (global.css + dark-mode) */
|
||||||
body[data-page="home"] .pf-header {
|
|
||||||
background: rgba(36, 59, 83, 0.85); /* Ton ancien bleu sombre */
|
|
||||||
backdrop-filter: blur(8px); /* Effet verre dépoli */
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Adaptation des couleurs des liens du header sur fond sombre */
|
/* === 2. HERO SECTION (Message de Bienvenue) === */
|
||||||
body[data-page="home"] .pf-logo {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
body[data-page="home"] .pf-burger-btn {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-page="home"] .pf-nav-link {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
body[data-page="home"] .pf-nav-link:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
body[data-page="home"] .pf-nav-link--active {
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
color: #243b53; /* Texte foncé sur fond blanc */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge utilisateur */
|
|
||||||
body[data-page="home"] .pf-user-badge {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
body[data-page="home"] .pf-logout-btn {
|
|
||||||
color: #fca5a5;
|
|
||||||
} /* Rouge clair */
|
|
||||||
body[data-page="home"] .pf-logout-btn:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === 3. HERO SECTION (Message de Bienvenue) === */
|
|
||||||
.pf-hero {
|
.pf-hero {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
@@ -70,7 +32,7 @@ body[data-page="home"] .pf-logout-btn:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pf-hero h1 {
|
.pf-hero h1 {
|
||||||
color: #ffffff;
|
color: var(--text-on-primary);
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); /* Ombre pour détacher le texte de l'image */
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -83,7 +45,7 @@ body[data-page="home"] .pf-logout-btn:hover {
|
|||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 4. GRILLE DES MODULES === */
|
/* === 3. GRILLE DES MODULES === */
|
||||||
|
|
||||||
/* Titre de section "Modules" */
|
/* Titre de section "Modules" */
|
||||||
body[data-page="home"] .pf-modules-grid {
|
body[data-page="home"] .pf-modules-grid {
|
||||||
@@ -100,7 +62,7 @@ body[data-page="home"] .pf-modules-grid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pf-module-card:hover {
|
.pf-module-card:hover {
|
||||||
background: #ffffff;
|
background: var(--bg-panel);
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
@@ -113,7 +75,7 @@ body[data-page="home"] .pf-modules-grid {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === 5. MOBILE RESPONSIVE === */
|
/* === 4. MOBILE RESPONSIVE === */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
body[data-page="home"] {
|
body[data-page="home"] {
|
||||||
/* Image spécifique mobile */
|
/* Image spécifique mobile */
|
||||||
@@ -131,9 +93,4 @@ body[data-page="home"] .pf-modules-grid {
|
|||||||
.pf-hero h1 {
|
.pf-hero h1 {
|
||||||
font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */
|
font-size: clamp(1.8rem, 6vw, 2.5rem); /* Typographie fluide */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ajustement header mobile */
|
|
||||||
body[data-page="home"] .pf-header {
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
set_exception_handler(function (\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
// ── Auto-create tables ────────────────────────────────────────────────────────
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_lists (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL DEFAULT 'Ma liste',
|
||||||
|
position INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_items (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, list_id INT NOT NULL DEFAULT 1,
|
||||||
|
category_id INT DEFAULT NULL, label VARCHAR(500) NOT NULL,
|
||||||
|
in_cart TINYINT(1) NOT NULL DEFAULT 0, position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_items_list (list_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_grocery_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, label_hash CHAR(64) NOT NULL,
|
||||||
|
label_display VARCHAR(500) NOT NULL,
|
||||||
|
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_grocery_hist_hash (label_hash), KEY idx_hist_last (last_used_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_list_categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, icon VARCHAR(10) NOT NULL DEFAULT '🏷️',
|
||||||
|
name VARCHAR(100) NOT NULL, position INT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS pf_item_category_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY, keyword VARCHAR(255) NOT NULL,
|
||||||
|
category_id INT NOT NULL, UNIQUE KEY uq_keyword (keyword)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||||||
|
|
||||||
|
// Migrations
|
||||||
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN list_id INT NOT NULL DEFAULT 1 AFTER id"); } catch (\Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD COLUMN category_id INT DEFAULT NULL AFTER list_id"); } catch (\Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE pf_grocery_items ADD KEY idx_items_list (list_id)"); } catch (\Exception $e) {}
|
||||||
|
|
||||||
|
// ── Seed categories if empty ──────────────────────────────────────────────────
|
||||||
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_list_categories")->fetchColumn() === 0) {
|
||||||
|
$cats = [
|
||||||
|
['🍎','Fruits & Légumes'],['🍖','Viande'],['🐟','Poissonnerie'],
|
||||||
|
['🍞','Boulangerie'],['🥛','Frais'],['❄️','Surgelés'],
|
||||||
|
['🧃','Boissons'],['🌾','Pâtes, Riz, Féculents'],['🥜','Épicerie salée'],
|
||||||
|
['🥫','Conserves'],['🍛','Plats cuisinés'],['🧂','Sauces & Condiments'],
|
||||||
|
['🥐','Petit déjeuner'],['🍪','Biscuits & Gâteaux'],['🍫','Confiserie'],
|
||||||
|
['🍦','Dessert'],['🧴','Beauté & Hygiène'],['🍼','Bébé'],
|
||||||
|
['🧼','Entretien'],['🐶','Animaux'],['🏡','Maison & Jardin'],['💊','Pharmacie'],
|
||||||
|
];
|
||||||
|
$ins = $pdo->prepare("INSERT INTO pf_list_categories (icon, name, position) VALUES (?,?,?)");
|
||||||
|
foreach ($cats as $i => $c) $ins->execute([$c[0], $c[1], $i * 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function liste_normalize(string $s): string {
|
||||||
|
return mb_strtolower(trim(mb_substr($s, 0, 500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_normalize_detect(string $s): string {
|
||||||
|
$s = mb_strtolower(trim($s));
|
||||||
|
$from = ['é','è','ê','ë','à','â','ä','ù','û','ü','î','ï','ô','ö','ç','œ','æ'];
|
||||||
|
$to = ['e','e','e','e','a','a','a','u','u','u','i','i','o','o','c','oe','ae'];
|
||||||
|
$s = str_replace($from, $to, $s);
|
||||||
|
return trim(preg_replace('/[^a-z0-9 ]/', '', $s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_touch_history(PDO $pdo, string $label): void {
|
||||||
|
$hash = hash('sha256', liste_normalize($label));
|
||||||
|
$pdo->prepare("INSERT INTO pf_grocery_history (label_hash, label_display) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE label_display=VALUES(label_display), last_used_at=NOW()")
|
||||||
|
->execute([$hash, trim($label)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_ensure_default(PDO $pdo): int {
|
||||||
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() === 0) {
|
||||||
|
$pdo->exec("INSERT INTO pf_lists (name, position) VALUES ('Ma liste', 0)");
|
||||||
|
return (int)$pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
return (int)$pdo->query("SELECT id FROM pf_lists ORDER BY position, id LIMIT 1")->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in keyword → category position mapping (position maps to seeded order above)
|
||||||
|
function liste_builtin_detect(string $normalized): ?int {
|
||||||
|
static $map = null;
|
||||||
|
if ($map === null) $map = [
|
||||||
|
// 1 = Fruits & Légumes
|
||||||
|
'pomme'=>1,'poire'=>1,'banane'=>1,'orange'=>1,'citron'=>1,'raisin'=>1,
|
||||||
|
'fraise'=>1,'framboise'=>1,'myrtille'=>1,'cerise'=>1,'peche'=>1,'abricot'=>1,
|
||||||
|
'mangue'=>1,'kiwi'=>1,'ananas'=>1,'melon'=>1,'pasteque'=>1,'prune'=>1,
|
||||||
|
'tomate'=>1,'concombre'=>1,'carotte'=>1,'oignon'=>1,'poireau'=>1,
|
||||||
|
'courgette'=>1,'aubergine'=>1,'poivron'=>1,'salade'=>1,'laitue'=>1,
|
||||||
|
'epinard'=>1,'brocoli'=>1,'chou'=>1,'choux'=>1,'haricot'=>1,'pois'=>1,
|
||||||
|
'champignon'=>1,'ail'=>1,'echalote'=>1,'pomme de terre'=>1,'patate'=>1,
|
||||||
|
'navet'=>1,'radis'=>1,'betterave'=>1,'fenouil'=>1,'asperge'=>1,
|
||||||
|
'artichaut'=>1,'celeri'=>1,'butternut'=>1,'courge'=>1,'potiron'=>1,
|
||||||
|
'gingembre'=>1,'avocat'=>1,'endive'=>1,'ciboulette'=>1,'persil'=>1,
|
||||||
|
'basilic'=>1,'menthe'=>1,'thym'=>1,'romarin'=>1,'coriandre'=>1,
|
||||||
|
'girofle'=>1,'citron vert'=>1,'lime'=>1,'pamplemousse'=>1,'litchi'=>1,
|
||||||
|
// 2 = Viande
|
||||||
|
'boeuf'=>2,'veau'=>2,'poulet'=>2,'porc'=>2,'agneau'=>2,'dinde'=>2,
|
||||||
|
'lapin'=>2,'jambon'=>2,'lardon'=>2,'saucisse'=>2,'saucisson'=>2,
|
||||||
|
'merguez'=>2,'chipolata'=>2,'steak'=>2,'escalope'=>2,'roti'=>2,
|
||||||
|
'gigot'=>2,'filet'=>2,'cuisses'=>2,'viande hachee'=>2,'andouille'=>2,
|
||||||
|
'magret'=>2,'canard'=>2,'foie'=>2,'boudin'=>2,'paupiette'=>2,
|
||||||
|
'entrecote'=>2,'cote de boeuf'=>2,'onglet'=>2,'bavette'=>2,
|
||||||
|
// 3 = Poissonnerie
|
||||||
|
'saumon'=>3,'thon'=>3,'dorade'=>3,'bar'=>3,'cabillaud'=>3,'sole'=>3,
|
||||||
|
'truite'=>3,'lieu'=>3,'maquereau'=>3,'sardine'=>3,'anchois'=>3,
|
||||||
|
'crevette'=>3,'moule'=>3,'huitre'=>3,'coquille'=>3,'poulpe'=>3,
|
||||||
|
'seiche'=>3,'langoustine'=>3,'lotte'=>3,'merlu'=>3,'colin'=>3,
|
||||||
|
'tilapia'=>3,'daurade'=>3,'aiglefin'=>3,'homard'=>3,'crabe'=>3,
|
||||||
|
// 4 = Boulangerie
|
||||||
|
'pain'=>4,'baguette'=>4,'brioche'=>4,'croissant'=>4,'pain de mie'=>4,
|
||||||
|
'ficelle'=>4,'fougasse'=>4,'ciabatta'=>4,'biscotte'=>4,'naan'=>4,
|
||||||
|
'pita'=>4,'toast'=>4,'bagel'=>4,'pain burger'=>4,'wrap'=>4,
|
||||||
|
// 5 = Frais
|
||||||
|
'lait'=>5,'creme'=>5,'yaourt'=>5,'yourt'=>5,'beurre'=>5,
|
||||||
|
'fromage'=>5,'camembert'=>5,'brie'=>5,'comte'=>5,'gruyere'=>5,
|
||||||
|
'emmental'=>5,'mozzarella'=>5,'ricotta'=>5,'mascarpone'=>5,
|
||||||
|
'feta'=>5,'chevre'=>5,'oeuf'=>5,'oeufs'=>5,'creme fraiche'=>5,
|
||||||
|
'fromage blanc'=>5,'petit suisse'=>5,'kefir'=>5,
|
||||||
|
// 6 = Surgelés
|
||||||
|
'surgele'=>6,'pizza surgelee'=>6,'frite surgelee'=>6,'poisson pane'=>6,
|
||||||
|
'legume surgele'=>6,'glace'=>6,'sorbet'=>6,'plat surgele'=>6,
|
||||||
|
'nugget'=>6,'edamame'=>6,'petits pois'=>6,'epinards'=>6,
|
||||||
|
// 7 = Boissons
|
||||||
|
'eau'=>7,'coca'=>7,'cola'=>7,'limonade'=>7,'sirop'=>7,
|
||||||
|
'biere'=>7,'vin'=>7,'champagne'=>7,'cafe'=>7,'the'=>7,
|
||||||
|
'tisane'=>7,'soda'=>7,'jus'=>7,'kombucha'=>7,'cidre'=>7,
|
||||||
|
'whisky'=>7,'rhum'=>7,'vodka'=>7,'prosecco'=>7,'rose'=>7,
|
||||||
|
// 8 = Pâtes, Riz, Féculents
|
||||||
|
'pate'=>8,'spaghetti'=>8,'tagliatelle'=>8,'penne'=>8,'fusilli'=>8,
|
||||||
|
'macaroni'=>8,'riz'=>8,'couscous'=>8,'quinoa'=>8,'lentille'=>8,
|
||||||
|
'pois chiche'=>8,'farine'=>8,'semoule'=>8,'polenta'=>8,'boulgour'=>8,
|
||||||
|
'orge'=>8,'feculent'=>8,'vermicelle'=>8,'nouille'=>8,'gnocchi'=>8,
|
||||||
|
// 9 = Épicerie salée
|
||||||
|
'chips'=>9,'crackers'=>9,'olives'=>9,'cornichon'=>9,'capres'=>9,
|
||||||
|
'tapenade'=>9,'houmous'=>9,'tzatziki'=>9,'sel'=>9,'poivre'=>9,
|
||||||
|
'epice'=>9,'cube bouillon'=>9,'bouillon'=>9,'noix de cajou'=>9,
|
||||||
|
'amande'=>9,'pistache'=>9,'noix'=>9,'cacahuete'=>9,
|
||||||
|
// 10 = Conserves
|
||||||
|
'conserve'=>10,'boite de tomate'=>10,'concentre de tomate'=>10,
|
||||||
|
'mais'=>10,'ratatouille'=>10,'cassoulet'=>10,'pate de foie'=>10,
|
||||||
|
'sardine boite'=>10,'thon boite'=>10,'maquereau boite'=>10,
|
||||||
|
'soupe boite'=>10,'haricot boite'=>10,
|
||||||
|
// 11 = Plats cuisinés
|
||||||
|
'quiche'=>11,'lasagne'=>11,'gratin'=>11,'pizza'=>11,'tartiflette'=>11,
|
||||||
|
'croque'=>11,'hachis parmentier'=>11,'paella'=>11,'moussaka'=>11,
|
||||||
|
'tarte'=>11,'flamiche'=>11,'pissaladiere'=>11,
|
||||||
|
// 12 = Sauces & Condiments
|
||||||
|
'ketchup'=>12,'mayonnaise'=>12,'moutarde'=>12,'vinaigrette'=>12,
|
||||||
|
'sauce soja'=>12,'tabasco'=>12,'huile'=>12,'vinaigre'=>12,
|
||||||
|
'sauce tomate'=>12,'pesto'=>12,'sriracha'=>12,'worcester'=>12,
|
||||||
|
'nuoc mam'=>12,'sauce'=>12,'condiment'=>12,'worcestershire'=>12,
|
||||||
|
// 13 = Petit déjeuner
|
||||||
|
'cereale'=>13,'muesli'=>13,'granola'=>13,'corn flakes'=>13,
|
||||||
|
'miel'=>13,'confiture'=>13,'nutella'=>13,'beurre de cacahuete'=>13,
|
||||||
|
'sirop d erable'=>13,'sirop erable'=>13,'chocolat en poudre'=>13,
|
||||||
|
// 14 = Biscuits & Gâteaux
|
||||||
|
'biscuit'=>14,'gateau'=>14,'cookie'=>14,'madeleine'=>14,
|
||||||
|
'financier'=>14,'quatre quarts'=>14,'brownie'=>14,'sable'=>14,
|
||||||
|
'speculoos'=>14,'oreo'=>14,'lu'=>14,'petit beurre'=>14,'galette'=>14,
|
||||||
|
'palmier'=>14,'macaron'=>14,'eclair'=>14,
|
||||||
|
// 15 = Confiserie
|
||||||
|
'chocolat'=>15,'bonbon'=>15,'reglisse'=>15,'caramel'=>15,
|
||||||
|
'nougat'=>15,'marshmallow'=>15,'guimauve'=>15,'sucette'=>15,
|
||||||
|
'chewing gum'=>15,'pastille'=>15,'calisson'=>15,
|
||||||
|
// 16 = Dessert
|
||||||
|
'yaourt dessert'=>16,'creme dessert'=>16,'mousse au chocolat'=>16,
|
||||||
|
'tiramisu'=>16,'creme brulee'=>16,'flan'=>16,'crepe'=>16,
|
||||||
|
'madeleine'=>16,'profiterole'=>16,'eclair'=>16,
|
||||||
|
// 17 = Beauté & Hygiène
|
||||||
|
'shampoing'=>17,'gel douche'=>17,'savon'=>17,'dentifrice'=>17,
|
||||||
|
'deodorant'=>17,'crème visage'=>17,'crème corps'=>17,'rasoir'=>17,
|
||||||
|
'mousse a raser'=>17,'coton'=>17,'lingette'=>17,'maquillage'=>17,
|
||||||
|
'parfum'=>17,'brosse a dents'=>17,'fil dentaire'=>17,'serum'=>17,
|
||||||
|
'hydratant'=>17,'demaquillant'=>17,
|
||||||
|
// 18 = Bébé
|
||||||
|
'couche'=>18,'biberon'=>18,'lait infantile'=>18,'compote bebe'=>18,
|
||||||
|
'pot bebe'=>18,'puree bebe'=>18,'lingette bebe'=>18,'savon bebe'=>18,
|
||||||
|
'creme bebe'=>18,'sucette bebe'=>18,
|
||||||
|
// 19 = Entretien
|
||||||
|
'lessive'=>19,'liquide vaisselle'=>19,'nettoyant'=>19,'degraissant'=>19,
|
||||||
|
'deboucheur'=>19,'anticalcaire'=>19,'eponge'=>19,'serpillere'=>19,
|
||||||
|
'sac poubelle'=>19,'papier toilette'=>19,'sopalin'=>19,'essuie tout'=>19,
|
||||||
|
'aluminium'=>19,'film plastique'=>19,'sac congelation'=>19,
|
||||||
|
'nettoyant wc'=>19,'desinfectant'=>19,'vitre'=>19,'javel'=>19,
|
||||||
|
// 20 = Animaux
|
||||||
|
'croquette'=>20,'patee'=>20,'litiere'=>20,'os'=>20,
|
||||||
|
'friandise animale'=>20,'nourriture chat'=>20,'nourriture chien'=>20,
|
||||||
|
// 21 = Maison & Jardin
|
||||||
|
'ampoule'=>21,'pile'=>21,'bougie'=>21,'allumette'=>21,
|
||||||
|
'ruban adhesif'=>21,'colle'=>21,'vis'=>21,'terreau'=>21,
|
||||||
|
'engrais'=>21,'pot de fleur'=>21,'arrosoir'=>21,'graine'=>21,
|
||||||
|
'tournevis'=>21,'marteau'=>21,'clou'=>21,'cle'=>21,
|
||||||
|
// 22 = Pharmacie
|
||||||
|
'doliprane'=>22,'ibuprofene'=>22,'paracetamol'=>22,'aspirine'=>22,
|
||||||
|
'bandage'=>22,'pansement'=>22,'thermometre'=>22,'serum physiologique'=>22,
|
||||||
|
'vitamine'=>22,'complement'=>22,'masque'=>22,'gant medical'=>22,
|
||||||
|
'sirop'=>22,'antihistaminique'=>22,'antidouleur'=>22,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check full phrase
|
||||||
|
if (isset($map[$normalized])) return $map[$normalized];
|
||||||
|
|
||||||
|
// Check individual words
|
||||||
|
$words = explode(' ', $normalized);
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if (strlen($w) >= 3 && isset($map[$w])) return $map[$w];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial match (contains)
|
||||||
|
foreach ($map as $kw => $catPos) {
|
||||||
|
if (str_contains($normalized, $kw)) return $catPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_detect_category(PDO $pdo, string $label): ?int {
|
||||||
|
$norm = liste_normalize_detect($label);
|
||||||
|
|
||||||
|
// 1. Learned rules (exact normalized label)
|
||||||
|
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||||
|
$r->execute([$norm]);
|
||||||
|
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||||
|
|
||||||
|
// 2. Learned rules (individual words)
|
||||||
|
foreach (explode(' ', $norm) as $w) {
|
||||||
|
if (strlen($w) < 3) continue;
|
||||||
|
$r = $pdo->prepare("SELECT category_id FROM pf_item_category_rules WHERE keyword=? LIMIT 1");
|
||||||
|
$r->execute([$w]);
|
||||||
|
if ($row = $r->fetch()) return (int)$row['category_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Built-in dictionary → map position to real category id
|
||||||
|
$pos = liste_builtin_detect($norm);
|
||||||
|
if ($pos === null) return null;
|
||||||
|
|
||||||
|
// Get category id by position order
|
||||||
|
$r = $pdo->query("SELECT id FROM pf_list_categories ORDER BY position, id");
|
||||||
|
$ids = $r->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
return $ids[$pos - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liste_learn(PDO $pdo, string $label, int $category_id): void {
|
||||||
|
$norm = liste_normalize_detect($label);
|
||||||
|
if (!$norm) return;
|
||||||
|
$pdo->prepare("INSERT INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE category_id=VALUES(category_id)")
|
||||||
|
->execute([$norm, $category_id]);
|
||||||
|
// Also learn individual meaningful words
|
||||||
|
foreach (explode(' ', $norm) as $w) {
|
||||||
|
if (strlen($w) >= 4) {
|
||||||
|
$pdo->prepare("INSERT IGNORE INTO pf_item_category_rules (keyword, category_id) VALUES (?,?)")
|
||||||
|
->execute([$w, $category_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$body = [];
|
||||||
|
if ($method === 'PUT' || $method === 'POST') {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw) $body = json_decode($raw, true) ?? [];
|
||||||
|
foreach ($_POST as $k => $v) if (!isset($body[$k])) $body[$k] = $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CATEGORIES ────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'categories' && $method === 'GET') {
|
||||||
|
$rows = $pdo->query("SELECT id, icon, name, position FROM pf_list_categories ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
echo json_encode(['categories' => $rows]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SET CATEGORY (+ learn) ────────────────────────────────────────────────────
|
||||||
|
if ($action === 'set_category' && $method === 'POST') {
|
||||||
|
$item_id = (int)($body['item_id'] ?? 0);
|
||||||
|
$cat_id_raw = $body['category_id'] ?? null;
|
||||||
|
$category_id = ($cat_id_raw === null || $cat_id_raw === '' || $cat_id_raw === '0') ? null : (int)$cat_id_raw;
|
||||||
|
|
||||||
|
if (!$item_id) { http_response_code(400); echo json_encode(['error' => 'item_id required']); exit; }
|
||||||
|
|
||||||
|
$pdo->prepare("UPDATE pf_grocery_items SET category_id=? WHERE id=?")->execute([$category_id, $item_id]);
|
||||||
|
|
||||||
|
// Learn from manual assignment
|
||||||
|
if ($category_id !== null) {
|
||||||
|
$row = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?");
|
||||||
|
$row->execute([$item_id]);
|
||||||
|
if ($r = $row->fetch()) liste_learn($pdo, $r['label'], $category_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LISTS ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'lists') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$firstId = liste_ensure_default($pdo);
|
||||||
|
$rows = $pdo->query("SELECT id, name, position FROM pf_lists ORDER BY position, id")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
echo json_encode(['lists' => $rows, 'default_id' => $firstId]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
|
||||||
|
if (!$name) { http_response_code(400); echo json_encode(['error' => 'name required']); exit; }
|
||||||
|
$pos = (int)$pdo->query("SELECT COALESCE(MAX(position),0)+1 FROM pf_lists")->fetchColumn();
|
||||||
|
$pdo->prepare("INSERT INTO pf_lists (name, position) VALUES (?,?)")->execute([$name, $pos]);
|
||||||
|
echo json_encode(['id' => (int)$pdo->lastInsertId(), 'name' => $name, 'position' => $pos]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$name = mb_substr(trim($body['name'] ?? ''), 0, 100);
|
||||||
|
if (!$id || !$name) { http_response_code(400); echo json_encode(['error' => 'invalid']); exit; }
|
||||||
|
$pdo->prepare("UPDATE pf_lists SET name=? WHERE id=?")->execute([$name, $id]);
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if ((int)$pdo->query("SELECT COUNT(*) FROM pf_lists")->fetchColumn() <= 1) {
|
||||||
|
http_response_code(400); echo json_encode(['error' => 'cannot delete last list']); exit;
|
||||||
|
}
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$id]);
|
||||||
|
$pdo->prepare("DELETE FROM pf_lists WHERE id=?")->execute([$id]);
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ITEMS ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'items') {
|
||||||
|
$list_id = (int)($_GET['list_id'] ?? $body['list_id'] ?? 0);
|
||||||
|
if ($method === 'GET') {
|
||||||
|
if (!$list_id) { http_response_code(400); echo json_encode(['error' => 'list_id required']); exit; }
|
||||||
|
$s = $pdo->prepare("SELECT id, list_id, category_id, label, in_cart, position FROM pf_grocery_items WHERE list_id=? ORDER BY in_cart, position, id");
|
||||||
|
$s->execute([$list_id]);
|
||||||
|
echo json_encode(['items' => $s->fetchAll(PDO::FETCH_ASSOC)]); exit;
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$label = trim($body['label'] ?? '');
|
||||||
|
if (!$list_id || !$label) { http_response_code(400); echo json_encode(['error' => 'missing fields']); exit; }
|
||||||
|
$norm = liste_normalize($label);
|
||||||
|
$dup = $pdo->prepare("SELECT COUNT(*) FROM pf_grocery_items WHERE list_id=? AND LOWER(TRIM(label))=?");
|
||||||
|
$dup->execute([$list_id, $norm]);
|
||||||
|
if ((int)$dup->fetchColumn() > 0) { echo json_encode(['duplicate' => true]); exit; }
|
||||||
|
$q = $pdo->prepare("SELECT COALESCE(MAX(position),0)+1 FROM pf_grocery_items WHERE list_id=?");
|
||||||
|
$q->execute([$list_id]);
|
||||||
|
$pos = (int)$q->fetchColumn();
|
||||||
|
$category_id = liste_detect_category($pdo, $label);
|
||||||
|
$pdo->prepare("INSERT INTO pf_grocery_items (list_id, category_id, label, in_cart, position) VALUES (?,?,?,0,?)")->execute([$list_id, $category_id, $label, $pos]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
liste_touch_history($pdo, $label);
|
||||||
|
echo json_encode(['id' => $id, 'list_id' => $list_id, 'category_id' => $category_id, 'label' => $label, 'in_cart' => 0, 'position' => $pos]); exit;
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
if (isset($body['in_cart'])) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=? WHERE id=?")->execute([(int)$body['in_cart'], $id]);
|
||||||
|
if (isset($body['label'])) {
|
||||||
|
$label = trim($body['label']);
|
||||||
|
if ($label) {
|
||||||
|
$pdo->prepare("UPDATE pf_grocery_items SET label=? WHERE id=?")->execute([$label, $id]);
|
||||||
|
liste_touch_history($pdo, $label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) { http_response_code(400); echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
$r = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE id=?"); $r->execute([$id]);
|
||||||
|
if ($row = $r->fetch()) liste_touch_history($pdo, $row['label']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE id=?")->execute([$id]);
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BULK ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'uncheck_all' && $method === 'POST') {
|
||||||
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
|
if ($lid) $pdo->prepare("UPDATE pf_grocery_items SET in_cart=0 WHERE list_id=?")->execute([$lid]);
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
if ($action === 'delete_picked' && $method === 'POST') {
|
||||||
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
|
if ($lid) {
|
||||||
|
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=? AND in_cart=1"); $rows->execute([$lid]);
|
||||||
|
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=? AND in_cart=1")->execute([$lid]);
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
if ($action === 'clear_all' && $method === 'POST') {
|
||||||
|
$lid = (int)($body['list_id'] ?? 0);
|
||||||
|
if ($lid) {
|
||||||
|
$rows = $pdo->prepare("SELECT label FROM pf_grocery_items WHERE list_id=?"); $rows->execute([$lid]);
|
||||||
|
foreach ($rows->fetchAll() as $r) liste_touch_history($pdo, $r['label']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_grocery_items WHERE list_id=?")->execute([$lid]);
|
||||||
|
}
|
||||||
|
echo json_encode(['ok' => true]); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HISTORY ───────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'history' && $method === 'GET') {
|
||||||
|
$max = 20;
|
||||||
|
$n = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='setting' AND reference_id='liste_history_max'"); $n->execute();
|
||||||
|
if ($r = $n->fetch()) $max = max(1, min(50, (int)$r['content']));
|
||||||
|
$s = $pdo->query("SELECT label_display FROM pf_grocery_history ORDER BY last_used_at DESC LIMIT $max");
|
||||||
|
echo json_encode(['history' => $s->fetchAll(PDO::FETCH_COLUMN)]); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'unknown action: ' . $action]);
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
/* ── Module Liste ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-main {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs bar ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.liste-tabs-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.liste-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: .35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .4rem .75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.liste-tab:hover { background: var(--hover-bg, #f8f9fa); }
|
||||||
|
.liste-tab.active {
|
||||||
|
background: var(--primary, #4361ee);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary, #4361ee);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-tab-name { pointer-events: none; }
|
||||||
|
|
||||||
|
.liste-tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 .1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: rgba(255,255,255,.75);
|
||||||
|
line-height: 1;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.liste-tab-btn:hover { color: #fff; }
|
||||||
|
|
||||||
|
.liste-tab-add {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px dashed var(--border, #dee2e6);
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.liste-tab-add:hover {
|
||||||
|
border-color: var(--primary, #4361ee);
|
||||||
|
color: var(--primary, #4361ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── New list form ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-new-form {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.liste-new-form input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: .45rem .75rem;
|
||||||
|
border: 1px solid var(--primary, #4361ee);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: .9rem;
|
||||||
|
background: var(--input-bg, #fff);
|
||||||
|
color: var(--text, #212529);
|
||||||
|
}
|
||||||
|
.liste-new-form button {
|
||||||
|
padding: .45rem .9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
.liste-new-form #btn-confirm-new-list {
|
||||||
|
background: var(--primary, #4361ee);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.liste-new-form #btn-cancel-new-list {
|
||||||
|
background: var(--muted-bg, #e9ecef);
|
||||||
|
color: var(--text, #212529);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ── Add card ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-add-card {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
.liste-add-card input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: .95rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text, #212529);
|
||||||
|
}
|
||||||
|
.liste-add-card button {
|
||||||
|
background: var(--primary, #4361ee);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .4rem .9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.btn-tool {
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text, #212529);
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-tool:hover { background: var(--hover-bg, #f8f9fa); }
|
||||||
|
.btn-tool-danger { color: var(--danger, #e63946); border-color: var(--danger, #e63946); }
|
||||||
|
.btn-tool-danger:hover { background: rgba(230,57,70,.08); }
|
||||||
|
|
||||||
|
/* ── Items ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .55rem .75rem;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.liste-row.in-cart { opacity: .55; }
|
||||||
|
.liste-row.in-cart .liste-label { text-decoration: line-through; }
|
||||||
|
|
||||||
|
.liste-check-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.liste-check {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
accent-color: var(--primary, #4361ee);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.liste-label { font-size: .95rem; }
|
||||||
|
|
||||||
|
.liste-row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.liste-row:hover .liste-row-actions,
|
||||||
|
.liste-row:focus-within .liste-row-actions { opacity: 1; }
|
||||||
|
|
||||||
|
.btn-edit-item, .btn-delete-item {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .2rem .3rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: .9rem;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.btn-edit-item:hover { background: var(--hover-bg, #f0f0f0); }
|
||||||
|
.btn-delete-item:hover { background: rgba(230,57,70,.1); }
|
||||||
|
|
||||||
|
.liste-cart-divider {
|
||||||
|
font-size: .78rem;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: .5rem .25rem .25rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-empty-items {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
}
|
||||||
|
.liste-empty-items span { font-size: 2rem; display: block; margin-bottom: .5rem; }
|
||||||
|
|
||||||
|
/* ── History ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-history-card {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: .75rem;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.liste-history-title {
|
||||||
|
font-size: .75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.liste-history-chips { display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||||
|
.liste-chip {
|
||||||
|
padding: .25rem .65rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
background: var(--muted-bg, #f8f9fa);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--text, #212529);
|
||||||
|
transition: background .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.liste-chip:hover {
|
||||||
|
background: var(--primary-light, #eef0fd);
|
||||||
|
border-color: var(--primary, #4361ee);
|
||||||
|
color: var(--primary, #4361ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toast ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.liste-toast {
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: .875rem;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 3px 12px rgba(0,0,0,.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity .2s, transform .2s;
|
||||||
|
}
|
||||||
|
.liste-toast.show { opacity: 1; transform: translateY(0); }
|
||||||
|
.liste-toast-warn { background: #f0a500; }
|
||||||
|
.liste-toast-error { background: #e63946; }
|
||||||
|
|
||||||
|
/* ── Empty state (no lists at all) ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
}
|
||||||
|
.liste-empty-icon { font-size: 3rem; margin-bottom: .75rem; }
|
||||||
|
|
||||||
|
/* ── Category section headers ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-section-header {
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
padding: .6rem .25rem .2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
.liste-cat-section-header:first-child { padding-top: .1rem; }
|
||||||
|
|
||||||
|
.liste-cat-section-count {
|
||||||
|
background: var(--muted-bg, #e9ecef);
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .7rem;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Category badge (per-item) ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .1rem .3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.liste-cat-badge:hover {
|
||||||
|
border-color: var(--primary, #4361ee);
|
||||||
|
background: var(--primary-light, #eef0fd);
|
||||||
|
}
|
||||||
|
.liste-cat-badge-empty {
|
||||||
|
opacity: .35;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.liste-row:hover .liste-cat-badge-empty { opacity: .7; }
|
||||||
|
|
||||||
|
/* ── Category picker popover ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liste-cat-picker {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border, #dee2e6);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.15);
|
||||||
|
width: 216px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-title {
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: var(--muted, #6c757d);
|
||||||
|
padding: .6rem .75rem .35rem;
|
||||||
|
border-bottom: 1px solid var(--border, #dee2e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
padding: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-cat-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
font-size: .83rem;
|
||||||
|
color: var(--text, #212529);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.liste-cat-picker-item:hover { background: var(--hover-bg, #f8f9fa); }
|
||||||
|
.liste-cat-picker-item.active {
|
||||||
|
background: var(--primary-light, #eef0fd);
|
||||||
|
color: var(--primary, #4361ee);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme='dark'] .liste-tab { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-tab:hover { background: #2a2a3e; }
|
||||||
|
[data-theme='dark'] .liste-tab.active { background: #4361ee; color: #fff; border-color: #4361ee; }
|
||||||
|
[data-theme='dark'] .liste-add-card,
|
||||||
|
[data-theme='dark'] .liste-row,
|
||||||
|
[data-theme='dark'] .liste-history-card { background: #1e1e2e; border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-add-card input { color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-chip { background: #2a2a3e; border-color: #3a3a4e; color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-chip:hover { background: #313155; border-color: #4361ee; color: #89b4fa; }
|
||||||
|
[data-theme='dark'] .btn-tool { background: #1e1e2e; border-color: #3a3a4e; color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .btn-tool:hover { background: #2a2a3e; }
|
||||||
|
[data-theme='dark'] .liste-new-form input { background: #1e1e2e; color: #cdd6f4; border-color: #4361ee; }
|
||||||
|
[data-theme='dark'] .liste-new-form #btn-cancel-new-list { background: #2a2a3e; color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-cat-badge { border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-badge:hover { background: #313155; border-color: #4361ee; }
|
||||||
|
[data-theme='dark'] .liste-cat-section-count { background: #2a2a3e; color: #8892b0; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker { background: #1e1e2e; border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-title { border-color: #3a3a4e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item { color: #cdd6f4; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item:hover { background: #2a2a3e; }
|
||||||
|
[data-theme='dark'] .liste-cat-picker-item.active { background: #313155; color: #89b4fa; }
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/* Liste module JS */
|
||||||
|
const API = '/modules/liste/api.php';
|
||||||
|
const T = window.LISTE_TRANSLATIONS || {};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
lists: [],
|
||||||
|
currentListId: null,
|
||||||
|
items: [],
|
||||||
|
history: [],
|
||||||
|
categories: [],
|
||||||
|
catMap: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
function toast(msg, type = 'info') {
|
||||||
|
let c = document.getElementById('liste-toast-container');
|
||||||
|
if (!c) {
|
||||||
|
c = document.createElement('div');
|
||||||
|
c.id = 'liste-toast-container';
|
||||||
|
c.className = 'liste-toast-container';
|
||||||
|
document.body.appendChild(c);
|
||||||
|
}
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `liste-toast liste-toast-${type}`;
|
||||||
|
t.textContent = msg;
|
||||||
|
c.appendChild(t);
|
||||||
|
setTimeout(() => t.classList.add('show'), 10);
|
||||||
|
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(action, method = 'GET', body = null, params = {}) {
|
||||||
|
const url = new URL(API, location.origin);
|
||||||
|
url.searchParams.set('action', action);
|
||||||
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Categories ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadCategories() {
|
||||||
|
if (state.categories.length) return;
|
||||||
|
const r = await api('categories');
|
||||||
|
state.categories = r.categories || [];
|
||||||
|
state.catMap = {};
|
||||||
|
state.categories.forEach(c => { state.catMap[c.id] = c; });
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePicker = null;
|
||||||
|
|
||||||
|
function openCategoryPicker(itemId, anchorEl) {
|
||||||
|
closeCategoryPicker();
|
||||||
|
const item = state.items.find(i => i.id === itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const picker = document.createElement('div');
|
||||||
|
picker.className = 'liste-cat-picker';
|
||||||
|
picker.innerHTML = `
|
||||||
|
<div class="liste-cat-picker-title">Catégorie</div>
|
||||||
|
<div class="liste-cat-picker-list">
|
||||||
|
<button class="liste-cat-picker-item${!item.category_id ? ' active' : ''}" data-cat="0">🏷️ Non classé</button>
|
||||||
|
${state.categories.map(c =>
|
||||||
|
`<button class="liste-cat-picker-item${item.category_id == c.id ? ' active' : ''}" data-cat="${c.id}">${esc(c.icon)} ${esc(c.name)}</button>`
|
||||||
|
).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
|
let top = rect.bottom + 4;
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + 220 > window.innerWidth) left = window.innerWidth - 228;
|
||||||
|
if (top + 340 > window.innerHeight) top = rect.top - Math.min(340, top + 340 - window.innerHeight) - 4;
|
||||||
|
picker.style.top = top + 'px';
|
||||||
|
picker.style.left = left + 'px';
|
||||||
|
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
activePicker = picker;
|
||||||
|
|
||||||
|
picker.querySelectorAll('.liste-cat-picker-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const catId = parseInt(btn.dataset.cat) || null;
|
||||||
|
await api('set_category', 'POST', { item_id: itemId, category_id: catId });
|
||||||
|
const it = state.items.find(i => i.id === itemId);
|
||||||
|
if (it) it.category_id = catId;
|
||||||
|
closeCategoryPicker();
|
||||||
|
renderItems();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', closeCategoryPicker, { once: true });
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCategoryPicker() {
|
||||||
|
if (activePicker) { activePicker.remove(); activePicker = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tabs ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderTabs() {
|
||||||
|
const container = document.getElementById('liste-tabs');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
state.lists.forEach(list => {
|
||||||
|
const tab = document.createElement('div');
|
||||||
|
tab.className = 'liste-tab' + (list.id === state.currentListId ? ' active' : '');
|
||||||
|
tab.dataset.id = list.id;
|
||||||
|
|
||||||
|
if (list.id === state.currentListId) {
|
||||||
|
tab.innerHTML = `
|
||||||
|
<span class="liste-tab-name">${esc(list.name)}</span>
|
||||||
|
<button class="liste-tab-btn liste-tab-rename" title="${esc(T.rename_list)}" data-id="${list.id}">✏</button>
|
||||||
|
${state.lists.length > 1 ? `<button class="liste-tab-btn liste-tab-delete" title="Supprimer" data-id="${list.id}">×</button>` : ''}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tab.innerHTML = `<span class="liste-tab-name">${esc(list.name)}</span>`;
|
||||||
|
tab.addEventListener('click', () => switchList(list.id));
|
||||||
|
}
|
||||||
|
container.appendChild(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.liste-tab-rename').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); startRename(parseInt(btn.dataset.id)); });
|
||||||
|
});
|
||||||
|
container.querySelectorAll('.liste-tab-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); deleteList(parseInt(btn.dataset.id)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(listId) {
|
||||||
|
const list = state.lists.find(l => l.id === listId);
|
||||||
|
if (!list) return;
|
||||||
|
const name = prompt(T.new_name || 'Nouveau nom :', list.name);
|
||||||
|
if (name === null || name.trim() === '') return;
|
||||||
|
api('lists', 'PUT', { name: name.trim() }, { id: listId }).then(r => {
|
||||||
|
if (r.ok) {
|
||||||
|
list.name = name.trim();
|
||||||
|
renderTabs();
|
||||||
|
toast(T.list_renamed || 'Liste renommée');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteList(listId) {
|
||||||
|
if (!confirm(T.confirm_delete_list || 'Supprimer cette liste et tous ses articles ?')) return;
|
||||||
|
const r = await api('lists', 'DELETE', null, { id: listId });
|
||||||
|
if (r.error) { toast(r.error, 'error'); return; }
|
||||||
|
state.lists = state.lists.filter(l => l.id !== listId);
|
||||||
|
if (state.currentListId === listId) {
|
||||||
|
state.currentListId = state.lists[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
await loadItems();
|
||||||
|
renderTabs();
|
||||||
|
toast(T.list_deleted || 'Liste supprimée');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List management ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-add-list')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('liste-new-form')?.classList.remove('hidden');
|
||||||
|
document.getElementById('btn-add-list')?.classList.add('hidden');
|
||||||
|
document.getElementById('new-list-name')?.focus();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-cancel-new-list')?.addEventListener('click', cancelNewList);
|
||||||
|
document.getElementById('btn-confirm-new-list')?.addEventListener('click', confirmNewList);
|
||||||
|
document.getElementById('new-list-name')?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') confirmNewList();
|
||||||
|
if (e.key === 'Escape') cancelNewList();
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelNewList() {
|
||||||
|
document.getElementById('liste-new-form')?.classList.add('hidden');
|
||||||
|
document.getElementById('btn-add-list')?.classList.remove('hidden');
|
||||||
|
if (document.getElementById('new-list-name')) document.getElementById('new-list-name').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmNewList() {
|
||||||
|
const input = document.getElementById('new-list-name');
|
||||||
|
const name = input?.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const r = await api('lists', 'POST', { name });
|
||||||
|
if (r.id) {
|
||||||
|
state.lists.push({ id: r.id, name: r.name, position: r.position });
|
||||||
|
state.currentListId = r.id;
|
||||||
|
cancelNewList();
|
||||||
|
await loadItems();
|
||||||
|
renderTabs();
|
||||||
|
toast((T.list_created || 'Liste créée') + ' : ' + r.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Switch list ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function switchList(listId) {
|
||||||
|
state.currentListId = listId;
|
||||||
|
await loadItems();
|
||||||
|
renderTabs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Items ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
if (!state.currentListId) {
|
||||||
|
renderItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await api('items', 'GET', null, { list_id: state.currentListId });
|
||||||
|
state.items = r.items || [];
|
||||||
|
renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems() {
|
||||||
|
const root = document.getElementById('liste-items-root');
|
||||||
|
if (!root) return;
|
||||||
|
const toolbar = document.getElementById('liste-toolbar');
|
||||||
|
if (!state.currentListId || state.items.length === 0) {
|
||||||
|
root.innerHTML = state.currentListId
|
||||||
|
? `<div class="liste-empty-items"><span>📝</span><p>Liste vide — ajoutez un article ci-dessus.</p></div>`
|
||||||
|
: '';
|
||||||
|
if (toolbar) toolbar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toolbar) toolbar.style.display = '';
|
||||||
|
|
||||||
|
const pending = state.items.filter(i => !i.in_cart);
|
||||||
|
const inCart = state.items.filter(i => i.in_cart);
|
||||||
|
const hasCategories = pending.some(i => i.category_id);
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (hasCategories && state.categories.length) {
|
||||||
|
// Group pending items by category
|
||||||
|
const groups = new Map();
|
||||||
|
pending.forEach(item => {
|
||||||
|
const key = item.category_id ?? 0;
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render in category order, uncategorized last
|
||||||
|
const catIds = state.categories.map(c => parseInt(c.id));
|
||||||
|
const orderedKeys = catIds.filter(id => groups.has(id));
|
||||||
|
if (groups.has(0)) orderedKeys.push(0);
|
||||||
|
|
||||||
|
orderedKeys.forEach(key => {
|
||||||
|
const items = groups.get(key);
|
||||||
|
const cat = key ? state.catMap[key] : null;
|
||||||
|
const label = cat ? `${cat.icon} ${cat.name}` : '🏷️ Non classé';
|
||||||
|
html += `<div class="liste-cat-section-header">${esc(label)} <span class="liste-cat-section-count">${items.length}</span></div>`;
|
||||||
|
html += items.map(rowHtml).join('');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html = pending.map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCart.length) {
|
||||||
|
html += `<div class="liste-cart-divider">Dans le panier (${inCart.length})</div>` + inCart.map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = html;
|
||||||
|
|
||||||
|
root.querySelectorAll('.liste-check').forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => toggleCart(parseInt(cb.dataset.id), cb.checked ? 1 : 0));
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.btn-edit-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => editItem(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.btn-delete-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteItem(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
root.querySelectorAll('.liste-cat-badge').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => { e.stopPropagation(); openCategoryPicker(parseInt(btn.dataset.id), btn); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(item) {
|
||||||
|
const checked = item.in_cart ? 'checked' : '';
|
||||||
|
const cls = item.in_cart ? 'liste-row in-cart' : 'liste-row';
|
||||||
|
const cat = item.category_id ? state.catMap[item.category_id] : null;
|
||||||
|
const badgeCls = 'liste-cat-badge' + (cat ? '' : ' liste-cat-badge-empty');
|
||||||
|
const badgeIcon = cat ? esc(cat.icon) : '🏷️';
|
||||||
|
const badgeTip = cat ? esc(cat.name) : 'Non classé';
|
||||||
|
return `<div class="${cls}" data-id="${item.id}">
|
||||||
|
<button class="${badgeCls}" data-id="${item.id}" title="${badgeTip}">${badgeIcon}</button>
|
||||||
|
<label class="liste-check-label">
|
||||||
|
<input type="checkbox" class="liste-check" data-id="${item.id}" ${checked}>
|
||||||
|
<span class="liste-label">${esc(item.label)}</span>
|
||||||
|
</label>
|
||||||
|
<div class="liste-row-actions">
|
||||||
|
<button class="btn-edit-item" data-id="${item.id}" title="Modifier">✏</button>
|
||||||
|
<button class="btn-delete-item" data-id="${item.id}" title="Supprimer">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addItem(label) {
|
||||||
|
const r = await api('items', 'POST', { list_id: state.currentListId, label });
|
||||||
|
if (r.duplicate) { toast(T.already_in || 'Déjà dans la liste', 'warn'); return; }
|
||||||
|
if (r.id) {
|
||||||
|
state.items.push(r);
|
||||||
|
renderItems();
|
||||||
|
renderHistory();
|
||||||
|
toast(T.added || 'Ajouté');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCart(id, next) {
|
||||||
|
await api('items', 'PUT', { in_cart: next }, { id });
|
||||||
|
const item = state.items.find(i => i.id === id);
|
||||||
|
if (item) item.in_cart = next;
|
||||||
|
renderItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editItem(id) {
|
||||||
|
const item = state.items.find(i => i.id === id);
|
||||||
|
if (!item) return;
|
||||||
|
const newLabel = prompt(T.edit_placeholder || 'Modifier :', item.label);
|
||||||
|
if (newLabel === null || newLabel.trim() === '') return;
|
||||||
|
await api('items', 'PUT', { label: newLabel.trim() }, { id });
|
||||||
|
item.label = newLabel.trim();
|
||||||
|
renderItems();
|
||||||
|
toast(T.updated || 'Modifié');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteItem(id) {
|
||||||
|
await api('items', 'DELETE', null, { id });
|
||||||
|
state.items = state.items.filter(i => i.id !== id);
|
||||||
|
renderItems();
|
||||||
|
toast(T.deleted || 'Supprimé');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-uncheck-all')?.addEventListener('click', async () => {
|
||||||
|
if (!state.currentListId) return;
|
||||||
|
await api('uncheck_all', 'POST', { list_id: state.currentListId });
|
||||||
|
state.items.forEach(i => i.in_cart = 0);
|
||||||
|
renderItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-delete-picked')?.addEventListener('click', async () => {
|
||||||
|
if (!state.currentListId) return;
|
||||||
|
await api('delete_picked', 'POST', { list_id: state.currentListId });
|
||||||
|
state.items = state.items.filter(i => !i.in_cart);
|
||||||
|
renderItems();
|
||||||
|
toast(T.deleted || 'Supprimés');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-all')?.addEventListener('click', async () => {
|
||||||
|
if (!confirm(T.confirm_clear || 'Vider toute la liste ?')) return;
|
||||||
|
await api('clear_all', 'POST', { list_id: state.currentListId });
|
||||||
|
state.items = [];
|
||||||
|
renderItems();
|
||||||
|
toast(T.deleted || 'Liste vidée');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add input ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-liste-add')?.addEventListener('click', () => {
|
||||||
|
const input = document.getElementById('liste-input');
|
||||||
|
const v = input?.value.trim();
|
||||||
|
if (v) { addItem(v); input.value = ''; input.focus(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('liste-input')?.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') document.getElementById('btn-liste-add')?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── History ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
const r = await api('history');
|
||||||
|
state.history = r.history || [];
|
||||||
|
renderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory() {
|
||||||
|
const root = document.getElementById('liste-history-root');
|
||||||
|
if (!root || !state.history.length) { if (root) root.innerHTML = ''; return; }
|
||||||
|
const currentLabels = new Set(state.items.map(i => i.label.toLowerCase().trim()));
|
||||||
|
const suggestions = state.history.filter(h => !currentLabels.has(h.toLowerCase().trim()));
|
||||||
|
if (!suggestions.length) { root.innerHTML = ''; return; }
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="liste-history-card">
|
||||||
|
<div class="liste-history-title">${T.history_title || 'Récemment utilisés'}</div>
|
||||||
|
<div class="liste-history-chips">
|
||||||
|
${suggestions.map(h => `<button class="liste-chip" data-label="${esc(h)}">${esc(h)}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
root.querySelectorAll('.liste-chip').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
if (state.currentListId) addItem(btn.dataset.label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const [listsR] = await Promise.all([api('lists'), loadCategories()]);
|
||||||
|
state.lists = listsR.lists || [];
|
||||||
|
state.currentListId = listsR.default_id || state.lists[0]?.id || null;
|
||||||
|
renderTabs();
|
||||||
|
await Promise.all([loadItems(), loadHistory()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
$UPLOAD_DIR = '/uploads/memo/';
|
||||||
|
if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function mOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; }
|
||||||
|
function mErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function mBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
function parseTags(string $raw): string {
|
||||||
|
$tags = array_unique(array_filter(array_map(function($t) {
|
||||||
|
return strtolower(trim(ltrim($t, '#'), " \t\n\r,"));
|
||||||
|
}, preg_split('/[\s,]+/', $raw))));
|
||||||
|
return implode(',', $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function imgExts(): array { return ['jpg','jpeg','png','gif','webp','svg','bmp']; }
|
||||||
|
function isImage(string $fname): bool { return in_array(strtolower(pathinfo($fname, PATHINFO_EXTENSION)), imgExts()); }
|
||||||
|
|
||||||
|
function handleUpload(string $field, string $dir): ?array {
|
||||||
|
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
|
||||||
|
$orig = $_FILES[$field]['name'];
|
||||||
|
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
|
||||||
|
$allowed = array_merge(imgExts(), ['pdf','doc','docx','txt','csv','zip','mp3','mp4']);
|
||||||
|
if (!in_array($ext, $allowed)) return null;
|
||||||
|
$fname = uniqid('m', true) . '.' . $ext;
|
||||||
|
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
|
||||||
|
return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NOTES ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'notes') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
$tag = trim($_GET['tag'] ?? '');
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$perPage = 24;
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
$n = $pdo->prepare("SELECT * FROM pf_memo_notes WHERE id = ?"); $n->execute([$id]);
|
||||||
|
$note = $n->fetch(); if (!$note) mErr('Note introuvable', 404);
|
||||||
|
$a = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE note_id = ? ORDER BY created_at");
|
||||||
|
$a->execute([$id]); $note['attachments'] = $a->fetchAll();
|
||||||
|
mOk($note);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($q) {
|
||||||
|
$like = '%' . $q . '%';
|
||||||
|
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
|
||||||
|
FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?
|
||||||
|
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
|
||||||
|
$stmt->execute([$like, $like, $like, $perPage, $offset]);
|
||||||
|
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?");
|
||||||
|
$total->execute([$like, $like, $like]);
|
||||||
|
} elseif ($tag) {
|
||||||
|
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
|
||||||
|
FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0
|
||||||
|
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
|
||||||
|
$stmt->execute([$tag, $perPage, $offset]);
|
||||||
|
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0");
|
||||||
|
$total->execute([$tag]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
|
||||||
|
FROM pf_memo_notes ORDER BY updated_at DESC LIMIT ? OFFSET ?");
|
||||||
|
$stmt->execute([$perPage, $offset]);
|
||||||
|
$total = $pdo->query("SELECT COUNT(*) FROM pf_memo_notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
mOk(['notes' => $stmt->fetchAll(), 'total' => (int)$total->fetchColumn(),
|
||||||
|
'page' => $page, 'per_page' => $perPage]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = $_POST ?: mBody();
|
||||||
|
$title = trim($d['title'] ?? '');
|
||||||
|
if (!$title) mErr('Titre requis');
|
||||||
|
$tags = parseTags($d['tags'] ?? '');
|
||||||
|
$pdo->prepare("INSERT INTO pf_memo_notes (title, content, tags) VALUES (?,?,?)")
|
||||||
|
->execute([$title, $d['content'] ?? '', $tags]);
|
||||||
|
$id = $pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Files
|
||||||
|
if (!empty($_FILES)) {
|
||||||
|
foreach ($_FILES as $key => $f) {
|
||||||
|
if ($f['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$up = handleUpload($key, $UPLOAD_DIR);
|
||||||
|
if ($up) {
|
||||||
|
$type = isImage($up['filename']) ? 'image' : 'file';
|
||||||
|
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
|
||||||
|
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// URLs from JSON body
|
||||||
|
foreach (($d['urls'] ?? []) as $urlItem) {
|
||||||
|
if (!empty($urlItem['url'])) {
|
||||||
|
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
|
||||||
|
->execute([$id, 'url', $urlItem['url'], $urlItem['label'] ?? '']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mOk(['id' => (int)$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
|
||||||
|
$d = mBody();
|
||||||
|
$title = trim($d['title'] ?? '');
|
||||||
|
if (!$title) mErr('Titre requis');
|
||||||
|
$tags = parseTags($d['tags'] ?? '');
|
||||||
|
$pdo->prepare("UPDATE pf_memo_notes SET title=?, content=?, tags=?, updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$title, $d['content'] ?? '', $tags, $id]);
|
||||||
|
mOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
|
||||||
|
$rows = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE note_id=? AND filename IS NOT NULL");
|
||||||
|
$rows->execute([$id]);
|
||||||
|
foreach ($rows->fetchAll() as $r) @unlink($UPLOAD_DIR . $r['filename']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_memo_notes WHERE id=?")->execute([$id]);
|
||||||
|
mOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TAGS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'tags') {
|
||||||
|
$rows = $pdo->query("SELECT tags FROM pf_memo_notes WHERE tags != ''")->fetchAll();
|
||||||
|
$counts = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
foreach (explode(',', $r['tags']) as $t) {
|
||||||
|
$t = trim($t);
|
||||||
|
if ($t) $counts[$t] = ($counts[$t] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
arsort($counts);
|
||||||
|
mOk(array_map(fn($t, $c) => ['tag' => $t, 'count' => $c], array_keys($counts), $counts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ATTACHMENTS ───────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'attachments') {
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$id = $_POST['note_id'] ?? null; if (!$id) mErr('note_id manquant');
|
||||||
|
// File upload
|
||||||
|
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$up = handleUpload('file', $UPLOAD_DIR);
|
||||||
|
if (!$up) mErr('Upload échoué ou format non autorisé');
|
||||||
|
$type = isImage($up['filename']) ? 'image' : 'file';
|
||||||
|
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
|
||||||
|
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
|
||||||
|
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => $type,
|
||||||
|
'filename' => $up['filename'], 'original_name' => $up['original_name']]);
|
||||||
|
}
|
||||||
|
// URL
|
||||||
|
$url = trim($_POST['url'] ?? '');
|
||||||
|
if ($url) {
|
||||||
|
$label = trim($_POST['label'] ?? '');
|
||||||
|
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
|
||||||
|
->execute([$id, 'url', $url, $label]);
|
||||||
|
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => 'url', 'url' => $url, 'label' => $label]);
|
||||||
|
}
|
||||||
|
mErr('Aucun fichier ou URL fourni');
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
|
||||||
|
$r = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE id=?"); $r->execute([$id]);
|
||||||
|
$row = $r->fetch();
|
||||||
|
if ($row && $row['filename']) @unlink($UPLOAD_DIR . $row['filename']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_memo_attachments WHERE id=?")->execute([$id]);
|
||||||
|
mOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FILE SERVE ────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'file') {
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
$row = null;
|
||||||
|
if ($id) { $s = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE id=?"); $s->execute([$id]); $row = $s->fetch(); }
|
||||||
|
if (!$row || !$row['filename'] || !file_exists($UPLOAD_DIR . $row['filename'])) { http_response_code(404); exit; }
|
||||||
|
$ext = strtolower(pathinfo($row['filename'], PATHINFO_EXTENSION));
|
||||||
|
$mime = [
|
||||||
|
'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif',
|
||||||
|
'webp'=>'image/webp','svg'=>'image/svg+xml','pdf'=>'application/pdf',
|
||||||
|
'txt'=>'text/plain','mp4'=>'video/mp4','mp3'=>'audio/mpeg',
|
||||||
|
][$ext] ?? 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($row['original_name'] ?? $row['filename']) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($UPLOAD_DIR . $row['filename']); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
mErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/* Memo module */
|
||||||
|
|
||||||
|
.memo-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.memo-sidebar {
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.memo-sidebar-top {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .6rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.memo-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
}
|
||||||
|
.memo-search input {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: .875rem;
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.memo-search input::placeholder { color: var(--text-muted); }
|
||||||
|
.memo-tags-list { padding: .5rem 0; }
|
||||||
|
.memo-tag-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: .4rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 0;
|
||||||
|
transition: background .12s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.memo-tag-item:hover { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
.memo-tag-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
|
||||||
|
.memo-tag-count {
|
||||||
|
font-size: .72rem;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.memo-tag-item.active .memo-tag-count { background: #dbeafe; color: var(--primary); }
|
||||||
|
|
||||||
|
/* Main area */
|
||||||
|
.memo-main { overflow-y: auto; background: var(--bg-page); }
|
||||||
|
|
||||||
|
/* Pages */
|
||||||
|
.memo-page { display: none; }
|
||||||
|
.memo-page.active { display: block; }
|
||||||
|
|
||||||
|
/* Note list */
|
||||||
|
.memo-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.memo-list-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||||
|
.memo-list-subtitle { font-size: .82rem; color: var(--text-muted); }
|
||||||
|
.notes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
.note-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, box-shadow .15s, transform .1s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.note-card:hover { border-color: var(--primary); box-shadow: 0 4px 12px rgba(59,130,246,.1); transform: translateY(-1px); }
|
||||||
|
.note-card-title { font-size: .95rem; font-weight: 700; color: var(--text-main); }
|
||||||
|
.note-card-snippet { font-size: .8rem; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.note-card-meta { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-top: .25rem; }
|
||||||
|
.note-card-date { font-size: .72rem; color: var(--text-muted); margin-left: auto; }
|
||||||
|
.note-tag-chip {
|
||||||
|
font-size: .7rem; font-weight: 600;
|
||||||
|
background: #eff6ff; color: var(--primary);
|
||||||
|
padding: .15rem .45rem; border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.note-tag-chip:hover { background: #dbeafe; }
|
||||||
|
|
||||||
|
/* Note view */
|
||||||
|
.memo-view-header {
|
||||||
|
padding: 1.5rem 1.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.memo-view-title { font-size: 1.6rem; font-weight: 800; color: var(--text-main); margin-bottom: .5rem; line-height: 1.2; }
|
||||||
|
.memo-view-meta { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .8rem; color: var(--text-muted); }
|
||||||
|
.memo-view-actions { display: flex; gap: .5rem; margin-left: auto; }
|
||||||
|
.memo-content-area {
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
margin: 1rem 1.5rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.memo-content-area.empty { color: var(--text-muted); font-style: italic; }
|
||||||
|
|
||||||
|
/* Markdown rendered content */
|
||||||
|
.md-body { color: var(--text-main); line-height: 1.7; font-size: .95rem; }
|
||||||
|
.md-body h1,.md-body h2,.md-body h3,.md-body h4 { font-weight: 700; margin: 1.25em 0 .5em; line-height: 1.3; color: var(--text-main); }
|
||||||
|
.md-body h1 { font-size: 1.5rem; }
|
||||||
|
.md-body h2 { font-size: 1.25rem; }
|
||||||
|
.md-body h3 { font-size: 1.05rem; }
|
||||||
|
.md-body p { margin: .75em 0; }
|
||||||
|
.md-body ul,.md-body ol { padding-left: 1.5rem; margin: .75em 0; }
|
||||||
|
.md-body li { margin: .25em 0; }
|
||||||
|
.md-body code { font-family: monospace; background: #f1f5f9; color: #c7254e; padding: .1em .35em; border-radius: 4px; font-size: .88em; }
|
||||||
|
.md-body pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 1em 0; }
|
||||||
|
.md-body pre code { background: none; color: inherit; padding: 0; }
|
||||||
|
.md-body blockquote { border-left: 3px solid var(--primary); margin: 1em 0; padding: .5em 1em; background: #eff6ff; color: var(--text-muted); border-radius: 0 6px 6px 0; }
|
||||||
|
.md-body a { color: var(--primary); }
|
||||||
|
.md-body hr { border: none; border-top: 1px solid var(--border-light); margin: 1.5em 0; }
|
||||||
|
.md-body table { width: 100%; border-collapse: collapse; margin: 1em 0; }
|
||||||
|
.md-body table th { background: #f8fafc; text-align: left; padding: .5rem .75rem; border: 1px solid var(--border-light); font-weight: 600; }
|
||||||
|
.md-body table td { padding: .5rem .75rem; border: 1px solid var(--border-light); }
|
||||||
|
.md-body mark { background: #fef3c7; color: #92400e; padding: .1em .2em; border-radius: 3px; }
|
||||||
|
.md-body img { max-width: 100%; border-radius: 8px; margin: .5em 0; }
|
||||||
|
|
||||||
|
/* Attachments */
|
||||||
|
.memo-attachments { padding: 0 1.5rem 1.5rem; }
|
||||||
|
.memo-att-section { margin-bottom: 1.25rem; }
|
||||||
|
.memo-att-title { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: .6rem; }
|
||||||
|
.att-img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .6rem; }
|
||||||
|
.att-img-item { position: relative; border-radius: 8px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; cursor: pointer; }
|
||||||
|
.att-img-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.att-img-item .att-del { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,.5); color: #fff; border: none; border-radius: 50%; width: 22px; height: 22px; font-size: .75rem; cursor: pointer; display: none; line-height: 1; }
|
||||||
|
.att-img-item:hover .att-del { display: flex; align-items: center; justify-content: center; }
|
||||||
|
.att-file-list { display: flex; flex-direction: column; gap: .4rem; }
|
||||||
|
.att-file-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
|
||||||
|
.att-file-item a { color: var(--primary); flex: 1; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.att-file-item a:hover { text-decoration: underline; }
|
||||||
|
.att-file-size { color: var(--text-muted); font-size: .72rem; white-space: nowrap; }
|
||||||
|
.att-url-list { display: flex; flex-direction: column; gap: .4rem; }
|
||||||
|
.att-url-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
|
||||||
|
.att-url-item a { color: var(--primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Edit form */
|
||||||
|
.memo-edit-area { padding: 1.25rem 1.5rem; }
|
||||||
|
.memo-edit-form { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 12px; padding: 1.5rem; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: .35rem; margin-bottom: 1.1rem; }
|
||||||
|
.form-label { font-size: .82rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s, box-shadow .15s; font-family: inherit; }
|
||||||
|
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
.form-control::placeholder { color: #cbd5e1; }
|
||||||
|
.edit-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
.edit-textarea { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: .82rem; min-height: 320px; resize: vertical; line-height: 1.6; }
|
||||||
|
.md-preview-box { min-height: 320px; padding: .75rem 1rem; background: #fafafa; border: 1px solid var(--border-light); border-radius: 8px; overflow-y: auto; }
|
||||||
|
.tags-input-wrap { display: flex; flex-wrap: wrap; gap: .35rem; padding: .35rem .6rem; border: 1px solid var(--border-light); border-radius: 8px; background: #fff; min-height: 42px; align-items: center; cursor: text; transition: border-color .15s; }
|
||||||
|
.tags-input-wrap:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
.tags-input-wrap input { border: none; outline: none; background: transparent; font-size: .875rem; color: var(--text-main); min-width: 80px; flex: 1; }
|
||||||
|
.tag-chip { display: inline-flex; align-items: center; gap: .25rem; background: #eff6ff; color: var(--primary); font-size: .78rem; font-weight: 600; padding: .2rem .5rem; border-radius: 999px; }
|
||||||
|
.tag-chip button { background: none; border: none; cursor: pointer; font-size: .8rem; color: var(--primary); line-height: 1; padding: 0; }
|
||||||
|
.attach-drop { border: 2px dashed var(--border-light); border-radius: 10px; padding: 1.25rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; color: var(--text-muted); font-size: .875rem; }
|
||||||
|
.attach-drop:hover,.attach-drop.drag-over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
|
||||||
|
.attach-preview-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
|
||||||
|
.attach-preview-item { position: relative; }
|
||||||
|
.attach-preview-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border-light); }
|
||||||
|
.attach-preview-item .rm { position: absolute; top: -6px; right: -6px; background: var(--danger); color: #fff; border: none; border-radius: 50%; width: 18px; height: 18px; font-size: .65rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.url-row { display: flex; gap: .5rem; }
|
||||||
|
.url-row input:first-child { flex: 1.5; }
|
||||||
|
.url-row input:last-child { flex: 1; }
|
||||||
|
|
||||||
|
/* Tags autocomplete */
|
||||||
|
.tags-suggestions { position: absolute; z-index: 100; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.1); max-height: 160px; overflow-y: auto; min-width: 160px; }
|
||||||
|
.tags-suggestions div { padding: .4rem .75rem; cursor: pointer; font-size: .82rem; color: var(--text-main); }
|
||||||
|
.tags-suggestions div:hover { background: var(--bg-page); }
|
||||||
|
|
||||||
|
/* Lightbox */
|
||||||
|
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); z-index: 10000; display: none; align-items: center; justify-content: center; cursor: zoom-out; }
|
||||||
|
.lightbox.show { display: flex; }
|
||||||
|
.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.memo-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
|
||||||
|
.memo-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Btns */
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .45rem .9rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); }
|
||||||
|
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
|
||||||
|
.btn-secondary:hover { background: var(--bg-page); }
|
||||||
|
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
|
||||||
|
.btn-danger:hover { background: #fef2f2; }
|
||||||
|
.btn-sm { padding: .28rem .65rem; font-size: .8rem; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.memo-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
|
||||||
|
.memo-toast { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 10px; padding: .7rem 1rem; font-size: .875rem; min-width: 200px; color: var(--text-main); box-shadow: 0 4px 16px rgba(0,0,0,.12); border-left: 4px solid var(--primary); animation: toastIn .2s ease; }
|
||||||
|
.memo-toast.error { border-left-color: var(--danger); }
|
||||||
|
@keyframes toastIn { from { opacity:0;transform:translateX(16px); } to { opacity:1;transform:none; } }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.memo-layout { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.memo-sidebar { display: none; }
|
||||||
|
.memo-sidebar.open { display: flex; position: fixed; inset: 0; z-index: 200; width: 260px; height: 100vh; box-shadow: 4px 0 20px rgba(0,0,0,.2); }
|
||||||
|
.edit-split { grid-template-columns: 1fr; }
|
||||||
|
.notes-grid { grid-template-columns: 1fr; padding: 1rem; }
|
||||||
|
.memo-view-header { padding: 1rem; }
|
||||||
|
.memo-content-area { margin: .75rem; }
|
||||||
|
.memo-attachments { padding: 0 .75rem .75rem; }
|
||||||
|
.memo-edit-area { padding: .75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .memo-sidebar { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .memo-sidebar-top { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .memo-search { background: var(--bg-page); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .memo-search input { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .memo-tag-item { color: var(--text-muted); }
|
||||||
|
[data-theme="dark"] .memo-tag-item:hover { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .memo-tag-item.active { background: rgba(59,130,246,.15); color: var(--primary); }
|
||||||
|
[data-theme="dark"] .memo-main { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .memo-list-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .note-card { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .note-card:hover { border-color: var(--primary); }
|
||||||
|
[data-theme="dark"] .note-card-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .memo-view-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .memo-content-area { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .md-body code { background: #1c2128; color: #f87171; }
|
||||||
|
[data-theme="dark"] .md-body blockquote { background: rgba(59,130,246,.08); }
|
||||||
|
[data-theme="dark"] .md-body table th { background: #1c2128; border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .md-body table td { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .att-file-item,.att-url-item { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .memo-edit-form { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .tags-input-wrap { background: #1c2128; border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .md-preview-box { background: #1c2128; border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .attach-drop { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .attach-drop:hover,.attach-drop.drag-over { background: rgba(59,130,246,.1); }
|
||||||
|
[data-theme="dark"] .btn-secondary { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
|
||||||
|
[data-theme="dark"] .tags-suggestions { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .tags-suggestions div:hover { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .memo-toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
// HouseHub — Memo module
|
||||||
|
const API = '/modules/memo/api.php';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s){ const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML; }
|
||||||
|
function fmtDate(d){ if(!d)return'--'; try{return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return d;} }
|
||||||
|
function fmtSize(b){ if(!b)return''; if(b<1024)return b+'o'; if(b<1048576)return Math.round(b/1024)+'ko'; return (b/1048576).toFixed(1)+'Mo'; }
|
||||||
|
function toast(msg,type='success'){
|
||||||
|
const c=document.getElementById('memo-toasts')||document.body;
|
||||||
|
const t=document.createElement('div');t.className='memo-toast'+(type==='error'?' error':'');t.textContent=msg;
|
||||||
|
c.appendChild(t);setTimeout(()=>t.remove(),3000);
|
||||||
|
}
|
||||||
|
async function api(action,method='GET',data=null,extra=''){
|
||||||
|
const opts={method,headers:{}};
|
||||||
|
if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
|
||||||
|
else if(data instanceof FormData){opts.body=data;}
|
||||||
|
const r=await fetch(API+'?action='+action+extra,opts);
|
||||||
|
const j=await r.json();
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur API');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let currentNoteId = null;
|
||||||
|
let currentPage = 'list';
|
||||||
|
let currentQ = '';
|
||||||
|
let currentTag = '';
|
||||||
|
let allTagsList = [];
|
||||||
|
let pendingFiles = [];
|
||||||
|
let pendingUrls = [];
|
||||||
|
|
||||||
|
// ─── Navigation ──────────────────────────────────────────────────────────────
|
||||||
|
function showPage(name){
|
||||||
|
document.querySelectorAll('.memo-page').forEach(p=>p.classList.remove('active'));
|
||||||
|
const el=document.getElementById('memo-page-'+name);
|
||||||
|
if(el)el.classList.add('active');
|
||||||
|
currentPage=name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar ─────────────────────────────────────────────────────────────────
|
||||||
|
async function loadSidebar(){
|
||||||
|
try{
|
||||||
|
allTagsList=await api('tags');
|
||||||
|
const el=document.getElementById('memo-tags-list');
|
||||||
|
if(!el)return;
|
||||||
|
const allActive=currentTag===''&¤tQ==='';
|
||||||
|
let html=`<div class="memo-tag-item${allActive?' active':''}" onclick="filterTag('')">
|
||||||
|
📝 Toutes les notes <span class="memo-tag-count" id="memo-all-count">…</span></div>`;
|
||||||
|
allTagsList.forEach(t=>{
|
||||||
|
const act=currentTag===t.tag?' active':'';
|
||||||
|
html+=`<div class="memo-tag-item${act}" onclick="filterTag('${escHtml(t.tag)}')">
|
||||||
|
<span># ${escHtml(t.tag)}</span><span class="memo-tag-count">${t.count}</span></div>`;
|
||||||
|
});
|
||||||
|
el.innerHTML=html;
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Note list ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadList(q='',tag='',page=1){
|
||||||
|
currentQ=q;currentTag=tag;showPage('list');
|
||||||
|
loadSidebar();
|
||||||
|
try{
|
||||||
|
let extra='&page='+page;
|
||||||
|
if(q)extra+='&q='+encodeURIComponent(q);
|
||||||
|
if(tag)extra+='&tag='+encodeURIComponent(tag);
|
||||||
|
const data=await api('notes','GET',null,extra);
|
||||||
|
const el=document.getElementById('memo-notes-grid');
|
||||||
|
const count=document.getElementById('memo-all-count');
|
||||||
|
const title=document.getElementById('memo-list-title');
|
||||||
|
const subtitle=document.getElementById('memo-list-subtitle');
|
||||||
|
if(count)count.textContent=data.total;
|
||||||
|
if(title){
|
||||||
|
title.textContent = q?`🔍 "${q}"` : tag?`# ${tag}` : '📝 Notes';
|
||||||
|
}
|
||||||
|
if(subtitle)subtitle.textContent=data.total+' note'+(data.total!==1?'s':'');
|
||||||
|
if(!data.notes.length){
|
||||||
|
el.innerHTML='<div class="memo-empty"><div class="icon">📝</div><p>'+(q||tag?'Aucune note trouvée.':'Aucune note. Créez-en une !')+'</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML=data.notes.map(n=>noteCardHtml(n)).join('');
|
||||||
|
// Pagination
|
||||||
|
const total_pages=Math.ceil(data.total/data.per_page);
|
||||||
|
const pag=document.getElementById('memo-pagination');
|
||||||
|
if(pag){
|
||||||
|
pag.innerHTML=total_pages>1?
|
||||||
|
(page>1?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page-1})">← Précédent</button>`:'<span></span>')+
|
||||||
|
`<span style="color:var(--text-muted);font-size:.82rem">Page ${page} / ${total_pages}</span>`+
|
||||||
|
(page<total_pages?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page+1})">Suivant →</button>`:'<span></span>')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteCardHtml(n){
|
||||||
|
const tags=(n.tags||'').split(',').filter(t=>t.trim()).map(t=>
|
||||||
|
`<span class="note-tag-chip" onclick="event.stopPropagation();filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
|
||||||
|
const snippet=n.snippet?(escHtml(n.snippet.replace(/\n/g,' '))):'';
|
||||||
|
return `<div class="note-card" onclick="viewNote(${n.id})">
|
||||||
|
<div class="note-card-title">${escHtml(n.title)}</div>
|
||||||
|
${snippet?`<div class="note-card-snippet">${snippet}</div>`:''}
|
||||||
|
<div class="note-card-meta">${tags}<span class="note-card-date">${fmtDate(n.updated_at)}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTag(tag){
|
||||||
|
currentTag=tag;currentQ='';
|
||||||
|
document.getElementById('memo-search-input')?.value!=null&&(document.getElementById('memo-search-input').value='');
|
||||||
|
loadList('',tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch(){
|
||||||
|
const q=document.getElementById('memo-search-input')?.value.trim()||'';
|
||||||
|
loadList(q,'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Note view ────────────────────────────────────────────────────────────────
|
||||||
|
async function viewNote(id){
|
||||||
|
currentNoteId=id;showPage('view');
|
||||||
|
try{
|
||||||
|
const note=await api('notes','GET',null,'&id='+id);
|
||||||
|
document.getElementById('view-title').textContent=note.title;
|
||||||
|
document.getElementById('view-date').textContent='Modifié '+fmtDate(note.updated_at);
|
||||||
|
// Tags
|
||||||
|
const tagEl=document.getElementById('view-tags');
|
||||||
|
tagEl.innerHTML=(note.tags||'').split(',').filter(t=>t.trim()).map(t=>
|
||||||
|
`<span class="note-tag-chip" onclick="filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
|
||||||
|
// Markdown rendering
|
||||||
|
const mdEl=document.getElementById('view-md');
|
||||||
|
if(note.content&&window.marked){
|
||||||
|
mdEl.innerHTML=marked.parse(note.content);
|
||||||
|
mdEl.classList.remove('empty');
|
||||||
|
} else if(note.content){
|
||||||
|
mdEl.textContent=note.content;mdEl.classList.remove('empty');
|
||||||
|
} else {
|
||||||
|
mdEl.innerHTML='<em>Aucun contenu.</em>';mdEl.classList.add('empty');
|
||||||
|
}
|
||||||
|
// Attachments
|
||||||
|
renderAttachmentsView(note.attachments||[],id);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachmentsView(atts,noteId){
|
||||||
|
const images=atts.filter(a=>a.type==='image');
|
||||||
|
const files =atts.filter(a=>a.type==='file');
|
||||||
|
const urls =atts.filter(a=>a.type==='url');
|
||||||
|
const el=document.getElementById('view-attachments');
|
||||||
|
el.innerHTML='';
|
||||||
|
if(!atts.length){el.style.display='none';return;}
|
||||||
|
el.style.display='block';
|
||||||
|
|
||||||
|
if(images.length){
|
||||||
|
el.innerHTML+=`<div class="memo-att-section">
|
||||||
|
<div class="memo-att-title">🖼️ Images</div>
|
||||||
|
<div class="att-img-grid">${images.map(a=>`
|
||||||
|
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
|
||||||
|
<img src="${API}?action=file&id=${a.id}" loading="lazy" alt="${escHtml(a.original_name||'')}">
|
||||||
|
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
|
||||||
|
</div>`).join('')}</div></div>`;
|
||||||
|
}
|
||||||
|
if(files.length){
|
||||||
|
el.innerHTML+=`<div class="memo-att-section">
|
||||||
|
<div class="memo-att-title">📎 Fichiers</div>
|
||||||
|
<div class="att-file-list">${files.map(a=>`
|
||||||
|
<div class="att-file-item">
|
||||||
|
<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
|
||||||
|
<span class="att-file-size">${fmtSize(a.size)}</span>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
|
||||||
|
</div>`).join('')}</div></div>`;
|
||||||
|
}
|
||||||
|
if(urls.length){
|
||||||
|
el.innerHTML+=`<div class="memo-att-section">
|
||||||
|
<div class="memo-att-title">🔗 Liens</div>
|
||||||
|
<div class="att-url-list">${urls.map(a=>`
|
||||||
|
<div class="att-url-item">
|
||||||
|
<a href="${escHtml(a.url)}" target="_blank" rel="noopener">${escHtml(a.label||a.url)}</a>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
|
||||||
|
</div>`).join('')}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAttachment(attId,noteId){
|
||||||
|
if(!confirm('Supprimer cet attachement ?'))return;
|
||||||
|
try{ await api('attachments','DELETE',null,'&id='+attId); toast('Supprimé'); viewNote(noteId); }
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightbox
|
||||||
|
function openLightbox(src){
|
||||||
|
const lb=document.getElementById('memo-lightbox');
|
||||||
|
const img=lb.querySelector('img');
|
||||||
|
img.src=src;lb.classList.add('show');
|
||||||
|
}
|
||||||
|
function closeLightbox(){ document.getElementById('memo-lightbox')?.classList.remove('show'); }
|
||||||
|
|
||||||
|
// ─── Edit / Create ────────────────────────────────────────────────────────────
|
||||||
|
let editingId=null;
|
||||||
|
let editTags=[];
|
||||||
|
let tagAutocomplete=[];
|
||||||
|
|
||||||
|
function openCreate(){
|
||||||
|
editingId=null;pendingFiles=[];pendingUrls=[];editTags=[];
|
||||||
|
document.getElementById('edit-page-title').textContent='Nouvelle note';
|
||||||
|
document.getElementById('edit-title').value='';
|
||||||
|
document.getElementById('edit-content').value='';
|
||||||
|
document.getElementById('md-live-preview').innerHTML='';
|
||||||
|
document.getElementById('edit-tags-chips').innerHTML='';
|
||||||
|
document.getElementById('edit-tags-input').value='';
|
||||||
|
document.getElementById('edit-attach-preview').innerHTML='';
|
||||||
|
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
|
||||||
|
renderTagChips();
|
||||||
|
showPage('edit');
|
||||||
|
document.getElementById('edit-title')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(id){
|
||||||
|
editingId=id;pendingFiles=[];pendingUrls=[];editTags=[];
|
||||||
|
try{
|
||||||
|
const note=await api('notes','GET',null,'&id='+id);
|
||||||
|
document.getElementById('edit-page-title').textContent='Modifier la note';
|
||||||
|
document.getElementById('edit-title').value=note.title;
|
||||||
|
document.getElementById('edit-content').value=note.content||'';
|
||||||
|
editTags=(note.tags||'').split(',').filter(t=>t.trim());
|
||||||
|
renderTagChips();
|
||||||
|
updatePreview();
|
||||||
|
// existing attachments shown below form
|
||||||
|
renderAttachmentsEdit(note.attachments||[]);
|
||||||
|
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
|
||||||
|
showPage('edit');
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachmentsEdit(atts){
|
||||||
|
const el=document.getElementById('edit-existing-attachments');
|
||||||
|
if(!atts.length){el.innerHTML='';return;}
|
||||||
|
el.innerHTML=`<div class="memo-att-title" style="margin-bottom:.5rem">Attachements existants</div>`+
|
||||||
|
atts.map(a=>`<div class="att-file-item">
|
||||||
|
${a.type==='url'?
|
||||||
|
`<a href="${escHtml(a.url)}" target="_blank">${escHtml(a.label||a.url)}</a>`:
|
||||||
|
`<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
|
||||||
|
<span class="att-file-size">${fmtSize(a.size)}</span>`}
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${editingId})">✕</button>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview(){
|
||||||
|
const content=document.getElementById('edit-content')?.value||'';
|
||||||
|
const el=document.getElementById('md-live-preview');
|
||||||
|
if(!el)return;
|
||||||
|
el.innerHTML=window.marked?marked.parse(content):'<em style="color:var(--text-muted)">Preview chargement…</em>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
function renderTagChips(){
|
||||||
|
const wrap=document.getElementById('edit-tags-chips');
|
||||||
|
wrap.innerHTML=editTags.map(t=>
|
||||||
|
`<span class="tag-chip">#${escHtml(t)}<button type="button" onclick="removeTag('${escHtml(t)}')" title="Retirer">×</button></span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
function removeTag(t){ editTags=editTags.filter(x=>x!==t);renderTagChips(); }
|
||||||
|
function addTagFromInput(){
|
||||||
|
const inp=document.getElementById('edit-tags-input');
|
||||||
|
const val=inp.value.trim().replace(/^#/,'').toLowerCase();
|
||||||
|
if(val&&!editTags.includes(val)){editTags.push(val);renderTagChips();}
|
||||||
|
inp.value='';
|
||||||
|
document.getElementById('tags-suggestions')?.remove();
|
||||||
|
}
|
||||||
|
function onTagKeydown(e){
|
||||||
|
if(e.key==='Enter'||e.key===','||e.key===' '){e.preventDefault();addTagFromInput();}
|
||||||
|
else if(e.key==='Backspace'&&!e.target.value&&editTags.length){
|
||||||
|
editTags.pop();renderTagChips();
|
||||||
|
} else showTagSuggestions(e.target.value);
|
||||||
|
}
|
||||||
|
function showTagSuggestions(q){
|
||||||
|
document.getElementById('tags-suggestions')?.remove();
|
||||||
|
if(!q||!allTagsList.length)return;
|
||||||
|
const matches=allTagsList.filter(t=>t.tag.includes(q.toLowerCase())).slice(0,6);
|
||||||
|
if(!matches.length)return;
|
||||||
|
const inp=document.getElementById('edit-tags-input');
|
||||||
|
const box=document.createElement('div');
|
||||||
|
box.id='tags-suggestions';box.className='tags-suggestions';
|
||||||
|
box.style.cssText=`position:absolute;top:${inp.offsetTop+inp.offsetHeight+4}px;left:${inp.offsetLeft}px`;
|
||||||
|
matches.forEach(t=>{const d=document.createElement('div');d.textContent='#'+t.tag;d.onclick=()=>{
|
||||||
|
if(!editTags.includes(t.tag)){editTags.push(t.tag);renderTagChips();}
|
||||||
|
inp.value='';box.remove();
|
||||||
|
};box.appendChild(d);});
|
||||||
|
inp.closest('.tags-input-wrap').style.position='relative';
|
||||||
|
inp.closest('.tags-input-wrap').appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File drop
|
||||||
|
function initDropZone(){
|
||||||
|
const zone=document.getElementById('edit-drop-zone');
|
||||||
|
const inp=document.getElementById('edit-file-input');
|
||||||
|
if(!zone)return;
|
||||||
|
zone.addEventListener('click',()=>inp?.click());
|
||||||
|
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over');});
|
||||||
|
zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
|
||||||
|
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');handleFileSelect(e.dataTransfer.files);});
|
||||||
|
inp?.addEventListener('change',()=>handleFileSelect(inp.files));
|
||||||
|
document.getElementById('edit-content')?.addEventListener('paste',e=>{
|
||||||
|
const files=[...e.clipboardData.files].filter(f=>f.type.startsWith('image/'));
|
||||||
|
if(files.length){e.preventDefault();handleFileSelect(files);}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function handleFileSelect(files){
|
||||||
|
const preview=document.getElementById('edit-attach-preview');
|
||||||
|
[...files].forEach(f=>{
|
||||||
|
pendingFiles.push(f);
|
||||||
|
const item=document.createElement('div');item.className='attach-preview-item';
|
||||||
|
if(f.type.startsWith('image/')){
|
||||||
|
const img=document.createElement('img');
|
||||||
|
const reader=new FileReader();
|
||||||
|
reader.onload=ev=>{img.src=ev.target.result;};
|
||||||
|
reader.readAsDataURL(f);
|
||||||
|
item.appendChild(img);
|
||||||
|
}else{
|
||||||
|
item.innerHTML=`<div style="background:#f1f5f9;padding:.3rem .5rem;border-radius:6px;font-size:.75rem;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(f.name)}</div>`;
|
||||||
|
}
|
||||||
|
const rm=document.createElement('button');rm.className='rm';rm.textContent='✕';
|
||||||
|
rm.onclick=()=>{pendingFiles=pendingFiles.filter(x=>x!==f);item.remove();};
|
||||||
|
item.appendChild(rm);
|
||||||
|
preview.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL rows
|
||||||
|
function addUrlRowHtml(){
|
||||||
|
return `<div class="url-row" style="margin-bottom:.4rem">
|
||||||
|
<input type="text" class="form-control url-input" placeholder="https://..." style="flex:1.5">
|
||||||
|
<input type="text" class="form-control url-label" placeholder="Libellé (optionnel)" style="flex:1">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="addUrlRow()">+</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function addUrlRow(){
|
||||||
|
const wrap=document.getElementById('edit-url-rows');
|
||||||
|
wrap.insertAdjacentHTML('beforeend',addUrlRowHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
async function saveNote(){
|
||||||
|
const title=document.getElementById('edit-title')?.value.trim();
|
||||||
|
if(!title){toast('Titre requis','error');return;}
|
||||||
|
const content=document.getElementById('edit-content')?.value||'';
|
||||||
|
const tags=editTags.join(',');
|
||||||
|
|
||||||
|
// Collect URLs
|
||||||
|
const urlRows=document.querySelectorAll('#edit-url-rows .url-row');
|
||||||
|
const urls=[];
|
||||||
|
urlRows.forEach(row=>{
|
||||||
|
const u=row.querySelector('.url-input')?.value.trim();
|
||||||
|
const l=row.querySelector('.url-label')?.value.trim()||'';
|
||||||
|
if(u)urls.push({url:u,label:l});
|
||||||
|
});
|
||||||
|
|
||||||
|
try{
|
||||||
|
let noteId;
|
||||||
|
if(editingId){
|
||||||
|
await api('notes','PUT',{title,content,tags},'&id='+editingId);
|
||||||
|
noteId=editingId;toast('Note mise à jour');
|
||||||
|
}else{
|
||||||
|
const r=await api('notes','POST',{title,content,tags,urls});
|
||||||
|
noteId=r.id;toast('Note créée');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload pending files
|
||||||
|
for(const f of pendingFiles){
|
||||||
|
const fd=new FormData();fd.append('file',f);fd.append('note_id',noteId);
|
||||||
|
try{await api('attachments','POST',fd);}catch(e){toast('Erreur upload: '+f.name,'error');}
|
||||||
|
}
|
||||||
|
// Add URLs (for edit mode)
|
||||||
|
if(editingId){
|
||||||
|
for(const u of urls){
|
||||||
|
const fd=new FormData();fd.append('note_id',noteId);fd.append('url',u.url);fd.append('label',u.label);
|
||||||
|
try{await api('attachments','POST',fd);}catch{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadList(currentQ,currentTag);
|
||||||
|
viewNote(noteId);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(id){
|
||||||
|
if(!confirm('Supprimer cette note définitivement ?'))return;
|
||||||
|
try{
|
||||||
|
await api('notes','DELETE',null,'&id='+id);
|
||||||
|
toast('Note supprimée');loadList(currentQ,currentTag);
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
|
loadList();
|
||||||
|
loadSidebar();
|
||||||
|
initDropZone();
|
||||||
|
// Search
|
||||||
|
const sinp=document.getElementById('memo-search-input');
|
||||||
|
if(sinp){
|
||||||
|
let t;sinp.addEventListener('input',()=>{clearTimeout(t);t=setTimeout(()=>doSearch(),300);});
|
||||||
|
sinp.addEventListener('keydown',e=>{if(e.key==='Enter')doSearch();});
|
||||||
|
}
|
||||||
|
// Content live preview
|
||||||
|
document.getElementById('edit-content')?.addEventListener('input',updatePreview);
|
||||||
|
// Lightbox close
|
||||||
|
document.getElementById('memo-lightbox')?.addEventListener('click',closeLightbox);
|
||||||
|
// Tags input
|
||||||
|
document.getElementById('edit-tags-input')?.addEventListener('keydown',onTagKeydown);
|
||||||
|
document.getElementById('edit-tags-input')?.addEventListener('blur',()=>setTimeout(()=>document.getElementById('tags-suggestions')?.remove(),150));
|
||||||
|
document.addEventListener('click',e=>{if(!e.target.closest('.tags-input-wrap'))document.getElementById('tags-suggestions')?.remove();});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function tOk($d) { ob_end_clean(); echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function tErr($m, $c = 400) { ob_end_clean(); http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function tBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
function planka_api($method, $path, $body = null, $token = null) {
|
||||||
|
$url = 'http://10.200.33.10:1337' . $path;
|
||||||
|
$headers = ['Content-Type: application/json'];
|
||||||
|
if ($token) $headers[] = 'Authorization: Bearer ' . $token;
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||||
|
$result = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return json_decode($result, true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function planka_token(PDO $pdo) {
|
||||||
|
$row = $pdo->query("SELECT admin_token, token_expires_at FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row && $row['token_expires_at'] > date('Y-m-d H:i:s')) return $row['admin_token'];
|
||||||
|
$resp = planka_api('POST', '/api/access-tokens', ['emailOrUsername' => 'admin', 'password' => 'ideine']);
|
||||||
|
$token = $resp['item'] ?? null;
|
||||||
|
if (!$token) return null;
|
||||||
|
$exp = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id,admin_token,token_expires_at) VALUES (1,?,?) ON DUPLICATE KEY UPDATE admin_token=VALUES(admin_token),token_expires_at=VALUES(token_expires_at)")->execute([$token, $exp]);
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = planka_token($pdo);
|
||||||
|
if (!$token) tErr('Impossible d\'obtenir un token Planka', 503);
|
||||||
|
|
||||||
|
if ($action === 'boards') {
|
||||||
|
$cfg = $pdo->query("SELECT project_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$project_id = $cfg['project_id'] ?? '1713338747178714130';
|
||||||
|
$resp = planka_api('GET', '/api/projects/' . $project_id, null, $token);
|
||||||
|
$boards = [];
|
||||||
|
if (isset($resp['included']['boards'])) {
|
||||||
|
foreach ($resp['included']['boards'] as $b) {
|
||||||
|
$boards[] = ['id' => $b['id'], 'name' => $b['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tOk($boards);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'board') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$resp = planka_api('GET', '/api/boards/' . $id, null, $token);
|
||||||
|
$inc = $resp['included'] ?? [];
|
||||||
|
|
||||||
|
// Filter: only lists with a real name (Planka creates unnamed "None" placeholder lists)
|
||||||
|
$lists = array_values(array_filter($inc['lists'] ?? [], fn($l) =>
|
||||||
|
!empty($l['name']) && $l['name'] !== 'None' &&
|
||||||
|
(isset($l['boardId']) ? $l['boardId'] === $id : true)
|
||||||
|
));
|
||||||
|
// Filter cards that belong to a list in this board
|
||||||
|
$listIds = array_column($lists, 'id');
|
||||||
|
$cards = array_values(array_filter($inc['cards'] ?? [], fn($c) =>
|
||||||
|
in_array($c['listId'] ?? '', $listIds)
|
||||||
|
));
|
||||||
|
|
||||||
|
tOk([
|
||||||
|
'lists' => $lists,
|
||||||
|
'cards' => $cards,
|
||||||
|
'labels' => $inc['labels'] ?? [],
|
||||||
|
'members' => $inc['users'] ?? [],
|
||||||
|
'cardLabels' => $inc['cardLabels'] ?? [],
|
||||||
|
'tasks' => $inc['tasks'] ?? [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'set_active_board') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id, active_board_id) VALUES (1,?) ON DUPLICATE KEY UPDATE active_board_id=VALUES(active_board_id)")->execute([$id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'create_list') {
|
||||||
|
$board_id = $_GET['board_id'] ?? null; if (!$board_id) tErr('board_id manquant');
|
||||||
|
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
|
||||||
|
$position = (float) ($_GET['position'] ?? 65535);
|
||||||
|
$resp = planka_api('POST', '/api/boards/' . $board_id . '/lists', [
|
||||||
|
'name' => $name,
|
||||||
|
'position' => $position,
|
||||||
|
'type' => 'active',
|
||||||
|
], $token);
|
||||||
|
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
|
||||||
|
tOk($resp['item']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'create_card') {
|
||||||
|
$list_id = $_GET['list_id'] ?? null; if (!$list_id) tErr('list_id manquant');
|
||||||
|
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
|
||||||
|
$pos = (float) ($_GET['position'] ?? 65535);
|
||||||
|
// Planka v2+: POST /api/lists/{listId}/cards with type required
|
||||||
|
$resp = planka_api('POST', '/api/lists/' . $list_id . '/cards', [
|
||||||
|
'name' => $name,
|
||||||
|
'position' => $pos,
|
||||||
|
'type' => 'story',
|
||||||
|
], $token);
|
||||||
|
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Erreur Planka'), 502);
|
||||||
|
tOk($resp['item']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'update_card') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
$payload = [];
|
||||||
|
if (isset($d['name'])) $payload['name'] = $d['name'];
|
||||||
|
if (isset($d['description'])) $payload['description'] = $d['description'];
|
||||||
|
if (array_key_exists('dueDate', $d)) $payload['dueDate'] = $d['dueDate'];
|
||||||
|
if (isset($d['listId'])) $payload['listId'] = $d['listId'];
|
||||||
|
if (isset($d['position'])) $payload['position'] = (float)$d['position'];
|
||||||
|
$resp = planka_api('PATCH', '/api/cards/' . $id, $payload, $token);
|
||||||
|
if (empty($resp['item'])) tErr($resp['message'] ?? ($resp['problems'][0] ?? 'Planka a rejeté la mise à jour'), 502);
|
||||||
|
tOk($resp['item']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'delete_card') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
planka_api('DELETE', '/api/cards/' . $id, null, $token);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'create_task') {
|
||||||
|
$card_id = $_GET['card_id'] ?? null; if (!$card_id) tErr('card_id manquant');
|
||||||
|
$name = trim($_GET['name'] ?? ''); if (!$name) tErr('name manquant');
|
||||||
|
$resp = planka_api('POST', '/api/cards/' . $card_id . '/tasks', [
|
||||||
|
'name' => $name,
|
||||||
|
'position' => 65535,
|
||||||
|
], $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'toggle_task') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$is_completed = ($_GET['is_completed'] ?? '0') === '1';
|
||||||
|
$resp = planka_api('PATCH', '/api/tasks/' . $id, ['isCompleted' => $is_completed], $token);
|
||||||
|
tOk($resp['item'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'set_project') {
|
||||||
|
$project_id = $_GET['project_id'] ?? null; if (!$project_id) tErr('project_id manquant');
|
||||||
|
$pdo->prepare("INSERT INTO pf_planka_config (id, project_id) VALUES (1,?) ON DUPLICATE KEY UPDATE project_id=VALUES(project_id)")->execute([$project_id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'config') {
|
||||||
|
$row = $pdo->query("SELECT project_id, active_board_id FROM pf_planka_config WHERE id=1")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
tOk($row ?: ['project_id' => '1713338747178714130', 'active_board_id' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
.pk-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .65rem 1.25rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn {
|
||||||
|
padding: .3rem .75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-icon:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-area {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-board-area::-webkit-scrollbar { height: 8px; }
|
||||||
|
.pk-board-area::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.pk-board-area::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 4px; }
|
||||||
|
|
||||||
|
.pk-list {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 272px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: .65rem .85rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-name {
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-count {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .1rem .45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-cards {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-cards::-webkit-scrollbar { width: 4px; }
|
||||||
|
.pk-cards::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 2px; }
|
||||||
|
|
||||||
|
.pk-card {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .6rem .75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 2px 8px rgba(59,130,246,.12);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card.is-dragging {
|
||||||
|
opacity: .5;
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,.3);
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
.pk-label:empty {
|
||||||
|
height: 6px;
|
||||||
|
width: 36px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-name {
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-due {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-due.overdue { color: #ef4444; }
|
||||||
|
.pk-due.soon { color: #f97316; }
|
||||||
|
|
||||||
|
.pk-task-count {
|
||||||
|
font-size: .72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: .05rem .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-card-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: .5rem .85rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
border-radius: 0 0 12px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .82rem;
|
||||||
|
text-align: left;
|
||||||
|
transition: background .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-card-btn:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-zone {
|
||||||
|
min-height: 4px;
|
||||||
|
transition: min-height .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-zone.drag-over {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 2px dashed var(--primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(59,130,246,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: .9rem;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pkSpin .7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pkSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.pk-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-backdrop.hidden { display: none; }
|
||||||
|
|
||||||
|
.pk-modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-header h3 { margin: 0; font-size: .95rem; color: var(--text-main); }
|
||||||
|
|
||||||
|
.pk-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .85rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-form-group { display: flex; flex-direction: column; gap: .3rem; }
|
||||||
|
.pk-form-label { font-size: .75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
|
||||||
|
.pk-input, .pk-textarea, .pk-select {
|
||||||
|
padding: .5rem .7rem;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color .15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-input:focus, .pk-textarea:focus, .pk-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59,130,246,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
.pk-checklist { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
|
||||||
|
.pk-task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem;
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-item:hover { background: var(--bg-page); }
|
||||||
|
|
||||||
|
.pk-task-cb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-label {
|
||||||
|
font-size: .83rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-task-label.done {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-task-row {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-add-task-row .pk-input { flex: 1; }
|
||||||
|
|
||||||
|
.pk-btn {
|
||||||
|
padding: .45rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .83rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-primary:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.pk-btn-secondary {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
|
||||||
|
.pk-btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-btn-danger:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.pk-btn-sm {
|
||||||
|
padding: .3rem .65rem;
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-label-color--berry-red { background: #ef4444; }
|
||||||
|
.pk-label-color--fresh-salad { background: #22c55e; }
|
||||||
|
.pk-label-color--pumpkin-orange { background: #f97316; }
|
||||||
|
.pk-label-color--morning-sky { background: #3b82f6; }
|
||||||
|
.pk-label-color--pink-tulip { background: #ec4899; }
|
||||||
|
.pk-label-color--midnight-blue { background: #1e40af; }
|
||||||
|
.pk-label-color--lagoon-blue { background: #0ea5e9; }
|
||||||
|
.pk-label-color--purpley { background: #a855f7; }
|
||||||
|
.pk-label-color--egg-white { background: #fef3c7; }
|
||||||
|
.pk-label-color--anti-flash-white { background: #f1f5f9; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .pk-modal { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pk-list { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pk-card { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .pk-input,
|
||||||
|
[data-theme="dark"] .pk-textarea,
|
||||||
|
[data-theme="dark"] .pk-select { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pk-list { width: 240px; }
|
||||||
|
.pk-page { height: auto; overflow: visible; }
|
||||||
|
.pk-board-area { overflow-x: auto; height: auto; padding-bottom: 4rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag & Drop amélioré ──────────────────────────────────────────────────── */
|
||||||
|
.pk-cards.drag-over {
|
||||||
|
background: rgba(59,130,246,.07);
|
||||||
|
outline: 2px dashed var(--primary, #4361ee);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.pk-card.is-dragging { opacity: .4; }
|
||||||
|
.pk-drop-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted, #9ca3af);
|
||||||
|
font-size: .78rem;
|
||||||
|
padding: .75rem;
|
||||||
|
border: 2px dashed var(--border-light, #e5e7eb);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-list-new {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px dashed var(--border-light, #e5e7eb);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: .75rem .5rem;
|
||||||
|
}
|
||||||
|
.pk-add-list-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted, #9ca3af);
|
||||||
|
font-size: .9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: .4rem .75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pk-add-list-btn:hover {
|
||||||
|
background: var(--bg-panel, #f3f4f6);
|
||||||
|
color: var(--primary, #4361ee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pk-drop-before {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: height .12s, background .12s;
|
||||||
|
margin: -2px 0;
|
||||||
|
}
|
||||||
|
.pk-drop-before.drop-before-active {
|
||||||
|
height: 28px;
|
||||||
|
background: rgba(59,130,246,.15);
|
||||||
|
border: 2px dashed var(--primary, #4361ee);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Indicateurs drop sur carte ────────────────────────────────────────────── */
|
||||||
|
.pk-card.drop-top { border-top: 3px solid var(--primary, #4361ee) !important; }
|
||||||
|
.pk-card.drop-bottom { border-bottom: 3px solid var(--primary, #4361ee) !important; }
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
define('PV_MODEL_DIR', '/uploads/printvault/models/');
|
||||||
|
define('PV_THUMB_DIR', '/uploads/printvault/thumbs/');
|
||||||
|
|
||||||
|
function pvOk($d) { ob_end_clean(); echo json_encode(['ok'=>true,'data'=>$d], JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE); exit; }
|
||||||
|
function pvErr($m,$c=400){ ob_end_clean(); http_response_code($c); echo json_encode(['ok'=>false,'error'=>$m]); exit; }
|
||||||
|
function pvBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
// ── STL parser ─────────────────────────────────────────────────────────────────
|
||||||
|
function parseSTL(string $path): array {
|
||||||
|
$data = file_get_contents($path, false, null, 0, 204);
|
||||||
|
if ($data === false || strlen($data) < 6) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$isAscii = strtolower(substr(ltrim($data), 0, 5)) === 'solid';
|
||||||
|
|
||||||
|
$size = filesize($path);
|
||||||
|
if ($size > 30 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0]; // skip >30MB
|
||||||
|
|
||||||
|
$data = file_get_contents($path);
|
||||||
|
if ($isAscii) return parseSTLAscii($data);
|
||||||
|
return parseSTLBinary($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSTLBinary(string $data): array {
|
||||||
|
if (strlen($data) < 84) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$n = unpack('V', substr($data, 80, 4))[1];
|
||||||
|
if (strlen($data) < 84 + $n * 50 || $n === 0) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
$mins = [PHP_FLOAT_MAX, PHP_FLOAT_MAX, PHP_FLOAT_MAX];
|
||||||
|
$maxs = [-PHP_FLOAT_MAX, -PHP_FLOAT_MAX, -PHP_FLOAT_MAX];
|
||||||
|
$vol = 0.0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$off = 84 + $i * 50;
|
||||||
|
$v1 = array_values(unpack('f3', substr($data, $off+12, 12)));
|
||||||
|
$v2 = array_values(unpack('f3', substr($data, $off+24, 12)));
|
||||||
|
$v3 = array_values(unpack('f3', substr($data, $off+36, 12)));
|
||||||
|
for ($j = 0; $j < 3; $j++) {
|
||||||
|
$vals = [$v1[$j], $v2[$j], $v3[$j]];
|
||||||
|
$mins[$j] = min($mins[$j], ...$vals);
|
||||||
|
$maxs[$j] = max($maxs[$j], ...$vals);
|
||||||
|
}
|
||||||
|
$vol += ($v1[0]*($v2[1]*$v3[2]-$v2[2]*$v3[1])
|
||||||
|
-$v1[1]*($v2[0]*$v3[2]-$v2[2]*$v3[0])
|
||||||
|
+$v1[2]*($v2[0]*$v3[1]-$v2[1]*$v3[0])) / 6.0;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'dim_x' => round($maxs[0]-$mins[0], 2),
|
||||||
|
'dim_y' => round($maxs[1]-$mins[1], 2),
|
||||||
|
'dim_z' => round($maxs[2]-$mins[2], 2),
|
||||||
|
'volume'=> round(abs($vol)/1000.0, 3),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSTLAscii(string $data): array {
|
||||||
|
preg_match_all('/vertex\s+([-\d.eE+]+)\s+([-\d.eE+]+)\s+([-\d.eE+]+)/', $data, $m);
|
||||||
|
if (empty($m[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xs = array_map('floatval', $m[1]);
|
||||||
|
$ys = array_map('floatval', $m[2]);
|
||||||
|
$zs = array_map('floatval', $m[3]);
|
||||||
|
return [
|
||||||
|
'dim_x' => round(max($xs)-min($xs), 2),
|
||||||
|
'dim_y' => round(max($ys)-min($ys), 2),
|
||||||
|
'dim_z' => round(max($zs)-min($zs), 2),
|
||||||
|
'volume'=> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3MF parser ─────────────────────────────────────────────────────────────────
|
||||||
|
function parse3MF(string $path): array {
|
||||||
|
if (!class_exists('ZipArchive')) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
if (filesize($path) > 50 * 1024 * 1024) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$z = new ZipArchive();
|
||||||
|
if ($z->open($path) !== true) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xml = null;
|
||||||
|
for ($i = 0; $i < $z->numFiles; $i++) {
|
||||||
|
if (str_ends_with($z->getNameIndex($i), '.model')) {
|
||||||
|
$xml = $z->getFromIndex($i); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$z->close();
|
||||||
|
if (!$xml) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$dom = simplexml_load_string($xml);
|
||||||
|
if (!$dom) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
|
||||||
|
preg_match_all('/x="([-\d.eE+]+)"/', $xml, $mx);
|
||||||
|
preg_match_all('/y="([-\d.eE+]+)"/', $xml, $my);
|
||||||
|
preg_match_all('/z="([-\d.eE+]+)"/', $xml, $mz);
|
||||||
|
if (empty($mx[1])) return ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$xs = array_map('floatval', $mx[1]); $ys = array_map('floatval', $my[1]); $zs = array_map('floatval', $mz[1]);
|
||||||
|
return [
|
||||||
|
'dim_x' => round(max($xs)-min($xs), 2),
|
||||||
|
'dim_y' => round(max($ys)-min($ys), 2),
|
||||||
|
'dim_z' => round(max($zs)-min($zs), 2),
|
||||||
|
'volume'=> 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GCode parser ───────────────────────────────────────────────────────────────
|
||||||
|
function parseGCode(string $path): array {
|
||||||
|
$time = $filament = $nozzle = $bed = '';
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
$lines = 0;
|
||||||
|
while (!feof($handle) && $lines < 300) {
|
||||||
|
$line = fgets($handle); $lines++;
|
||||||
|
if (preg_match('/;TIME:(\d+)/', $line, $m)) {
|
||||||
|
$s = (int)$m[1]; $time = ($s>=3600 ? floor($s/3600).'h':'').sprintf('%02d', ($s%3600)/60).'m';
|
||||||
|
} elseif (preg_match('/;estimated printing time[^=]*=\s*(.+)/i', $line, $m)) {
|
||||||
|
$time = trim($m[1]);
|
||||||
|
} elseif (preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) {
|
||||||
|
$filament = $m[1].'m';
|
||||||
|
} elseif (preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) {
|
||||||
|
$filament = round((float)$m[1]/1000, 2).'m';
|
||||||
|
} elseif (preg_match('/M104\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M109\s+S(\d+)/i', $line, $m) && !$nozzle) { $nozzle = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M140\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
|
||||||
|
elseif (preg_match('/M190\s+S(\d+)/i', $line, $m) && !$bed) { $bed = $m[1].'°C'; }
|
||||||
|
}
|
||||||
|
// Scan tail for slicer comments
|
||||||
|
fseek($handle, -min(8192, filesize($path)), SEEK_END);
|
||||||
|
while (!feof($handle)) {
|
||||||
|
$line = fgets($handle);
|
||||||
|
if (!$time && preg_match('/;TIME:(\d+)/', $line, $m)) { $s=(int)$m[1]; $time=($s>=3600?floor($s/3600).'h':'').sprintf('%02d',($s%3600)/60).'m'; }
|
||||||
|
if (!$filament && preg_match('/;Filament used:\s*([\d.]+)m/i', $line, $m)) $filament = $m[1].'m';
|
||||||
|
if (!$filament && preg_match('/;filament used \[mm\]\s*=\s*([\d.]+)/i', $line, $m)) $filament = round((float)$m[1]/1000,2).'m';
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
return compact('time', 'filament', 'nozzle', 'bed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// ── CATEGORIES ─────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'categories') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query("SELECT c.*, COUNT(m.id) as count FROM pf_pv_categories c LEFT JOIN pf_pv_models m ON m.category=c.name GROUP BY c.id ORDER BY c.name")->fetchAll();
|
||||||
|
pvOk($rows);
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = pvBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
|
||||||
|
$pdo->prepare("INSERT IGNORE INTO pf_pv_categories (name,color) VALUES (?,?)")->execute([$name, $d['color'] ?? '#8b5cf6']);
|
||||||
|
pvOk(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MODELS ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'models') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
if ($id) {
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$r = $s->fetch(); if (!$r) pvErr('Introuvable', 404);
|
||||||
|
pvOk($r);
|
||||||
|
}
|
||||||
|
$where = []; $params = [];
|
||||||
|
if ($cat = ($_GET['category'] ?? '')) { $where[] = 'category=?'; $params[] = $cat; }
|
||||||
|
if ($ft = ($_GET['file_type'] ?? '')) {
|
||||||
|
if ($ft === 'gcode') { $where[] = "file_type IN ('gcode','gco','g')"; }
|
||||||
|
else { $where[] = 'file_type=?'; $params[] = $ft; }
|
||||||
|
}
|
||||||
|
if ($q = ($_GET['search'] ?? '')) {
|
||||||
|
$like = '%'.$q.'%';
|
||||||
|
$where[] = '(name LIKE ? OR description LIKE ? OR tags LIKE ?)';
|
||||||
|
array_push($params, $like, $like, $like);
|
||||||
|
}
|
||||||
|
$sql = "SELECT * FROM pf_pv_models" . ($where ? ' WHERE '.implode(' AND ',$where) : '') . " ORDER BY created_at DESC";
|
||||||
|
$s = $pdo->prepare($sql); $s->execute($params);
|
||||||
|
pvOk($s->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) pvErr('Fichier manquant');
|
||||||
|
$f = $_FILES['file'];
|
||||||
|
$ext = strtolower(pathinfo($f['name'], PATHINFO_EXTENSION));
|
||||||
|
$allowed = ['stl','3mf','gcode','gco','g'];
|
||||||
|
if (!in_array($ext, $allowed)) pvErr('Format non supporté');
|
||||||
|
|
||||||
|
$fname = uniqid('pv', true) . '.' . $ext;
|
||||||
|
$dest = PV_MODEL_DIR . $fname;
|
||||||
|
if (!move_uploaded_file($f['tmp_name'], $dest)) pvErr('Erreur déplacement fichier');
|
||||||
|
|
||||||
|
// Parse geometry / metadata
|
||||||
|
$geo = ['dim_x'=>0,'dim_y'=>0,'dim_z'=>0,'volume'=>0];
|
||||||
|
$gcode = ['time'=>'','filament'=>'','nozzle'=>'','bed'=>''];
|
||||||
|
if ($ext === 'stl') $geo = parseSTL($dest);
|
||||||
|
elseif ($ext === '3mf') $geo = parse3MF($dest);
|
||||||
|
elseif (in_array($ext, ['gcode','gco','g'])) $gcode = parseGCode($dest);
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? pathinfo($f['name'], PATHINFO_FILENAME));
|
||||||
|
$pdo->prepare("INSERT INTO pf_pv_models (name,description,category,tags,file_type,filename,original_name,file_size,dim_x,dim_y,dim_z,volume,gcode_time,gcode_filament,gcode_nozzle,gcode_bed) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
|
||||||
|
->execute([
|
||||||
|
$name,
|
||||||
|
$_POST['description'] ?? '',
|
||||||
|
$_POST['category'] ?? 'Non classé',
|
||||||
|
$_POST['tags'] ?? '',
|
||||||
|
$ext,
|
||||||
|
$fname,
|
||||||
|
$f['name'],
|
||||||
|
filesize($dest),
|
||||||
|
$geo['dim_x'], $geo['dim_y'], $geo['dim_z'], $geo['volume'],
|
||||||
|
$gcode['time'], $gcode['filament'], $gcode['nozzle'], $gcode['bed'],
|
||||||
|
]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
$row = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?");
|
||||||
|
$row->execute([$id]); pvOk($row->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
|
||||||
|
$d = pvBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) pvErr('Nom requis');
|
||||||
|
$pdo->prepare("UPDATE pf_pv_models SET name=?,description=?,category=?,tags=?,updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$name, $d['description']??'', $d['category']??'Non classé', $d['tags']??'', $id]);
|
||||||
|
pvOk(['updated'=>true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0); if (!$id) pvErr('ID manquant');
|
||||||
|
$s = $pdo->prepare("SELECT filename, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||||
|
@unlink(PV_MODEL_DIR . $row['filename']);
|
||||||
|
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
|
||||||
|
$pdo->prepare("DELETE FROM pf_pv_models WHERE id=?")->execute([$id]);
|
||||||
|
pvOk(['deleted'=>true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GCODE PATHS ────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'gcode_paths') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$max = (int)($_GET['max'] ?? 0) ?: PHP_INT_MAX; // no cap by default
|
||||||
|
$s = $pdo->prepare("SELECT filename, file_type FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||||
|
$ext = strtolower($row['file_type']);
|
||||||
|
if (!in_array($ext, ['gcode','gco','g'])) pvErr('Pas un fichier GCode');
|
||||||
|
$path = PV_MODEL_DIR . $row['filename'];
|
||||||
|
if (!file_exists($path)) pvErr('Fichier introuvable', 404);
|
||||||
|
|
||||||
|
$segments = [];
|
||||||
|
$x=0.0; $y=0.0; $z=0.0; $e=0.0; $lastE=0.0;
|
||||||
|
$minZ=PHP_FLOAT_MAX; $maxZ=0.0; $count=0;
|
||||||
|
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
while (!feof($handle) && count($segments) < $max) {
|
||||||
|
$line = trim(fgets($handle));
|
||||||
|
if (($p = strpos($line, ';')) !== false) $line = substr($line, 0, $p);
|
||||||
|
$line = trim($line);
|
||||||
|
if (!$line) continue;
|
||||||
|
$cmd = strtoupper(strtok($line, ' '));
|
||||||
|
if ($cmd === 'G92') {
|
||||||
|
if (preg_match('/E([-\d.]+)/i', $line, $m)) $lastE = (float)$m[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($cmd !== 'G0' && $cmd !== 'G1') continue;
|
||||||
|
$nx=$x; $ny=$y; $nz=$z; $ne=$e;
|
||||||
|
if (preg_match('/X([-\d.]+)/i', $line, $m)) $nx = (float)$m[1];
|
||||||
|
if (preg_match('/Y([-\d.]+)/i', $line, $m)) $ny = (float)$m[1];
|
||||||
|
if (preg_match('/Z([-\d.]+)/i', $line, $m)) $nz = (float)$m[1];
|
||||||
|
if (preg_match('/E([-\d.]+)/i', $line, $m)) $ne = (float)$m[1];
|
||||||
|
$extruding = ($cmd === 'G1' && $ne > $lastE) ? 1 : 0;
|
||||||
|
if ($nx !== $x || $ny !== $y || $nz !== $z) {
|
||||||
|
// Compact flat array: x1,y1,z1,x2,y2,z2,ext (2 decimal precision)
|
||||||
|
$segments[] = round($x,2); $segments[] = round($y,2); $segments[] = round($z,2);
|
||||||
|
$segments[] = round($nx,2); $segments[] = round($ny,2); $segments[] = round($nz,2);
|
||||||
|
$segments[] = $extruding;
|
||||||
|
$minZ = min($minZ, $nz);
|
||||||
|
$maxZ = max($maxZ, $nz);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
$x=$nx; $y=$ny; $z=$nz;
|
||||||
|
if ($ne !== $e) { $lastE = $e = $ne; }
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
pvOk(['flat' => $segments, 'min_z' => round($minZ === PHP_FLOAT_MAX ? 0 : $minZ, 2), 'max_z' => round($maxZ, 2), 'total' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FILE SERVE ─────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'file') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
|
||||||
|
$path = PV_MODEL_DIR . $row['filename'];
|
||||||
|
if (!file_exists($path)) { http_response_code(404); exit; }
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf','gcode'=>'text/plain','gco'=>'text/plain','g'=>'text/plain'][$ext] ?? 'application/octet-stream';
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Disposition: attachment; filename="' . rawurlencode($row['original_name']) . '"');
|
||||||
|
header('Content-Length: ' . filesize($path));
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
readfile($path); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MODEL FILE FOR VIEWER (inline, no download) ────────────────────────────────
|
||||||
|
if ($action === 'model_data') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) { http_response_code(404); exit; }
|
||||||
|
$path = PV_MODEL_DIR . $row['filename'];
|
||||||
|
if (!file_exists($path)) { http_response_code(404); exit; }
|
||||||
|
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$mime = ['stl'=>'model/stl','3mf'=>'model/3mf'][$ext] ?? 'application/octet-stream';
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Disposition: inline; filename="' . rawurlencode($row['filename']) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
readfile($path); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── THUMBNAIL SAVE ──────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'thumb' && $method === 'POST') {
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$s = $pdo->prepare("SELECT id, thumb FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$row = $s->fetch(); if (!$row) pvErr('Introuvable', 404);
|
||||||
|
|
||||||
|
if (empty($_FILES['thumb']) || $_FILES['thumb']['error'] !== UPLOAD_ERR_OK) pvErr('Pas de fichier');
|
||||||
|
if ($row['thumb']) @unlink(PV_THUMB_DIR . $row['thumb']);
|
||||||
|
|
||||||
|
$thumbName = 'thumb_' . uniqid('', true) . '.png';
|
||||||
|
if (!move_uploaded_file($_FILES['thumb']['tmp_name'], PV_THUMB_DIR . $thumbName)) pvErr('Erreur sauvegarde');
|
||||||
|
$pdo->prepare("UPDATE pf_pv_models SET thumb=? WHERE id=?")->execute([$thumbName, $id]);
|
||||||
|
pvOk(['thumb' => $thumbName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pvErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/* PrintVault module */
|
||||||
|
|
||||||
|
.pv-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pv-sidebar {
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pv-sidebar-top {
|
||||||
|
padding: .75rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
|
||||||
|
display: flex; flex-direction: column; gap: .5rem;
|
||||||
|
}
|
||||||
|
.pv-search {
|
||||||
|
display: flex; align-items: center; gap: .4rem;
|
||||||
|
background: var(--bg-page); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px; padding: .35rem .6rem;
|
||||||
|
}
|
||||||
|
.pv-search input { border: none; outline: none; background: transparent; font-size: .85rem; color: var(--text-main); flex: 1; }
|
||||||
|
.pv-search input::placeholder { color: var(--text-muted); }
|
||||||
|
.pv-main { overflow-y: auto; background: var(--bg-page); }
|
||||||
|
|
||||||
|
/* Sidebar nav */
|
||||||
|
.pv-nav-section { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: .75rem .75rem .25rem; }
|
||||||
|
.pv-nav-item {
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
padding: .45rem .75rem; cursor: pointer;
|
||||||
|
font-size: .85rem; color: var(--text-muted);
|
||||||
|
border-radius: 8px; margin: 1px .5rem;
|
||||||
|
transition: background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.pv-nav-item:hover { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
.pv-nav-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
|
||||||
|
.pv-nav-badge { margin-left: auto; font-size: .7rem; font-weight: 700; background: var(--bg-page); color: var(--text-muted); padding: 1px 6px; border-radius: 999px; }
|
||||||
|
.pv-nav-item.active .pv-nav-badge { background: #dbeafe; color: var(--primary); }
|
||||||
|
.pv-cat-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* File type filter chips */
|
||||||
|
.pv-type-chips { display: flex; gap: .3rem; flex-wrap: wrap; padding: .25rem .75rem .5rem; }
|
||||||
|
.pv-chip {
|
||||||
|
font-size: .72rem; font-weight: 600; padding: .2rem .5rem;
|
||||||
|
border-radius: 999px; cursor: pointer; border: 1.5px solid var(--border-light);
|
||||||
|
color: var(--text-muted); transition: all .12s;
|
||||||
|
}
|
||||||
|
.pv-chip:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.pv-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||||
|
.pv-chip.stl.active { background: #8b5cf6; border-color: #8b5cf6; }
|
||||||
|
.pv-chip.3mf.active { background: #3b82f6; border-color: #3b82f6; }
|
||||||
|
.pv-chip.gcode.active { background: #10b981; border-color: #10b981; }
|
||||||
|
|
||||||
|
/* Main header */
|
||||||
|
.pv-main-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem; background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light); gap: .75rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pv-main-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||||
|
.pv-subtitle { font-size: .8rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Models grid */
|
||||||
|
.pv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
.pv-card {
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px; overflow: hidden; cursor: pointer;
|
||||||
|
transition: border-color .15s, box-shadow .12s;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.pv-card:hover { border-color: #cbd5e1; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||||
|
.pv-card-thumb {
|
||||||
|
aspect-ratio: 4/3; background: #f1f5f9;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 3rem; overflow: hidden;
|
||||||
|
}
|
||||||
|
.pv-card-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.pv-card-body { padding: .75rem; flex: 1; display: flex; flex-direction: column; gap: .25rem; }
|
||||||
|
.pv-card-name { font-size: .875rem; font-weight: 600; color: var(--text-main); line-height: 1.3; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.pv-card-meta { font-size: .72rem; color: var(--text-muted); display: flex; gap: .4rem; flex-wrap: wrap; margin-top: auto; padding-top: .25rem; }
|
||||||
|
.pv-type-badge { font-size: .68rem; font-weight: 700; padding: .1rem .4rem; border-radius: 4px; text-transform: uppercase; }
|
||||||
|
.pv-type-stl { background: #ede9fe; color: #7c3aed; }
|
||||||
|
.pv-type-3mf { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.pv-type-gcode { background: #dcfce7; color: #15803d; }
|
||||||
|
.pv-type-g { background: #dcfce7; color: #15803d; }
|
||||||
|
.pv-type-gco { background: #dcfce7; color: #15803d; }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.pv-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
|
||||||
|
.pv-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.pv-modal-backdrop {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; padding: 1rem;
|
||||||
|
}
|
||||||
|
.pv-modal-backdrop.show { display: flex; }
|
||||||
|
.pv-modal {
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 14px; width: 100%; max-width: 520px;
|
||||||
|
max-height: 92vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,.15);
|
||||||
|
animation: pvModalIn .18s ease;
|
||||||
|
}
|
||||||
|
@keyframes pvModalIn { from{opacity:0;transform:scale(.96) translateY(-8px)} to{opacity:1;transform:none} }
|
||||||
|
.pv-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); }
|
||||||
|
.pv-modal-header h3 { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||||
|
.pv-modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.2rem; border-radius: 6px; padding: .2rem .4rem; }
|
||||||
|
.pv-modal-close:hover { background: var(--bg-page); }
|
||||||
|
.pv-modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
|
||||||
|
.pv-modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; }
|
||||||
|
|
||||||
|
/* Upload zone */
|
||||||
|
.pv-drop-zone {
|
||||||
|
border: 2px dashed var(--border-light); border-radius: 10px;
|
||||||
|
padding: 2rem; text-align: center; cursor: pointer;
|
||||||
|
transition: border-color .15s, background .15s; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pv-drop-zone:hover, .pv-drop-zone.over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
|
||||||
|
.pv-drop-zone .icon { font-size: 2rem; margin-bottom: .5rem; }
|
||||||
|
.pv-file-chosen { font-size: .82rem; color: var(--primary); font-weight: 600; margin-top: .4rem; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.form-label { font-size: .8rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s; font-family: inherit; }
|
||||||
|
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .4rem .85rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); }
|
||||||
|
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
|
||||||
|
.btn-secondary:hover { background: var(--bg-page); }
|
||||||
|
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
|
||||||
|
.btn-danger:hover { background: #fef2f2; }
|
||||||
|
.btn-sm { padding: .25rem .55rem; font-size: .78rem; }
|
||||||
|
.btn-ghost { background: transparent; border: none; color: var(--text-muted); padding: .2rem .4rem; }
|
||||||
|
.btn-ghost:hover { color: var(--text-main); background: var(--bg-page); border-radius: 6px; }
|
||||||
|
.btn-icon { padding: .3rem; min-width: 30px; justify-content: center; }
|
||||||
|
|
||||||
|
/* Detail panel */
|
||||||
|
.pv-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem .75rem; }
|
||||||
|
.pv-detail-item { display: flex; flex-direction: column; gap: .1rem; }
|
||||||
|
.pv-detail-label { font-size: .72rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.pv-detail-value { font-size: .875rem; color: var(--text-main); }
|
||||||
|
.pv-detail-thumb { width: 100%; aspect-ratio: 4/3; background: #f1f5f9; border-radius: 10px; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 4rem; margin-bottom: .75rem; }
|
||||||
|
.pv-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
/* Toasts */
|
||||||
|
.pv-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
|
||||||
|
.pv-toast { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 10px; padding: .7rem 1rem; font-size: .875rem; min-width: 200px; color: var(--text-main); box-shadow: 0 4px 16px rgba(0,0,0,.12); border-left: 4px solid var(--primary); animation: pvToastIn .2s ease; }
|
||||||
|
.pv-toast.error { border-left-color: var(--danger); }
|
||||||
|
@keyframes pvToastIn { from{opacity:0;transform:translateX(16px)} to{opacity:1;transform:none} }
|
||||||
|
|
||||||
|
/* Upload progress */
|
||||||
|
.pv-progress { height: 4px; background: var(--border-light); border-radius: 999px; overflow: hidden; margin-top: .4rem; }
|
||||||
|
.pv-progress-bar { height: 100%; background: var(--primary); border-radius: 999px; transition: width .3s; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pv-layout { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.pv-sidebar { display: none; }
|
||||||
|
.pv-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); padding: .75rem; }
|
||||||
|
.pv-main-header { padding: .75rem 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .pv-sidebar { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pv-card { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pv-card-thumb { background: #1c2128; }
|
||||||
|
[data-theme="dark"] .pv-modal { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .pv-drop-zone { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .pv-drop-zone:hover { background: rgba(59,130,246,.1); }
|
||||||
|
[data-theme="dark"] .pv-main-header { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pv-type-stl { background: rgba(139,92,246,.2); }
|
||||||
|
[data-theme="dark"] .pv-type-3mf { background: rgba(59,130,246,.2); }
|
||||||
|
[data-theme="dark"] .pv-type-gcode, [data-theme="dark"] .pv-type-g, [data-theme="dark"] .pv-type-gco { background: rgba(16,185,129,.2); }
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// HouseHub — PrintVault module (local storage)
|
||||||
|
const API = '/modules/printvault/api.php';
|
||||||
|
|
||||||
|
function escHtml(s) { const d = document.createElement('div'); d.textContent = String(s ?? ''); return d.innerHTML; }
|
||||||
|
function fmtSize(b) { if (!b) return '—'; return b > 1048576 ? (b/1048576).toFixed(1)+' Mo' : Math.round(b/1024)+' Ko'; }
|
||||||
|
function fmtDate(s) { if (!s) return '—'; return new Date(s).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); }
|
||||||
|
|
||||||
|
function toast(msg, type='success') {
|
||||||
|
const c = document.getElementById('pv-toasts');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'pv-toast' + (type==='error' ? ' error' : '');
|
||||||
|
t.textContent = msg; c.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(action, method='GET', data=null, extra='') {
|
||||||
|
const opts = {method, credentials:'same-origin', headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}};
|
||||||
|
if (data && !(data instanceof FormData)) { opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(data); }
|
||||||
|
else if (data instanceof FormData) opts.body = data;
|
||||||
|
const r = await fetch(API+'?action='+action+extra, opts);
|
||||||
|
const text = await r.text();
|
||||||
|
let j; try { j = JSON.parse(text); } catch(e) { console.error('Bad JSON:', text.slice(0,300)); throw new Error('Erreur serveur'); }
|
||||||
|
if (!j.ok) throw new Error(j.error || 'Erreur');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────────
|
||||||
|
let currentCategory = null;
|
||||||
|
let currentType = null;
|
||||||
|
let searchQuery = '';
|
||||||
|
let allModels = [];
|
||||||
|
let categories = [];
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadAll();
|
||||||
|
document.getElementById('pv-search')?.addEventListener('input', e => { searchQuery = e.target.value.trim(); renderModels(); });
|
||||||
|
document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click', e => { if (e.target===m) m.classList.remove('show'); }));
|
||||||
|
|
||||||
|
const dz = document.getElementById('pv-drop-zone');
|
||||||
|
if (dz) {
|
||||||
|
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||||
|
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||||
|
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); if (e.dataTransfer.files[0]) setUploadFile(e.dataTransfer.files[0]); });
|
||||||
|
dz.addEventListener('click', () => document.getElementById('pv-file-input').click());
|
||||||
|
document.getElementById('pv-file-input').addEventListener('change', e => { if (e.target.files[0]) setUploadFile(e.target.files[0]); });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [cats] = await Promise.all([api('categories')]);
|
||||||
|
categories = cats;
|
||||||
|
await loadModels();
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||||||
|
function renderSidebar() {
|
||||||
|
const el = document.getElementById('pv-sidebar-nav');
|
||||||
|
let html = `
|
||||||
|
<div class="pv-nav-section">Catégories</div>
|
||||||
|
<div class="pv-nav-item${currentCategory===null?' active':''}" onclick="setCategory(null)">
|
||||||
|
🗂 Toutes <span class="pv-nav-badge">${allModels.length}</span>
|
||||||
|
</div>`;
|
||||||
|
categories.forEach(c => {
|
||||||
|
const count = allModels.filter(m => m.category === c.name).length;
|
||||||
|
html += `<div class="pv-nav-item${currentCategory===c.name?' active':''}" onclick="setCategory('${escHtml(c.name).replace(/'/g,"\\'")}')">
|
||||||
|
<span class="pv-cat-dot" style="background:${escHtml(c.color)}"></span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.name)}</span>
|
||||||
|
<span class="pv-nav-badge">${count}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCategory(cat) { currentCategory = cat; renderSidebar(); renderModels(); }
|
||||||
|
function setType(type) {
|
||||||
|
currentType = currentType === type ? null : type;
|
||||||
|
document.querySelectorAll('.pv-chip').forEach(c => c.classList.toggle('active', c.dataset.type === currentType));
|
||||||
|
renderModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Models ───────────────────────────────────────────────────────────────────
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
allModels = await api('models');
|
||||||
|
renderSidebar();
|
||||||
|
renderModels();
|
||||||
|
} catch(e) { toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterModels() {
|
||||||
|
return allModels.filter(m => {
|
||||||
|
if (currentCategory && m.category !== currentCategory) return false;
|
||||||
|
if (currentType) {
|
||||||
|
const ext = m.file_type.toLowerCase();
|
||||||
|
if (currentType === 'gcode' && !['gcode','gco','g'].includes(ext)) return false;
|
||||||
|
if (currentType !== 'gcode' && ext !== currentType) return false;
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
if (!m.name.toLowerCase().includes(q) && !(m.description||'').toLowerCase().includes(q) && !(m.tags||'').toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels() {
|
||||||
|
const el = document.getElementById('pv-grid');
|
||||||
|
const filtered = filterModels();
|
||||||
|
document.getElementById('pv-subtitle').textContent = filtered.length + ' modèle' + (filtered.length > 1 ? 's' : '');
|
||||||
|
if (!filtered.length) {
|
||||||
|
el.innerHTML = `<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>${allModels.length ? 'Aucun modèle dans ce filtre.' : 'Aucun modèle. Uploadez votre premier fichier !'}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = filtered.map(m => cardHtml(m)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeIcon = {stl:'🟣','3mf':'🔵',gcode:'🟢',g:'🟢',gco:'🟢'};
|
||||||
|
|
||||||
|
function cardHtml(m) {
|
||||||
|
const ext = m.file_type.toLowerCase();
|
||||||
|
const icon = typeIcon[ext] || '📄';
|
||||||
|
const thumbUrl = m.thumb ? `/uploads/printvault/thumbs/${encodeURIComponent(m.thumb)}` : null;
|
||||||
|
const thumbHtml = thumbUrl
|
||||||
|
? `<img src="${escHtml(thumbUrl)}" alt="" loading="lazy" onerror="this.parentNode.innerHTML='${icon}'">`
|
||||||
|
: icon;
|
||||||
|
const dims = m.dim_x > 0 ? `${Math.round(m.dim_x)}×${Math.round(m.dim_y)}×${Math.round(m.dim_z)}mm` : '';
|
||||||
|
const gcInfo = m.gcode_time || '';
|
||||||
|
return `<a class="pv-card" href="/printvault-viewer.php?id=${m.id}" style="text-decoration:none">
|
||||||
|
<div class="pv-card-thumb">${thumbHtml}</div>
|
||||||
|
<div class="pv-card-body">
|
||||||
|
<div class="pv-card-name">${escHtml(m.name)}</div>
|
||||||
|
<div class="pv-card-meta">
|
||||||
|
<span class="pv-type-badge pv-type-${escHtml(ext)}">${escHtml(m.file_type.toUpperCase())}</span>
|
||||||
|
${dims ? `<span>${escHtml(dims)}</span>` : ''}
|
||||||
|
${gcInfo ? `<span>⏱ ${escHtml(gcInfo)}</span>` : ''}
|
||||||
|
<span style="margin-left:auto">${escHtml(fmtSize(m.file_size))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Upload ───────────────────────────────────────────────────────────────────
|
||||||
|
let uploadFile = null;
|
||||||
|
|
||||||
|
function openUpload() {
|
||||||
|
uploadFile = null;
|
||||||
|
document.getElementById('pv-file-input').value = '';
|
||||||
|
document.getElementById('pv-upload-filename').textContent = '';
|
||||||
|
document.getElementById('pv-upload-name').value = '';
|
||||||
|
document.getElementById('pv-upload-desc').value = '';
|
||||||
|
document.getElementById('pv-upload-tags').value = '';
|
||||||
|
const sel = document.getElementById('pv-upload-cat');
|
||||||
|
sel.innerHTML = categories.map(c => `<option value="${escHtml(c.name)}">${escHtml(c.name)}</option>`).join('');
|
||||||
|
document.getElementById('pv-upload-progress').style.display = 'none';
|
||||||
|
document.getElementById('pv-upload-modal').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadFile(f) {
|
||||||
|
const ext = f.name.split('.').pop().toLowerCase();
|
||||||
|
if (!['stl','3mf','gcode','gco','g'].includes(ext)) { toast('Format non supporté', 'error'); return; }
|
||||||
|
uploadFile = f;
|
||||||
|
document.getElementById('pv-upload-filename').textContent = f.name;
|
||||||
|
if (!document.getElementById('pv-upload-name').value)
|
||||||
|
document.getElementById('pv-upload-name').value = f.name.replace(/\.[^.]+$/,'').replace(/[_-]/g,' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUpload() {
|
||||||
|
if (!uploadFile) { toast('Choisissez un fichier', 'error'); return; }
|
||||||
|
const name = document.getElementById('pv-upload-name').value.trim();
|
||||||
|
if (!name) { toast('Nom requis', 'error'); return; }
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', uploadFile);
|
||||||
|
fd.append('name', name);
|
||||||
|
fd.append('description', document.getElementById('pv-upload-desc').value);
|
||||||
|
fd.append('category', document.getElementById('pv-upload-cat').value);
|
||||||
|
fd.append('tags', document.getElementById('pv-upload-tags').value);
|
||||||
|
|
||||||
|
const prog = document.getElementById('pv-upload-progress');
|
||||||
|
const bar = document.getElementById('pv-upload-bar');
|
||||||
|
prog.style.display = ''; bar.style.width = '20%';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('models', 'POST', fd);
|
||||||
|
bar.style.width = '100%';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('pv-upload-modal').classList.remove('show');
|
||||||
|
toast('Modèle ajouté ✓');
|
||||||
|
loadModels();
|
||||||
|
}, 300);
|
||||||
|
} catch(e) { prog.style.display = 'none'; toast(e.message, 'error'); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/* PrintVault — detail/viewer page */
|
||||||
|
|
||||||
|
.pv-detail-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top bar */
|
||||||
|
.pv-detail-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .75rem 1.25rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pv-detail-topbar-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pv-detail-topbar-title h1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body: viewer + sidebar */
|
||||||
|
.pv-detail-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 320px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewer area */
|
||||||
|
.pv-detail-left {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.pv-viewer-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pv-viewer-wrap canvas { display: block; width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
.pv-viewer-loading {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 1rem; background: #0f172a; z-index: 10; color: #94a3b8; font-size: .9rem;
|
||||||
|
}
|
||||||
|
.pv-spinner {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 3px solid #1e293b; border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%; animation: pvSpin .8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes pvSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.pv-viewer-controls-hint {
|
||||||
|
position: absolute; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||||
|
font-size: .72rem; color: #475569; background: rgba(15,23,42,.7);
|
||||||
|
padding: .3rem .7rem; border-radius: 999px; pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pv-viewer-screenshot-btn {
|
||||||
|
position: absolute; top: .75rem; right: .75rem;
|
||||||
|
background: rgba(15,23,42,.8); border: 1px solid rgba(255,255,255,.15);
|
||||||
|
color: #e2e8f0; border-radius: 8px; padding: .35rem .6rem;
|
||||||
|
cursor: pointer; font-size: .85rem; transition: background .15s;
|
||||||
|
}
|
||||||
|
.pv-viewer-screenshot-btn:hover { background: rgba(59,130,246,.4); }
|
||||||
|
|
||||||
|
/* Thumbnail fallback */
|
||||||
|
.pv-detail-thumb-large {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 8rem; background: #1e293b;
|
||||||
|
}
|
||||||
|
.pv-detail-thumb-large img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.pv-detail-thumb-placeholder { color: #334155; }
|
||||||
|
|
||||||
|
/* Right sidebar metadata */
|
||||||
|
.pv-detail-right {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.pv-meta-section {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.pv-meta-section:last-child { border-bottom: none; }
|
||||||
|
.pv-meta-title {
|
||||||
|
font-size: .72rem; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .06em; color: var(--text-muted); margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.pv-meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
|
||||||
|
.pv-meta-item { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.pv-meta-item--full { grid-column: 1 / -1; }
|
||||||
|
.pv-meta-label { font-size: .72rem; color: var(--text-muted); font-weight: 500; }
|
||||||
|
.pv-meta-value { font-size: .875rem; color: var(--text-main); font-weight: 500; }
|
||||||
|
|
||||||
|
/* Description & tags */
|
||||||
|
.pv-detail-description { font-size: .875rem; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
.pv-tags-row { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
.pv-tag {
|
||||||
|
font-size: .72rem; background: var(--bg-page); border: 1px solid var(--border-light);
|
||||||
|
padding: .15rem .5rem; border-radius: 999px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GCode metadata cards */
|
||||||
|
.pv-gcode-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
.pv-gcode-card {
|
||||||
|
background: var(--bg-page);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .85rem .9rem;
|
||||||
|
display: flex; flex-direction: column; gap: .2rem;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.pv-gcode-card:hover { border-color: #cbd5e1; }
|
||||||
|
.pv-gcode-icon { font-size: 1.3rem; margin-bottom: .1rem; }
|
||||||
|
.pv-gcode-value {
|
||||||
|
font-size: 1.05rem; font-weight: 700; color: var(--text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.pv-gcode-label {
|
||||||
|
font-size: .72rem; color: var(--text-muted); line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GCode legend */
|
||||||
|
.pv-gcode-viewer-legend {
|
||||||
|
position: absolute; bottom: 2.5rem; left: 1rem;
|
||||||
|
background: rgba(15,23,42,.8); backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid rgba(255,255,255,.1);
|
||||||
|
border-radius: 8px; padding: .5rem .75rem;
|
||||||
|
display: flex; flex-direction: column; gap: .3rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pv-legend-item {
|
||||||
|
font-size: .72rem; color: #94a3b8;
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pv-detail-body { grid-template-columns: 1fr; grid-template-rows: 55vh 1fr; }
|
||||||
|
.pv-detail-right { border-left: none; border-top: 1px solid var(--border-light); }
|
||||||
|
.pv-detail-page { height: auto; overflow: auto; }
|
||||||
|
.pv-detail-body { overflow: visible; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .pv-detail-topbar { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pv-detail-right { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .pv-gcode-card { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .pv-detail-thumb-large { background: #0d1117; }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
ob_start();
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||||
|
|
||||||
|
set_exception_handler(function(\Throwable $e) {
|
||||||
|
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
|
||||||
|
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
require_once dirname(__DIR__, 2) . '/includes/db.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function tOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function tErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function tBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
function discordNotify(PDO $pdo, string $msg): void {
|
||||||
|
$s = $pdo->prepare("SELECT content FROM pf_notes WHERE note_type='todo_settings' AND reference_id='webhook_discord'");
|
||||||
|
$s->execute(); $url = $s->fetchColumn();
|
||||||
|
if (!$url) return;
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [CURLOPT_POST=>true, CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>4,
|
||||||
|
CURLOPT_HTTPHEADER=>['Content-Type: application/json'],
|
||||||
|
CURLOPT_POSTFIELDS=>json_encode(['username'=>'HouseHub Todo','content'=>$msg])]);
|
||||||
|
curl_exec($ch); curl_close($ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LISTS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'lists') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT l.*, COUNT(t.id) as total,
|
||||||
|
SUM(CASE WHEN t.done=0 THEN 1 ELSE 0 END) as pending
|
||||||
|
FROM pf_todo_lists l
|
||||||
|
LEFT JOIN pf_todos t ON t.list_id = l.id
|
||||||
|
GROUP BY l.id ORDER BY l.position ASC, l.id ASC"
|
||||||
|
)->fetchAll();
|
||||||
|
tOk($rows);
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = tBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) tErr('Nom requis');
|
||||||
|
$pdo->prepare("INSERT INTO pf_todo_lists (name, color, icon) VALUES (?,?,?)")
|
||||||
|
->execute([$name, $d['color'] ?? '#3b82f6', $d['icon'] ?? '📋']);
|
||||||
|
tOk(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) tErr('Nom requis');
|
||||||
|
$pdo->prepare("UPDATE pf_todo_lists SET name=?, color=?, icon=? WHERE id=?")
|
||||||
|
->execute([$name, $d['color'] ?? '#3b82f6', $d['icon'] ?? '📋', $id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("DELETE FROM pf_todo_lists WHERE id=?")->execute([$id]);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TODOS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'todos') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$list_id = $_GET['list_id'] ?? null;
|
||||||
|
$show_done = ($_GET['show_done'] ?? '0') === '1';
|
||||||
|
$priority = $_GET['priority'] ?? null;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($list_id === 'all' || $list_id === null) {
|
||||||
|
// all
|
||||||
|
} elseif ($list_id === 'today') {
|
||||||
|
$where[] = 't.due_date = CURDATE()';
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} elseif ($list_id === 'upcoming') {
|
||||||
|
$where[] = 't.due_date >= CURDATE()';
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} elseif ($list_id === 'overdue') {
|
||||||
|
$where[] = 't.due_date < CURDATE()';
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} elseif ($list_id === 'done') {
|
||||||
|
$where[] = 't.done = 1';
|
||||||
|
} else {
|
||||||
|
$where[] = 't.list_id = ?'; $params[] = $list_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$show_done && !in_array($list_id, ['done', 'overdue'])) {
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priority) { $where[] = 't.priority = ?'; $params[] = $priority; }
|
||||||
|
|
||||||
|
$sql = "SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon
|
||||||
|
FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id";
|
||||||
|
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
$sql .= ' ORDER BY t.done ASC, FIELD(t.priority,"high","medium","low","none") ASC, t.due_date ASC, t.created_at ASC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql); $stmt->execute($params);
|
||||||
|
tOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = tBody();
|
||||||
|
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
||||||
|
$pdo->prepare("INSERT INTO pf_todos (list_id, title, notes, due_date, due_time, priority) VALUES (?,?,?,?,?,?)")
|
||||||
|
->execute([
|
||||||
|
($d['list_id'] ?? null) ?: null,
|
||||||
|
$title,
|
||||||
|
$d['notes'] ?? null,
|
||||||
|
($d['due_date'] ?? null) ?: null,
|
||||||
|
($d['due_time'] ?? null) ?: null,
|
||||||
|
$d['priority'] ?? 'none'
|
||||||
|
]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
$new = $pdo->prepare("SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id WHERE t.id=?");
|
||||||
|
$new->execute([$id]);
|
||||||
|
$row = $new->fetch();
|
||||||
|
// Notifications handled by cron at due_time each day
|
||||||
|
tOk($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
|
||||||
|
// Toggle done
|
||||||
|
if (isset($d['done'])) {
|
||||||
|
$done = $d['done'] ? 1 : 0;
|
||||||
|
$pdo->prepare("UPDATE pf_todos SET done=?, done_at=?, updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$done, $done ? date('Y-m-d H:i:s') : null, $id]);
|
||||||
|
if ($done) {
|
||||||
|
$t = $pdo->prepare("SELECT title FROM pf_todos WHERE id=?"); $t->execute([$id]);
|
||||||
|
$row = $t->fetch();
|
||||||
|
if ($row) discordNotify($pdo, "✅ **Tâche terminée** : " . $row['title']);
|
||||||
|
}
|
||||||
|
tOk(['done' => $done]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full update
|
||||||
|
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
||||||
|
// Reset notified_date so cron fires again today if time changed
|
||||||
|
$pdo->prepare("UPDATE pf_todos SET list_id=?, title=?, notes=?, due_date=?, due_time=?, priority=?, notified_date=NULL, updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([($d['list_id'] ?? null) ?: null, $title, $d['notes'] ?? null, ($d['due_date'] ?? null) ?: null, ($d['due_time'] ?? null) ?: null, $d['priority'] ?? 'none', $id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("DELETE FROM pf_todos WHERE id=?")->execute([$id]);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STATS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'stats') {
|
||||||
|
tOk([
|
||||||
|
'total' => $pdo->query("SELECT COUNT(*) FROM pf_todos")->fetchColumn(),
|
||||||
|
'pending' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE done=0")->fetchColumn(),
|
||||||
|
'done' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE done=1")->fetchColumn(),
|
||||||
|
'today' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE due_date=CURDATE() AND done=0")->fetchColumn(),
|
||||||
|
'overdue' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE due_date < CURDATE() AND done=0")->fetchColumn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SETTINGS ───────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'settings') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query("SELECT reference_id, content FROM pf_notes WHERE note_type='todo_settings'")->fetchAll();
|
||||||
|
$s = [];
|
||||||
|
foreach ($rows as $r) $s[$r['reference_id']] = $r['content'];
|
||||||
|
tOk($s);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$d = tBody();
|
||||||
|
foreach ($d as $key => $val) {
|
||||||
|
$pdo->prepare("INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('todo_settings',?,?) ON DUPLICATE KEY UPDATE content=?")
|
||||||
|
->execute([$key, $val ?? '', $val ?? '']);
|
||||||
|
}
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
/* Todo module */
|
||||||
|
|
||||||
|
.todo-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.todo-sidebar {
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.todo-sidebar-top {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.todo-main {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar items */
|
||||||
|
.todo-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 1px 0.5rem;
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
color 0.12s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.todo-nav-item:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
.todo-nav-item.active {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.todo-nav-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.todo-nav-item.active .todo-nav-badge {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.todo-nav-badge.urgent {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.todo-nav-section {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.75rem 0.75rem 0.25rem;
|
||||||
|
}
|
||||||
|
.todo-list-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main header */
|
||||||
|
.todo-main-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1.1rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.todo-main-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.todo-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.todo-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick add */
|
||||||
|
.todo-quick-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 1.5rem 0.75rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.todo-quick-add:focus-within {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
.todo-quick-add input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.todo-quick-add input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Todo list */
|
||||||
|
.todo-list {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
.todo-group-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.75rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
.todo-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.todo-item:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.todo-item.done {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.todo-item.done .todo-title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.todo-check {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.todo-check:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.todo-check.checked {
|
||||||
|
background: var(--success);
|
||||||
|
border-color: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.todo-check.checked::after {
|
||||||
|
content: "✓";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Priority colors for checkbox */
|
||||||
|
.todo-item[data-priority="high"] .todo-check {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.todo-item[data-priority="high"] .todo-check:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.todo-item[data-priority="medium"] .todo-check {
|
||||||
|
border-color: var(--warning);
|
||||||
|
}
|
||||||
|
.todo-item[data-priority="low"] .todo-check {
|
||||||
|
border-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.todo-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.todo-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.todo-due {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.todo-due.overdue {
|
||||||
|
color: var(--danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.todo-due.today {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.todo-list-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.todo-priority-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.pri-high {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
.pri-medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.pri-low {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.todo-notes-preview {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.todo-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.todo-item:hover .todo-item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.todo-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.todo-empty .icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.todo-empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.todo-modal-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.todo-modal-backdrop.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.todo-modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
animation: modalIn 0.18s ease;
|
||||||
|
}
|
||||||
|
@keyframes modalIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.todo-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
.todo-modal-header h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.todo-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
.todo-modal-close:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
.todo-modal-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
.todo-modal-footer {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
box-shadow 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Priority selector */
|
||||||
|
.priority-opts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.priority-opt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.priority-opt:hover {
|
||||||
|
border-color: #94a3b8;
|
||||||
|
}
|
||||||
|
.priority-opt.sel-none {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.priority-opt.sel-low {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.priority-opt.sel-medium {
|
||||||
|
border-color: var(--warning);
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
.priority-opt.sel-high {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color picker for list */
|
||||||
|
.color-swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.color-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
.color-swatch.selected {
|
||||||
|
border-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.25rem;
|
||||||
|
min-width: 28px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
background: var(--bg-page);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats bar */
|
||||||
|
.todo-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.todo-stat-item {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.todo-stat-item strong {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done animation */
|
||||||
|
@keyframes doneCheck {
|
||||||
|
from {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.todo-check.just-done {
|
||||||
|
animation: doneCheck 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show done toggle */
|
||||||
|
.show-done-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.show-done-btn:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.todo-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.todo-toast {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 200px;
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
animation: toastIn 0.2s ease;
|
||||||
|
}
|
||||||
|
.todo-toast.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
@keyframes toastIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.todo-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.todo-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.todo-list {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
.todo-main-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.todo-quick-add {
|
||||||
|
margin: 0.75rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .todo-sidebar,
|
||||||
|
.todo-sidebar-top {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-nav-item:hover {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-nav-item.active {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-main {
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-main-header {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-quick-add {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-quick-add input {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-item {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-item:hover {
|
||||||
|
border-color: #484f58;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-title {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-check {
|
||||||
|
border-color: #484f58;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-check:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-modal {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-modal-header,
|
||||||
|
.todo-modal-footer {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .form-control {
|
||||||
|
background: #1c2128;
|
||||||
|
border-color: var(--border-light);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .btn-secondary {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .btn-danger {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-stats {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .todo-toast {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// HouseHub — Todo module
|
||||||
|
const API = '/modules/todo/api.php';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
|
||||||
|
function fmtDate(d){if(!d)return null;const dt=new Date(d+'T00:00:00');return dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'});}
|
||||||
|
function fmtTime(t){if(!t)return null;return t.slice(0,5);}
|
||||||
|
function isToday(d){if(!d)return false;return d===new Date().toISOString().slice(0,10);}
|
||||||
|
function isOverdue(d){if(!d)return false;return d<new Date().toISOString().slice(0,10);}
|
||||||
|
function toast(msg,type='success'){
|
||||||
|
const c=document.getElementById('todo-toasts');
|
||||||
|
const t=document.createElement('div');t.className='todo-toast'+(type==='error'?' error':'');
|
||||||
|
t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3000);
|
||||||
|
}
|
||||||
|
async function api(action,method='GET',data=null,extra=''){
|
||||||
|
const opts={method,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}};
|
||||||
|
if(data){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
|
||||||
|
const r=await fetch(API+'?action='+action+extra,opts);
|
||||||
|
const text=await r.text();
|
||||||
|
let j;
|
||||||
|
try{j=JSON.parse(text);}catch(e){console.error('Bad JSON from API:',text.slice(0,200));throw new Error('Erreur serveur');}
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let currentFilter='all';
|
||||||
|
let showDone=false;
|
||||||
|
let lists=[];
|
||||||
|
let editTodoId=null;
|
||||||
|
let editListId=null;
|
||||||
|
let selectedPriority='none';
|
||||||
|
let selectedColor='#3b82f6';
|
||||||
|
let selectedIcon='📋';
|
||||||
|
|
||||||
|
const COLORS=['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#64748b'];
|
||||||
|
const ICONS=['📋','🏠','🛒','💼','💪','🎯','📚','✈️','🎮','❤️','⭐','🔧'];
|
||||||
|
|
||||||
|
// ─── Sidebar ─────────────────────────────────────────────────────────────────
|
||||||
|
async function loadSidebar(){
|
||||||
|
try{
|
||||||
|
const[data,stats]=await Promise.all([api('lists'),api('stats')]);
|
||||||
|
lists=data;
|
||||||
|
const el=document.getElementById('todo-sidebar-lists');
|
||||||
|
|
||||||
|
let html=`
|
||||||
|
<div class="todo-nav-section">Vues</div>
|
||||||
|
<div class="todo-nav-item${currentFilter==='all'?' active':''}" onclick="setFilter('all')">
|
||||||
|
<span>📋</span> Toutes
|
||||||
|
<span class="todo-nav-badge">${stats.pending||0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="todo-nav-item${currentFilter==='done'?' active':''}" onclick="setFilter('done')">
|
||||||
|
<span>✅</span> Terminées
|
||||||
|
<span class="todo-nav-badge">${stats.done||0}</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
html+=`<div class="todo-nav-section" style="display:flex;align-items:center;justify-content:space-between;padding-right:.75rem">
|
||||||
|
Listes <button class="btn btn-ghost btn-icon btn-sm" onclick="openListModal()" title="Nouvelle liste" style="padding:.1rem .3rem;font-size:.85rem">+</button>
|
||||||
|
</div>`;
|
||||||
|
lists.forEach(l=>{
|
||||||
|
const act=currentFilter==='list_'+l.id?' active':'';
|
||||||
|
html+=`<div class="todo-nav-item${act}" onclick="setFilter('list_${l.id}')">
|
||||||
|
<span class="todo-list-dot" style="background:${escHtml(l.color)}"></span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(l.icon)} ${escHtml(l.name)}</span>
|
||||||
|
<span class="todo-nav-badge">${l.pending||0}</span>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="event.stopPropagation();openListModal(${l.id})" title="Modifier" style="opacity:.5;padding:.1rem .2rem;font-size:.8rem">✏️</button>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.innerHTML=html;
|
||||||
|
}catch(e){console.error(e);}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(f){
|
||||||
|
currentFilter=f;showDone=f==='done';
|
||||||
|
loadSidebar();loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Todo list ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadTodos(){
|
||||||
|
try{
|
||||||
|
let extra='';
|
||||||
|
if(currentFilter==='done')extra='&list_id=done&show_done=1';
|
||||||
|
else if(currentFilter.startsWith('list_'))extra='&list_id='+currentFilter.slice(5);
|
||||||
|
if(showDone&¤tFilter!=='done')extra+='&show_done=1';
|
||||||
|
|
||||||
|
const todos=await api('todos','GET',null,extra);
|
||||||
|
renderTodos(todos);
|
||||||
|
updateHeader();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeader(){
|
||||||
|
const el=document.getElementById('todo-header-title');
|
||||||
|
if(!el)return;
|
||||||
|
const map={all:'Toutes les tâches',done:'Tâches terminées'};
|
||||||
|
if(map[currentFilter]){el.textContent=map[currentFilter];return;}
|
||||||
|
if(currentFilter.startsWith('list_')){
|
||||||
|
const l=lists.find(x=>x.id==currentFilter.slice(5));
|
||||||
|
el.textContent=l?(l.icon+' '+l.name):'Liste';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShowDone(){
|
||||||
|
showDone=!showDone;
|
||||||
|
const btn=document.getElementById('show-done-btn');
|
||||||
|
if(btn)btn.innerHTML=showDone?'🙈 Masquer terminées':'👁 Afficher terminées';
|
||||||
|
loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTodos(todos){
|
||||||
|
const el=document.getElementById('todo-list');
|
||||||
|
if(!todos.length){
|
||||||
|
el.innerHTML=`<div class="todo-empty"><div class="icon">${currentFilter==='done'?'✅':'📋'}</div>
|
||||||
|
<p>${currentFilter==='done'?'Aucune tâche terminée.':'Aucune tâche pour le moment.'}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pending=todos.filter(t=>!parseInt(t.done));
|
||||||
|
const done=todos.filter(t=>parseInt(t.done));
|
||||||
|
let html='';
|
||||||
|
pending.forEach(t=>{html+=todoItemHtml(t);});
|
||||||
|
if(done.length&&showDone){
|
||||||
|
html+=`<div class="todo-group-label">Terminées (${done.length})</div>`;
|
||||||
|
done.forEach(t=>{html+=todoItemHtml(t);});
|
||||||
|
}else if(done.length&&!showDone&¤tFilter!=='done'){
|
||||||
|
html+=`<div style="padding:.5rem 0"><button class="show-done-btn" onclick="showDone=true;loadTodos()">
|
||||||
|
✅ Afficher ${done.length} tâche${done.length>1?'s':''} terminée${done.length>1?'s':''}</button></div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoItemHtml(t){
|
||||||
|
const isDone=parseInt(t.done);
|
||||||
|
const checkClass='todo-check'+(isDone?' checked':'');
|
||||||
|
const itemClass='todo-item'+(isDone?' done':'');
|
||||||
|
const dueCls=isOverdue(t.due_date)&&!isDone?' overdue':isToday(t.due_date)&&!isDone?' today':'';
|
||||||
|
const dueLabel=t.due_time?('🔔 '+fmtTime(t.due_time)):null;
|
||||||
|
const priBadge=t.priority&&t.priority!=='none'?
|
||||||
|
`<span class="todo-priority-badge pri-${t.priority}">${t.priority==='high'?'Urgent':t.priority==='medium'?'Normal':'Bas'}</span>`:'';
|
||||||
|
const listBadge=t.list_name?
|
||||||
|
`<span class="todo-list-badge" style="background:${escHtml(t.list_color||'#3b82f6')}22;color:${escHtml(t.list_color||'#3b82f6')}">${escHtml(t.list_icon||'')} ${escHtml(t.list_name)}</span>`:'';
|
||||||
|
return `<div class="${itemClass}" data-id="${t.id}" data-priority="${escHtml(t.priority||'none')}">
|
||||||
|
<div class="${checkClass}" onclick="toggleDone(event,${t.id},${isDone?0:1})"></div>
|
||||||
|
<div class="todo-content" onclick="openEditTodo(${t.id})">
|
||||||
|
<div class="todo-title">${escHtml(t.title)}</div>
|
||||||
|
${t.notes?`<div class="todo-notes-preview">${escHtml(t.notes)}</div>`:''}
|
||||||
|
<div class="todo-meta">
|
||||||
|
${dueLabel?`<span class="todo-due">${escHtml(dueLabel)}</span>`:''}
|
||||||
|
${priBadge}${listBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-item-actions">
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="openEditTodo(${t.id})" title="Modifier">✏️</button>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="deleteTodo(event,${t.id})" title="Supprimer">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDone(e,id,done){
|
||||||
|
e.stopPropagation();
|
||||||
|
try{
|
||||||
|
await api('todos','PUT',{done:done===1},'&id='+id);
|
||||||
|
const item=document.querySelector(`.todo-item[data-id="${id}"]`);
|
||||||
|
if(item){
|
||||||
|
const check=item.querySelector('.todo-check');
|
||||||
|
check.classList.add('just-done');
|
||||||
|
setTimeout(()=>{check.classList.remove('just-done');loadTodos();loadSidebar();},300);
|
||||||
|
}
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodo(e,id){
|
||||||
|
e.stopPropagation();
|
||||||
|
if(!confirm('Supprimer cette tâche ?'))return;
|
||||||
|
try{await api('todos','DELETE',null,'&id='+id);toast('Supprimée');loadTodos();loadSidebar();}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Todo modal ───────────────────────────────────────────────────────────────
|
||||||
|
function openAddTodo(){
|
||||||
|
editTodoId=null;
|
||||||
|
document.getElementById('todo-modal-title').textContent='Nouvelle tâche';
|
||||||
|
document.getElementById('todo-form-title').value='';
|
||||||
|
document.getElementById('todo-form-notes').value='';
|
||||||
|
document.getElementById('todo-form-time').value='';
|
||||||
|
document.getElementById('todo-delete-btn').style.display='none';
|
||||||
|
const sel=document.getElementById('todo-form-list');
|
||||||
|
sel.innerHTML='<option value="">— Aucune —</option>'+lists.map(l=>`<option value="${l.id}">${escHtml(l.icon)} ${escHtml(l.name)}</option>`).join('');
|
||||||
|
if(currentFilter.startsWith('list_'))sel.value=currentFilter.slice(5);
|
||||||
|
setPriority('none');
|
||||||
|
openModal('todo-modal');
|
||||||
|
setTimeout(()=>document.getElementById('todo-form-title').focus(),50);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditTodo(id){
|
||||||
|
editTodoId=id;
|
||||||
|
try{
|
||||||
|
const all=await api('todos','GET',null,'&show_done=1');
|
||||||
|
const t=all.find(x=>x.id==id);
|
||||||
|
if(!t)return;
|
||||||
|
document.getElementById('todo-modal-title').textContent='Modifier la tâche';
|
||||||
|
document.getElementById('todo-form-title').value=t.title;
|
||||||
|
document.getElementById('todo-form-notes').value=t.notes||'';
|
||||||
|
document.getElementById('todo-form-time').value=t.due_time?t.due_time.slice(0,5):'';
|
||||||
|
document.getElementById('todo-delete-btn').style.display='';
|
||||||
|
const sel=document.getElementById('todo-form-list');
|
||||||
|
sel.innerHTML='<option value="">— Aucune —</option>'+lists.map(l=>`<option value="${l.id}">${escHtml(l.icon)} ${escHtml(l.name)}</option>`).join('');
|
||||||
|
sel.value=t.list_id||'';
|
||||||
|
setPriority(t.priority||'none');
|
||||||
|
openModal('todo-modal');
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPriority(p){
|
||||||
|
selectedPriority=p;
|
||||||
|
document.querySelectorAll('.priority-opt').forEach(el=>{
|
||||||
|
el.className='priority-opt';
|
||||||
|
if(el.dataset.p===p)el.classList.add('sel-'+p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTodo(){
|
||||||
|
const title=document.getElementById('todo-form-title').value.trim();
|
||||||
|
if(!title){toast('Titre requis','error');return;}
|
||||||
|
const time=document.getElementById('todo-form-time').value;
|
||||||
|
if(!time){toast('Heure de rappel requise','error');return;}
|
||||||
|
const data={
|
||||||
|
title,
|
||||||
|
notes:document.getElementById('todo-form-notes').value||null,
|
||||||
|
due_time:time,
|
||||||
|
list_id:document.getElementById('todo-form-list').value||null,
|
||||||
|
priority:selectedPriority
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
if(editTodoId){await api('todos','PUT',data,'&id='+editTodoId);toast('Tâche mise à jour');}
|
||||||
|
else{await api('todos','POST',data);toast('Tâche ajoutée ✓');}
|
||||||
|
closeModal('todo-modal');loadTodos();loadSidebar();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodoFromModal(){
|
||||||
|
if(!editTodoId||!confirm('Supprimer cette tâche ?'))return;
|
||||||
|
try{await api('todos','DELETE',null,'&id='+editTodoId);closeModal('todo-modal');toast('Supprimée');loadTodos();loadSidebar();}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List modal ───────────────────────────────────────────────────────────────
|
||||||
|
function openListModal(id=null){
|
||||||
|
editListId=id;
|
||||||
|
let list=id?lists.find(l=>l.id==id):null;
|
||||||
|
document.getElementById('list-modal-title').textContent=id?'Modifier la liste':'Nouvelle liste';
|
||||||
|
document.getElementById('list-form-name').value=list?.name||'';
|
||||||
|
document.getElementById('list-delete-btn').style.display=id?'':'none';
|
||||||
|
selectedColor=list?.color||'#3b82f6';
|
||||||
|
selectedIcon=list?.icon||'📋';
|
||||||
|
|
||||||
|
document.getElementById('list-color-swatches').innerHTML=COLORS.map(c=>
|
||||||
|
`<div class="color-swatch${c===selectedColor?' selected':''}" style="background:${c}" onclick="selectColor('${c}')"></div>`).join('');
|
||||||
|
|
||||||
|
document.getElementById('list-icon-opts').innerHTML=ICONS.map(i=>
|
||||||
|
`<button type="button" class="btn btn-sm btn-secondary${i===selectedIcon?' ':''}" onclick="selectIcon(this,'${i}')"
|
||||||
|
style="${i===selectedIcon?'background:#eff6ff;border-color:var(--primary)':''}">${i}</button>`).join('');
|
||||||
|
|
||||||
|
openModal('list-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectColor(c){
|
||||||
|
selectedColor=c;
|
||||||
|
document.querySelectorAll('.color-swatch').forEach(s=>{
|
||||||
|
const hex=rgbToHex(s.style.background);
|
||||||
|
s.classList.toggle('selected',hex===c||s.style.background===c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(rgb){
|
||||||
|
const m=rgb.match(/\d+/g);if(!m||m.length<3)return rgb;
|
||||||
|
return '#'+m.slice(0,3).map(x=>parseInt(x).toString(16).padStart(2,'0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIcon(btn,icon){
|
||||||
|
selectedIcon=icon;
|
||||||
|
document.querySelectorAll('#list-icon-opts button').forEach(b=>{b.style.background='';b.style.borderColor='';});
|
||||||
|
btn.style.background='#eff6ff';btn.style.borderColor='var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveList(){
|
||||||
|
const name=document.getElementById('list-form-name').value.trim();
|
||||||
|
if(!name){toast('Nom requis','error');return;}
|
||||||
|
const data={name,color:selectedColor,icon:selectedIcon};
|
||||||
|
try{
|
||||||
|
if(editListId){await api('lists','PUT',data,'&id='+editListId);}
|
||||||
|
else{await api('lists','POST',data);}
|
||||||
|
closeModal('list-modal');toast(editListId?'Liste mise à jour':'Liste créée ✓');
|
||||||
|
loadSidebar();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteList(){
|
||||||
|
if(!editListId||!confirm('Supprimer la liste et toutes ses tâches ?'))return;
|
||||||
|
try{await api('lists','DELETE',null,'&id='+editListId);closeModal('list-modal');toast('Liste supprimée');setFilter('all');}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Settings modal ───────────────────────────────────────────────────────────
|
||||||
|
async function openSettings(){
|
||||||
|
try{
|
||||||
|
const s=await api('settings');
|
||||||
|
document.getElementById('settings-webhook').value=s.webhook_discord||'';
|
||||||
|
openModal('settings-modal');
|
||||||
|
}catch(e){openModal('settings-modal');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(){
|
||||||
|
const webhook=document.getElementById('settings-webhook').value.trim();
|
||||||
|
try{
|
||||||
|
await api('settings','PUT',{webhook_discord:webhook});
|
||||||
|
toast('Paramètres enregistrés ✓');closeModal('settings-modal');
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal helpers ────────────────────────────────────────────────────────────
|
||||||
|
function openModal(id){document.getElementById(id)?.classList.add('show');}
|
||||||
|
function closeModal(id){document.getElementById(id)?.classList.remove('show');}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
|
loadSidebar();
|
||||||
|
loadTodos();
|
||||||
|
document.querySelectorAll('.todo-modal-backdrop').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
|
||||||
|
});
|
||||||
+416
@@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Planka — HouseHub';
|
||||||
|
$activePage = 'planka';
|
||||||
|
$pageCss = '/modules/planka/assets/planka.css';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="pk-page" id="pk-page">
|
||||||
|
|
||||||
|
<div class="pk-topbar">
|
||||||
|
<h1>📋 Planka</h1>
|
||||||
|
<div class="pk-board-selector" id="pk-board-selector"></div>
|
||||||
|
<div class="pk-topbar-actions">
|
||||||
|
<button class="pk-btn-icon" onclick="openSettings()" title="Paramètres">⚙️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pk-board-area" id="pk-board-area">
|
||||||
|
<div class="pk-loading" id="pk-loading">
|
||||||
|
<div class="pk-spinner"></div>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL CARTE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pk-modal-backdrop hidden" id="pk-card-modal">
|
||||||
|
<div class="pk-modal">
|
||||||
|
<div class="pk-modal-header">
|
||||||
|
<h3>Carte</h3>
|
||||||
|
<button class="pk-modal-close" onclick="closeCardModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-body">
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Titre</label>
|
||||||
|
<input type="text" id="pk-card-name" class="pk-input" placeholder="Titre de la carte">
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Description</label>
|
||||||
|
<textarea id="pk-card-desc" class="pk-textarea" placeholder="Description…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Date d'échéance</label>
|
||||||
|
<input type="date" id="pk-card-due" class="pk-input">
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Colonne</label>
|
||||||
|
<select id="pk-card-list" class="pk-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Checklist</label>
|
||||||
|
<div class="pk-checklist" id="pk-task-list"></div>
|
||||||
|
<div class="pk-add-task-row">
|
||||||
|
<input type="text" id="pk-new-task-name" class="pk-input" placeholder="Nouvelle tâche…">
|
||||||
|
<button class="pk-btn pk-btn-secondary pk-btn-sm" onclick="addTask()">+ Ajouter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-footer">
|
||||||
|
<button class="pk-btn pk-btn-danger" id="pk-delete-btn" onclick="deleteCard()">Supprimer</button>
|
||||||
|
<button class="pk-btn pk-btn-secondary" onclick="closeCardModal()">Annuler</button>
|
||||||
|
<button class="pk-btn pk-btn-primary" onclick="saveCard()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL PARAMÈTRES ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pk-modal-backdrop hidden" id="pk-settings-modal">
|
||||||
|
<div class="pk-modal">
|
||||||
|
<div class="pk-modal-header">
|
||||||
|
<h3>⚙️ Paramètres Planka</h3>
|
||||||
|
<button class="pk-modal-close" onclick="closeSettings()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-body">
|
||||||
|
<div class="pk-form-group">
|
||||||
|
<label class="pk-form-label">Project ID</label>
|
||||||
|
<input type="text" id="pk-project-id" class="pk-input" value="1713338747178714130">
|
||||||
|
<span style="font-size:.72rem;color:var(--text-muted);margin-top:.2rem">ID du projet Planka à afficher.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pk-modal-footer">
|
||||||
|
<button class="pk-btn pk-btn-secondary" onclick="closeSettings()">Annuler</button>
|
||||||
|
<button class="pk-btn pk-btn-primary" onclick="saveSettings()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = 'modules/planka/api.php';
|
||||||
|
let state = { boards: [], activeBoardId: null, lists: [], cards: [], labels: [], cardLabels: [], tasks: [] };
|
||||||
|
let editingCard = null;
|
||||||
|
|
||||||
|
async function apiFetch(action, params = {}, method = 'GET', body = null) {
|
||||||
|
const qs = new URLSearchParams({ action, ...params }).toString();
|
||||||
|
const opts = { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } };
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const r = await fetch(`${API}?${qs}`, opts);
|
||||||
|
const txt = await r.text();
|
||||||
|
try { return JSON.parse(txt); } catch { return { ok: false, error: txt }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const cfg = await apiFetch('config');
|
||||||
|
if (cfg.ok) {
|
||||||
|
if (cfg.data.active_board_id) state.activeBoardId = cfg.data.active_board_id;
|
||||||
|
}
|
||||||
|
const res = await apiFetch('boards');
|
||||||
|
if (!res.ok) { document.getElementById('pk-loading').textContent = 'Erreur: ' + (res.error || 'impossible de charger les boards'); return; }
|
||||||
|
state.boards = res.data;
|
||||||
|
if (!state.activeBoardId && state.boards.length) state.activeBoardId = state.boards[0].id;
|
||||||
|
renderBoardSelector();
|
||||||
|
if (state.activeBoardId) await loadBoard(state.activeBoardId);
|
||||||
|
else document.getElementById('pk-loading').textContent = 'Aucun board disponible.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoardSelector() {
|
||||||
|
const el = document.getElementById('pk-board-selector');
|
||||||
|
el.innerHTML = state.boards.map(b => `<button class="pk-board-btn${b.id === state.activeBoardId ? ' active' : ''}" onclick="switchBoard('${b.id}')">${b.name}</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchBoard(id) {
|
||||||
|
state.activeBoardId = id;
|
||||||
|
renderBoardSelector();
|
||||||
|
await apiFetch('set_active_board', { id });
|
||||||
|
await loadBoard(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoard(id) {
|
||||||
|
document.getElementById('pk-board-area').innerHTML = '<div class="pk-loading"><div class="pk-spinner"></div>Chargement…</div>';
|
||||||
|
const res = await apiFetch('board', { id });
|
||||||
|
if (!res.ok) { document.getElementById('pk-board-area').textContent = 'Erreur: ' + res.error; return; }
|
||||||
|
const d = res.data;
|
||||||
|
state.lists = d.lists || [];
|
||||||
|
state.cards = d.cards || [];
|
||||||
|
state.labels = d.labels || [];
|
||||||
|
state.cardLabels = d.cardLabels || [];
|
||||||
|
state.tasks = d.tasks || [];
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
const area = document.getElementById('pk-board-area');
|
||||||
|
const sorted = [...state.lists].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
area.innerHTML = sorted.map(list => renderList(list)).join('') + `
|
||||||
|
<div class="pk-list pk-list-new">
|
||||||
|
<button class="pk-add-list-btn" onclick="promptCreateList()">+ Ajouter une liste</button>
|
||||||
|
</div>`;
|
||||||
|
// ── Drag source ──────────────────────────────────────────────────────────
|
||||||
|
area.querySelectorAll('.pk-card').forEach(el => {
|
||||||
|
el.addEventListener('click', () => openCardModal(el.dataset.id));
|
||||||
|
el.addEventListener('dragstart', e => {
|
||||||
|
e.dataTransfer.setData('card-id', el.dataset.id);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
setTimeout(() => el.classList.add('is-dragging'), 0);
|
||||||
|
});
|
||||||
|
el.addEventListener('dragend', () => {
|
||||||
|
el.classList.remove('is-dragging');
|
||||||
|
area.querySelectorAll('.drop-top,.drop-bottom,.drag-over').forEach(x =>
|
||||||
|
x.classList.remove('drop-top','drop-bottom','drag-over')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drop ON a card: top half = before, bottom half = after ──────────
|
||||||
|
el.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const mid = el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2;
|
||||||
|
if (e.clientY < mid) { el.classList.add('drop-top'); el.classList.remove('drop-bottom'); }
|
||||||
|
else { el.classList.add('drop-bottom'); el.classList.remove('drop-top'); }
|
||||||
|
});
|
||||||
|
el.addEventListener('dragleave', () => el.classList.remove('drop-top','drop-bottom'));
|
||||||
|
el.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const isBefore = el.classList.contains('drop-top');
|
||||||
|
el.classList.remove('drop-top','drop-bottom');
|
||||||
|
const cardId = e.dataTransfer.getData('card-id');
|
||||||
|
if (!cardId || cardId === el.dataset.id) return;
|
||||||
|
const listId = el.dataset.listId;
|
||||||
|
const sorted = state.cards.filter(c => c.listId === listId && c.id !== cardId)
|
||||||
|
.sort((a, b) => (a.position||0) - (b.position||0));
|
||||||
|
const idx = sorted.findIndex(c => c.id === el.dataset.id);
|
||||||
|
let pos;
|
||||||
|
if (isBefore) {
|
||||||
|
pos = idx === 0
|
||||||
|
? (sorted[0].position || 65535) / 2
|
||||||
|
: ((sorted[idx-1].position||0) + (sorted[idx].position||0)) / 2;
|
||||||
|
} else {
|
||||||
|
pos = idx === sorted.length - 1
|
||||||
|
? (sorted[idx].position || 0) + 65535
|
||||||
|
: ((sorted[idx].position||0) + (sorted[idx+1].position||0)) / 2;
|
||||||
|
}
|
||||||
|
moveCard(cardId, listId, pos);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Drop at end of column (empty list or after all cards) ───────────────
|
||||||
|
area.querySelectorAll('.pk-cards').forEach(el => {
|
||||||
|
el.addEventListener('dragover', e => {
|
||||||
|
// Only highlight column when not over a card
|
||||||
|
if (!e.target.closest('.pk-card')) { e.preventDefault(); el.classList.add('drag-over'); }
|
||||||
|
});
|
||||||
|
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
|
||||||
|
el.addEventListener('drop', e => {
|
||||||
|
if (e.target.closest('.pk-card')) return; // handled by card listener
|
||||||
|
e.preventDefault();
|
||||||
|
el.classList.remove('drag-over');
|
||||||
|
const cardId = e.dataTransfer.getData('card-id');
|
||||||
|
if (!cardId) return;
|
||||||
|
const listId = el.dataset.listId;
|
||||||
|
const others = state.cards.filter(c => c.listId === listId && c.id !== cardId);
|
||||||
|
const maxPos = others.reduce((m, c) => Math.max(m, c.position||0), 0);
|
||||||
|
moveCard(cardId, listId, maxPos + 65535);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(list) {
|
||||||
|
const cards = state.cards.filter(c => c.listId === list.id).sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
return `
|
||||||
|
<div class="pk-list" data-list-id="${list.id}">
|
||||||
|
<div class="pk-list-header">
|
||||||
|
<span class="pk-list-name">${esc(list.name)}</span>
|
||||||
|
<span class="pk-list-count">${cards.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pk-cards" id="cards-${list.id}" data-list-id="${list.id}">
|
||||||
|
${cards.length === 0 ? '<div class="pk-drop-hint">Déposer ici</div>' : ''}
|
||||||
|
${cards.map(c => renderCard(c)).join('')}
|
||||||
|
</div>
|
||||||
|
<button class="pk-add-card-btn" onclick="quickAddCard('${list.id}')">+ Ajouter une carte</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
|
const labels = (state.cardLabels || []).filter(cl => cl.cardId === card.id).map(cl => {
|
||||||
|
const lbl = (state.labels || []).find(l => l.id === cl.labelId);
|
||||||
|
if (!lbl) return '';
|
||||||
|
return `<span class="pk-label pk-label-color--${lbl.color || 'morning-sky'}">${esc(lbl.name || '')}</span>`;
|
||||||
|
}).join('');
|
||||||
|
const cardTasks = (state.tasks || []).filter(t => t.cardId === card.id);
|
||||||
|
const doneCount = cardTasks.filter(t => t.isCompleted).length;
|
||||||
|
const dueHtml = card.dueDate ? (() => {
|
||||||
|
const d = new Date(card.dueDate); const now = new Date();
|
||||||
|
const diff = (d - now) / 86400000;
|
||||||
|
const cls = diff < 0 ? 'overdue' : diff < 3 ? 'soon' : '';
|
||||||
|
return `<span class="pk-due ${cls}">📅 ${formatDate(card.dueDate)}</span>`;
|
||||||
|
})() : '';
|
||||||
|
const taskHtml = cardTasks.length ? `<span class="pk-task-count">☑ ${doneCount}/${cardTasks.length}</span>` : '';
|
||||||
|
return `
|
||||||
|
<div class="pk-card" data-id="${card.id}" data-list-id="${card.listId}" data-position="${card.position ?? 0}" draggable="true">
|
||||||
|
${labels ? `<div class="pk-card-labels">${labels}</div>` : ''}
|
||||||
|
<div class="pk-card-name">${esc(card.name)}</div>
|
||||||
|
${dueHtml || taskHtml ? `<div class="pk-card-meta">${dueHtml}${taskHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveCard(cardId, listId, position) {
|
||||||
|
const card = state.cards.find(c => c.id === cardId);
|
||||||
|
if (!card) return;
|
||||||
|
// Skip no-op (same list, same position range)
|
||||||
|
if (card.listId === listId && Math.abs((card.position || 0) - position) < 1) return;
|
||||||
|
const body = { listId, position };
|
||||||
|
const res = await apiFetch('update_card', { id: cardId }, 'PATCH', body);
|
||||||
|
if (!res.ok) { showToast('Erreur : ' + (res.error || 'déplacement impossible'), 'error'); return; }
|
||||||
|
card.listId = listId;
|
||||||
|
card.position = position;
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte déplacée ✓');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickAddCard(listId) {
|
||||||
|
const name = prompt('Nom de la carte :');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const listCards = state.cards.filter(c => c.listId === listId);
|
||||||
|
const maxPos = listCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
|
||||||
|
const res = await apiFetch('create_card', { list_id: listId, name: name.trim(), position: maxPos + 65535 });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.cards.push(res.data);
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte créée');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCardModal(id) {
|
||||||
|
const card = state.cards.find(c => c.id === id);
|
||||||
|
if (!card) return;
|
||||||
|
editingCard = card;
|
||||||
|
document.getElementById('pk-card-name').value = card.name || '';
|
||||||
|
document.getElementById('pk-card-desc').value = card.description || '';
|
||||||
|
document.getElementById('pk-card-due').value = card.dueDate ? card.dueDate.substring(0, 10) : '';
|
||||||
|
const sel = document.getElementById('pk-card-list');
|
||||||
|
sel.innerHTML = state.lists.map(l => `<option value="${l.id}"${l.id === card.listId ? ' selected' : ''}>${esc(l.name)}</option>`).join('');
|
||||||
|
renderTaskList(card);
|
||||||
|
document.getElementById('pk-card-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('pk-new-task-name').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskList(card) {
|
||||||
|
const el = document.getElementById('pk-task-list');
|
||||||
|
const tasks = (state.tasks || []).filter(t => t.cardId === card.id).sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||||
|
el.innerHTML = tasks.map(t => `
|
||||||
|
<div class="pk-task-item" data-task-id="${t.id}">
|
||||||
|
<input type="checkbox" class="pk-task-cb" ${t.isCompleted ? 'checked' : ''} onchange="toggleTask('${t.id}', this.checked)">
|
||||||
|
<span class="pk-task-label${t.isCompleted ? ' done' : ''}">${esc(t.name)}</span>
|
||||||
|
</div>`).join('') || '<span style="font-size:.78rem;color:var(--text-muted)">Aucune tâche</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCardModal() {
|
||||||
|
document.getElementById('pk-card-modal').classList.add('hidden');
|
||||||
|
editingCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCard() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const name = document.getElementById('pk-card-name').value.trim();
|
||||||
|
if (!name) { showToast('Titre requis', 'error'); return; }
|
||||||
|
const due = document.getElementById('pk-card-due').value;
|
||||||
|
const listId = document.getElementById('pk-card-list').value;
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
description: document.getElementById('pk-card-desc').value,
|
||||||
|
dueDate: due ? due + 'T00:00:00.000Z' : null,
|
||||||
|
};
|
||||||
|
// Planka requires position whenever listId is sent — only include if list changed
|
||||||
|
if (listId !== editingCard.listId) {
|
||||||
|
body.listId = listId;
|
||||||
|
const targetCards = state.cards.filter(c => c.listId === listId);
|
||||||
|
const maxPos = targetCards.reduce((m, c) => Math.max(m, c.position || 0), 0);
|
||||||
|
body.position = maxPos + 65535;
|
||||||
|
}
|
||||||
|
const res = await apiFetch('update_card', { id: editingCard.id }, 'PATCH', body);
|
||||||
|
if (!res.ok) { showToast('Erreur : ' + (res.error || 'sauvegarde impossible'), 'error'); return; }
|
||||||
|
const idx = state.cards.findIndex(c => c.id === editingCard.id);
|
||||||
|
if (idx >= 0) state.cards[idx] = { ...state.cards[idx], ...res.data, listId: listId };
|
||||||
|
closeCardModal();
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte enregistrée ✓');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCard() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const ok = await pachaConfirm('Supprimer la carte ?', 'Cette action est irréversible.');
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await apiFetch('delete_card', { id: editingCard.id });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.cards = state.cards.filter(c => c.id !== editingCard.id);
|
||||||
|
closeCardModal();
|
||||||
|
renderBoard();
|
||||||
|
showToast('Carte supprimée');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTask() {
|
||||||
|
if (!editingCard) return;
|
||||||
|
const name = document.getElementById('pk-new-task-name').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const res = await apiFetch('create_task', { card_id: editingCard.id, name });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
state.tasks.push(res.data);
|
||||||
|
document.getElementById('pk-new-task-name').value = '';
|
||||||
|
renderTaskList(editingCard);
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTask(id, checked) {
|
||||||
|
const res = await apiFetch('toggle_task', { id, is_completed: checked ? '1' : '0' });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
const t = state.tasks.find(t => t.id === id);
|
||||||
|
if (t) { t.isCompleted = checked; renderTaskList(editingCard); renderBoard(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
apiFetch('config').then(res => {
|
||||||
|
if (res.ok && res.data.project_id) document.getElementById('pk-project-id').value = res.data.project_id;
|
||||||
|
});
|
||||||
|
document.getElementById('pk-settings-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() { document.getElementById('pk-settings-modal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const project_id = document.getElementById('pk-project-id').value.trim();
|
||||||
|
if (!project_id) return;
|
||||||
|
const res = await apiFetch('set_project', { project_id });
|
||||||
|
if (!res.ok) { showToast('Erreur: ' + res.error, 'error'); return; }
|
||||||
|
closeSettings();
|
||||||
|
state.activeBoardId = null;
|
||||||
|
showToast('Projet mis à jour, rechargement…');
|
||||||
|
setTimeout(() => init(), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptCreateList() {
|
||||||
|
const name = prompt('Nom de la nouvelle liste :');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
const maxPos = state.lists.reduce((m, l) => Math.max(m, l.position || 0), 0);
|
||||||
|
const res = await apiFetch('create_list', { board_id: state.activeBoardId, name: name.trim(), position: maxPos + 65535 });
|
||||||
|
if (!res.ok) { showToast('Erreur : ' + (res.error || 'création impossible'), 'error'); return; }
|
||||||
|
state.lists.push(res.data);
|
||||||
|
renderBoard();
|
||||||
|
showToast('Liste créée ✓');
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
if (!$id) { header('Location: /printvault.php'); exit; }
|
||||||
|
|
||||||
|
$s = $pdo->prepare("SELECT * FROM pf_pv_models WHERE id=?"); $s->execute([$id]);
|
||||||
|
$model = $s->fetch();
|
||||||
|
if (!$model) { header('Location: /printvault.php'); exit; }
|
||||||
|
|
||||||
|
$s2 = $pdo->query("SELECT name, color FROM pf_pv_categories ORDER BY name");
|
||||||
|
$categories = $s2->fetchAll();
|
||||||
|
|
||||||
|
$ext = strtolower($model['file_type']);
|
||||||
|
$canView = in_array($ext, ['stl','3mf','gcode','gco','g']);
|
||||||
|
$isGcode = in_array($ext, ['gcode','gco','g']);
|
||||||
|
$thumbUrl = $model['thumb'] ? '/uploads/printvault/thumbs/' . rawurlencode($model['thumb']) : null;
|
||||||
|
$typeIcon = ['stl'=>'🟣','3mf'=>'🔵','gcode'=>'🟢','gco'=>'🟢','g'=>'🟢'][$ext] ?? '📄';
|
||||||
|
|
||||||
|
function fmtSize(int $b): string {
|
||||||
|
if ($b > 1048576) return round($b/1048576,1).' Mo';
|
||||||
|
return round($b/1024).' Ko';
|
||||||
|
}
|
||||||
|
function fmtDate(string $s): string {
|
||||||
|
return date('d/m/Y', strtotime($s));
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageTitle = htmlspecialchars($model['name']) . ' — PrintVault';
|
||||||
|
$activePage = 'printvault';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/printvault/assets/printvault.css">
|
||||||
|
<link rel="stylesheet" href="/modules/printvault/assets/viewer.css">
|
||||||
|
<div id="pv-toasts" class="pv-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="pv-detail-page">
|
||||||
|
|
||||||
|
<!-- ── TOP BAR ────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-detail-topbar">
|
||||||
|
<a href="/printvault.php" class="btn btn-secondary btn-sm">← Retour</a>
|
||||||
|
<div class="pv-detail-topbar-title">
|
||||||
|
<span class="pv-type-badge pv-type-<?= $ext ?>"><?= strtoupper($ext) ?></span>
|
||||||
|
<h1><?= htmlspecialchars($model['name']) ?></h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.5rem;margin-left:auto">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="openEdit()">✏️ Modifier</button>
|
||||||
|
<a href="/modules/printvault/api.php?action=file&id=<?= $id ?>" class="btn btn-secondary btn-sm" download>⬇ Télécharger</a>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteModel()">🗑 Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── BODY ───────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-detail-body">
|
||||||
|
|
||||||
|
<!-- Left: viewer or thumbnail -->
|
||||||
|
<div class="pv-detail-left">
|
||||||
|
<?php if ($canView): ?>
|
||||||
|
<div class="pv-viewer-wrap" id="viewer-wrap">
|
||||||
|
<div id="viewer-loading" class="pv-viewer-loading">
|
||||||
|
<div class="pv-spinner"></div>
|
||||||
|
<div id="viewer-loading-text">Chargement…</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="viewer-canvas"></canvas>
|
||||||
|
<div class="pv-viewer-controls-hint">Clic+glisser : rotation · Scroll : zoom</div>
|
||||||
|
<?php if (!$isGcode): ?>
|
||||||
|
<button class="pv-viewer-screenshot-btn" onclick="takeScreenshot()" title="Sauvegarder comme aperçu">📸</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($isGcode): ?>
|
||||||
|
<div class="pv-gcode-viewer-legend" id="gcode-legend">
|
||||||
|
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#3b82f6;border-radius:2px;vertical-align:middle"></span> Couches basses</div>
|
||||||
|
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#10b981;border-radius:2px;vertical-align:middle"></span> Couches moyennes</div>
|
||||||
|
<div class="pv-legend-item"><span style="display:inline-block;width:12px;height:3px;background:#ef4444;border-radius:2px;vertical-align:middle"></span> Couches hautes</div>
|
||||||
|
<div class="pv-legend-item" style="color:#475569"><span style="display:inline-block;width:12px;height:1px;background:#475569;border-radius:2px;vertical-align:middle;opacity:.5"></span> Déplacements</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php elseif ($thumbUrl): ?>
|
||||||
|
<div class="pv-detail-thumb-large">
|
||||||
|
<img src="<?= htmlspecialchars($thumbUrl) ?>" alt="">
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="pv-detail-thumb-large pv-detail-thumb-placeholder">
|
||||||
|
<span><?= $typeIcon ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: metadata -->
|
||||||
|
<div class="pv-detail-right">
|
||||||
|
|
||||||
|
<?php if ($model['description']): ?>
|
||||||
|
<div class="pv-meta-section">
|
||||||
|
<p class="pv-detail-description"><?= htmlspecialchars($model['description']) ?></p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($model['tags']): ?>
|
||||||
|
<div class="pv-meta-section pv-tags-row">
|
||||||
|
<?php foreach (array_filter(explode(',', $model['tags'])) as $tag): ?>
|
||||||
|
<span class="pv-tag"><?= htmlspecialchars(trim($tag)) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- File info -->
|
||||||
|
<div class="pv-meta-section">
|
||||||
|
<div class="pv-meta-title">Informations</div>
|
||||||
|
<div class="pv-meta-grid">
|
||||||
|
<div class="pv-meta-item"><span class="pv-meta-label">Catégorie</span><span class="pv-meta-value"><?= htmlspecialchars($model['category']) ?></span></div>
|
||||||
|
<div class="pv-meta-item"><span class="pv-meta-label">Taille fichier</span><span class="pv-meta-value"><?= fmtSize((int)$model['file_size']) ?></span></div>
|
||||||
|
<div class="pv-meta-item"><span class="pv-meta-label">Ajouté le</span><span class="pv-meta-value"><?= fmtDate($model['created_at']) ?></span></div>
|
||||||
|
<?php if ($model['dim_x'] > 0): ?>
|
||||||
|
<div class="pv-meta-item pv-meta-item--full">
|
||||||
|
<span class="pv-meta-label">Dimensions</span>
|
||||||
|
<span class="pv-meta-value"><?= round($model['dim_x']) ?> × <?= round($model['dim_y']) ?> × <?= round($model['dim_z']) ?> mm<?= $model['volume'] > 0 ? ' · '.round($model['volume'],2).' cm³' : '' ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($isGcode && ($model['gcode_time'] || $model['gcode_filament'] || $model['gcode_nozzle'] || $model['gcode_bed'])): ?>
|
||||||
|
<!-- GCode metadata cards -->
|
||||||
|
<div class="pv-meta-section">
|
||||||
|
<div class="pv-meta-title">Métadonnées impression</div>
|
||||||
|
<div class="pv-gcode-cards">
|
||||||
|
<?php if ($model['gcode_time']): ?>
|
||||||
|
<div class="pv-gcode-card">
|
||||||
|
<div class="pv-gcode-icon">⏱</div>
|
||||||
|
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_time']) ?></div>
|
||||||
|
<div class="pv-gcode-label">Temps d'impression estimé</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($model['gcode_filament']): ?>
|
||||||
|
<div class="pv-gcode-card">
|
||||||
|
<div class="pv-gcode-icon">🧵</div>
|
||||||
|
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_filament']) ?></div>
|
||||||
|
<div class="pv-gcode-label">Filament utilisé</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($model['gcode_nozzle']): ?>
|
||||||
|
<div class="pv-gcode-card">
|
||||||
|
<div class="pv-gcode-icon">🔥</div>
|
||||||
|
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_nozzle']) ?></div>
|
||||||
|
<div class="pv-gcode-label">Température buse</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($model['gcode_bed']): ?>
|
||||||
|
<div class="pv-gcode-card">
|
||||||
|
<div class="pv-gcode-icon">🟦</div>
|
||||||
|
<div class="pv-gcode-value"><?= htmlspecialchars($model['gcode_bed']) ?></div>
|
||||||
|
<div class="pv-gcode-label">Température plateau</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : ÉDITION ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-modal-backdrop" id="pv-edit-modal">
|
||||||
|
<div class="pv-modal">
|
||||||
|
<div class="pv-modal-header">
|
||||||
|
<h3>Modifier le modèle</h3>
|
||||||
|
<button class="pv-modal-close" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nom *</label>
|
||||||
|
<input type="text" id="edit-name" class="form-control" value="<?= htmlspecialchars($model['name']) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" id="edit-desc" class="form-control" value="<?= htmlspecialchars($model['description'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Catégorie</label>
|
||||||
|
<select id="edit-cat" class="form-control">
|
||||||
|
<?php foreach ($categories as $c): ?>
|
||||||
|
<option value="<?= htmlspecialchars($c['name']) ?>" <?= $c['name'] === $model['category'] ? 'selected' : '' ?>><?= htmlspecialchars($c['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<input type="text" id="edit-tags" class="form-control" value="<?= htmlspecialchars($model['tags'] ?? '') ?>" placeholder="tag1, tag2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('pv-edit-modal').classList.remove('show')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveEdit()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MODEL_ID = <?= $id ?>;
|
||||||
|
const MODEL_EXT = '<?= $ext ?>';
|
||||||
|
const API = '/modules/printvault/api.php';
|
||||||
|
|
||||||
|
function toast(msg, type='success') {
|
||||||
|
const c = document.getElementById('pv-toasts');
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'pv-toast' + (type==='error'?' error':'');
|
||||||
|
t.textContent = msg; c.appendChild(t); setTimeout(()=>t.remove(),3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() { document.getElementById('pv-edit-modal').classList.add('show'); }
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
const name = document.getElementById('edit-name').value.trim();
|
||||||
|
if (!name) { toast('Nom requis','error'); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(API+'?action=models&id='+MODEL_ID, {
|
||||||
|
method:'PUT', credentials:'same-origin',
|
||||||
|
headers:{'Content-Type':'application/json','Accept':'application/json','X-Requested-With':'XMLHttpRequest'},
|
||||||
|
body: JSON.stringify({name, description:document.getElementById('edit-desc').value, category:document.getElementById('edit-cat').value, tags:document.getElementById('edit-tags').value})
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) throw new Error(j.error);
|
||||||
|
toast('Mis à jour ✓');
|
||||||
|
document.getElementById('pv-edit-modal').classList.remove('show');
|
||||||
|
setTimeout(()=>location.reload(),600);
|
||||||
|
} catch(e) { toast(e.message,'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteModel() {
|
||||||
|
if (!confirm('Supprimer ce modèle ?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(API+'?action=models&id='+MODEL_ID, {method:'DELETE',credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.ok) throw new Error(j.error);
|
||||||
|
window.location.href = '/printvault.php';
|
||||||
|
} catch(e) { toast(e.message,'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.pv-modal-backdrop').forEach(m => m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php if ($canView): ?>
|
||||||
|
<script type="importmap">
|
||||||
|
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
<?php if (!$isGcode): ?>
|
||||||
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||||
|
<?php if ($ext === '3mf'): ?>
|
||||||
|
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
const canvas = document.getElementById('viewer-canvas');
|
||||||
|
const wrap = document.getElementById('viewer-wrap');
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0f172a);
|
||||||
|
|
||||||
|
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||||
|
const key = new THREE.DirectionalLight(0xffffff, 1.2); key.position.set(5,10,7.5); scene.add(key);
|
||||||
|
const fill= new THREE.DirectionalLight(0x8ab4f8, 0.4); fill.position.set(-5,-3,-5); scene.add(fill);
|
||||||
|
const rim = new THREE.DirectionalLight(0xffffff, 0.3); rim.position.set(0,5,-10); scene.add(rim);
|
||||||
|
|
||||||
|
const grid = new THREE.GridHelper(300,50,0x1e293b,0x1e293b);
|
||||||
|
grid.material.opacity=0.4; grid.material.transparent=true; scene.add(grid);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, wrap.clientWidth/wrap.clientHeight, 0.1, 10000);
|
||||||
|
camera.position.set(150,150,150);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, preserveDrawingBuffer:true});
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(wrap.clientWidth, wrap.clientHeight);
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping=true; controls.dampingFactor=0.05;
|
||||||
|
|
||||||
|
function fitCamera(obj) {
|
||||||
|
const box = new THREE.Box3().setFromObject(obj);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const dist = Math.abs(Math.max(size.x,size.y,size.z)/Math.sin(camera.fov*Math.PI/180/2))*0.8;
|
||||||
|
camera.position.set(center.x+dist*.7, center.y+dist*.6, center.z+dist*.7);
|
||||||
|
controls.target.copy(center);
|
||||||
|
grid.position.y = box.min.y - 0.5;
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
<?php if ($isGcode): ?>
|
||||||
|
// ── GCode path viewer ─────────────────────────────────────────────────────────
|
||||||
|
function zToColor(z, minZ, maxZ) {
|
||||||
|
const t = maxZ > minZ ? (z - minZ) / (maxZ - minZ) : 0;
|
||||||
|
// Blue(0,0,1) → Cyan(0,1,1) → Green(0,1,0) → Yellow(1,1,0) → Red(1,0,0)
|
||||||
|
let r, g, b;
|
||||||
|
if (t < 0.25) { r=0; g=t*4; b=1; }
|
||||||
|
else if (t < 0.5) { r=0; g=1; b=1-(t-0.25)*4; }
|
||||||
|
else if (t < 0.75) { r=(t-0.5)*4; g=1; b=0; }
|
||||||
|
else { r=1; g=1-(t-0.75)*4; b=0; }
|
||||||
|
return new THREE.Color(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('viewer-loading-text').textContent = 'Chargement des trajectoires…';
|
||||||
|
|
||||||
|
document.getElementById('viewer-loading-text').textContent = 'Parsing GCode…';
|
||||||
|
|
||||||
|
fetch('/modules/printvault/api.php?action=gcode_paths&id=<?= $id ?>', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(j => {
|
||||||
|
if (!j.ok) { document.getElementById('viewer-loading-text').textContent = j.error; return; }
|
||||||
|
const {flat, min_z, max_z, total} = j.data;
|
||||||
|
|
||||||
|
document.getElementById('viewer-loading-text').textContent = 'Construction du rendu…';
|
||||||
|
|
||||||
|
// flat = [x1,y1,z1,x2,y2,z2,ext, x1,y1,z1,...] stride 7
|
||||||
|
const extPos=[], extCol=[], travPos=[];
|
||||||
|
for (let i=0; i < flat.length; i+=7) {
|
||||||
|
const x1=flat[i],y1=flat[i+1],z1=flat[i+2];
|
||||||
|
const x2=flat[i+3],y2=flat[i+4],z2=flat[i+5];
|
||||||
|
const ext=flat[i+6];
|
||||||
|
if (ext) {
|
||||||
|
const col = zToColor(z1, min_z, max_z);
|
||||||
|
extPos.push(x1,z1,-y1, x2,z2,-y2);
|
||||||
|
extCol.push(col.r,col.g,col.b, col.r,col.g,col.b);
|
||||||
|
} else {
|
||||||
|
travPos.push(x1,z1,-y1, x2,z2,-y2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = new THREE.Group();
|
||||||
|
if (extPos.length) {
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(extPos, 3));
|
||||||
|
geo.setAttribute('color', new THREE.Float32BufferAttribute(extCol, 3));
|
||||||
|
group.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({vertexColors:true})));
|
||||||
|
}
|
||||||
|
if (travPos.length) {
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(travPos, 3));
|
||||||
|
group.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({color:0x334155,transparent:true,opacity:0.25})));
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.add(group);
|
||||||
|
fitCamera(group);
|
||||||
|
document.getElementById('viewer-loading').style.display = 'none';
|
||||||
|
document.getElementById('viewer-loading-text').textContent = `${total.toLocaleString()} segments`;
|
||||||
|
})
|
||||||
|
.catch(err => { document.getElementById('viewer-loading-text').textContent = 'Erreur: '+err.message; });
|
||||||
|
|
||||||
|
<?php else: ?>
|
||||||
|
// ── STL / 3MF viewer ─────────────────────────────────────────────────────────
|
||||||
|
const material = new THREE.MeshPhysicalMaterial({color:0x4cc9f0,metalness:0.3,roughness:0.4,side:THREE.DoubleSide});
|
||||||
|
let meshGroup = null;
|
||||||
|
|
||||||
|
<?php if ($ext === 'stl'): ?>
|
||||||
|
new STLLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $id ?>',
|
||||||
|
geo => {
|
||||||
|
geo.computeVertexNormals();
|
||||||
|
meshGroup = new THREE.Group(); meshGroup.add(new THREE.Mesh(geo,material));
|
||||||
|
scene.add(meshGroup); fitCamera(meshGroup);
|
||||||
|
document.getElementById('viewer-loading').style.display='none';
|
||||||
|
},
|
||||||
|
xhr => { if(xhr.total) document.getElementById('viewer-loading-text').textContent='Chargement… '+Math.round(xhr.loaded/xhr.total*100)+'%'; },
|
||||||
|
err => { document.getElementById('viewer-loading-text').textContent='Erreur: '+err.message; }
|
||||||
|
);
|
||||||
|
<?php elseif ($ext === '3mf'): ?>
|
||||||
|
new ThreeMFLoader().load('/modules/printvault/api.php?action=model_data&id=<?= $id ?>',
|
||||||
|
obj => {
|
||||||
|
obj.traverse(c=>{if(c.isMesh)c.material=material;});
|
||||||
|
meshGroup=obj; scene.add(meshGroup); fitCamera(meshGroup);
|
||||||
|
document.getElementById('viewer-loading').style.display='none';
|
||||||
|
},
|
||||||
|
xhr => { if(xhr.total) document.getElementById('viewer-loading-text').textContent='Chargement… '+Math.round(xhr.loaded/xhr.total*100)+'%'; },
|
||||||
|
err => { document.getElementById('viewer-loading-text').textContent='Erreur: '+err.message; }
|
||||||
|
);
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
window.takeScreenshot = async () => {
|
||||||
|
renderer.render(scene,camera);
|
||||||
|
canvas.toBlob(async blob=>{
|
||||||
|
const fd=new FormData(); fd.append('thumb',blob,'thumb.png');
|
||||||
|
const r=await fetch(API+'?action=thumb&id='+MODEL_ID,{method:'POST',credentials:'same-origin',body:fd,headers:{'X-Requested-With':'XMLHttpRequest'}});
|
||||||
|
const j=await r.json();
|
||||||
|
if(j.ok){toast('Aperçu sauvegardé ✓');}
|
||||||
|
},'image/png',0.9);
|
||||||
|
};
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
(function animate(){requestAnimationFrame(animate);controls.update();renderer.render(scene,camera);})();
|
||||||
|
|
||||||
|
window.addEventListener('resize',()=>{
|
||||||
|
renderer.setSize(wrap.clientWidth,wrap.clientHeight);
|
||||||
|
camera.aspect=wrap.clientWidth/wrap.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'PrintVault — HouseHub';
|
||||||
|
$activePage = 'printvault';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/printvault/assets/printvault.css">
|
||||||
|
<div id="pv-toasts" class="pv-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="pv-layout">
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ──────────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="pv-sidebar">
|
||||||
|
<div class="pv-sidebar-top">
|
||||||
|
<button class="btn btn-primary" onclick="openUpload()" style="width:100%;justify-content:center">+ Upload</button>
|
||||||
|
<div class="pv-search">
|
||||||
|
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
|
||||||
|
<input type="text" id="pv-search" placeholder="Rechercher…">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-type-chips">
|
||||||
|
<span class="pv-chip stl" data-type="stl" onclick="setType('stl')">STL</span>
|
||||||
|
<span class="pv-chip 3mf" data-type="3mf" onclick="setType('3mf')">3MF</span>
|
||||||
|
<span class="pv-chip gcode" data-type="gcode" onclick="setType('gcode')">GCode</span>
|
||||||
|
</div>
|
||||||
|
<div id="pv-sidebar-nav"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ─────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-main">
|
||||||
|
<div class="pv-main-header">
|
||||||
|
<div>
|
||||||
|
<h2>🖨️ PrintVault</h2>
|
||||||
|
<div class="pv-subtitle" id="pv-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openUpload()">+ Upload</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-grid" id="pv-grid">
|
||||||
|
<div class="pv-empty" style="grid-column:1/-1"><div class="icon">🖨️</div><p>Chargement…</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : UPLOAD ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="pv-modal-backdrop" id="pv-upload-modal">
|
||||||
|
<div class="pv-modal">
|
||||||
|
<div class="pv-modal-header">
|
||||||
|
<h3>Uploader un modèle</h3>
|
||||||
|
<button class="pv-modal-close" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-body">
|
||||||
|
<div class="pv-drop-zone" id="pv-drop-zone">
|
||||||
|
<div class="icon">📁</div>
|
||||||
|
<div>Glissez un fichier ici ou cliquez</div>
|
||||||
|
<div style="font-size:.75rem;margin-top:.25rem;color:var(--text-muted)">STL · 3MF · GCode</div>
|
||||||
|
<div class="pv-file-chosen" id="pv-upload-filename"></div>
|
||||||
|
<input type="file" id="pv-file-input" accept=".stl,.3mf,.gcode,.gco,.g" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nom *</label>
|
||||||
|
<input type="text" id="pv-upload-name" class="form-control" placeholder="Nom du modèle">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input type="text" id="pv-upload-desc" class="form-control" placeholder="Optionnel">
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Catégorie</label>
|
||||||
|
<select id="pv-upload-cat" class="form-control"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<input type="text" id="pv-upload-tags" class="form-control" placeholder="tag1, tag2">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pv-upload-progress" style="display:none">
|
||||||
|
<div class="pv-progress"><div class="pv-progress-bar" id="pv-upload-bar" style="width:0%"></div></div>
|
||||||
|
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.3rem">Upload en cours… (parsing géométrie)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pv-modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('pv-upload-modal').classList.remove('show')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveUpload()">Uploader</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/printvault/assets/printvault.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
session_start();
|
session_start();
|
||||||
require_once __DIR__ . '/includes/i18n.php';
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
require_once __DIR__ . '/includes/meta_db.php';
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
|
||||||
if (isset($_SESSION['user'])) {
|
if (isset($_SESSION['user'])) {
|
||||||
header('Location: /index.php');
|
header('Location: /index.php');
|
||||||
@@ -40,6 +41,9 @@ function createFamilyDb(PDO $meta, string $db_host, string $db_user, string $db_
|
|||||||
// ─── Traitement du formulaire ─────────────────────────────────────────────────
|
// ─── Traitement du formulaire ─────────────────────────────────────────────────
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||||
|
$error = "Session invalide (CSRF). Rechargez la page.";
|
||||||
|
} else {
|
||||||
$action = $_POST['action'] ?? 'create';
|
$action = $_POST['action'] ?? 'create';
|
||||||
$username = trim($_POST['username'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$display_name = trim($_POST['display_name'] ?? '');
|
$display_name = trim($_POST['display_name'] ?? '');
|
||||||
@@ -93,6 +97,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'family_id' => (int)$family['id'],
|
'family_id' => (int)$family['id'],
|
||||||
];
|
];
|
||||||
$_SESSION['family_db'] = $family['db_name'];
|
$_SESSION['family_db'] = $family['db_name'];
|
||||||
|
session_regenerate_id(true);
|
||||||
header('Location: /index.php');
|
header('Location: /index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'family_id' => $family_id,
|
'family_id' => $family_id,
|
||||||
];
|
];
|
||||||
$_SESSION['family_db'] = $db_name;
|
$_SESSION['family_db'] = $db_name;
|
||||||
|
session_regenerate_id(true);
|
||||||
header('Location: /index.php');
|
header('Location: /index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$error = "Erreur lors de la création : " . $e->getMessage();
|
$error = "Erreur lors de la création : " . $e->getMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$pageTitle = "Inscription — HouseHub";
|
$pageTitle = "Inscription — HouseHub";
|
||||||
@@ -172,6 +179,7 @@ require __DIR__ . '/header.php';
|
|||||||
|
|
||||||
<!-- Formulaire commun -->
|
<!-- Formulaire commun -->
|
||||||
<form method="post" action="/register.php" id="reg-form">
|
<form method="post" action="/register.php" id="reg-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" id="reg-action" value="create">
|
<input type="hidden" name="action" id="reg-action" value="create">
|
||||||
|
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
|
|||||||
+34
@@ -235,3 +235,37 @@ VALUES (1, 'admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/ig
|
|||||||
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
|
-- Personnes (IDs fixes correspondant aux constantes dans config.php)
|
||||||
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
|
INSERT IGNORE INTO pf_people (id, name) VALUES (2, 'Alex');
|
||||||
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
|
INSERT IGNORE INTO pf_people (id, name) VALUES (3, 'Laia');
|
||||||
|
|
||||||
|
-- ─── Calendar iOS ─────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_events (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
family_id INT NOT NULL,
|
||||||
|
created_by_user_id INT NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
is_all_day TINYINT(1) DEFAULT 0,
|
||||||
|
timezone VARCHAR(64) DEFAULT 'Europe/Paris',
|
||||||
|
rrule VARCHAR(500) DEFAULT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'confirmed',
|
||||||
|
external_uid VARCHAR(255) DEFAULT NULL,
|
||||||
|
sync_state VARCHAR(30) DEFAULT 'pending_push',
|
||||||
|
deleted_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_external_uid (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_calendar_event_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
calendar_event_id INT NOT NULL,
|
||||||
|
external_uid VARCHAR(255) NOT NULL,
|
||||||
|
external_etag VARCHAR(255) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_calendar_event (calendar_event_id),
|
||||||
|
UNIQUE KEY uq_external_link (external_uid)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
+334
-61
@@ -2,6 +2,8 @@
|
|||||||
require __DIR__ . '/includes/auth.php';
|
require __DIR__ . '/includes/auth.php';
|
||||||
require_login();
|
require_login();
|
||||||
require_once __DIR__ . '/includes/meta_db.php';
|
require_once __DIR__ . '/includes/meta_db.php';
|
||||||
|
require_once __DIR__ . '/includes/crypto.php';
|
||||||
|
require_once __DIR__ . '/includes/icloud_caldav.php';
|
||||||
require_once __DIR__ . '/includes/i18n.php';
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
$user_id = $_SESSION['user']['id'];
|
$user_id = $_SESSION['user']['id'];
|
||||||
@@ -10,12 +12,42 @@ $family_id = $_SESSION['user']['family_id'];
|
|||||||
$error = null;
|
$error = null;
|
||||||
$success = null;
|
$success = null;
|
||||||
|
|
||||||
|
$meta_pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS user_calendar_integrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL DEFAULT 'icloud_caldav',
|
||||||
|
username VARCHAR(255) NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL,
|
||||||
|
dav_principal_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
calendar_url VARCHAR(1024) DEFAULT NULL,
|
||||||
|
status VARCHAR(30) DEFAULT 'connected',
|
||||||
|
last_sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_user_provider (user_id, provider)
|
||||||
|
)");
|
||||||
|
|
||||||
// ─── Actions ──────────────────────────────────────────────────────────────────
|
// ─── Actions ──────────────────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf($_POST['csrf_token'] ?? null)) {
|
||||||
|
$error = "Session invalide (CSRF). Rechargez la page.";
|
||||||
|
} else {
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
|
|
||||||
|
if ($action === 'update_family_info') {
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
|
||||||
|
$foyer = $pdo->query("SELECT currency, zone_scolaire FROM pf_foyer_settings WHERE id = 1")->fetch();
|
||||||
|
$currency = isset($_POST['currency']) ? trim($_POST['currency']) : ($foyer['currency'] ?? '€');
|
||||||
|
$zone = isset($_POST['zone_scolaire']) ? trim($_POST['zone_scolaire']) : ($foyer['zone_scolaire'] ?? 'C');
|
||||||
|
$stmtSave = $pdo->prepare("UPDATE pf_foyer_settings SET currency = ?, zone_scolaire = ? WHERE id = 1");
|
||||||
|
$stmtSave->execute([$currency, $zone]);
|
||||||
|
$success = "Paramètres du foyer mis à jour avec succès.";
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'set_modules' && $family_id) {
|
if ($action === 'set_modules' && $family_id) {
|
||||||
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage'];
|
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo', 'liste', 'calendar_ios', 'printvault', 'planka'];
|
||||||
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
|
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
|
||||||
if (empty($enabled)) {
|
if (empty($enabled)) {
|
||||||
$error = "Vous devez garder au moins un module actif.";
|
$error = "Vous devez garder au moins un module actif.";
|
||||||
@@ -112,6 +144,84 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old);
|
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $old) @unlink($old);
|
||||||
$success = "Image d'accueil réinitialisée.";
|
$success = "Image d'accueil réinitialisée.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($action === 'grocery_history_max' && $family_id) {
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
if (!isset($pdo)) {
|
||||||
|
$error = "Base famille indisponible.";
|
||||||
|
} else {
|
||||||
|
$n = (int) ($_POST['history_max'] ?? 20);
|
||||||
|
$n = max(1, min(50, $n));
|
||||||
|
try {
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO pf_notes (note_type, reference_id, content) VALUES ('grocery_settings','history_max',?) ON DUPLICATE KEY UPDATE content=VALUES(content)"
|
||||||
|
)->execute([(string) $n]);
|
||||||
|
$success = tr('groceries_settings_saved');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = tr('groceries_settings_error') . ' ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_ios_save') {
|
||||||
|
$username = trim($_POST['icloud_username'] ?? '');
|
||||||
|
$appPassword = hh_normalize_apple_app_password((string) ($_POST['icloud_app_password'] ?? ''));
|
||||||
|
$calendarUrl = trim($_POST['icloud_calendar_url'] ?? '');
|
||||||
|
|
||||||
|
if (!$username || !$appPassword || !$calendarUrl) {
|
||||||
|
$error = "Merci de renseigner identifiant iCloud, mot de passe d'app et URL CalDAV.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($username, $appPassword, $calendarUrl);
|
||||||
|
$encrypted = hh_encrypt_secret($appPassword);
|
||||||
|
$meta_pdo->prepare("
|
||||||
|
INSERT INTO user_calendar_integrations (user_id, provider, username, secret_encrypted, calendar_url, status, updated_at)
|
||||||
|
VALUES (?, 'icloud_caldav', ?, ?, ?, 'connected', NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE username=VALUES(username), secret_encrypted=VALUES(secret_encrypted), calendar_url=VALUES(calendar_url), status='connected', updated_at=NOW()
|
||||||
|
")->execute([$user_id, $username, $encrypted, $resolvedUrl]);
|
||||||
|
$msg = "Connexion calendrier iOS enregistrée.";
|
||||||
|
if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) {
|
||||||
|
$msg .= " URL du calendrier détectée automatiquement (tu avais mis la racine iCloud).";
|
||||||
|
}
|
||||||
|
$success = $msg;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = "Impossible d'enregistrer la connexion iOS: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_ios_test') {
|
||||||
|
$row = $meta_pdo->prepare("SELECT id, username, secret_encrypted, calendar_url FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
|
||||||
|
$row->execute([$user_id]);
|
||||||
|
$integration = $row->fetch();
|
||||||
|
if (!$integration) {
|
||||||
|
$error = "Aucune connexion iOS configurée.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$pwd = hh_normalize_apple_app_password(hh_decrypt_secret($integration['secret_encrypted']));
|
||||||
|
$calendarUrl = trim($integration['calendar_url']);
|
||||||
|
$resolvedUrl = hh_icloud_resolve_calendar_url_if_needed($integration['username'], $pwd, $calendarUrl);
|
||||||
|
$code = hh_caldav_test_calendar_collection($integration['username'], $pwd, $resolvedUrl);
|
||||||
|
if (in_array($code, [200, 207], true)) {
|
||||||
|
if (rtrim($resolvedUrl, '/') !== rtrim($calendarUrl, '/')) {
|
||||||
|
$meta_pdo->prepare("UPDATE user_calendar_integrations SET calendar_url = ? WHERE id = ?")
|
||||||
|
->execute([$resolvedUrl, $integration['id']]);
|
||||||
|
}
|
||||||
|
$success = "Connexion iCloud CalDAV valide (HTTP $code).";
|
||||||
|
} else {
|
||||||
|
$error = "Test connexion échoué (HTTP $code). Vérifie identifiant Apple, mot de passe d’app (16 caractères) et que le compte iCloud a le Calendrier activé.";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = "Test connexion impossible: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'calendar_ios_disconnect') {
|
||||||
|
$meta_pdo->prepare("DELETE FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'")->execute([$user_id]);
|
||||||
|
$success = "Connexion iOS supprimée.";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Chargement données ───────────────────────────────────────────────────────
|
// ─── Chargement données ───────────────────────────────────────────────────────
|
||||||
@@ -131,29 +241,49 @@ if ($family_id) {
|
|||||||
$members = $mem->fetchAll();
|
$members = $mem->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$calendarIntegration = $meta_pdo->prepare("SELECT username, calendar_url, status, last_sync_at FROM user_calendar_integrations WHERE user_id = ? AND provider='icloud_caldav'");
|
||||||
|
$calendarIntegration->execute([$user_id]);
|
||||||
|
$calendarIntegration = $calendarIntegration->fetch();
|
||||||
|
|
||||||
|
$groceryHistoryMaxSetting = 20;
|
||||||
|
if ($family_id) {
|
||||||
|
require_once __DIR__ . '/includes/db.php';
|
||||||
|
if (isset($pdo)) {
|
||||||
|
try {
|
||||||
|
$gv = $pdo->query("SELECT content FROM pf_notes WHERE note_type='grocery_settings' AND reference_id='history_max'")->fetchColumn();
|
||||||
|
if ($gv !== false && $gv !== null && $gv !== '') {
|
||||||
|
$groceryHistoryMaxSetting = max(1, min(50, (int) $gv));
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$pageTitle = "Paramètres — HouseHub";
|
$pageTitle = "Paramètres — HouseHub";
|
||||||
$activePage = "settings";
|
$activePage = "settings";
|
||||||
require __DIR__ . '/header.php';
|
require __DIR__ . '/header.php';
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="pf-container" style="max-width:680px;margin:32px auto;padding:0 16px">
|
<div class="pf-container pf-settings-page">
|
||||||
|
|
||||||
<h1 style="font-size:1.4rem;font-weight:700;margin-bottom:32px">⚙️ Paramètres</h1>
|
<h1 class="pf-settings-title">⚙️ Paramètres</h1>
|
||||||
|
|
||||||
<?php if ($success): ?>
|
<?php if ($success): ?>
|
||||||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:8px;padding:12px 16px;margin-bottom:24px;color:#166534">✓ <?= htmlspecialchars($success) ?></div>
|
<div class="pf-alert pf-alert--success">✓ <?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($error): ?>
|
<?php if ($error): ?>
|
||||||
<div style="background:#fee2e2;border:1px solid #fca5a5;border-radius:8px;padding:12px 16px;margin-bottom:24px;color:#991b1b">✗ <?= htmlspecialchars($error) ?></div>
|
<div class="pf-alert pf-alert--error">✗ <?= htmlspecialchars($error) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- ── Langue ─────────────────────────────────────────────────────────── -->
|
<!-- ── Langue ─────────────────────────────────────────────────────────── -->
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🌐 Langue / Language</h2>
|
<h2 class="pf-card-h2">🌐 Langue / Language</h2>
|
||||||
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap">
|
<form method="post" class="pf-lang-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="set_lang">
|
<input type="hidden" name="action" value="set_lang">
|
||||||
<?php
|
<?php
|
||||||
$s = 'style="vertical-align:middle;border-radius:2px;margin-right:5px"';
|
$s = 'class="pf-flag-inline"';
|
||||||
$fr_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="6" height="12" fill="#002395"/><rect x="6" width="6" height="12" fill="#fff"/><rect x="12" width="6" height="12" fill="#ED2939"/></svg>';
|
$fr_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="6" height="12" fill="#002395"/><rect x="6" width="6" height="12" fill="#fff"/><rect x="12" width="6" height="12" fill="#ED2939"/></svg>';
|
||||||
$en_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width="20" height="13" '.$s.'><rect width="60" height="30" fill="#012169"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" stroke-width="4"/><path d="M30,0 V30 M0,15 H60" stroke="#fff" stroke-width="10"/><path d="M30,0 V30 M0,15 H60" stroke="#C8102E" stroke-width="6"/></svg>';
|
$en_flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 30" width="20" height="13" '.$s.'><rect width="60" height="30" fill="#012169"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" stroke-width="6"/><path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" stroke-width="4"/><path d="M30,0 V30 M0,15 H60" stroke="#fff" stroke-width="10"/><path d="M30,0 V30 M0,15 H60" stroke="#C8102E" stroke-width="6"/></svg>';
|
||||||
$senyera = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="18" height="12" fill="#FCDD09"/><rect y="1.33" width="18" height="1.33" fill="#DA121A"/><rect y="4" width="18" height="1.33" fill="#DA121A"/><rect y="6.67" width="18" height="1.33" fill="#DA121A"/><rect y="9.33" width="18" height="1.33" fill="#DA121A"/></svg>';
|
$senyera = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" width="20" height="13" '.$s.'><rect width="18" height="12" fill="#FCDD09"/><rect y="1.33" width="18" height="1.33" fill="#DA121A"/><rect y="4" width="18" height="1.33" fill="#DA121A"/><rect y="6.67" width="18" height="1.33" fill="#DA121A"/><rect y="9.33" width="18" height="1.33" fill="#DA121A"/></svg>';
|
||||||
@@ -166,8 +296,7 @@ require __DIR__ . '/header.php';
|
|||||||
foreach ($langs as $code => $lang):
|
foreach ($langs as $code => $lang):
|
||||||
?>
|
?>
|
||||||
<button type="submit" name="lang" value="<?= $code ?>"
|
<button type="submit" name="lang" value="<?= $code ?>"
|
||||||
class="pf-btn <?= $currentLang === $code ? '' : 'btn-secondary' ?>"
|
class="pf-btn <?= $currentLang === $code ? 'pf-btn--current-lang' : 'btn-secondary' ?>">
|
||||||
style="<?= $currentLang === $code ? 'pointer-events:none' : '' ?>">
|
|
||||||
<?= $lang['flag'] ?> <?= $lang['label'] ?>
|
<?= $lang['flag'] ?> <?= $lang['label'] ?>
|
||||||
</button>
|
</button>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -176,29 +305,34 @@ require __DIR__ . '/header.php';
|
|||||||
|
|
||||||
<!-- ── Modules ────────────────────────────────────────────────────────── -->
|
<!-- ── Modules ────────────────────────────────────────────────────────── -->
|
||||||
<?php if ($family_id): ?>
|
<?php if ($family_id): ?>
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:6px">🧩 Modules actifs</h2>
|
<h2 class="pf-card-h2 pf-card-h2--tight">🧩 Modules actifs</h2>
|
||||||
<p style="color:#64748b;font-size:0.85rem;margin-bottom:20px">Choisissez les modules visibles dans la navigation (partagé avec tous les membres de l'espace).</p>
|
<p class="pf-muted-note">Choisissez les modules visibles dans la navigation (partagé avec tous les membres de l'espace).</p>
|
||||||
<?php
|
<?php
|
||||||
$enabledMods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts'];
|
$enabledMods = $_SESSION['enabled_modules'] ?? ['calendar','budget','holidays','gifts','calendar_ios'];
|
||||||
$allModules = [
|
$allModules = [
|
||||||
'calendar' => ['icon' => '📅', 'label' => tr('menu_calendar')],
|
'calendar' => ['icon' => '📅', 'label' => tr('menu_calendar')],
|
||||||
'budget' => ['icon' => '💰', 'label' => tr('menu_budget')],
|
'budget' => ['icon' => '💰', 'label' => tr('menu_budget')],
|
||||||
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
|
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
|
||||||
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
|
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
|
||||||
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
|
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
|
||||||
|
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
|
||||||
|
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
|
||||||
|
'liste' => ['icon' => '📝', 'label' => tr('menu_liste')],
|
||||||
|
'calendar_ios' => ['icon' => '📱', 'label' => tr('menu_calendar_ios')],
|
||||||
|
'printvault' => ['icon' => '🖨️', 'label' => tr('menu_printvault')],
|
||||||
|
'planka' => ['icon' => '📋', 'label' => tr('menu_planka')],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="set_modules">
|
<input type="hidden" name="action" value="set_modules">
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:20px">
|
<div class="pf-stack-md">
|
||||||
<?php foreach ($allModules as $key => $mod): $active = in_array($key, $enabledMods); ?>
|
<?php foreach ($allModules as $key => $mod): $active = in_array($key, $enabledMods); ?>
|
||||||
<label style="display:flex;align-items:center;gap:14px;padding:14px 16px;border:2px solid <?= $active ? '#2563eb' : '#e2e8f0' ?>;border-radius:10px;cursor:pointer;background:<?= $active ? '#eff6ff' : '#f8fafc' ?>;transition:all .15s">
|
<label class="pf-module-tile">
|
||||||
<input type="checkbox" name="mod_<?= $key ?>" <?= $active ? 'checked' : '' ?>
|
<input type="checkbox" name="mod_<?= $key ?>" <?= $active ? 'checked' : '' ?> class="pf-checkbox-lg">
|
||||||
style="width:18px;height:18px;accent-color:#2563eb;cursor:pointer"
|
<span class="pf-tile-icon"><?= $mod['icon'] ?></span>
|
||||||
onchange="this.closest('label').style.borderColor=this.checked?'#2563eb':'#e2e8f0';this.closest('label').style.background=this.checked?'#eff6ff':'#f8fafc'">
|
<span class="pf-tile-label"><?= $mod['label'] ?></span>
|
||||||
<span style="font-size:1.3rem"><?= $mod['icon'] ?></span>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem"><?= $mod['label'] ?></span>
|
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,50 +341,176 @@ require __DIR__ . '/header.php';
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$enabledModsForListe = $_SESSION['enabled_modules'] ?? [];
|
||||||
|
if ($family_id && in_array('liste', $enabledModsForListe, true)):
|
||||||
|
?>
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<h2 class="pf-card-h2 pf-card-h2--tight">📝 <?= htmlspecialchars(tr('liste_settings_title')) ?></h2>
|
||||||
|
<p class="pf-muted-note"><?= htmlspecialchars(tr('liste_settings_intro')) ?></p>
|
||||||
|
<form method="post" class="pf-stack-md" style="margin-top:1rem">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="action" value="grocery_history_max">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label" for="history_max"><?= htmlspecialchars(tr('liste_settings_history_max')) ?></label>
|
||||||
|
<input type="number" name="history_max" id="history_max" class="pf-input" style="max-width:120px" min="1" max="50" step="1" value="<?= (int) $groceryHistoryMaxSetting ?>" required>
|
||||||
|
<p class="pf-muted-note" style="margin-top:0.35rem"><?= htmlspecialchars(tr('liste_settings_history_hint')) ?></p>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="pf-btn"><?= htmlspecialchars(tr('btn_save')) ?></button>
|
||||||
|
</form>
|
||||||
|
<p class="pf-muted-note" style="margin-top:1rem">
|
||||||
|
<a href="/liste.php" style="color:var(--primary);font-weight:600;"><?= htmlspecialchars(tr('mod_liste_name')) ?></a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<h2 class="pf-card-h2 pf-card-h2--tight">📱 Intégration Calendrier iOS (CalDAV)</h2>
|
||||||
|
<p class="pf-muted-note">Configurez ici votre calendrier iCloud pour synchroniser les événements créés dans HouseHub.</p>
|
||||||
|
<p class="pf-muted-note" style="margin-top:8px;">
|
||||||
|
Pour voir et gérer les événements : ouvrez le module
|
||||||
|
<a href="/calendar-ios.php" style="color:var(--primary);font-weight:600;">Calendrier iOS</a>
|
||||||
|
(menu du haut ou burger « Calendrier iOS », ou carte sur l’accueil). Le module doit être coché dans « Modules actifs » ci-dessus.
|
||||||
|
</p>
|
||||||
|
<form method="post" class="pf-stack-md">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="action" value="calendar_ios_save">
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Identifiant Apple (email iCloud)</label>
|
||||||
|
<input type="text" name="icloud_username" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['username'] ?? '') ?>" placeholder="nom@icloud.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Mot de passe d'app Apple</label>
|
||||||
|
<input type="password" name="icloud_app_password" class="pf-input" placeholder="xxxx-xxxx-xxxx-xxxx" required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">URL du calendrier CalDAV</label>
|
||||||
|
<input type="url" name="icloud_calendar_url" class="pf-input" value="<?= htmlspecialchars($calendarIntegration['calendar_url'] ?? '') ?>" placeholder="https://caldav.icloud.com/..." required>
|
||||||
|
</div>
|
||||||
|
<div class="pf-flex-gap-8">
|
||||||
|
<button type="submit" class="pf-btn">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="pf-flex-gap-8 pf-mt-sm">
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="action" value="calendar_ios_test">
|
||||||
|
<button type="submit" class="pf-btn btn-secondary">Tester la connexion</button>
|
||||||
|
</form>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="action" value="calendar_ios_disconnect">
|
||||||
|
<button type="submit" class="pf-btn btn-secondary">Déconnecter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="pf-muted-note" style="margin-top:10px;">
|
||||||
|
Après enregistrement, utilisez « Tester la connexion » : un message vert confirme que l’URL CalDAV répond avec tes identifiants.
|
||||||
|
Sur la page Calendrier iOS, le bouton « Synchroniser » envoie les événements vers iCloud et récupère ceux créés sur l’iPhone.
|
||||||
|
</p>
|
||||||
|
<?php if (!empty($calendarIntegration['last_sync_at'])): ?>
|
||||||
|
<p class="pf-muted-note">Dernière synchro: <?= htmlspecialchars($calendarIntegration['last_sync_at']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ── Fond page d'accueil ───────────────────────────────────────────── -->
|
<!-- ── Fond page d'accueil ───────────────────────────────────────────── -->
|
||||||
<?php if ($family_id):
|
<?php if ($family_id):
|
||||||
$bg_file = null;
|
$bg_file = null;
|
||||||
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; }
|
foreach (glob('/uploads/home_bg_' . $family_id . '.*') as $f) { $bg_file = $f; break; }
|
||||||
$has_custom_bg = $bg_file !== null;
|
$has_custom_bg = $bg_file !== null;
|
||||||
?>
|
?>
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:6px">🖼️ Image d'accueil</h2>
|
<h2 class="pf-card-h2 pf-card-h2--tight">🖼️ Image d'accueil</h2>
|
||||||
<p style="color:#64748b;font-size:0.85rem;margin-bottom:20px">Photo de fond affichée sur la page d'accueil (partagée avec tous les membres).</p>
|
<p class="pf-muted-note">Photo de fond affichée sur la page d'accueil (partagée avec tous les membres).</p>
|
||||||
|
|
||||||
<?php if ($has_custom_bg): ?>
|
<?php if ($has_custom_bg): ?>
|
||||||
<div style="margin-bottom:16px;border-radius:10px;overflow:hidden;max-height:160px;position:relative">
|
<div class="pf-home-bg-preview">
|
||||||
<img src="/home-bg.php" alt="Fond actuel" style="width:100%;height:160px;object-fit:cover">
|
<img src="/home-bg.php" alt="Fond actuel">
|
||||||
<span style="position:absolute;top:8px;right:8px;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:3px 8px;border-radius:6px">Image personnalisée</span>
|
<span class="pf-badge-overlay">Image personnalisée</span>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" style="display:flex;align-items:flex-end;gap:10px;flex-wrap:wrap">
|
<form method="post" enctype="multipart/form-data" class="pf-inline-form-end">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="upload_home_bg">
|
<input type="hidden" name="action" value="upload_home_bg">
|
||||||
<div class="pf-form-group" style="flex:1;min-width:200px">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label">Nouvelle image (jpg, png, webp — max 10 Mo)</label>
|
<label class="pf-label">Nouvelle image (jpg, png, webp — max 10 Mo)</label>
|
||||||
<input type="file" name="home_bg" class="pf-input" accept="image/jpeg,image/png,image/webp" required>
|
<input type="file" name="home_bg" class="pf-input" accept="image/jpeg,image/png,image/webp" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="pf-btn" style="flex-shrink:0">Enregistrer</button>
|
<button type="submit" class="pf-btn pf-shrink-0">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($has_custom_bg): ?>
|
<?php if ($has_custom_bg): ?>
|
||||||
<form method="post" style="margin-top:10px">
|
<form method="post" class="pf-mt-sm">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="reset_home_bg">
|
<input type="hidden" name="action" value="reset_home_bg">
|
||||||
<button type="submit" class="pf-btn btn-secondary" style="font-size:.85rem"
|
<button type="submit" class="pf-btn btn-secondary pf-btn-sm-text"
|
||||||
onclick="return confirm('Revenir à l\'image par défaut ?')">🔄 Remettre l'image par défaut</button>
|
onclick="return confirm('Revenir à l\'image par défaut ?')">🔄 Remettre l'image par défaut</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── Famille ──────────────────────────────────────────────────────────── -->
|
||||||
|
<?php
|
||||||
|
$enabledMods = $_SESSION['enabled_modules'] ?? [];
|
||||||
|
|
||||||
|
// On cible précisément qui a besoin de quoi
|
||||||
|
$showCurrency = count(array_intersect(['budget', 'holidays', 'gifts', 'garage'], $enabledMods)) > 0;
|
||||||
|
$showZone = in_array('calendar', $enabledMods);
|
||||||
|
|
||||||
|
// Si au moins un des champs doit être affiché, on rend la section visible
|
||||||
|
if ($family_id && ($showCurrency || $showZone)):
|
||||||
|
$currentCurrency = defined('CURRENCY') ? CURRENCY : '€';
|
||||||
|
$currentZone = defined('ZONE_SCOLAIRE') ? ZONE_SCOLAIRE : 'C';
|
||||||
|
?>
|
||||||
|
<section class="pf-panel-card">
|
||||||
|
<h2 class="pf-card-h2">👪 Configuration du foyer</h2>
|
||||||
|
<p class="pf-muted-note">Configurez les indicateurs partagés par votre foyer.</p>
|
||||||
|
|
||||||
|
<form method="post" class="pf-stack-md">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
|
<input type="hidden" name="action" value="update_family_info">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
|
||||||
|
<?php if ($showCurrency): ?>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Devise monétaire</label>
|
||||||
|
<input type="text" name="currency" class="pf-input" value="<?= htmlspecialchars($currentCurrency) ?>" placeholder="ex: €, $, CHF" required maxlength="10">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($showZone): ?>
|
||||||
|
<div class="pf-form-group">
|
||||||
|
<label class="pf-label">Zone de vacances scolaires (France)</label>
|
||||||
|
<select name="zone_scolaire" class="pf-input" style="background:var(--bg-panel);">
|
||||||
|
<option value="A" <?= $currentZone === 'A' ? 'selected' : '' ?>>Zone A</option>
|
||||||
|
<option value="B" <?= $currentZone === 'B' ? 'selected' : '' ?>>Zone B</option>
|
||||||
|
<option value="C" <?= $currentZone === 'C' ? 'selected' : '' ?>>Zone C</option>
|
||||||
|
<option value="Autre" <?= $currentZone === 'Autre' ? 'selected' : '' ?>>Hors France / Autre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pf-btn">Enregistrer les paramètres du foyer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Profil ──────────────────────────────────────────────────────────── -->
|
<!-- ── Profil ──────────────────────────────────────────────────────────── -->
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">👤 Mon profil</h2>
|
<h2 class="pf-card-h2">👤 Mon profil</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="update_profile">
|
<input type="hidden" name="action" value="update_profile">
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label">Nom d'utilisateur</label>
|
<label class="pf-label">Nom d'utilisateur</label>
|
||||||
<input type="text" class="pf-input" value="<?= htmlspecialchars($user['username']) ?>" disabled style="background:#f8fafc;color:#94a3b8">
|
<input type="text" class="pf-input pf-input--disabled-muted" value="<?= htmlspecialchars($user['username']) ?>" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label">Prénom / Pseudo affiché</label>
|
<label class="pf-label">Prénom / Pseudo affiché</label>
|
||||||
@@ -261,9 +521,10 @@ require __DIR__ . '/header.php';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Mot de passe ────────────────────────────────────────────────────── -->
|
<!-- ── Mot de passe ────────────────────────────────────────────────────── -->
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🔒 Changer le mot de passe</h2>
|
<h2 class="pf-card-h2">🔒 Changer le mot de passe</h2>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="change_password">
|
<input type="hidden" name="action" value="change_password">
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label">Mot de passe actuel</label>
|
<label class="pf-label">Mot de passe actuel</label>
|
||||||
@@ -283,55 +544,57 @@ require __DIR__ . '/header.php';
|
|||||||
|
|
||||||
<?php if ($family): ?>
|
<?php if ($family): ?>
|
||||||
<!-- ── Espace familial ─────────────────────────────────────────────────── -->
|
<!-- ── Espace familial ─────────────────────────────────────────────────── -->
|
||||||
<section style="background:white;border:1px solid #e2e8f0;border-radius:12px;padding:24px;margin-bottom:24px">
|
<section class="pf-panel-card">
|
||||||
<h2 style="font-size:1rem;font-weight:700;margin-bottom:20px">🏠 Mon espace familial</h2>
|
<h2 class="pf-card-h2">🏠 Mon espace familial</h2>
|
||||||
|
|
||||||
<form method="post" style="margin-bottom:20px">
|
<form method="post" class="pf-mb-section">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="update_family_name">
|
<input type="hidden" name="action" value="update_family_name">
|
||||||
<div class="pf-form-group">
|
<div class="pf-form-group">
|
||||||
<label class="pf-label">Nom de l'espace</label>
|
<label class="pf-label">Nom de l'espace</label>
|
||||||
<div style="display:flex;gap:8px">
|
<div class="pf-flex-gap-8">
|
||||||
<input type="text" name="family_name" class="pf-input" value="<?= htmlspecialchars($family['name']) ?>" required>
|
<input type="text" name="family_name" class="pf-input" value="<?= htmlspecialchars($family['name']) ?>" required>
|
||||||
<button type="submit" class="pf-btn" style="flex-shrink:0">Enregistrer</button>
|
<button type="submit" class="pf-btn pf-shrink-0">Enregistrer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Code d'invitation -->
|
<!-- Code d'invitation -->
|
||||||
<div style="background:#f8fafc;border:2px dashed #cbd5e1;border-radius:10px;padding:20px">
|
<div class="pf-dashed-panel">
|
||||||
<p style="font-size:0.85rem;color:#64748b;margin-bottom:8px">Code d'invitation — partagez-le pour inviter quelqu'un</p>
|
<p class="pf-muted-note pf-muted-note--tight">Code d'invitation — partagez-le pour inviter quelqu'un</p>
|
||||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
<div class="pf-invite-row">
|
||||||
<code id="invite-code" style="font-size:1.3rem;font-weight:700;letter-spacing:3px;color:#1e40af;cursor:pointer"
|
<code id="invite-code" class="pf-invite-code"
|
||||||
onclick="copyCode()" title="Cliquer pour copier">
|
onclick="copyCode()" title="Cliquer pour copier">
|
||||||
<?= htmlspecialchars($family['invite_code']) ?>
|
<?= htmlspecialchars($family['invite_code']) ?>
|
||||||
</code>
|
</code>
|
||||||
<span id="copy-msg" style="color:#16a34a;font-size:0.85rem;opacity:0;transition:opacity .3s">✓ Copié !</span>
|
<span id="copy-msg" class="pf-copy-msg">✓ Copié !</span>
|
||||||
<form method="post" style="margin-left:auto">
|
<form method="post" class="pf-ml-auto">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_token()) ?>">
|
||||||
<input type="hidden" name="action" value="regen_invite">
|
<input type="hidden" name="action" value="regen_invite">
|
||||||
<button type="submit" class="pf-btn btn-secondary" style="font-size:0.85rem"
|
<button type="submit" class="pf-btn btn-secondary pf-btn-sm-text"
|
||||||
onclick="return confirm('Regénérer le code ? L\'ancien sera invalidé.')">
|
onclick="return confirm('Regénérer le code ? L\'ancien sera invalidé.')">
|
||||||
🔄 Nouveau code
|
🔄 Nouveau code
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin-top:12px;font-size:0.8rem;color:#94a3b8">
|
<p class="pf-invite-footnote">
|
||||||
Lien d'inscription : <strong><?= $_SERVER['HTTP_HOST'] ?>/register.php</strong>
|
Lien d'inscription : <strong><?= $_SERVER['HTTP_HOST'] ?>/register.php</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Membres -->
|
<!-- Membres -->
|
||||||
<h3 style="font-size:0.95rem;font-weight:600;margin:20px 0 12px">Membres (<?= count($members) ?>)</h3>
|
<h3 class="pf-members-heading">Membres (<?= count($members) ?>)</h3>
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
<div class="pf-stack-sm">
|
||||||
<?php foreach ($members as $m): ?>
|
<?php foreach ($members as $m): ?>
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:#f8fafc;border-radius:8px">
|
<div class="pf-member-card">
|
||||||
<div>
|
<div>
|
||||||
<strong><?= htmlspecialchars($m['display_name']) ?></strong>
|
<strong><?= htmlspecialchars($m['display_name']) ?></strong>
|
||||||
<span style="color:#94a3b8;font-size:0.85rem;margin-left:6px">@<?= htmlspecialchars($m['username']) ?></span>
|
<span class="pf-muted-inline">@<?= htmlspecialchars($m['username']) ?></span>
|
||||||
<?php if ($m['is_admin']): ?>
|
<?php if ($m['is_admin']): ?>
|
||||||
<span style="background:#dbeafe;color:#1d4ed8;padding:1px 7px;border-radius:8px;font-size:0.75rem;font-weight:600;margin-left:6px">Admin</span>
|
<span class="pf-admin-badge">Admin</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.8rem;color:#94a3b8"><?= date('d/m/Y', strtotime($m['created_at'])) ?></span>
|
<span class="pf-muted-tiny"><?= date('d/m/Y', strtotime($m['created_at'])) ?></span>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,20 +602,30 @@ require __DIR__ . '/header.php';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($_SESSION['user']['is_admin']): ?>
|
<?php if ($_SESSION['user']['is_admin']): ?>
|
||||||
<div style="text-align:center;margin-top:8px">
|
<div class="pf-settings-admin-link">
|
||||||
<a href="/admin/" style="color:#2563eb;font-size:0.9rem">→ Panneau d'administration</a>
|
<a href="/admin/">→ Panneau d'administration</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
document.querySelectorAll('form[method="post"]').forEach((form) => {
|
||||||
|
if (!form.querySelector('input[name="csrf_token"]')) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'csrf_token';
|
||||||
|
input.value = window.CSRF_TOKEN || '';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function copyCode() {
|
function copyCode() {
|
||||||
const code = document.getElementById('invite-code').textContent.trim();
|
const code = document.getElementById('invite-code').textContent.trim();
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
const msg = document.getElementById('copy-msg');
|
const msg = document.getElementById('copy-msg');
|
||||||
msg.style.opacity = '1';
|
msg.classList.add('is-visible');
|
||||||
setTimeout(() => msg.style.opacity = '0', 2000);
|
setTimeout(() => msg.classList.remove('is-visible'), 2000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Todo — HouseHub';
|
||||||
|
$activePage = 'todo';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/todo/assets/todo.css">
|
||||||
|
<div id="todo-toasts" class="todo-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="todo-layout">
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="todo-sidebar">
|
||||||
|
<div class="todo-sidebar-top">
|
||||||
|
<button class="btn btn-primary" onclick="openAddTodo()" style="width:100%;justify-content:center">+ Nouvelle tâche</button>
|
||||||
|
</div>
|
||||||
|
<!-- Contenu rendu dynamiquement par JS (vues + listes) -->
|
||||||
|
<div id="todo-sidebar-lists"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-main">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="todo-main-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="todo-header-title">Toutes les tâches</h2>
|
||||||
|
<div class="todo-subtitle" id="todo-view-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-header-actions">
|
||||||
|
<button class="show-done-btn" id="show-done-btn" onclick="toggleShowDone()">
|
||||||
|
👁 Afficher terminées
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="openSettings()" title="Paramètres Todo">⚙️</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddTodo()">+ Tâche</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Todo list -->
|
||||||
|
<div class="todo-list" id="todo-list"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : TÂCHE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-modal-backdrop" id="todo-modal">
|
||||||
|
<div class="todo-modal">
|
||||||
|
<div class="todo-modal-header">
|
||||||
|
<h3 id="todo-modal-title">Nouvelle tâche</h3>
|
||||||
|
<button class="todo-modal-close" onclick="closeModal('todo-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-body">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titre *</label>
|
||||||
|
<input type="text" id="todo-form-title" class="form-control" placeholder="Ex : Appeler le plombier">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea id="todo-form-notes" class="form-control" placeholder="Détails, liens, infos…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Heure de rappel *</label>
|
||||||
|
<input type="time" id="todo-form-time" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Liste</label>
|
||||||
|
<select id="todo-form-list" class="form-control">
|
||||||
|
<option value="">— Aucune —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Priorité</label>
|
||||||
|
<div class="priority-opts">
|
||||||
|
<div class="priority-opt sel-none" data-p="none" onclick="setPriority('none')">— Aucune</div>
|
||||||
|
<div class="priority-opt" data-p="low" onclick="setPriority('low')">🟢 Basse</div>
|
||||||
|
<div class="priority-opt" data-p="medium" onclick="setPriority('medium')">🟡 Moyenne</div>
|
||||||
|
<div class="priority-opt" data-p="high" onclick="setPriority('high')">🔴 Haute</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-footer">
|
||||||
|
<button class="btn btn-danger btn-sm" id="todo-delete-btn" onclick="deleteTodoFromModal()" style="display:none;margin-right:auto">Supprimer</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('todo-modal')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveTodo()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : LISTE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-modal-backdrop" id="list-modal">
|
||||||
|
<div class="todo-modal">
|
||||||
|
<div class="todo-modal-header">
|
||||||
|
<h3 id="list-modal-title">Nouvelle liste</h3>
|
||||||
|
<button class="todo-modal-close" onclick="closeModal('list-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-body">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nom *</label>
|
||||||
|
<input type="text" id="list-form-name" class="form-control" placeholder="Ex : Maison, Travail…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Icône</label>
|
||||||
|
<div id="list-icon-opts" style="display:flex;flex-wrap:wrap;gap:.35rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Couleur</label>
|
||||||
|
<div class="color-swatches" id="list-color-swatches"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-footer">
|
||||||
|
<button class="btn btn-danger btn-sm" id="list-delete-btn" onclick="deleteList()" style="display:none;margin-right:auto">Supprimer</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('list-modal')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveList()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : PARAMÈTRES ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-modal-backdrop" id="settings-modal">
|
||||||
|
<div class="todo-modal">
|
||||||
|
<div class="todo-modal-header">
|
||||||
|
<h3>⚙️ Paramètres Todo</h3>
|
||||||
|
<button class="todo-modal-close" onclick="closeModal('settings-modal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Webhook Discord</label>
|
||||||
|
<input type="url" id="settings-webhook" class="form-control" placeholder="https://discord.com/api/webhooks/…">
|
||||||
|
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">
|
||||||
|
Recevoir une notification Discord quand une tâche est créée ou terminée.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal('settings-modal')">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/todo/assets/todo.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
Reference in New Issue
Block a user