Merge branch 'main' into antigravity - Resolve conflict in NotesPage.tsx (Archive fix + Kanban)

This commit is contained in:
Michael committed 2025-12-08 08:46:23 +01:00
commit ad3f415469
50 files changed
+4916 -359

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
# Instructions de déploiement - Correction des notes
## Problème actuel
Erreur 500 sur `/api/notes` - Les notes ne s'affichent pas
## Causes identifiées
### 1. Colonnes manquantes (PRINCIPAL)
Les colonnes `parent_id` et `level` ajoutées pendant les phases 1-5 pour le support des subtasks n'existaient pas dans la base PostgreSQL de production.
### 2. Types booléens incorrects (SECONDAIRE)
Les données booléennes migrées de SQLite sont stockées en INTEGER (0/1) au lieu de BOOLEAN (TRUE/FALSE).
## ✅ Solution AUTOMATIQUE (Recommandée)
La correction se fait **automatiquement au démarrage de Docker** !
### Déploiement avec Docker
```bash
cd /chemin/vers/noteflow
git pull origin claude/fix-notes-sync-issue-01AMeEdrKQErsM7bvhKkqEEF
docker-compose down
docker-compose up -d --build
```
Le conteneur Docker va automatiquement:
1. Attendre que PostgreSQL soit prêt
2. **Exécuter les migrations de schéma** ✨
- Ajouter les colonnes `parent_id` et `level` pour les subtasks
- Ajouter tous les autres champs manquants
3. **Corriger automatiquement les types booléens**
- Convertir INTEGER → BOOLEAN pour les champs concernés
4. Démarrer le serveur
C'est tout ! Les notes s'afficheront correctement après le redémarrage.
**Note:** Les migrations sont idempotentes et ne causeront aucune perte de données.
---
## Solution manuelle (Si vous n'utilisez pas Docker)
### Étape 1 : Déployer le nouveau code
```bash
cd /chemin/vers/noteflow
git pull origin claude/fix-notes-sync-issue-01AMeEdrKQErsM7bvhKkqEEF
```
### Étape 2 : Exécuter le script de correction
```bash
# Option A: Script Node.js
node scripts/fix-boolean-data.js
# Option B: Script SQL (plus rapide)
psql -U noteflow -d noteflow -f scripts/fix-postgres-boolean-types.sql
```
### Étape 3 : Redémarrer le serveur
```bash
# Si vous utilisez PM2:
pm2 restart noteflow
# Si vous utilisez systemd:
sudo systemctl restart noteflow
# Ou simplement:
npm run start
```
### Étape 4 : Vérifier
Ouvrez https://note.ffnancy.fr et vérifiez que les notes s'affichent correctement.
## Alternative manuelle (SQL direct)
Si le script ne fonctionne pas, vous pouvez exécuter ces commandes SQL directement:
```sql
-- Se connecter à PostgreSQL
psql -U noteflow -d noteflow
-- Convertir les colonnes
ALTER TABLE users ALTER COLUMN is_admin TYPE BOOLEAN USING CASE WHEN is_admin = 0 THEN FALSE WHEN is_admin = 1 THEN TRUE ELSE is_admin::BOOLEAN END;
ALTER TABLE notes ALTER COLUMN archived TYPE BOOLEAN USING CASE WHEN archived = 0 THEN FALSE WHEN archived = 1 THEN TRUE ELSE archived::BOOLEAN END;
ALTER TABLE note_todos ALTER COLUMN completed TYPE BOOLEAN USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END;
ALTER TABLE note_todos ALTER COLUMN priority TYPE BOOLEAN USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END;
ALTER TABLE global_todos ALTER COLUMN completed TYPE BOOLEAN USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END;
ALTER TABLE global_todos ALTER COLUMN priority TYPE BOOLEAN USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END;
ALTER TABLE rss_feeds ALTER COLUMN enabled TYPE BOOLEAN USING CASE WHEN enabled = 0 THEN FALSE WHEN enabled = 1 THEN TRUE ELSE enabled::BOOLEAN END;
ALTER TABLE calendar_events ALTER COLUMN all_day TYPE BOOLEAN USING CASE WHEN all_day = 0 THEN FALSE WHEN all_day = 1 THEN TRUE ELSE all_day::BOOLEAN END;
```
## Commits déployés
- `616e257` - Correction initiale des types BOOLEAN
- `6f2f366` - Correction des champs INTEGER (priority, in_progress)
- `a8ebca6` - Ajout du script de correction des données
## Support
Si vous rencontrez des problèmes, vérifiez:
1. La connexion PostgreSQL fonctionne: `psql -U noteflow -d noteflow -c "SELECT 1"`
2. Le fichier .env contient bien DATABASE_URL
3. Les logs du serveur: `pm2 logs noteflow` ou `journalctl -u noteflow -f`
+58 -92
View File
@@ -1,7 +1,53 @@
// Configuration et initialisation de la base de données PostgreSQL // Configuration et initialisation de la base de données PostgreSQL
const { Pool } = require('pg'); const { Pool, types } = require('pg');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const logger = require('./logger'); const logger = require('./logger');
const timezoneLogger = require('../services/timezone-logger');
// IMPORTANT: Parser personnalisé pour TIMESTAMPTZ
// DOIT être appelé AVANT le Pool pour être pris en compte !
// Types TIMESTAMP(TZ) PostgreSQL :
// - 1082 = DATE
// - 1114 = TIMESTAMP WITHOUT TIME ZONE
// - 1184 = TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ)
// Parser pour TIMESTAMPTZ (1184)
types.setTypeParser(1184, function (stringValue) {
if (!stringValue) return null;
const originalValue = stringValue;
let result;
// Si déjà au format ISO avec Z ou timezone (+HH:MM ou +HH)
if (stringValue.includes('Z') || stringValue.match(/[+-]\d{2}(:\d{2})?$/)) {
result = new Date(stringValue).toISOString();
timezoneLogger.log('PARSER', `[1184 TIMESTAMPTZ] Input avec TZ: "${originalValue}" → Output: "${result}"`);
} else {
// Si format "YYYY-MM-DD HH:MM:SS" sans timezone
// Avec timezone=UTC, on sait que c'est en UTC
const isoString = stringValue.replace(' ', 'T') + 'Z';
result = new Date(isoString).toISOString();
timezoneLogger.log('PARSER', `[1184 TIMESTAMPTZ] Input sans TZ: "${originalValue}" → ISO+Z: "${isoString}" → Output: "${result}"`);
}
return result;
});
// Parser pour TIMESTAMP WITHOUT TIME ZONE (1114)
// IMPORTANT: Les valeurs TIMESTAMP sont stockées en heure locale (Europe/Paris)
// mais PostgreSQL les retourne sans fuseau horaire.
// Exemple: Google envoie "2025-11-17T10:20:00+01:00" → PG stocke "2025-11-17 10:20:00"
// On doit interpréter cette heure comme UTC (car PG a converti lors de l'insertion)
types.setTypeParser(1114, function (stringValue) {
if (!stringValue) return null;
// PostgreSQL retourne "YYYY-MM-DD HH:MM:SS" sans fuseau horaire
// Cette valeur doit être traitée comme UTC car PostgreSQL stocke en UTC en interne
const isoString = stringValue.replace(' ', 'T') + 'Z';
const result = new Date(isoString).toISOString();
timezoneLogger.log('PARSER', `[1114 TIMESTAMP] Input: "${stringValue}" → Output: "${result}"`);
return result;
});
// Configuration PostgreSQL depuis DATABASE_URL ou variables d'environnement // Configuration PostgreSQL depuis DATABASE_URL ou variables d'environnement
const DATABASE_URL = process.env.DATABASE_URL || const DATABASE_URL = process.env.DATABASE_URL ||
@@ -13,6 +59,9 @@ const pool = new Pool({
max: 20, // Maximum de connexions dans le pool max: 20, // Maximum de connexions dans le pool
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 2000,
// IMPORTANT: Forcer le timezone à UTC pour toutes les connexions
// Cela garantit que PostgreSQL renvoie toujours les dates en UTC
options: '-c timezone=UTC'
}); });
// Gestion des erreurs du pool // Gestion des erreurs du pool
@@ -73,10 +122,10 @@ async function initDatabase() {
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
text TEXT NOT NULL, text TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE, completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP,
priority BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE,
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
) )
`); `);
@@ -87,9 +136,10 @@ async function initDatabase() {
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL, text TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE, completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMP,
priority BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP in_progress INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
) )
`); `);
@@ -174,8 +224,8 @@ async function initDatabase() {
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT, description TEXT,
start_time TIMESTAMP NOT NULL, start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMP NOT NULL, end_time TIMESTAMPTZ NOT NULL,
location TEXT, location TEXT,
color_id TEXT, color_id TEXT,
html_link TEXT, html_link TEXT,
@@ -227,78 +277,6 @@ async function initDatabase() {
logger.info('✓ Tables PostgreSQL créées avec succès'); logger.info('✓ Tables PostgreSQL créées avec succès');
// Créer les triggers pour mise à jour automatique des dates de purge
// Trigger pour notes.archived_at
await client.query(`
CREATE OR REPLACE FUNCTION update_notes_archived_at()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.archived = TRUE AND OLD.archived = FALSE THEN
NEW.archived_at = CURRENT_TIMESTAMP;
ELSIF NEW.archived = FALSE THEN
NEW.archived_at = NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await client.query(`
DROP TRIGGER IF EXISTS trigger_notes_archived_at ON notes;
CREATE TRIGGER trigger_notes_archived_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION update_notes_archived_at();
`);
// Trigger pour global_todos.completed_at
await client.query(`
CREATE OR REPLACE FUNCTION update_global_todos_completed_at()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.completed = TRUE AND OLD.completed = FALSE THEN
NEW.completed_at = CURRENT_TIMESTAMP;
ELSIF NEW.completed = FALSE THEN
NEW.completed_at = NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await client.query(`
DROP TRIGGER IF EXISTS trigger_global_todos_completed_at ON global_todos;
CREATE TRIGGER trigger_global_todos_completed_at
BEFORE UPDATE ON global_todos
FOR EACH ROW
EXECUTE FUNCTION update_global_todos_completed_at();
`);
// Trigger pour note_todos.completed_at
await client.query(`
CREATE OR REPLACE FUNCTION update_note_todos_completed_at()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.completed = TRUE AND (OLD.completed IS NULL OR OLD.completed = FALSE) THEN
NEW.completed_at = CURRENT_TIMESTAMP;
ELSIF NEW.completed = FALSE THEN
NEW.completed_at = NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await client.query(`
DROP TRIGGER IF EXISTS trigger_note_todos_completed_at ON note_todos;
CREATE TRIGGER trigger_note_todos_completed_at
BEFORE UPDATE ON note_todos
FOR EACH ROW
EXECUTE FUNCTION update_note_todos_completed_at();
`);
logger.info('✓ Triggers de purge automatique créés');
// Créer l'utilisateur admin par défaut si la table est vide // Créer l'utilisateur admin par défaut si la table est vide
const usersCount = await client.query('SELECT COUNT(*) as count FROM users'); const usersCount = await client.query('SELECT COUNT(*) as count FROM users');
@@ -334,20 +312,8 @@ async function runQuery(sql, params = []) {
const client = await pool.connect(); const client = await pool.connect();
try { try {
const result = await client.query(sql, params); const result = await client.query(sql, params);
// Si c'est un INSERT/UPDATE avec RETURNING, retourner la ligne complète
if (result.rows && result.rows.length > 0) {
logger.info(`[runQuery] Requête avec RETURNING - ID: ${result.rows[0].id}, rowCount: ${result.rowCount}`);
return {
...result.rows[0], // Retourne toutes les colonnes de la première ligne
changes: result.rowCount
};
}
// Sinon, retourner juste le nombre de lignes affectées
logger.info(`[runQuery] Requête sans RETURNING - rowCount: ${result.rowCount}`);
return { return {
id: result.rowCount, id: result.rows[0]?.id || result.rowCount,
changes: result.rowCount changes: result.rowCount
}; };
} catch (error) { } catch (error) {
File renamed without changes.
+7 -1
View File
@@ -53,12 +53,14 @@ types.setTypeParser(1114, function (stringValue) {
const DATABASE_URL = process.env.DATABASE_URL || const DATABASE_URL = process.env.DATABASE_URL ||
`postgresql://${process.env.PGUSER || 'noteflow'}:${process.env.PGPASSWORD || 'noteflow_secure_password_change_me'}@${process.env.PGHOST || 'localhost'}:${process.env.PGPORT || '5499'}/${process.env.PGDATABASE || 'noteflow'}`; `postgresql://${process.env.PGUSER || 'noteflow'}:${process.env.PGPASSWORD || 'noteflow_secure_password_change_me'}@${process.env.PGHOST || 'localhost'}:${process.env.PGPORT || '5499'}/${process.env.PGDATABASE || 'noteflow'}`;
logger.info(`PostgreSQL CONNECTION URL: ${DATABASE_URL.replace(/:[^:@]+@/, ':***@')}`);
// Créer le pool de connexions PostgreSQL // Créer le pool de connexions PostgreSQL
const pool = new Pool({ const pool = new Pool({
connectionString: DATABASE_URL, connectionString: DATABASE_URL,
max: 20, // Maximum de connexions dans le pool max: 20, // Maximum de connexions dans le pool
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 10000, // Increased from 2000 to 10000
// IMPORTANT: Forcer le timezone à UTC pour toutes les connexions // IMPORTANT: Forcer le timezone à UTC pour toutes les connexions
// Cela garantit que PostgreSQL renvoie toujours les dates en UTC // Cela garantit que PostgreSQL renvoie toujours les dates en UTC
options: '-c timezone=UTC' options: '-c timezone=UTC'
@@ -124,6 +126,8 @@ async function initDatabase() {
completed BOOLEAN DEFAULT FALSE, completed BOOLEAN DEFAULT FALSE,
priority BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE,
position INTEGER DEFAULT 0, position INTEGER DEFAULT 0,
parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE,
level INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP completed_at TIMESTAMP
) )
@@ -138,6 +142,8 @@ async function initDatabase() {
completed BOOLEAN DEFAULT FALSE, completed BOOLEAN DEFAULT FALSE,
priority BOOLEAN DEFAULT FALSE, priority BOOLEAN DEFAULT FALSE,
in_progress INTEGER DEFAULT 0, in_progress INTEGER DEFAULT 0,
parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE,
level INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP completed_at TIMESTAMP
) )
+23
View File
@@ -83,6 +83,29 @@ echo ""
echo "🔄 Migration des timezones du calendrier..." echo "🔄 Migration des timezones du calendrier..."
node scripts/migrate-calendar-timezone.js 2>/dev/null || echo " ℹ️ Migration timezone déjà effectuée ou non nécessaire" node scripts/migrate-calendar-timezone.js 2>/dev/null || echo " ℹ️ Migration timezone déjà effectuée ou non nécessaire"
echo ""
echo "🔧 Correction des types booléens PostgreSQL..."
# Exécuter le script SQL de correction des types booléens
if [ -f "/app/scripts/fix-postgres-boolean-types.sql" ]; then
# Extraire les informations de connexion depuis DATABASE_URL
PGHOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
PGPORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
PGUSER=$(echo $DATABASE_URL | sed -n 's/.*\/\/\([^:]*\):.*/\1/p')
PGPASSWORD=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p')
PGDATABASE=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p')
export PGHOST PGPORT PGUSER PGPASSWORD PGDATABASE
# Exécuter le script SQL
if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -f /app/scripts/fix-postgres-boolean-types.sql > /dev/null 2>&1; then
echo " ✅ Types booléens corrigés"
else
echo " ℹ️ Correction des types booléens déjà effectuée ou non nécessaire"
fi
else
echo " ⚠️ Script de correction non trouvé"
fi
echo "" echo ""
echo "========================================" echo "========================================"
echo "🚀 Démarrage du serveur NoteFlow..." echo "🚀 Démarrage du serveur NoteFlow..."
+11
View File
@@ -0,0 +1,11 @@
-- Migration: Add due_date to note_todos table
-- Description: Adds a due_date column to track task deadlines
-- Add due_date column to note_todos table
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
-- Create index on due_date for faster queries
CREATE INDEX IF NOT EXISTS idx_note_todos_due_date ON note_todos(due_date);
-- Create index on combination of completed and due_date for efficient queries of pending tasks
CREATE INDEX IF NOT EXISTS idx_note_todos_completed_due_date ON note_todos(completed, due_date);
@@ -0,0 +1,11 @@
-- Migration: Add due_date to global_todos table
-- Description: Adds a due_date column to track task deadlines for global todos
-- Add due_date column to global_todos table
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
-- Create index on due_date for faster queries
CREATE INDEX IF NOT EXISTS idx_global_todos_due_date ON global_todos(due_date);
-- Create index on combination of completed and due_date for efficient queries of pending tasks
CREATE INDEX IF NOT EXISTS idx_global_todos_completed_due_date ON global_todos(completed, due_date);
+16
View File
@@ -0,0 +1,16 @@
-- Migration: Add subtasks support to todos tables
-- Description: Adds parent_id column to enable hierarchical task structure
-- Add parent_id column to note_todos table (note todos)
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE;
-- Add parent_id column to global_todos table
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE;
-- Create indexes for faster subtask queries
CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id);
CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id);
-- Add level column to track depth (optional, for UI optimization)
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0;
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0;
+14 -20
View File
@@ -45,6 +45,7 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
@@ -138,7 +139,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -3682,7 +3682,6 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3693,7 +3692,6 @@
"integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==", "integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -3704,7 +3702,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3761,7 +3758,6 @@
"integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==", "integrity": "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/scope-manager": "8.46.4",
"@typescript-eslint/types": "8.46.4", "@typescript-eslint/types": "8.46.4",
@@ -4047,7 +4043,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4482,7 +4477,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -5289,6 +5283,18 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5335,8 +5341,7 @@
"version": "8.6.0", "version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/embla-carousel-react": { "node_modules/embla-carousel-react": {
"version": "8.6.0", "version": "8.6.0",
@@ -5537,7 +5542,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -6992,7 +6996,6 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -8226,7 +8229,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@@ -8366,7 +8368,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -8757,7 +8758,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -8798,7 +8798,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -9851,7 +9850,6 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -10056,7 +10054,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -10185,7 +10182,6 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -10433,7 +10429,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -10525,7 +10520,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
+1
View File
@@ -69,6 +69,7 @@
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dotenv": "^17.2.3",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
+1 -1
View File
@@ -853,7 +853,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
endTimeUTC, endTimeUTC,
location || null, location || null,
response.data.htmlLink || null, response.data.htmlLink || null,
isAllDay ? 1 : 0, isAllDay, // BOOLEAN instead of 0/1
req.params.id req.params.id
]); ]);
+29 -14
View File
@@ -45,7 +45,7 @@ router.use(authenticateToken);
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const showArchived = req.query.archived === 'true'; const showArchived = req.query.archived === 'true';
const archivedFilter = showArchived ? 1 : 0; const archivedFilter = showArchived; // BOOLEAN instead of 0/1
const notes = await getAll(` const notes = await getAll(`
SELECT SELECT
@@ -61,7 +61,7 @@ router.get('/', async (req, res) => {
// Charger les todos, images, fichiers et tags pour chaque note // Charger les todos, images, fichiers et tags pour chaque note
for (const note of notes) { for (const note of notes) {
const todos = await getAll(` const todos = await getAll(`
SELECT id, text, completed, position SELECT id, text, completed, position, parent_id, level
FROM note_todos FROM note_todos
WHERE note_id = $1 WHERE note_id = $1
ORDER BY position ASC, id ASC ORDER BY position ASC, id ASC
@@ -274,7 +274,7 @@ router.put('/:id/archive', async (req, res) => {
await runQuery( await runQuery(
'UPDATE notes SET archived = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', 'UPDATE notes SET archived = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[archived ? 1 : 0, req.params.id] [archived, req.params.id] // BOOLEAN instead of 0/1
); );
logger.info(`Note ${archived ? 'archivée' : 'désarchivée'}: ${req.params.id}`); logger.info(`Note ${archived ? 'archivée' : 'désarchivée'}: ${req.params.id}`);
@@ -391,10 +391,13 @@ router.delete('/:id/image', async (req, res) => {
/** /**
* POST /api/notes/:id/todos * POST /api/notes/:id/todos
* Ajouter un todo à une note * Ajouter un todo à une note (supports subtasks via parent_id)
*/ */
router.post('/:id/todos', router.post('/:id/todos',
[body('text').trim().notEmpty().withMessage('Le texte est requis')], [
body('text').trim().notEmpty().withMessage('Le texte est requis'),
body('parent_id').optional().isInt()
],
async (req, res) => { async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -408,17 +411,27 @@ router.post('/:id/todos',
return res.status(404).json({ error: 'Note non trouvée' }); return res.status(404).json({ error: 'Note non trouvée' });
} }
const { text } = req.body; const { text, parent_id } = req.body;
let level = 0;
logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"`); // Si c'est un subtask, vérifier que le parent existe et calculer le niveau
if (parent_id) {
const parent = await getOne('SELECT id, level FROM note_todos WHERE id = $1 AND note_id = $2', [parent_id, req.params.id]);
if (!parent) {
return res.status(404).json({ error: 'Todo parent non trouvé' });
}
level = (parent.level || 0) + 1;
}
logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`);
const result = await runQuery(` const result = await runQuery(`
INSERT INTO note_todos (note_id, text, position) INSERT INTO note_todos (note_id, text, parent_id, level, position)
VALUES ($1, $2, (SELECT COALESCE(MAX(position), FALSE) + 1 FROM note_todos WHERE note_id = $3)) VALUES ($1, $2, $3, $4, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $5 AND COALESCE(parent_id, 0) = COALESCE($6, 0)))
RETURNING * RETURNING *
`, [req.params.id, text, req.params.id]); `, [req.params.id, text, parent_id || null, level, req.params.id, parent_id || null]);
logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}`); logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}, level: ${level}`);
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]); await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]);
@@ -426,7 +439,9 @@ router.post('/:id/todos',
id: result.id, id: result.id,
text, text,
completed: false, completed: false,
position: 0 position: result.position || 0,
parent_id: parent_id || null,
level: level
}); });
} catch (error) { } catch (error) {
logger.error('Erreur lors de l\'ajout du todo:', error); logger.error('Erreur lors de l\'ajout du todo:', error);
@@ -473,7 +488,7 @@ router.put('/todos/:todoId',
if (completed !== undefined) { if (completed !== undefined) {
paramCount++; paramCount++;
updates.push(`completed = $${paramCount}`); updates.push(`completed = $${paramCount}`);
params.push(completed ? 1 : 0); params.push(completed); // BOOLEAN instead of 0/1
} }
if (position !== undefined) { if (position !== undefined) {
paramCount++; paramCount++;
@@ -957,7 +972,7 @@ router.patch('/:id/priority', async (req, res) => {
return res.status(404).json({ error: 'Note non trouvée' }); return res.status(404).json({ error: 'Note non trouvée' });
} }
await runQuery('UPDATE notes SET priority = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [priority ? 1 : 0, req.params.id]); await runQuery('UPDATE notes SET priority = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [priority ? 1 : 0, req.params.id]); // notes.priority is INTEGER not BOOLEAN
logger.info(`Priorité de la note ${req.params.id} modifiée: ${priority}`); logger.info(`Priorité de la note ${req.params.id} modifiée: ${priority}`);
res.json({ message: 'Priorité modifiée avec succès', priority }); res.json({ message: 'Priorité modifiée avec succès', priority });
+37 -9
View File
@@ -143,7 +143,10 @@ router.post('/chat', authenticateToken, async (req, res) => {
try { try {
const { model, messages } = req.body; const { model, messages } = req.body;
logger.info(`[OPENROUTER CHAT] Début requête - model: "${model}", messages: ${messages?.length || 0}`);
if (!model || !messages || !Array.isArray(messages)) { if (!model || !messages || !Array.isArray(messages)) {
logger.warn('[OPENROUTER CHAT] Requête invalide - modèle ou messages manquants');
return res.status(400).json({ error: 'Modèle et messages requis' }); return res.status(400).json({ error: 'Modèle et messages requis' });
} }
@@ -151,11 +154,21 @@ router.post('/chat', authenticateToken, async (req, res) => {
const setting = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_api_key']); const setting = await getOne('SELECT value FROM settings WHERE key = $1', ['openrouter_api_key']);
if (!setting || !setting.value) { if (!setting || !setting.value) {
return res.status(400).json({ error: 'Clé API OpenRouter non configurée' }); logger.warn('[OPENROUTER CHAT] Clé API non configurée');
return res.status(400).json({ error: 'Clé API OpenRouter non configurée. Allez dans Paramètres pour la configurer.' });
} }
logger.info(`[OPENROUTER CHAT] Clé API trouvée, appel à OpenRouter...`);
// Appeler l'API OpenRouter // Appeler l'API OpenRouter
const fetch = (await import('node-fetch')).default; const fetch = (await import('node-fetch')).default;
const requestBody = {
model: model,
messages: messages
};
logger.info(`[OPENROUTER CHAT] Requête OpenRouter: ${JSON.stringify({ model, messageCount: messages.length })}`);
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -164,25 +177,40 @@ router.post('/chat', authenticateToken, async (req, res) => {
'X-Title': 'NoteFlow', 'X-Title': 'NoteFlow',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody)
model: model,
messages: messages
})
}); });
logger.info(`[OPENROUTER CHAT] Réponse reçue - status: ${response.status} ${response.statusText}`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
logger.error(`Erreur API OpenRouter chat: ${response.status}`, errorData); const errorMessage = errorData.error?.message || errorData.error || `Erreur ${response.status}: ${response.statusText}`;
logger.error(`[OPENROUTER CHAT] Erreur API OpenRouter:`, {
status: response.status,
statusText: response.statusText,
error: errorData
});
return res.status(response.status).json({ return res.status(response.status).json({
error: errorData.error?.message || 'Erreur lors de la communication avec l\'IA' error: errorMessage
}); });
} }
const data = await response.json(); const data = await response.json();
logger.info(`[OPENROUTER CHAT] Succès - réponse générée (${data.choices?.[0]?.message?.content?.length || 0} caractères)`);
res.json(data); res.json(data);
} catch (error) { } catch (error) {
logger.error('Erreur lors du chat OpenRouter:', error); logger.error('[OPENROUTER CHAT] Erreur exception:', {
res.status(500).json({ error: 'Erreur serveur' }); message: error.message,
stack: error.stack,
name: error.name
});
res.status(500).json({
error: `Erreur serveur: ${error.message || 'Erreur inconnue'}`
});
} }
}); });
+1 -1
View File
@@ -88,7 +88,7 @@ router.put('/feeds/:id', requireAdmin, async (req, res) => {
await runQuery( await runQuery(
'UPDATE rss_feeds SET enabled = $1 WHERE id = $2', 'UPDATE rss_feeds SET enabled = $1 WHERE id = $2',
[enabled ? 1 : 0, req.params.id] [enabled, req.params.id] // BOOLEAN instead of 0/1
); );
logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`); logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
+1 -1
View File
@@ -90,7 +90,7 @@ router.put('/feeds/:id', requireAdmin, async (req, res) => {
await runQuery( await runQuery(
'UPDATE rss_feeds SET enabled = $1 WHERE id = $2', 'UPDATE rss_feeds SET enabled = $1 WHERE id = $2',
[enabled ? 1 : 0, req.params.id] [enabled, req.params.id] // BOOLEAN instead of 0/1
); );
logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`); logger.info(`Flux ${enabled ? 'activé' : 'désactivé'}: ${req.params.id}`);
+33 -18
View File
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
// Essayer d'abord avec le champ priority (nouvelle version) // Essayer d'abord avec le champ priority (nouvelle version)
try { try {
const todos = await getAll(` const todos = await getAll(`
SELECT id, text, completed, priority, in_progress, created_at SELECT id, text, completed, priority, in_progress, parent_id, level, created_at
FROM global_todos FROM global_todos
WHERE user_id = $1 WHERE user_id = $1
ORDER BY priority DESC, created_at DESC ORDER BY priority DESC, created_at DESC
@@ -38,7 +38,7 @@ router.get('/', async (req, res) => {
`, [req.user.id]); `, [req.user.id]);
// Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend // Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend
const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false })); const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false, parent_id: null, level: 0 }));
res.json(todosWithPriority); res.json(todosWithPriority);
} }
} catch (error) { } catch (error) {
@@ -49,10 +49,13 @@ router.get('/', async (req, res) => {
/** /**
* POST /api/todos * POST /api/todos
* Créer un nouveau todo global * Créer un nouveau todo global (supports subtasks via parent_id)
*/ */
router.post('/', router.post('/',
[body('text').trim().notEmpty().withMessage('Le texte est requis')], [
body('text').trim().notEmpty().withMessage('Le texte est requis'),
body('parent_id').optional().isInt()
],
async (req, res) => { async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -60,17 +63,27 @@ router.post('/',
return res.status(400).json({ error: 'Données invalides', details: errors.array() }); return res.status(400).json({ error: 'Données invalides', details: errors.array() });
} }
const { text } = req.body; const { text, parent_id } = req.body;
let level = 0;
logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"`); // Si c'est un subtask, vérifier que le parent existe et calculer le niveau
if (parent_id) {
const parent = await getOne('SELECT id, level FROM global_todos WHERE id = $1 AND user_id = $2', [parent_id, req.user.id]);
if (!parent) {
return res.status(404).json({ error: 'Todo parent non trouvé' });
}
level = (parent.level || 0) + 1;
}
logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`);
const result = await runQuery(` const result = await runQuery(`
INSERT INTO global_todos (user_id, text) INSERT INTO global_todos (user_id, text, parent_id, level)
VALUES ($1, $2) VALUES ($1, $2, $3, $4)
RETURNING * RETURNING *
`, [req.user.id, text]); `, [req.user.id, text, parent_id || null, level]);
logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}`); logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}, level: ${level}`);
res.status(201).json({ res.status(201).json({
id: result.id, id: result.id,
@@ -78,6 +91,8 @@ router.post('/',
completed: false, completed: false,
priority: false, priority: false,
in_progress: false, in_progress: false,
parent_id: parent_id || null,
level: level,
created_at: new Date().toISOString() created_at: new Date().toISOString()
}); });
} catch (error) { } catch (error) {
@@ -125,17 +140,17 @@ router.put('/:id',
if (completed !== undefined) { if (completed !== undefined) {
paramCount++; paramCount++;
updates.push(`completed = $${paramCount}`); updates.push(`completed = $${paramCount}`);
params.push(completed ? 1 : 0); params.push(completed); // BOOLEAN instead of 0/1
} }
if (priority !== undefined) { if (priority !== undefined) {
paramCount++; paramCount++;
updates.push(`priority = $${paramCount}`); updates.push(`priority = $${paramCount}`);
params.push(priority ? 1 : 0); params.push(priority); // BOOLEAN instead of 0/1
} }
if (in_progress !== undefined) { if (in_progress !== undefined) {
paramCount++; paramCount++;
updates.push(`in_progress = $${paramCount}`); updates.push(`in_progress = $${paramCount}`);
params.push(in_progress ? 1 : 0); params.push(in_progress ? 1 : 0); // global_todos.in_progress is INTEGER not BOOLEAN
} }
if (updates.length > 0) { if (updates.length > 0) {
@@ -164,12 +179,12 @@ router.patch('/:id/toggle', async (req, res) => {
return res.status(404).json({ error: 'Todo non trouvé' }); return res.status(404).json({ error: 'Todo non trouvé' });
} }
const newCompleted = todo.completed ? 0 : 1; const newCompleted = !todo.completed; // BOOLEAN instead of 0/1
await runQuery('UPDATE global_todos SET completed = $1 WHERE id = $2', [newCompleted, req.params.id]); await runQuery('UPDATE global_todos SET completed = $1 WHERE id = $2', [newCompleted, req.params.id]);
logger.info(`Todo global ${newCompleted ? 'complété' : 'réouvert'} (ID: ${req.params.id}) par ${req.user.username}`); logger.info(`Todo global ${newCompleted ? 'complété' : 'réouvert'} (ID: ${req.params.id}) par ${req.user.username}`);
res.json({ message: 'Todo modifié avec succès', completed: newCompleted === 1 }); res.json({ message: 'Todo modifié avec succès', completed: newCompleted });
} catch (error) { } catch (error) {
logger.error('Erreur lors du toggle du todo:', error); logger.error('Erreur lors du toggle du todo:', error);
res.status(500).json({ error: 'Erreur serveur' }); res.status(500).json({ error: 'Erreur serveur' });
@@ -189,12 +204,12 @@ router.patch('/:id/priority', async (req, res) => {
return res.status(404).json({ error: 'Todo non trouvé' }); return res.status(404).json({ error: 'Todo non trouvé' });
} }
const newPriority = todo.priority ? 0 : 1; const newPriority = !todo.priority; // BOOLEAN instead of 0/1
await runQuery('UPDATE global_todos SET priority = $1 WHERE id = $2', [newPriority, req.params.id]); await runQuery('UPDATE global_todos SET priority = $1 WHERE id = $2', [newPriority, req.params.id]);
logger.info(`Todo global ${newPriority ? 'marqué prioritaire' : 'démarqué prioritaire'} (ID: ${req.params.id}) par ${req.user.username}`); logger.info(`Todo global ${newPriority ? 'marqué prioritaire' : 'démarqué prioritaire'} (ID: ${req.params.id}) par ${req.user.username}`);
res.json({ message: 'Priorité modifiée avec succès', priority: newPriority === 1 }); res.json({ message: 'Priorité modifiée avec succès', priority: newPriority });
} catch (priorityError) { } catch (priorityError) {
// Si le champ priority n'existe pas encore, retourner un message d'erreur explicite // Si le champ priority n'existe pas encore, retourner un message d'erreur explicite
if (priorityError.message && priorityError.message.includes('priority')) { if (priorityError.message && priorityError.message.includes('priority')) {
@@ -225,7 +240,7 @@ router.patch('/:id/in-progress', async (req, res) => {
return res.status(404).json({ error: 'Todo non trouvé' }); return res.status(404).json({ error: 'Todo non trouvé' });
} }
const newInProgress = todo.in_progress ? 0 : 1; const newInProgress = todo.in_progress ? 0 : 1; // global_todos.in_progress is INTEGER not BOOLEAN
await runQuery('UPDATE global_todos SET in_progress = $1 WHERE id = $2', [newInProgress, req.params.id]); await runQuery('UPDATE global_todos SET in_progress = $1 WHERE id = $2', [newInProgress, req.params.id]);
logger.info(`Todo global ${newInProgress ? 'marqué en cours' : 'démarqué en cours'} (ID: ${req.params.id}) par ${req.user.username}`); logger.info(`Todo global ${newInProgress ? 'marqué en cours' : 'démarqué en cours'} (ID: ${req.params.id}) par ${req.user.username}`);
+3 -3
View File
@@ -56,12 +56,12 @@ router.post('/',
// Hasher le mot de passe // Hasher le mot de passe
const passwordHash = await bcrypt.hash(password, 12); const passwordHash = await bcrypt.hash(password, 12);
logger.info(`[CREATE USER] Début création - username: "${username}", is_admin: ${is_admin ? 1 : 0}`); logger.info(`[CREATE USER] Début création - username: "${username}", is_admin: ${is_admin || false}`);
// Créer l'utilisateur // Créer l'utilisateur
const result = await runQuery( const result = await runQuery(
'INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) RETURNING *', 'INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, $3) RETURNING *',
[username, passwordHash, is_admin ? 1 : 0] [username, passwordHash, is_admin || false] // BOOLEAN instead of 0/1
); );
logger.info(`[CREATE USER] Utilisateur créé avec succès - ID: ${result.id}, username: "${username}"`); logger.info(`[CREATE USER] Utilisateur créé avec succès - ID: ${result.id}, username: "${username}"`);
@@ -111,7 +111,7 @@ router.put('/:id',
// Mettre à jour le statut admin si fourni // Mettre à jour le statut admin si fourni
if (typeof is_admin !== 'undefined') { if (typeof is_admin !== 'undefined') {
await runQuery('UPDATE users SET is_admin = $1 WHERE id = $2', [is_admin ? 1 : 0, userId]); await runQuery('UPDATE users SET is_admin = $1 WHERE id = $2', [is_admin, userId]); // BOOLEAN instead of 0/1
} }
logger.info(`Utilisateur modifié: ${user.username} (ID: ${userId})`); logger.info(`Utilisateur modifié: ${user.username} (ID: ${userId})`);
+60
View File
@@ -0,0 +1,60 @@
# Scripts de correction PostgreSQL
## Problème: Notes ne s'affichent pas (Erreur 500)
Si vous voyez une erreur 500 sur `/api/notes` après la migration vers PostgreSQL, c'est probablement dû à un problème de conversion des données booléennes.
### Cause
Lors de la migration de SQLite vers PostgreSQL:
- SQLite stocke les booléens comme INTEGER (0/1)
- PostgreSQL utilise le type BOOLEAN (TRUE/FALSE)
- Si les données ont été migrées sans conversion, les valeurs restent en INTEGER
- Les requêtes SQL qui utilisent `completed = TRUE` échouent
### Solution
Exécutez le script de correction sur votre base de données de production:
```bash
# Sur le serveur de production
cd /path/to/noteflow
node scripts/fix-boolean-data.js
```
Ce script va:
1. Se connecter à votre base PostgreSQL
2. Vérifier les types des colonnes booléennes
3. Convertir les colonnes INTEGER en BOOLEAN si nécessaire
4. Convertir automatiquement les valeurs 0/1 en FALSE/TRUE
### Colonnes concernées
- `users.is_admin`
- `notes.archived`
- `note_todos.completed`, `note_todos.priority`
- `global_todos.completed`, `global_todos.priority`
- `rss_feeds.enabled`
- `calendar_events.all_day`
### Variables d'environnement
Le script utilise `DATABASE_URL` de votre fichier `.env`:
```env
DATABASE_URL=postgresql://noteflow:password@localhost:5499/noteflow
```
### Migration future
Le script `migrate-sqlite-to-postgres.js` a été amélioré pour convertir automatiquement les booléens lors des futures migrations. Vous n'aurez plus besoin d'exécuter `fix-boolean-data.js` après de nouvelles migrations.
### Vérification
Après l'exécution du script, redémarrez votre serveur et vérifiez que les notes s'affichent correctement.
```bash
pm2 restart noteflow
# ou
systemctl restart noteflow
```
+49
View File
@@ -185,6 +185,55 @@ async function autoMigrate() {
await client.query(`CREATE INDEX IF NOT EXISTS idx_global_todos_priority ON global_todos(priority DESC, created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_global_todos_priority ON global_todos(priority DESC, created_at DESC)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_note_todos_priority ON note_todos(priority DESC, position)`); await client.query(`CREATE INDEX IF NOT EXISTS idx_note_todos_priority ON note_todos(priority DESC, position)`);
// Migration 4: Support des subtasks (parent_id, level)
logger.info(' Vérification: support des subtasks (parent_id, level)...');
const noteTodosParentIdExists = await client.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name='note_todos' AND column_name='parent_id'
`);
if (noteTodosParentIdExists.rows.length === 0) {
logger.info(' → Ajout du champ parent_id à note_todos');
await client.query(`ALTER TABLE note_todos ADD COLUMN parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id)`);
}
const noteTodosLevelExists = await client.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name='note_todos' AND column_name='level'
`);
if (noteTodosLevelExists.rows.length === 0) {
logger.info(' → Ajout du champ level à note_todos');
await client.query(`ALTER TABLE note_todos ADD COLUMN level INTEGER DEFAULT 0`);
}
const globalTodosParentIdExists = await client.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name='global_todos' AND column_name='parent_id'
`);
if (globalTodosParentIdExists.rows.length === 0) {
logger.info(' → Ajout du champ parent_id à global_todos');
await client.query(`ALTER TABLE global_todos ADD COLUMN parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id)`);
}
const globalTodosLevelExists = await client.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name='global_todos' AND column_name='level'
`);
if (globalTodosLevelExists.rows.length === 0) {
logger.info(' → Ajout du champ level à global_todos');
await client.query(`ALTER TABLE global_todos ADD COLUMN level INTEGER DEFAULT 0`);
}
await client.query('COMMIT'); await client.query('COMMIT');
logger.info('✅ Migrations automatiques terminées avec succès'); logger.info('✅ Migrations automatiques terminées avec succès');
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// Script de correction des données booléennes dans PostgreSQL
// Convertit les valeurs 0/1 (INTEGER) en FALSE/TRUE (BOOLEAN)
require('dotenv').config();
const { Pool } = require('pg');
const DATABASE_URL = process.env.DATABASE_URL ||
`postgresql://noteflow:noteflow_secure_password_change_me@localhost:5499/noteflow`;
console.log('\n========== CORRECTION DES DONNÉES BOOLÉENNES ==========\n');
const pool = new Pool({
connectionString: DATABASE_URL,
});
async function fixBooleanColumns() {
const client = await pool.connect();
try {
console.log('✓ Connexion PostgreSQL établie\n');
// Liste des tables et colonnes avec type BOOLEAN
const booleanColumns = [
{ table: 'users', columns: ['is_admin'] },
{ table: 'notes', columns: ['archived'] },
{ table: 'note_todos', columns: ['completed', 'priority'] },
{ table: 'global_todos', columns: ['completed', 'priority'] },
{ table: 'rss_feeds', columns: ['enabled'] },
{ table: 'calendar_events', columns: ['all_day'] }
];
for (const { table, columns } of booleanColumns) {
console.log(`📋 Table: ${table}`);
for (const column of columns) {
try {
// Vérifier le type actuel de la colonne
const typeCheck = await client.query(`
SELECT data_type
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2
`, [table, column]);
if (typeCheck.rows.length === 0) {
console.log(` ⏭️ Colonne ${column} n'existe pas, skip`);
continue;
}
const dataType = typeCheck.rows[0].data_type;
console.log(` 📊 ${column}: type actuel = ${dataType}`);
if (dataType === 'boolean') {
// Si la colonne est déjà BOOLEAN, vérifier s'il y a des valeurs INTEGER incorrectes
// En réalité, PostgreSQL convertit automatiquement lors de l'insertion
// Mais on peut forcer la conversion pour être sûr
console.log(` ✓ ${column} est déjà BOOLEAN`);
} else if (dataType === 'integer') {
// Si la colonne est INTEGER, la convertir en BOOLEAN
console.log(` 🔧 Conversion ${column}: INTEGER → BOOLEAN`);
await client.query(`
ALTER TABLE ${table}
ALTER COLUMN ${column} TYPE BOOLEAN
USING CASE
WHEN ${column} = 0 THEN FALSE
WHEN ${column} = 1 THEN TRUE
ELSE ${column}::BOOLEAN
END
`);
console.log(` ✅ ${column} converti en BOOLEAN`);
}
} catch (err) {
console.error(` ❌ Erreur sur ${column}:`, err.message);
}
}
console.log('');
}
console.log('✅ Correction terminée!\n');
} catch (error) {
console.error('❌ Erreur:', error);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
fixBooleanColumns();
+91
View File
@@ -0,0 +1,91 @@
-- Script de correction des types booléens PostgreSQL
-- Convertit les colonnes INTEGER en BOOLEAN et les données 0/1 en FALSE/TRUE
-- Ce script est idempotent: il peut être exécuté plusieurs fois sans problème
-- Fonction pour vérifier et corriger le type d'une colonne
DO $$
DECLARE
column_info RECORD;
BEGIN
-- Corriger users.is_admin
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'is_admin';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting users.is_admin from INTEGER to BOOLEAN';
ALTER TABLE users ALTER COLUMN is_admin TYPE BOOLEAN
USING CASE WHEN is_admin = 0 THEN FALSE WHEN is_admin = 1 THEN TRUE ELSE is_admin::BOOLEAN END;
END IF;
-- Corriger notes.archived
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'notes' AND column_name = 'archived';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting notes.archived from INTEGER to BOOLEAN';
ALTER TABLE notes ALTER COLUMN archived TYPE BOOLEAN
USING CASE WHEN archived = 0 THEN FALSE WHEN archived = 1 THEN TRUE ELSE archived::BOOLEAN END;
END IF;
-- Corriger note_todos.completed
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'note_todos' AND column_name = 'completed';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting note_todos.completed from INTEGER to BOOLEAN';
ALTER TABLE note_todos ALTER COLUMN completed TYPE BOOLEAN
USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END;
END IF;
-- Corriger note_todos.priority
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'note_todos' AND column_name = 'priority';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting note_todos.priority from INTEGER to BOOLEAN';
ALTER TABLE note_todos ALTER COLUMN priority TYPE BOOLEAN
USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END;
END IF;
-- Corriger global_todos.completed
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'global_todos' AND column_name = 'completed';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting global_todos.completed from INTEGER to BOOLEAN';
ALTER TABLE global_todos ALTER COLUMN completed TYPE BOOLEAN
USING CASE WHEN completed = 0 THEN FALSE WHEN completed = 1 THEN TRUE ELSE completed::BOOLEAN END;
END IF;
-- Corriger global_todos.priority
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'global_todos' AND column_name = 'priority';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting global_todos.priority from INTEGER to BOOLEAN';
ALTER TABLE global_todos ALTER COLUMN priority TYPE BOOLEAN
USING CASE WHEN priority = 0 THEN FALSE WHEN priority = 1 THEN TRUE ELSE priority::BOOLEAN END;
END IF;
-- Corriger rss_feeds.enabled
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'rss_feeds' AND column_name = 'enabled';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting rss_feeds.enabled from INTEGER to BOOLEAN';
ALTER TABLE rss_feeds ALTER COLUMN enabled TYPE BOOLEAN
USING CASE WHEN enabled = 0 THEN FALSE WHEN enabled = 1 THEN TRUE ELSE enabled::BOOLEAN END;
END IF;
-- Corriger calendar_events.all_day
SELECT data_type INTO column_info FROM information_schema.columns
WHERE table_name = 'calendar_events' AND column_name = 'all_day';
IF FOUND AND column_info.data_type = 'integer' THEN
RAISE NOTICE 'Converting calendar_events.all_day from INTEGER to BOOLEAN';
ALTER TABLE calendar_events ALTER COLUMN all_day TYPE BOOLEAN
USING CASE WHEN all_day = 0 THEN FALSE WHEN all_day = 1 THEN TRUE ELSE all_day::BOOLEAN END;
END IF;
RAISE NOTICE 'Boolean type correction completed successfully';
END $$;
+20 -1
View File
@@ -69,6 +69,16 @@ function getSqliteData(tableName) {
}); });
} }
// Colonnes booléennes connues (pour conversion 0/1 → FALSE/TRUE)
const BOOLEAN_COLUMNS = {
users: ['is_admin'],
notes: ['archived'],
note_todos: ['completed', 'priority'],
global_todos: ['completed', 'priority'],
rss_feeds: ['enabled'],
calendar_events: ['all_day']
};
/** /**
* Insérer les données dans PostgreSQL * Insérer les données dans PostgreSQL
*/ */
@@ -80,6 +90,7 @@ async function insertPostgresData(tableName, rows) {
const client = await pgPool.connect(); const client = await pgPool.connect();
let inserted = 0; let inserted = 0;
const booleanCols = BOOLEAN_COLUMNS[tableName] || [];
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
@@ -87,10 +98,18 @@ async function insertPostgresData(tableName, rows) {
for (const row of rows) { for (const row of rows) {
// Convertir les noms de colonnes et valeurs // Convertir les noms de colonnes et valeurs
const columns = Object.keys(row); const columns = Object.keys(row);
const values = Object.values(row).map(val => { const values = columns.map((col, idx) => {
const val = Object.values(row)[idx];
// Convertir les valeurs SQLite vers PostgreSQL // Convertir les valeurs SQLite vers PostgreSQL
if (val === null) return null; if (val === null) return null;
if (typeof val === 'boolean') return val; if (typeof val === 'boolean') return val;
// Convertir les colonnes booléennes: 0/1 → FALSE/TRUE
if (booleanCols.includes(col) && (val === 0 || val === 1)) {
return val === 1;
}
if (typeof val === 'number') return val; if (typeof val === 'number') return val;
// Dates // Dates
if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}/.test(val)) { if (typeof val === 'string' && /^\d{4}-\d{2}-\d{2}/.test(val)) {
+3
View File
@@ -1,4 +1,7 @@
// Serveur Express principal pour l'application NoteFlow // Serveur Express principal pour l'application NoteFlow
// Load environment variables from .env file
require('dotenv').config();
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
+1 -1
View File
@@ -75,7 +75,7 @@ const App = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="notes-theme"> <ThemeProvider defaultTheme="system" storageKey="notes-theme">
<TooltipProvider> <TooltipProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Calendar } from "@/components/ui/calendar";
import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
export interface SearchFilters {
tags: string[]; // Tag names
dateFrom?: Date;
dateTo?: Date;
hasTodos?: boolean;
hasImages?: boolean;
hasFiles?: boolean;
priority?: boolean;
}
export interface TagOption {
id: number;
name: string;
}
interface AdvancedSearchProps {
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
availableTags: TagOption[];
}
export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) {
const [open, setOpen] = useState(false);
const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null);
const [tagSelectorOpen, setTagSelectorOpen] = useState(false);
const hasActiveFilters = filters.tags.length > 0 ||
filters.dateFrom !== undefined ||
filters.dateTo !== undefined ||
filters.hasTodos !== undefined ||
filters.hasImages !== undefined ||
filters.hasFiles !== undefined ||
filters.priority !== undefined;
const clearFilters = () => {
onFiltersChange({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
};
const toggleTag = (tagName: string) => {
const newTags = filters.tags.includes(tagName)
? filters.tags.filter(name => name !== tagName)
: [...filters.tags, tagName];
onFiltersChange({ ...filters, tags: newTags });
};
const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name));
return (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Filtres avancés
{hasActiveFilters && (
<Badge variant="secondary" className="ml-1 px-1.5">
{filters.tags.length +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0) +
(filters.hasTodos ? 1 : 0) +
(filters.hasImages ? 1 : 0) +
(filters.hasFiles ? 1 : 0) +
(filters.priority ? 1 : 0)}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Filtres avancés</h4>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Réinitialiser
</Button>
)}
</div>
{/* Tags Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Tags</Label>
<Popover open={tagSelectorOpen} onOpenChange={setTagSelectorOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<TagIcon className="h-4 w-4" />
{selectedTags.length > 0 ? (
<span className="text-xs">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} sélectionné{selectedTags.length > 1 ? 's' : ''}
</span>
) : (
<span className="text-xs text-muted-foreground">Sélectionner des tags</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-0" align="start">
<Command>
<CommandInput placeholder="Rechercher un tag..." />
<CommandList>
<CommandEmpty>Aucun tag trouvé</CommandEmpty>
<CommandGroup>
{availableTags.map((tag) => (
<CommandItem
key={tag.id}
onSelect={() => toggleTag(tag.name)}
className="cursor-pointer"
>
<Checkbox
checked={filters.tags.includes(tag.name)}
className="mr-2"
/>
<TagIcon className="h-3 w-3 mr-2" />
{tag.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTags.map((tag) => (
<Badge key={tag.id} variant="secondary" className="text-xs gap-1">
{tag.name}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => toggleTag(tag.name)}
/>
</Badge>
))}
</div>
)}
</div>
{/* Date Range Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Période</Label>
<div className="grid grid-cols-2 gap-2">
<Popover open={datePickerOpen === 'from'} onOpenChange={(open) => setDatePickerOpen(open ? 'from' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateFrom ? filters.dateFrom.toLocaleDateString('fr-FR') : 'Du'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateFrom}
onSelect={(date) => {
onFiltersChange({ ...filters, dateFrom: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
<Popover open={datePickerOpen === 'to'} onOpenChange={(open) => setDatePickerOpen(open ? 'to' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateTo ? filters.dateTo.toLocaleDateString('fr-FR') : 'Au'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateTo}
onSelect={(date) => {
onFiltersChange({ ...filters, dateTo: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{(filters.dateFrom || filters.dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => onFiltersChange({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Effacer les dates
</Button>
)}
</div>
{/* Content Type Filters */}
<div className="space-y-2">
<Label className="text-xs font-medium">Contenu</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="hasTodos"
checked={filters.hasTodos === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasTodos: checked ? true : undefined })
}
/>
<label htmlFor="hasTodos" className="text-xs cursor-pointer">
Avec des tâches
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasImages"
checked={filters.hasImages === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasImages: checked ? true : undefined })
}
/>
<label htmlFor="hasImages" className="text-xs cursor-pointer">
Avec des images
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasFiles: checked ? true : undefined })
}
/>
<label htmlFor="hasFiles" className="text-xs cursor-pointer">
Avec des fichiers
</label>
</div>
</div>
</div>
{/* Priority Filter */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="priority"
checked={filters.priority === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, priority: checked ? true : undefined })
}
/>
<label htmlFor="priority" className="text-xs cursor-pointer font-medium">
Notes prioritaires uniquement
</label>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-8 gap-1">
<X className="h-3 w-3" />
Effacer
</Button>
)}
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Download, Upload, FileJson, FileSpreadsheet, AlertCircle, CheckCircle } from "lucide-react";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
import {
exportToJSON,
downloadBackup,
downloadCSV,
exportNotesToCSV,
parseBackupJSON,
readFileAsText,
getBackupStats,
type BackupData
} from "@/utils/exportImport";
import { showSuccess, showError } from "@/utils/toast";
interface ExportImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
notes: Note[];
todos: Todo[];
settings?: Record<string, any>;
onImport?: (backup: BackupData) => Promise<void>;
}
export function ExportImportModal({
open,
onOpenChange,
notes,
todos,
settings,
onImport
}: ExportImportModalProps) {
const [importing, setImporting] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<BackupData | null>(null);
const handleExportJSON = async () => {
try {
const jsonData = await exportToJSON(notes, todos, settings);
downloadBackup(jsonData);
showSuccess('Backup exporté avec succès');
} catch (error) {
showError('Erreur lors de l\'export');
}
};
const handleExportCSV = () => {
try {
const csvData = exportNotesToCSV(notes);
downloadCSV(csvData);
showSuccess('CSV exporté avec succès');
} catch (error) {
showError('Erreur lors de l\'export CSV');
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
try {
const content = await readFileAsText(file);
const backup = parseBackupJSON(content);
if (backup) {
setPreviewData(backup);
} else {
showError('Fichier de backup invalide');
setImportFile(null);
}
} catch (error) {
showError('Erreur lors de la lecture du fichier');
setImportFile(null);
}
};
const handleImport = async () => {
if (!previewData || !onImport) return;
setImporting(true);
try {
await onImport(previewData);
showSuccess('Données importées avec succès');
setImportFile(null);
setPreviewData(null);
onOpenChange(false);
} catch (error) {
showError('Erreur lors de l\'import');
} finally {
setImporting(false);
}
};
const stats = notes.length > 0 ? {
notesCount: notes.length,
archivedCount: notes.filter(n => n.archived).length,
priorityCount: notes.filter(n => n.priority).length,
todosCount: todos.length,
tagsCount: new Set(notes.flatMap(n => n.tags?.map(t => t.name) || [])).size
} : null;
const previewStats = previewData ? getBackupStats(previewData) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Export / Import</DialogTitle>
<DialogDescription>
Exportez ou importez toutes vos données
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="export">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="export">Export</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
</TabsList>
<TabsContent value="export" className="space-y-4">
{/* Current Data Stats */}
{stats && (
<div className="grid grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.notesCount}</div>
<div className="text-xs text-muted-foreground">Notes</div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.todosCount}</div>
<div className="text-xs text-muted-foreground">Tâches</div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.tagsCount}</div>
<div className="text-xs text-muted-foreground">Tags</div>
</div>
</div>
)}
{/* Export Options */}
<div className="space-y-3">
<div>
<Label className="text-sm font-medium mb-2 block">Format JSON (Backup complet)</Label>
<Button onClick={handleExportJSON} className="w-full" variant="outline">
<FileJson className="h-4 w-4 mr-2" />
Exporter en JSON
</Button>
<p className="text-xs text-muted-foreground mt-1">
Inclut toutes les notes, tâches, tags, et paramètres
</p>
</div>
<div>
<Label className="text-sm font-medium mb-2 block">Format CSV (Notes uniquement)</Label>
<Button onClick={handleExportCSV} className="w-full" variant="outline">
<FileSpreadsheet className="h-4 w-4 mr-2" />
Exporter en CSV
</Button>
<p className="text-xs text-muted-foreground mt-1">
Tableau des notes pour Excel ou Google Sheets
</p>
</div>
</div>
<Alert>
<Download className="h-4 w-4" />
<AlertDescription>
Les fichiers seront téléchargés dans votre dossier de téléchargements
</AlertDescription>
</Alert>
</TabsContent>
<TabsContent value="import" className="space-y-4">
{/* File Upload */}
<div>
<Label htmlFor="import-file" className="text-sm font-medium mb-2 block">
Sélectionner un fichier de backup (JSON)
</Label>
<Input
id="import-file"
type="file"
accept=".json"
onChange={handleFileSelect}
/>
</div>
{/* Preview */}
{previewData && previewStats && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium">Backup valide</span>
<Badge variant="outline">{previewData.version}</Badge>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.notesCount}</div>
<div className="text-xs text-muted-foreground">Notes</div>
</div>
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.todosCount}</div>
<div className="text-xs text-muted-foreground">Tâches</div>
</div>
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.tagsCount}</div>
<div className="text-xs text-muted-foreground">Tags</div>
</div>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Créé le {new Date(previewData.exportDate).toLocaleDateString('fr-FR')}
</AlertDescription>
</Alert>
<Button
onClick={handleImport}
disabled={importing || !onImport}
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
{importing ? 'Import en cours...' : 'Importer les données'}
</Button>
{!onImport && (
<p className="text-xs text-muted-foreground text-center">
La fonction d'import n'est pas disponible
</p>
)}
</div>
)}
{!previewData && !importFile && (
<div className="text-center py-8 text-muted-foreground">
<Upload className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Sélectionnez un fichier de backup pour commencer</p>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react";
import { Note } from "@/services/NotesService";
interface KanbanColumn {
id: string;
title: string;
color: string;
}
const DEFAULT_COLUMNS: KanbanColumn[] = [
{ id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' },
{ id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' },
{ id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' },
{ id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' },
{ id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' },
];
interface KanbanBoardProps {
notes: Note[];
onNoteClick: (note: Note) => void;
onCreateNote: (columnId: string) => void;
onMoveNote?: (noteId: number, newStatus: string) => void;
}
export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) {
const [draggedNote, setDraggedNote] = useState<Note | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
// Group notes by their kanban_status tag or default to 'backlog'
const getNoteStatus = (note: Note): string => {
const statusTag = note.tags?.find(tag =>
DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase())
);
return statusTag?.name.toLowerCase() || 'backlog';
};
const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => {
acc[column.id] = notes.filter(note =>
!note.archived && getNoteStatus(note) === column.id
);
return acc;
}, {} as Record<string, Note[]>);
const handleDragStart = (note: Note) => {
setDraggedNote(note);
};
const handleDragOver = (e: React.DragEvent, columnId: string) => {
e.preventDefault();
setDragOverColumn(columnId);
};
const handleDragLeave = () => {
setDragOverColumn(null);
};
const handleDrop = (e: React.DragEvent, columnId: string) => {
e.preventDefault();
setDragOverColumn(null);
if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) {
onMoveNote(draggedNote.id!, columnId);
}
setDraggedNote(null);
};
const handleDragEnd = () => {
setDraggedNote(null);
setDragOverColumn(null);
};
return (
<div className="flex gap-4 h-full overflow-x-auto pb-4">
{DEFAULT_COLUMNS.map((column) => (
<div
key={column.id}
className="flex-shrink-0 w-80"
onDragOver={(e) => handleDragOver(e, column.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, column.id)}
>
<Card className={`h-full flex flex-col ${dragOverColumn === column.id ? 'ring-2 ring-primary' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${column.color}`} />
{column.title}
<Badge variant="secondary" className="ml-1">
{notesByColumn[column.id]?.length || 0}
</Badge>
</CardTitle>
<Button
size="sm"
variant="ghost"
onClick={() => onCreateNote(column.id)}
className="h-7 w-7 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<ScrollArea className="h-[calc(100vh-250px)]">
<div className="space-y-2 pr-4">
{notesByColumn[column.id]?.map((note) => (
<div
key={note.id}
draggable
onDragStart={() => handleDragStart(note)}
onDragEnd={handleDragEnd}
onClick={() => onNoteClick(note)}
className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${
draggedNote?.id === note.id ? 'opacity-50' : ''
} ${note.priority ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start gap-2 mb-2">
<GripVertical className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{note.title || "Sans titre"}
</h4>
{note.content && (
<p className="text-xs text-muted-foreground line-clamp-2">
{note.content.replace(/<[^>]*>/g, '')}
</p>
)}
</div>
</div>
{/* Metadata badges */}
<div className="flex flex-wrap gap-1 mt-2">
{note.todos && note.todos.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<CheckSquare className="h-3 w-3 mr-1" />
{note.todos.filter(t => t.completed).length}/{note.todos.length}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs h-5">
<TagIcon className="h-2 w-2 mr-1" />
{tag.name}
</Badge>
))}
{note.tags.length > 2 && (
<Badge variant="outline" className="text-xs h-5">
+{note.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
{/* Date */}
{note.updated_at && (
<p className="text-xs text-muted-foreground mt-2">
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short'
})}
</p>
)}
</div>
))}
{notesByColumn[column.id]?.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
Aucune note
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
))}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Keyboard } from "lucide-react";
import { type KeyboardShortcut, formatShortcut } from "@/hooks/useKeyboardShortcuts";
interface KeyboardShortcutsHelpProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcuts: KeyboardShortcut[];
}
export function KeyboardShortcutsHelp({ open, onOpenChange, shortcuts }: KeyboardShortcutsHelpProps) {
// Group shortcuts by category
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
// Determine category based on description
let category = 'Général';
if (shortcut.description.toLowerCase().includes('note')) {
category = 'Notes';
} else if (shortcut.description.toLowerCase().includes('recherch')) {
category = 'Recherche';
} else if (shortcut.description.toLowerCase().includes('todo') || shortcut.description.toLowerCase().includes('tâche')) {
category = 'Tâches';
} else if (shortcut.description.toLowerCase().includes('navigation')) {
category = 'Navigation';
} else if (shortcut.description.toLowerCase().includes('édition') || shortcut.description.toLowerCase().includes('gras') || shortcut.description.toLowerCase().includes('italique')) {
category = 'Édition';
}
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {} as Record<string, KeyboardShortcut[]>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-5 w-5" />
Raccourcis clavier
</DialogTitle>
<DialogDescription>
Utilisez ces raccourcis pour naviguer plus rapidement
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[500px] pr-4">
<div className="space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{category}
</h3>
<div className="space-y-2">
{categoryShortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-accent transition-colors"
>
<span className="text-sm">{shortcut.description}</span>
<Badge variant="secondary" className="font-mono text-xs">
{formatShortcut(shortcut)}
</Badge>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="text-xs text-muted-foreground text-center pt-4 border-t">
Appuyez sur <Badge variant="outline" className="mx-1">?</Badge> pour afficher cette aide
</div>
</DialogContent>
</Dialog>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface PomodoroModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}
export function PomodoroModal({ open, onOpenChange, children }: PomodoroModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl">Pomodoro Timer</DialogTitle>
</DialogHeader>
<div className="mt-4">
{children}
</div>
</DialogContent>
</Dialog>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Play, Pause, RotateCcw, Clock, Coffee, Trophy } from "lucide-react";
import { showSuccess } from "@/utils/toast";
type TimerMode = 'work' | 'shortBreak' | 'longBreak';
const TIMER_DURATIONS = {
work: 25 * 60, // 25 minutes
shortBreak: 5 * 60, // 5 minutes
longBreak: 15 * 60, // 15 minutes
};
const SESSIONS_BEFORE_LONG_BREAK = 4;
interface PomodoroTimerProps {
onStateChange?: (isRunning: boolean, timeLeft: number, mode: TimerMode) => void;
}
export function PomodoroTimer({ onStateChange }: PomodoroTimerProps = {}) {
// Load initial state from localStorage
const [mode, setMode] = useState<TimerMode>(() => {
const saved = localStorage.getItem('pomodoroMode');
return (saved as TimerMode) || 'work';
});
const [timeLeft, setTimeLeft] = useState(() => {
const saved = localStorage.getItem('pomodoroTimeLeft');
return saved ? parseInt(saved) : TIMER_DURATIONS.work;
});
const [isRunning, setIsRunning] = useState(() => {
const saved = localStorage.getItem('pomodoroIsRunning');
return saved === 'true';
});
const [sessionsCompleted, setSessionsCompleted] = useState(() => {
const saved = localStorage.getItem('pomodoroSessionsCompleted');
return saved ? parseInt(saved) : 0;
});
const [endTime, setEndTime] = useState<number | null>(() => {
const saved = localStorage.getItem('pomodoroEndTime');
return saved ? parseInt(saved) : null;
});
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Save state to localStorage whenever it changes and dispatch custom event
useEffect(() => {
localStorage.setItem('pomodoroMode', mode);
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [mode]);
useEffect(() => {
localStorage.setItem('pomodoroTimeLeft', timeLeft.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [timeLeft]);
useEffect(() => {
localStorage.setItem('pomodoroIsRunning', isRunning.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [isRunning]);
useEffect(() => {
localStorage.setItem('pomodoroSessionsCompleted', sessionsCompleted.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [sessionsCompleted]);
useEffect(() => {
if (endTime !== null) {
localStorage.setItem('pomodoroEndTime', endTime.toString());
} else {
localStorage.removeItem('pomodoroEndTime');
}
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [endTime]);
// Restore timer state on mount if it was running
useEffect(() => {
// Clean up invalid states on mount
if (isRunning && endTime === null) {
// If running but no endTime, stop the timer
setIsRunning(false);
setTimeLeft(TIMER_DURATIONS[mode]);
} else if (endTime !== null && isRunning) {
// Recalculate remaining time based on endTime
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTime - now) / 1000));
if (remaining > 0) {
setTimeLeft(remaining);
} else {
// Timer expired while page was closed/reloaded
setIsRunning(false);
setEndTime(null);
setTimeLeft(0);
}
} else if (endTime !== null && !isRunning) {
// If paused with an endTime, clean up the endTime
setEndTime(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only on mount
// Listen for localStorage changes from other instances
useEffect(() => {
const handleStorageChange = () => {
const savedMode = localStorage.getItem('pomodoroMode') as TimerMode;
const savedTimeLeft = localStorage.getItem('pomodoroTimeLeft');
const savedIsRunning = localStorage.getItem('pomodoroIsRunning');
const savedSessions = localStorage.getItem('pomodoroSessionsCompleted');
const savedEndTime = localStorage.getItem('pomodoroEndTime');
if (savedMode && savedMode !== mode) {
setMode(savedMode);
}
if (savedTimeLeft && parseInt(savedTimeLeft) !== timeLeft) {
setTimeLeft(parseInt(savedTimeLeft));
}
if (savedIsRunning && (savedIsRunning === 'true') !== isRunning) {
setIsRunning(savedIsRunning === 'true');
}
if (savedSessions && parseInt(savedSessions) !== sessionsCompleted) {
setSessionsCompleted(parseInt(savedSessions));
}
const newEndTime = savedEndTime ? parseInt(savedEndTime) : null;
if (newEndTime !== endTime) {
setEndTime(newEndTime);
}
};
window.addEventListener('pomodoroStateChange', handleStorageChange);
return () => window.removeEventListener('pomodoroStateChange', handleStorageChange);
}, [mode, timeLeft, isRunning, sessionsCompleted, endTime]);
// Function to play notification sound using Web Audio API
const playNotificationSound = () => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Create a pleasant notification sound (two beeps)
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
// Second beep
setTimeout(() => {
const oscillator2 = audioContext.createOscillator();
const gainNode2 = audioContext.createGain();
oscillator2.connect(gainNode2);
gainNode2.connect(audioContext.destination);
oscillator2.frequency.value = 1000;
oscillator2.type = 'sine';
gainNode2.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator2.start(audioContext.currentTime);
oscillator2.stop(audioContext.currentTime + 0.2);
}, 250);
} catch (e) {
console.error('Audio play failed:', e);
}
};
// Notify parent component of state changes
useEffect(() => {
if (onStateChange) {
onStateChange(isRunning, timeLeft, mode);
}
}, [isRunning, timeLeft, mode, onStateChange]);
const handleTimerComplete = useCallback(() => {
setIsRunning(false);
setEndTime(null);
// Play notification sound
playNotificationSound();
if (mode === 'work') {
const newSessionCount = sessionsCompleted + 1;
setSessionsCompleted(newSessionCount);
// Determine next break type
if (newSessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) {
setMode('longBreak');
setTimeLeft(TIMER_DURATIONS.longBreak);
showSuccess(`Session terminée ! Temps pour une longue pause (${TIMER_DURATIONS.longBreak / 60} min)`);
} else {
setMode('shortBreak');
setTimeLeft(TIMER_DURATIONS.shortBreak);
showSuccess(`Session terminée ! Temps pour une courte pause (${TIMER_DURATIONS.shortBreak / 60} min)`);
}
} else {
// Break completed, back to work
setMode('work');
setTimeLeft(TIMER_DURATIONS.work);
showSuccess('Pause terminée ! Prêt pour une nouvelle session ?');
}
}, [mode, sessionsCompleted]);
// Manage timer interval - using timestamps for accuracy across tab switches
useEffect(() => {
if (isRunning && endTime) {
intervalRef.current = setInterval(() => {
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTime - now) / 1000));
setTimeLeft(remaining);
if (remaining <= 0) {
handleTimerComplete();
}
}, 100); // Check every 100ms for better accuracy
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [isRunning, endTime, handleTimerComplete]);
const handleStart = () => {
const now = Date.now();
const newEndTime = now + (timeLeft * 1000);
setEndTime(newEndTime);
setIsRunning(true);
};
const handlePause = () => {
setIsRunning(false);
setEndTime(null);
// timeLeft is already updated by the interval
};
const handleReset = () => {
setIsRunning(false);
setEndTime(null);
setTimeLeft(TIMER_DURATIONS[mode]);
};
const handleModeChange = (newMode: TimerMode) => {
setMode(newMode);
setTimeLeft(TIMER_DURATIONS[newMode]);
setIsRunning(false);
setEndTime(null);
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const progress = ((TIMER_DURATIONS[mode] - timeLeft) / TIMER_DURATIONS[mode]) * 100;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Pomodoro Timer
<Badge variant="outline" className="ml-auto">
<Trophy className="h-3 w-3 mr-1" />
{sessionsCompleted} session{sessionsCompleted !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Mode Selector */}
<div className="flex gap-2">
<Button
variant={mode === 'work' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('work')}
className="flex-1"
disabled={isRunning}
>
Travail
</Button>
<Button
variant={mode === 'shortBreak' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('shortBreak')}
className="flex-1"
disabled={isRunning}
>
<Coffee className="h-4 w-4 mr-1" />
Courte
</Button>
<Button
variant={mode === 'longBreak' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('longBreak')}
className="flex-1"
disabled={isRunning}
>
<Coffee className="h-4 w-4 mr-1" />
Longue
</Button>
</div>
{/* Timer Display */}
<div className="relative">
<div className="text-6xl font-bold text-center py-8 tabular-nums">
{formatTime(timeLeft)}
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Controls */}
<div className="flex gap-2">
{!isRunning ? (
<Button onClick={handleStart} className="flex-1" size="lg">
<Play className="h-5 w-5 mr-2" />
Démarrer
</Button>
) : (
<Button onClick={handlePause} variant="secondary" className="flex-1" size="lg">
<Pause className="h-5 w-5 mr-2" />
Pause
</Button>
)}
<Button onClick={handleReset} variant="outline" size="lg">
<RotateCcw className="h-5 w-5" />
</Button>
</div>
{/* Session Info */}
<div className="text-center text-sm text-muted-foreground">
{mode === 'work' && (
<p>Session {(sessionsCompleted % SESSIONS_BEFORE_LONG_BREAK) + 1} sur {SESSIONS_BEFORE_LONG_BREAK}</p>
)}
{mode === 'shortBreak' && <p>Pause courte - Profitez-en pour vous étirer</p>}
{mode === 'longBreak' && <p>Pause longue - Temps de vraiment déconnecter</p>}
</div>
</CardContent>
</Card>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Zap, Send } from "lucide-react";
import NotesService from "@/services/NotesService";
import { showSuccess, showError } from "@/utils/toast";
interface QuickCaptureWidgetProps {
onNoteCaptured?: () => void;
}
export function QuickCaptureWidget({ onNoteCaptured }: QuickCaptureWidgetProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCapture = async () => {
if (!content.trim()) {
showError("Le contenu ne peut pas être vide");
return;
}
setIsSubmitting(true);
try {
const noteTitle = title.trim() || `Note rapide - ${new Date().toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}`;
await NotesService.createNote(noteTitle, content);
showSuccess("Note capturée avec succès");
// Reset form
setTitle("");
setContent("");
setOpen(false);
// Notify parent component
if (onNoteCaptured) {
onNoteCaptured();
}
} catch (error) {
showError("Erreur lors de la capture de la note");
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Cmd/Ctrl + Enter to submit
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleCapture();
}
// Escape to close
if (e.key === 'Escape') {
setOpen(false);
}
};
return (
<>
{/* Floating Action Button */}
<Button
className="fixed bottom-24 right-4 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all z-40"
onClick={() => setOpen(true)}
title="Capture rapide (Ctrl+Q)"
>
<Zap className="h-6 w-6" />
</Button>
{/* Quick Capture Modal */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" />
Capture rapide
</DialogTitle>
<DialogDescription>
Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Optional Title */}
<div>
<Input
placeholder="Titre (optionnel)"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-medium"
autoFocus={false}
/>
<p className="text-xs text-muted-foreground mt-1">
Si vide, un titre avec la date sera généré automatiquement
</p>
</div>
{/* Content */}
<Textarea
placeholder="Votre note..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] resize-none"
autoFocus
/>
{/* Actions */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl</kbd> +{' '}
<kbd className="px-2 py-1 bg-muted rounded text-xs">Enter</kbd> pour enregistrer
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Annuler
</Button>
<Button
onClick={handleCapture}
disabled={isSubmitting || !content.trim()}
className="gap-2"
>
<Send className="h-4 w-4" />
{isSubmitting ? "Enregistrement..." : "Capturer"}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
FileText,
CheckSquare,
Tag as TagIcon,
TrendingUp,
Calendar as CalendarIcon,
Clock,
Target,
Zap
} from "lucide-react";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
interface StatsDashboardProps {
notes: Note[];
todos: Todo[];
}
export function StatsDashboard({ notes, todos }: StatsDashboardProps) {
// Calculate statistics
const totalNotes = notes.length;
const archivedNotes = notes.filter(n => n.archived).length;
const activeNotes = totalNotes - archivedNotes;
const priorityNotes = notes.filter(n => n.priority && !n.archived).length;
const totalTodos = todos.length;
const completedTodos = todos.filter(t => t.completed).length;
const activeTodos = totalTodos - completedTodos;
const completionRate = totalTodos > 0 ? (completedTodos / totalTodos) * 100 : 0;
// Notes with specific content
const notesWithTodos = notes.filter(n => n.todos && n.todos.length > 0 && !n.archived).length;
const notesWithImages = notes.filter(n => n.images && n.images.length > 0 && !n.archived).length;
const notesWithFiles = notes.filter(n => n.files && n.files.length > 0 && !n.archived).length;
// Get all unique tags
const allTags = new Set<string>();
notes.forEach(note => {
note.tags?.forEach(tag => allTags.add(tag.name));
});
const totalTags = allTags.size;
// Recent activity (notes updated in last 7 days)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentlyUpdated = notes.filter(n =>
n.updated_at && new Date(n.updated_at) > sevenDaysAgo
).length;
// Calculate productivity score (0-100)
const productivityScore = Math.round(
(completionRate * 0.4) +
((activeNotes > 0 ? Math.min(priorityNotes / activeNotes, 1) : 0) * 20) +
((recentlyUpdated / Math.max(activeNotes, 1)) * 40)
);
return (
<div className="space-y-4">
{/* Productivity Score */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
Score de Productivité
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-3xl font-bold">{productivityScore}</span>
<Badge variant={productivityScore >= 70 ? "default" : productivityScore >= 40 ? "secondary" : "outline"}>
{productivityScore >= 70 ? "Excellent" : productivityScore >= 40 ? "Bien" : "À améliorer"}
</Badge>
</div>
<Progress value={productivityScore} className="h-2" />
<p className="text-xs text-muted-foreground">
Basé sur le taux de complétion, notes prioritaires et activité récente
</p>
</div>
</CardContent>
</Card>
{/* Notes Statistics */}
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Total</span>
<span className="text-2xl font-bold">{totalNotes}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Actives</span>
<Badge variant="secondary">{activeNotes}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Archivées</span>
<Badge variant="outline">{archivedNotes}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Prioritaires</span>
<Badge variant="destructive">{priorityNotes}</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckSquare className="h-4 w-4" />
Tâches
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Total</span>
<span className="text-2xl font-bold">{totalTodos}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Complétées</span>
<Badge variant="secondary">{completedTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">En cours</span>
<Badge variant="default">{activeTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Taux</span>
<Badge variant="outline">{completionRate.toFixed(0)}%</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Content Statistics */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4" />
Contenu
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec tâches</span>
<Badge variant="secondary">{notesWithTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec images</span>
<Badge variant="secondary">{notesWithImages}</Badge>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec fichiers</span>
<Badge variant="secondary">{notesWithFiles}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Tags uniques</span>
<Badge variant="secondary">{totalTags}</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Activité Récente
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Derniers 7 jours</span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{recentlyUpdated}</span>
<span className="text-sm text-muted-foreground">notes modifiées</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { StatsDashboard } from "./StatsDashboard";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
interface StatsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
notes: Note[];
todos: Todo[];
}
export function StatsModal({ open, onOpenChange, notes, todos }: StatsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">Statistiques</DialogTitle>
</DialogHeader>
<div className="mt-4">
<StatsDashboard notes={notes} todos={todos} />
</div>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Search, Sparkles } from "lucide-react";
import { DEFAULT_TEMPLATES, getTemplatesByCategory, searchTemplates, type NoteTemplate } from "@/utils/noteTemplates";
interface TemplateSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectTemplate: (template: NoteTemplate) => void;
}
export function TemplateSelector({ open, onOpenChange, onSelectTemplate }: TemplateSelectorProps) {
const [searchQuery, setSearchQuery] = useState("");
const templates = searchQuery.trim()
? searchTemplates(searchQuery)
: DEFAULT_TEMPLATES;
const templatesByCategory = getTemplatesByCategory();
const handleSelectTemplate = (template: NoteTemplate) => {
onSelectTemplate(template);
onOpenChange(false);
setSearchQuery("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Choisir un template
</DialogTitle>
<DialogDescription>
Démarrez rapidement avec un modèle prédéfini
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un template..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Templates Grid */}
<ScrollArea className="h-[400px] pr-4">
{searchQuery.trim() ? (
// Search Results
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onClick={() => handleSelectTemplate(template)}
/>
))}
{templates.length === 0 && (
<div className="col-span-2 text-center py-8 text-muted-foreground">
Aucun template trouvé
</div>
)}
</div>
) : (
// Templates by Category
<div className="space-y-6">
{Array.from(templatesByCategory.entries()).map(([category, categoryTemplates]) => (
<div key={category}>
<h3 className="font-semibold mb-3 text-sm text-muted-foreground uppercase tracking-wide">
{category}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{categoryTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onClick={() => handleSelectTemplate(template)}
/>
))}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
interface TemplateCardProps {
template: NoteTemplate;
onClick: () => void;
}
function TemplateCard({ template, onClick }: TemplateCardProps) {
return (
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 hover:bg-accent hover:border-primary transition-all"
onClick={onClick}
>
<div className="flex items-center gap-2 w-full">
<span className="text-2xl">{template.icon}</span>
<span className="font-semibold text-left flex-1">{template.name}</span>
</div>
<p className="text-sm text-muted-foreground text-left">
{template.description}
</p>
{template.todos && template.todos.length > 0 && (
<Badge variant="secondary" className="text-xs">
{template.todos.length} tâche{template.todos.length > 1 ? 's' : ''}
</Badge>
)}
</Button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Clair
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Sombre
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Système
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
description: string;
handler: () => void;
preventDefault?: boolean;
}
/**
* Custom hook for managing keyboard shortcuts
*/
export function useKeyboardShortcuts(
shortcuts: KeyboardShortcut[],
enabled = true
) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore keyboard shortcuts when typing in input fields, textareas, or contentEditable elements
// UNLESS the shortcut has a modifier key (Ctrl, Alt, Meta)
const target = event.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
for (const shortcut of shortcuts) {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatch = shortcut.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey;
const altMatch = shortcut.alt ? event.altKey : !event.altKey;
const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
const metaMatch = shortcut.meta ? event.metaKey : !event.metaKey;
if (keyMatch && ctrlMatch && altMatch && shiftMatch && metaMatch) {
// Skip shortcuts without modifiers when user is typing in input fields
const hasModifier = shortcut.ctrl || shortcut.alt || shortcut.meta;
if (isInputField && !hasModifier) {
continue;
}
if (shortcut.preventDefault !== false) {
event.preventDefault();
}
shortcut.handler();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, enabled]);
}
/**
* Format keyboard shortcut for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.ctrl || shortcut.meta) {
parts.push(navigator.platform.includes('Mac') ? '⌘' : 'Ctrl');
}
if (shortcut.alt) {
parts.push(navigator.platform.includes('Mac') ? '⌥' : 'Alt');
}
if (shortcut.shift) {
parts.push(navigator.platform.includes('Mac') ? '⇧' : 'Shift');
}
parts.push(shortcut.key.toUpperCase());
return parts.join('+');
}
/**
* Common keyboard shortcuts used across the app
*/
export const COMMON_SHORTCUTS = {
NEW_NOTE: { key: 'n', ctrl: true, description: 'Nouvelle note' },
SEARCH: { key: 'k', ctrl: true, description: 'Rechercher' },
QUICK_CAPTURE: { key: 'q', ctrl: true, description: 'Capture rapide' },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true, description: 'Basculer sidebar' },
SAVE: { key: 's', ctrl: true, description: 'Sauvegarder' },
CLOSE: { key: 'Escape', description: 'Fermer' },
HELP: { key: '?', shift: true, description: 'Aide raccourcis' },
ARCHIVE: { key: 'e', ctrl: true, description: 'Archiver note' },
DELETE: { key: 'Delete', ctrl: true, description: 'Supprimer' },
NEXT: { key: 'j', description: 'Suivant' },
PREVIOUS: { key: 'k', description: 'Précédent' },
TOGGLE_TODO: { key: 'Enter', ctrl: true, description: 'Toggle todo' },
BOLD: { key: 'b', ctrl: true, description: 'Gras' },
ITALIC: { key: 'i', ctrl: true, description: 'Italique' },
LINK: { key: 'k', ctrl: true, shift: true, description: 'Lien' },
};
+633 -42
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,7 +16,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { RichTextEditor } from "@/components/RichTextEditor"; import { RichTextEditor } from "@/components/RichTextEditor";
import { import {
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List, Sparkles, BarChart3, Clock, HelpCircle
} from "lucide-react"; } from "lucide-react";
import AuthService from "@/services/AuthService"; import AuthService from "@/services/AuthService";
import AdminService from "@/services/AdminService"; import AdminService from "@/services/AdminService";
@@ -31,6 +32,21 @@ import { showError, showSuccess } from "@/utils/toast";
import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { InputModal } from "@/components/modals/InputModal"; import { InputModal } from "@/components/modals/InputModal";
import { AddUserModal } from "@/components/modals/AddUserModal"; import { AddUserModal } from "@/components/modals/AddUserModal";
import { compressImage, formatFileSize } from "@/utils/imageCompression";
import { ModeToggle } from "@/components/mode-toggle";
import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport";
import { TemplateSelector } from "@/components/TemplateSelector";
import type { NoteTemplate } from "@/utils/noteTemplates";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
import { PomodoroModal } from "@/components/PomodoroModal";
import { PomodoroTimer } from "@/components/PomodoroTimer";
import { KanbanBoard } from "@/components/KanbanBoard";
import { getSmartTagSuggestions } from "@/utils/smartTags";
import { QuickCaptureWidget } from "@/components/QuickCaptureWidget";
import { StatsDashboard } from "@/components/StatsDashboard";
import { StatsModal } from "@/components/StatsModal";
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp";
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts";
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
@@ -106,10 +122,36 @@ interface UserType {
is_admin: boolean; is_admin: boolean;
} }
// Function to clean HTML content and decode entities
const cleanHtmlContent = (html: string): string => {
if (!html) return '';
// Create a temporary div to decode HTML entities
const temp = document.createElement('div');
temp.innerHTML = html;
// Get text content (removes HTML tags and decodes entities)
let text = temp.textContent || temp.innerText || '';
// Remove any remaining HTML entity artifacts
text = text.replace(/&[a-z]+;?/gi, ' ').replace(/\s+/g, ' ').trim();
return text;
};
const Index = () => { const Index = () => {
const [user, setUser] = useState<UserType | null>(null); const [user, setUser] = useState<UserType | null>(null);
const [showAdmin, setShowAdmin] = useState(false); const [showAdmin, setShowAdmin] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [openNote, setOpenNote] = useState<Note | null>(null); const [openNote, setOpenNote] = useState<Note | null>(null);
const [users, setUsers] = useState<UserType[]>([]); const [users, setUsers] = useState<UserType[]>([]);
@@ -120,6 +162,7 @@ const Index = () => {
const [settings, setSettings] = useState<AppSettings>({}); const [settings, setSettings] = useState<AppSettings>({});
const [adminTab, setAdminTab] = useState("users"); const [adminTab, setAdminTab] = useState("users");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list');
const [noteTags, setNoteTags] = useState<Tag[]>([]); const [noteTags, setNoteTags] = useState<Tag[]>([]);
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]); const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false); const [loadingModels, setLoadingModels] = useState(false);
@@ -144,11 +187,20 @@ const Index = () => {
const [addUserModal, setAddUserModal] = useState(false); const [addUserModal, setAddUserModal] = useState(false);
const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false });
const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false });
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false); const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
const [addTagModal, setAddTagModal] = useState(false); const [addTagModal, setAddTagModal] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null); const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [addEventModal, setAddEventModal] = useState(false); const [addEventModal, setAddEventModal] = useState(false);
const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false }); const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false });
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [showStatsDashboard, setShowStatsDashboard] = useState(false);
const [showPomodoroModal, setShowPomodoroModal] = useState(false);
// Pomodoro timer states
const [pomodoroRunning, setPomodoroRunning] = useState(false);
const [pomodoroTimeLeft, setPomodoroTimeLeft] = useState(25 * 60);
const [pomodoroMode, setPomodoroMode] = useState<'work' | 'shortBreak' | 'longBreak'>('work');
// Pagination states // Pagination states
const [notesPage, setNotesPage] = useState(0); const [notesPage, setNotesPage] = useState(0);
@@ -165,6 +217,7 @@ const Index = () => {
// Chatbox states // Chatbox states
const [chatOpen, setChatOpen] = useState(false); const [chatOpen, setChatOpen] = useState(false);
const [chatExpandedModal, setChatExpandedModal] = useState(false);
const [chatMessages, setChatMessages] = useState<Array<{ role: string; content: string }>>([]); const [chatMessages, setChatMessages] = useState<Array<{ role: string; content: string }>>([]);
const [chatInput, setChatInput] = useState(""); const [chatInput, setChatInput] = useState("");
const [chatLoading, setChatLoading] = useState(false); const [chatLoading, setChatLoading] = useState(false);
@@ -234,6 +287,20 @@ const Index = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// Handle Pomodoro timer state changes
const handlePomodoroStateChange = (isRunning: boolean, timeLeft: number, mode: 'work' | 'shortBreak' | 'longBreak') => {
setPomodoroRunning(isRunning);
setPomodoroTimeLeft(timeLeft);
setPomodoroMode(mode);
};
// Format time for Pomodoro display
const formatPomodoroTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const loadNotes = async () => { const loadNotes = async () => {
try { try {
const fetchedNotes = await NotesService.getNotes(showArchived); const fetchedNotes = await NotesService.getNotes(showArchived);
@@ -414,12 +481,32 @@ const Index = () => {
navigate("/login"); navigate("/login");
}; };
const handleCreateNote = async () => { const handleCreateNote = () => {
setTemplateSelectorOpen(true);
};
const handleCreateNoteFromTemplate = async (template: NoteTemplate) => {
try { try {
const newNote = await NotesService.createNote("Nouvelle note"); const title = template.name === "Note vide" ? "Nouvelle note" : template.name;
const newNote = await NotesService.createNote(title, template.content);
if (newNote) { if (newNote) {
setNotes([newNote, ...notes]); // Add todos if template has any
setOpenNote(newNote); if (template.todos && template.todos.length > 0) {
for (const todo of template.todos) {
await NotesService.addTodo(newNote.id!, todo.text);
}
// Reload note to get the todos
const notes = await NotesService.getNotes();
const updatedNote = notes.find(n => n.id === newNote.id);
if (updatedNote) {
setNotes([updatedNote, ...notes.filter(n => n.id !== newNote.id)]);
setOpenNote(updatedNote);
}
} else {
setNotes([newNote, ...notes]);
setOpenNote(newNote);
}
showSuccess("Note créée depuis le template");
} }
} catch (error) { } catch (error) {
showError("Erreur lors de la création de la note"); showError("Erreur lors de la création de la note");
@@ -809,12 +896,71 @@ const Index = () => {
} }
}; };
const filteredNotes = notes.filter(note => // Extract unique tags from all notes
(showArchived ? note.archived : !note.archived) && const availableTags: TagOption[] = Array.from(
(searchQuery === "" || new Map(
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) || notes.flatMap(note =>
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
); )
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes.filter(note => {
// Archived filter
if (showArchived ? !note.archived : note.archived) return false;
// Text search
if (searchQuery !== "" &&
!(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
});
// Pagination for notes // Pagination for notes
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
@@ -826,7 +972,7 @@ const Index = () => {
// Reset page when changing filters or search // Reset page when changing filters or search
useEffect(() => { useEffect(() => {
setNotesPage(0); setNotesPage(0);
}, [showArchived, searchQuery]); }, [showArchived, searchQuery, searchFilters]);
useEffect(() => { useEffect(() => {
setTodosActivePage(0); setTodosActivePage(0);
@@ -841,6 +987,66 @@ const Index = () => {
setCalendarPage(0); setCalendarPage(0);
}, [calendarEvents.length]); }, [calendarEvents.length]);
// Define keyboard shortcuts
const keyboardShortcuts: KeyboardShortcut[] = [
{
key: 'n',
ctrl: true,
description: 'Nouvelle note',
handler: () => !showAdmin && handleCreateNote()
},
{
key: 'k',
ctrl: true,
description: 'Rechercher',
handler: () => {
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
if (searchInput) searchInput.focus();
}
},
{
key: 'q',
ctrl: true,
description: 'Capture rapide',
handler: () => {
const captureButton = document.querySelector('[title="Capture rapide (Ctrl+Q)"]') as HTMLButtonElement;
if (captureButton) captureButton.click();
}
},
{
key: 'Escape',
description: 'Fermer note/modal',
handler: () => {
if (openNote) setOpenNote(null);
else if (showAdmin) setShowAdmin(false);
else if (showKeyboardHelp) setShowKeyboardHelp(false);
},
preventDefault: false
},
{
key: 'e',
ctrl: true,
description: 'Archiver note',
handler: async () => {
if (openNote?.id) {
await handleUpdateNote({ archived: !openNote.archived });
}
}
},
{
key: 's',
ctrl: true,
description: 'Sauvegarder (auto)',
handler: () => {
// Note: Auto-save is handled by the editor
showSuccess('Auto-save actif');
}
}
];
// Register keyboard shortcuts
useKeyboardShortcuts(keyboardShortcuts, !showAdmin);
// Pagination for todos // Pagination for todos
const activeTodos = todos.filter(t => !t.completed); const activeTodos = todos.filter(t => !t.completed);
const completedTodos = todos.filter(t => t.completed); const completedTodos = todos.filter(t => t.completed);
@@ -929,6 +1135,41 @@ const Index = () => {
Nouvelle Note Nouvelle Note
</Button> </Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowStatsDashboard(true)}
title="Statistiques"
>
<BarChart3 className="h-5 w-5" />
</Button>
<Button
variant={pomodoroRunning ? "default" : "ghost"}
size="icon"
onClick={() => setShowPomodoroModal(true)}
title="Pomodoro Timer"
className="relative"
>
<Clock className="h-5 w-5" />
{pomodoroRunning && (
<span className="absolute -bottom-1 -right-1 text-[10px] font-mono bg-primary text-primary-foreground px-1 rounded">
{formatPomodoroTime(pomodoroTimeLeft)}
</span>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowKeyboardHelp(true)}
title="Aide - Raccourcis clavier"
>
<HelpCircle className="h-5 w-5" />
</Button>
<ModeToggle />
{user && ( {user && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -1122,20 +1363,51 @@ const Index = () => {
</div> </div>
</div> </div>
<div className="relative w-96"> <div className="flex items-center gap-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" /> <div className="relative w-96">
<Input <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
type="search" <Input
placeholder="Rechercher dans les notes..." type="search"
className="pl-10 h-12 text-base" placeholder="Rechercher dans les notes..."
value={searchQuery} className="pl-10 h-12 text-base"
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<AdvancedSearch
filters={searchFilters}
onFiltersChange={setSearchFilters}
availableTags={availableTags}
/> />
{/* View Mode Toggle */}
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={notesViewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('list')}
className="gap-2"
>
<List className="h-4 w-4" />
Liste
</Button>
<Button
variant={notesViewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('kanban')}
className="gap-2"
>
<LayoutGrid className="h-4 w-4" />
Kanban
</Button>
</div>
</div> </div>
</div> </div>
{/* Notes Grid - 1 column for better visibility */} {/* Notes View - List or Kanban */}
<div className="grid grid-cols-1 gap-4"> {notesViewMode === 'list' ? (
<>
<div className="grid grid-cols-1 gap-4">
{filteredNotes.length > 0 ? ( {filteredNotes.length > 0 ? (
paginatedNotes.map(note => ( paginatedNotes.map(note => (
<Card <Card
@@ -1191,7 +1463,7 @@ const Index = () => {
{note.title || "Sans titre"} {note.title || "Sans titre"}
</h3> </h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3"> <p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"} {note.content ? cleanHtmlContent(note.content) : "Note vide"}
</p> </p>
<div className="flex gap-2 flex-wrap mb-2"> <div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && ( {note.todos && note.todos.length > 0 && (
@@ -1273,6 +1545,41 @@ const Index = () => {
</Button> </Button>
</div> </div>
)} )}
</>
) : (
/* Kanban View */
<KanbanBoard
notes={filteredNotes}
onNoteClick={handleOpenNote}
onCreateNote={(columnId) => {
setTemplateSelectorOpen(true);
// TODO: Set initial tag based on columnId
}}
onMoveNote={async (noteId, newStatus) => {
// Move note to new column by updating its status tag
const note = notes.find(n => n.id === noteId);
if (!note) return;
// Remove old status tags
const oldStatusTags = note.tags?.filter(tag =>
['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase())
) || [];
for (const tag of oldStatusTags) {
if (tag.id && note.id) {
await TagsService.deleteTag(note.id, tag.id);
}
}
// Add new status tag
if (note.id) {
await TagsService.addTag(note.id, newStatus);
await loadNotes(); // Refresh notes
showSuccess(`Note déplacée vers ${newStatus}`);
}
}}
/>
)}
</> </>
) : ( ) : (
/* Open Note Editor */ /* Open Note Editor */
@@ -1281,6 +1588,19 @@ const Index = () => {
<div></div> <div></div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => {
if (openNote) {
downloadNoteAsMarkdown(openNote);
showSuccess("Note exportée en Markdown");
}
}}
title="Exporter en Markdown"
>
<FileDown className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@@ -1313,7 +1633,7 @@ const Index = () => {
<Input <Input
type="text" type="text"
placeholder="Titre de la note" placeholder="Titre de la note"
className="text-2xl font-semibold border-none shadow-none h-auto px-0 focus-visible:ring-0" className="text-2xl font-semibold"
value={openNote?.title || ""} value={openNote?.title || ""}
onChange={(e) => handleUpdateNote({ title: e.target.value })} onChange={(e) => handleUpdateNote({ title: e.target.value })}
/> />
@@ -1342,20 +1662,42 @@ const Index = () => {
<input <input
id="image-upload" id="image-upload"
type="file" type="file"
accept="image/*" accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden" className="hidden"
onChange={async (e) => { onChange={async (e) => {
if (e.target.files && e.target.files[0] && openNote?.id) { if (e.target.files && e.target.files[0] && openNote?.id) {
const originalFile = e.target.files[0];
const originalSize = formatFileSize(originalFile.size);
try { try {
const image = await NotesService.uploadImage(openNote.id, e.target.files[0]); // Compresser l'image avant l'upload
const compressedFile = await compressImage(originalFile);
const compressedSize = formatFileSize(compressedFile.size);
// Afficher un message si l'image a été compressée
if (compressedFile.size < originalFile.size) {
showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`);
}
// Vérifier la taille finale (5MB max après compression)
const maxSize = 5 * 1024 * 1024; // 5MB
if (compressedFile.size > maxSize) {
showError("L'image est trop grande même après compression (max 5MB)");
e.target.value = '';
return;
}
const image = await NotesService.uploadImage(openNote.id, compressedFile);
if (image) { if (image) {
const updatedImages = [...(openNote.images || []), image]; const updatedImages = [...(openNote.images || []), image];
setOpenNote({ ...openNote, images: updatedImages }); setOpenNote({ ...openNote, images: updatedImages });
await handleUpdateNote({ images: updatedImages }); setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, images: updatedImages } : note));
showSuccess("Image ajoutée"); showSuccess("Image ajoutée");
} else {
showError("Erreur lors de l'upload de l'image");
} }
} catch (error) { } catch (error) {
showError("Erreur lors de l'upload de l'image"); showError(error instanceof Error ? error.message : "Erreur lors de l'upload de l'image");
} }
e.target.value = ''; e.target.value = '';
} }
@@ -1409,6 +1751,34 @@ const Index = () => {
</div> </div>
)} )}
{/* Smart Tag Suggestions */}
{openNote && (() => {
const existingTags = noteTags.map(t => t.tag);
const suggestions = getSmartTagSuggestions(openNote, notes, existingTags);
return suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Suggestions de tags
</p>
<div className="flex gap-2 flex-wrap">
{suggestions.map((suggestion) => (
<Badge
key={suggestion}
variant="outline"
className="text-xs px-2 py-1 gap-1 cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => confirmAddTag(suggestion)}
>
<Plus className="h-3 w-3" />
{suggestion}
</Badge>
))}
</div>
</div>
);
})()}
{/* Todos */} {/* Todos */}
{openNote.todos && openNote.todos.length > 0 && ( {openNote.todos && openNote.todos.length > 0 && (
<> <>
@@ -1421,9 +1791,14 @@ const Index = () => {
<Checkbox <Checkbox
checked={todo.completed} checked={todo.completed}
onCheckedChange={async () => { onCheckedChange={async () => {
const updatedTodos = [...(openNote.todos || [])]; if (!todo.id) return;
updatedTodos[index] = { ...todo, completed: !todo.completed }; const success = await NotesService.toggleTodo(todo.id, !todo.completed);
await handleUpdateNote({ todos: updatedTodos }); if (success) {
const updatedTodos = [...(openNote.todos || [])];
updatedTodos[index] = { ...todo, completed: !todo.completed };
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
}
}} }}
/> />
<span className={todo.completed ? "line-through text-muted-foreground flex-1 text-sm" : "flex-1 text-sm"}> <span className={todo.completed ? "line-through text-muted-foreground flex-1 text-sm" : "flex-1 text-sm"}>
@@ -1434,9 +1809,14 @@ const Index = () => {
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={async () => { onClick={async () => {
const updatedTodos = [...(openNote.todos || [])]; if (!todo.id) return;
updatedTodos.splice(index, 1); const success = await NotesService.deleteTodo(todo.id);
await handleUpdateNote({ todos: updatedTodos }); if (success) {
const updatedTodos = openNote.todos.filter((_, i) => i !== index);
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
showSuccess("Tâche supprimée");
}
}} }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
@@ -1598,8 +1978,10 @@ const Index = () => {
)} )}
</div> </div>
{/* Right Column: Todos & RSS Boxes side by side */} {/* Right Column: Todos & RSS */}
<div className="grid grid-cols-2 gap-6"> <div className="space-y-6">
{/* Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Todos Box */} {/* Todos Box */}
<Card className="shadow-lg"> <Card className="shadow-lg">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -1829,6 +2211,7 @@ const Index = () => {
</Card> </Card>
</div> </div>
</div>
</div> </div>
</div> </div>
@@ -2222,9 +2605,13 @@ const Index = () => {
label="Tâche" label="Tâche"
placeholder="Entrez la tâche..." placeholder="Entrez la tâche..."
onConfirm={async (text) => { onConfirm={async (text) => {
if (openNote) { if (openNote?.id) {
const updatedTodos = [...(openNote.todos || []), { text, completed: false }]; const newTodo = await NotesService.addTodo(openNote.id, text);
await handleUpdateNote({ todos: updatedTodos }); if (newTodo) {
const updatedTodos = [...(openNote.todos || []), newTodo];
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
}
} }
}} }}
confirmText="Ajouter" confirmText="Ajouter"
@@ -2240,6 +2627,13 @@ const Index = () => {
confirmText="Ajouter" confirmText="Ajouter"
/> />
{/* Template Selector */}
<TemplateSelector
open={templateSelectorOpen}
onOpenChange={setTemplateSelectorOpen}
onSelectTemplate={handleCreateNoteFromTemplate}
/>
{/* Add Event Modal */} {/* Add Event Modal */}
<Dialog open={addEventModal} onOpenChange={setAddEventModal}> <Dialog open={addEventModal} onOpenChange={setAddEventModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -2598,13 +2992,14 @@ const Index = () => {
{/* AI Chatbox */} {/* AI Chatbox */}
{!chatOpen ? ( {!chatOpen ? (
<Button <Button
className="fixed bottom-4 right-4 h-14 w-14 rounded-full shadow-lg" className="fixed bottom-4 right-4 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all z-40"
onClick={() => setChatOpen(true)} onClick={() => setChatOpen(true)}
title="Assistant IA"
> >
<MessageSquare className="h-6 w-6" /> <MessageSquare className="h-6 w-6" />
</Button> </Button>
) : ( ) : (
<Card className="fixed bottom-4 right-4 w-96 h-[600px] shadow-2xl flex flex-col"> <Card className="fixed bottom-4 right-4 w-96 h-[600px] shadow-2xl flex flex-col z-50">
<CardHeader className="pb-3 border-b"> <CardHeader className="pb-3 border-b">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
@@ -2612,11 +3007,23 @@ const Index = () => {
Assistant IA Assistant IA
</CardTitle> </CardTitle>
<div className="flex gap-2"> <div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setChatExpandedModal(true);
setChatOpen(false);
}}
title="Agrandir"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleClearChat} onClick={handleClearChat}
disabled={chatMessages.length === 0} disabled={chatMessages.length === 0}
title="Effacer la conversation"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -2624,6 +3031,7 @@ const Index = () => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setChatOpen(false)} onClick={() => setChatOpen(false)}
title="Fermer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -2792,6 +3200,154 @@ const Index = () => {
</Card> </Card>
)} )}
{/* Expanded Chat Modal */}
<Dialog open={chatExpandedModal} onOpenChange={setChatExpandedModal}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-3 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-xl flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Assistant IA
</DialogTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={chatMessages.length === 0}
title="Effacer la conversation"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Popover open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelSelectorOpen}
className="flex-1 justify-between text-xs h-9"
disabled={chatLoading || loadingModels}
>
<span className="truncate">
{loadingModels ? (
"Chargement des modèles..."
) : openRouterModels.length === 0 ? (
user?.is_admin ? "Configurer clé API OpenRouter" : "Aucun modèle IA configuré"
) : selectedModel ? (
openRouterModels.find(m => m.id === selectedModel)?.name || selectedModel
) : (
"Sélectionner un modèle"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[500px] p-0">
<Command>
<CommandInput placeholder="Rechercher un modèle..." />
<CommandEmpty>Aucun modèle trouvé.</CommandEmpty>
<CommandList>
<CommandGroup>
{openRouterModels.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={(currentValue) => {
setSelectedModel(currentValue);
setModelSelectorOpen(false);
}}
>
<Check
className={`mr-2 h-4 w-4 ${selectedModel === model.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground">{model.id}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Bienvenue dans l'Assistant IA</h3>
<p className="text-sm text-muted-foreground max-w-md">
Posez vos questions et obtenez des réponses intelligentes grâce à l'IA.
{openRouterModels.length === 0 && user?.is_admin && (
<span className="block mt-2">Configurez une clé API OpenRouter dans les paramètres.</span>
)}
</p>
</div>
) : (
chatMessages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-3 ${msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
</div>
))
)}
{chatLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-3">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce delay-100">●</span>
<span className="animate-bounce delay-200">●</span>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
<div className="border-t px-6 py-4">
<form
onSubmit={(e) => {
e.preventDefault();
handleSendMessage();
}}
className="flex gap-2"
>
<Input
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Posez une question..."
disabled={chatLoading || !selectedModel}
className="flex-1"
/>
<Button
type="submit"
size="icon"
disabled={chatLoading || !chatInput.trim() || !selectedModel}
>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</DialogContent>
</Dialog>
{/* Image modal for fullscreen view */} {/* Image modal for fullscreen view */}
{selectedImage && ( {selectedImage && (
<div <div
@@ -2816,6 +3372,41 @@ const Index = () => {
</div> </div>
</div> </div>
)} )}
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
open={showKeyboardHelp}
onOpenChange={setShowKeyboardHelp}
shortcuts={keyboardShortcuts}
/>
{/* Statistics Modal */}
<StatsModal
open={showStatsDashboard}
onOpenChange={setShowStatsDashboard}
notes={notes}
todos={todos}
/>
{/* Pomodoro Timer - Always mounted in hidden div to keep timer running */}
<div style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
<PomodoroTimer onStateChange={handlePomodoroStateChange} />
</div>
{/* Pomodoro Timer Modal - Shows timer controls when opened */}
<Dialog open={showPomodoroModal} onOpenChange={setShowPomodoroModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl">Pomodoro Timer</DialogTitle>
</DialogHeader>
<div className="mt-4">
<PomodoroTimer onStateChange={handlePomodoroStateChange} />
</div>
</DialogContent>
</Dialog>
{/* Quick Capture Widget */}
<QuickCaptureWidget onNoteCaptured={loadNotes} />
</div> </div>
); );
}; };
+65 -18
View File
@@ -17,7 +17,8 @@ import {
Paperclip, Paperclip,
Tag as TagIcon, Tag as TagIcon,
X, X,
MoreVertical MoreVertical,
FileDown
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -27,6 +28,8 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import NotesService, { Note } from "@/services/NotesService"; import NotesService, { Note } from "@/services/NotesService";
import { showError, showSuccess } from "@/utils/toast"; import { showError, showSuccess } from "@/utils/toast";
import { compressImage, formatFileSize } from "@/utils/imageCompression";
import { downloadNoteAsMarkdown } from "@/utils/markdownExport";
export default function NoteDetailPage() { export default function NoteDetailPage() {
const { id } = useParams(); const { id } = useParams();
@@ -80,41 +83,76 @@ export default function NoteDetailPage() {
}; };
const handleAddTodo = async () => { const handleAddTodo = async () => {
if (!note || !todoText.trim()) return; if (!note?.id || !todoText.trim()) return;
const newTodo = { text: todoText, completed: false }; const newTodo = await NotesService.addTodo(note.id, todoText.trim());
const updatedTodos = [...(note.todos || []), newTodo]; if (newTodo) {
await updateNote({ todos: updatedTodos }); const updatedTodos = [...(note.todos || []), newTodo];
setTodoText(""); setNote({ ...note, todos: updatedTodos });
setAddTodoModal(false); setTodoText("");
showSuccess("Tâche ajoutée"); setAddTodoModal(false);
}
}; };
const handleToggleTodo = async (index: number) => { const handleToggleTodo = async (index: number) => {
if (!note?.todos) return; if (!note?.todos) return;
const updatedTodos = [...note.todos]; const todo = note.todos[index];
updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed }; if (!todo.id) return;
await updateNote({ todos: updatedTodos });
const success = await NotesService.toggleTodo(todo.id, !todo.completed);
if (success) {
const updatedTodos = [...note.todos];
updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed };
setNote({ ...note, todos: updatedTodos });
}
}; };
const handleDeleteTodo = async (index: number) => { const handleDeleteTodo = async (index: number) => {
if (!note?.todos) return; if (!note?.todos) return;
const updatedTodos = [...note.todos]; const todo = note.todos[index];
updatedTodos.splice(index, 1); if (!todo.id) return;
await updateNote({ todos: updatedTodos });
showSuccess("Tâche supprimée"); const success = await NotesService.deleteTodo(todo.id);
if (success) {
const updatedTodos = note.todos.filter((_, i) => i !== index);
setNote({ ...note, todos: updatedTodos });
showSuccess("Tâche supprimée");
}
}; };
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !e.target.files[0] || !note?.id) return; if (!e.target.files || !e.target.files[0] || !note?.id) return;
const originalFile = e.target.files[0];
const originalSize = formatFileSize(originalFile.size);
try { try {
const image = await NotesService.uploadImage(note.id, e.target.files[0]); // Compresser l'image avant l'upload
const compressedFile = await compressImage(originalFile);
const compressedSize = formatFileSize(compressedFile.size);
// Afficher un message si l'image a été compressée
if (compressedFile.size < originalFile.size) {
showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`);
}
// Vérifier la taille finale (5MB max après compression)
const maxSize = 5 * 1024 * 1024; // 5MB
if (compressedFile.size > maxSize) {
showError("L'image est trop grande même après compression (max 5MB)");
e.target.value = '';
return;
}
const image = await NotesService.uploadImage(note.id, compressedFile);
if (image) { if (image) {
const updatedImages = [...(note.images || []), image]; const updatedImages = [...(note.images || []), image];
setNote({ ...note, images: updatedImages }); setNote({ ...note, images: updatedImages });
showSuccess("Image ajoutée"); showSuccess("Image ajoutée");
} else {
showError("Erreur lors de l'upload de l'image");
} }
} catch (error) { } catch (error) {
showError("Erreur lors de l'upload"); showError(error instanceof Error ? error.message : "Erreur lors de l'upload");
} }
e.target.value = ''; e.target.value = '';
}; };
@@ -163,6 +201,15 @@ export default function NoteDetailPage() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
if (note) {
downloadNoteAsMarkdown(note);
showSuccess("Note exportée en Markdown");
}
}}>
<FileDown className="h-4 w-4 mr-2" />
Exporter en Markdown
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => { <DropdownMenuItem onClick={async () => {
if (note?.id) { if (note?.id) {
const newArchived = !note.archived; const newArchived = !note.archived;
@@ -216,7 +263,7 @@ export default function NoteDetailPage() {
<input <input
id="image-upload" id="image-upload"
type="file" type="file"
accept="image/*" accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden" className="hidden"
onChange={handleImageUpload} onChange={handleImageUpload}
/> />
+255 -114
View File
@@ -5,17 +5,30 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { MobileFAB } from "@/components/mobile/MobileFAB"; import { MobileFAB } from "@/components/mobile/MobileFAB";
import { MobileCard } from "@/components/mobile/MobileCard"; import { MobileCard } from "@/components/mobile/MobileCard";
import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText } from "lucide-react"; import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText, LayoutGrid, List } from "lucide-react";
import NotesService, { Note } from "@/services/NotesService"; import NotesService, { Note } from "@/services/NotesService";
import { showError, showSuccess } from "@/utils/toast"; import { showError, showSuccess } from "@/utils/toast";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
import { KanbanBoard } from "@/components/KanbanBoard";
import TagsService from "@/services/TagsService";
const NOTES_PER_PAGE = 10; const NOTES_PER_PAGE = 10;
export default function NotesPage() { export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const navigate = useNavigate(); const navigate = useNavigate();
const loadNotes = async () => { const loadNotes = async () => {
@@ -31,13 +44,71 @@ export default function NotesPage() {
loadNotes(); loadNotes();
}, [showArchived]); }, [showArchived]);
// Extract unique tags from all notes
const availableTags: TagOption[] = Array.from(
new Map(
notes.flatMap(note =>
(note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
)
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes const filteredNotes = notes
.filter(note => { .filter(note => {
const matchArchived = showArchived ? note.archived : !note.archived; // Archived filter
const matchSearch = searchQuery === "" || if (showArchived ? !note.archived : note.archived) return false;
note.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content?.toLowerCase().includes(searchQuery.toLowerCase()); // Text search
return matchArchived && matchSearch; if (searchQuery !== "" &&
!(note.title?.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content?.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
}) })
.sort((a, b) => { .sort((a, b) => {
if (a.priority && !b.priority) return -1; if (a.priority && !b.priority) return -1;
@@ -51,7 +122,10 @@ export default function NotesPage() {
const handleCreateNote = async () => { const handleCreateNote = async () => {
try { try {
const newNote = await NotesService.createNote("Nouvelle note", ""); const newNote = await NotesService.createNote("Nouvelle note", "");
navigate(`/mobile/notes/${newNote.id}`); if (newNote) {
showSuccess("Note créée");
navigate(`/mobile/notes/${newNote.id}`);
}
} catch (error) { } catch (error) {
showError("Erreur lors de la création de la note"); showError("Erreur lors de la création de la note");
} }
@@ -89,6 +163,16 @@ export default function NotesPage() {
/> />
</div> </div>
{/* Advanced Filters */}
<AdvancedSearch
filters={searchFilters}
onFiltersChange={(newFilters) => {
setSearchFilters(newFilters);
setPage(0);
}}
availableTags={availableTags}
/>
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -114,124 +198,181 @@ export default function NotesPage() {
Archivées ({notes.filter(n => n.archived).length}) Archivées ({notes.filter(n => n.archived).length})
</Button> </Button>
</div> </div>
{/* View Mode Toggle */}
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="flex-1 gap-2"
>
<List className="h-4 w-4" />
Liste
</Button>
<Button
variant={viewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('kanban')}
className="flex-1 gap-2"
>
<LayoutGrid className="h-4 w-4" />
Kanban
</Button>
</div>
</div> </div>
{/* Notes List */} {/* Notes View - List or Kanban */}
<div className="p-4 space-y-3"> {viewMode === 'list' ? (
{paginatedNotes.length > 0 ? ( <div className="p-4 space-y-3">
paginatedNotes.map(note => ( {paginatedNotes.length > 0 ? (
<MobileCard paginatedNotes.map(note => (
key={note.id} <MobileCard
onClick={() => navigate(`/mobile/notes/${note.id}`)} key={note.id}
> onClick={() => navigate(`/mobile/notes/${note.id}`)}
<div className="relative"> >
{/* Priority Button */} <div className="relative">
<Button {/* Priority Button */}
variant="ghost" <Button
size="icon" variant="ghost"
className={`absolute -top-1 -right-1 h-8 w-8 ${note.priority ? 'opacity-100' : 'opacity-50' size="icon"
}`} className={`absolute -top-1 -right-1 h-8 w-8 ${note.priority ? 'opacity-100' : 'opacity-50'
onClick={(e) => handleTogglePriority(note, e)} }`}
> onClick={(e) => handleTogglePriority(note, e)}
<span className={`text-lg font-bold ${note.priority ? 'text-red-500' : 'text-muted-foreground'}`}> >
! <span className={`text-lg font-bold ${note.priority ? 'text-red-500' : 'text-muted-foreground'}`}>
</span> !
</Button> </span>
</Button>
<h3 className="font-semibold mb-2 line-clamp-1 pr-8"> <h3 className="font-semibold mb-2 line-clamp-1 pr-8">
{note.title || "Sans titre"} {note.title || "Sans titre"}
</h3> </h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3"> <p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"} {note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
</p> </p>
{/* Badges */} {/* Badges */}
<div className="flex gap-2 flex-wrap mb-2"> <div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && ( {note.todos && note.todos.length > 0 && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<CheckSquare className="h-3 w-3 mr-1" /> <CheckSquare className="h-3 w-3 mr-1" />
{note.todos.length} {note.todos.length}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary" className="text-xs">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
<TagIcon className="h-3 w-3 mr-1" />
{tag.name}
</Badge> </Badge>
)) )}
)} {note.images && note.images.length > 0 && (
{note.tags && note.tags.length > 2 && ( <Badge variant="secondary" className="text-xs">
<Badge variant="outline" className="text-xs"> <ImageIcon className="h-3 w-3 mr-1" />
+{note.tags.length - 2} {note.images.length}
</Badge> </Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
<TagIcon className="h-3 w-3 mr-1" />
{tag.name}
</Badge>
))
)}
{note.tags && note.tags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{note.tags.length - 2}
</Badge>
)}
</div>
{/* Date */}
{note.updated_at && (
<p className="text-xs text-muted-foreground">
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</p>
)} )}
</div> </div>
</MobileCard>
))
) : (
<div className="text-center py-16">
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNote} size="lg">
<Plus className="h-5 w-5 mr-2" />
Créer votre première note
</Button>
)}
</div>
)}
{/* Date */} {/* Pagination */}
{note.updated_at && ( {totalPages > 1 && (
<p className="text-xs text-muted-foreground"> <div className="flex items-center justify-center gap-4 px-4 pb-4">
{new Date(note.updated_at).toLocaleDateString('fr-FR', { <Button
day: '2-digit', variant="ghost"
month: '2-digit', size="sm"
year: 'numeric' onClick={() => setPage(p => Math.max(0, p - 1))}
})} disabled={page === 0}
</p> >
)} ‹ Précédent
</div>
</MobileCard>
))
) : (
<div className="text-center py-16">
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNote} size="lg">
<Plus className="h-5 w-5 mr-2" />
Créer votre première note
</Button> </Button>
)} <span className="text-sm text-muted-foreground">
</div> {page + 1} / {totalPages}
)} </span>
</div> <Button
variant="ghost"
size="sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
>
Suivant ›
</Button>
</div>
)}
</div>
) : (
/* Kanban View */
<div className="p-2 overflow-x-auto">
<KanbanBoard
notes={filteredNotes}
onNoteClick={(note) => navigate(`/mobile/notes/${note.id}`)}
onCreateNote={(columnId) => {
handleCreateNote();
}}
onMoveNote={async (noteId, newStatus) => {
const note = notes.find(n => n.id === noteId);
if (!note) return;
{/* Pagination */} // Remove old status tags
{totalPages > 1 && ( const oldStatusTags = note.tags?.filter(tag =>
<div className="flex items-center justify-center gap-4 px-4 pb-4"> ['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase())
<Button ) || [];
variant="ghost"
size="sm" for (const tag of oldStatusTags) {
onClick={() => setPage(p => Math.max(0, p - 1))} if (tag.id && note.id) {
disabled={page === 0} await TagsService.deleteTag(note.id, tag.id);
> }
‹ Précédent }
</Button>
<span className="text-sm text-muted-foreground"> // Add new status tag
{page + 1} / {totalPages} if (note.id) {
</span> await TagsService.addTag(note.id, newStatus);
<Button await loadNotes();
variant="ghost" showSuccess(`Note déplacée vers ${newStatus}`);
size="sm" }
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} }}
disabled={page === totalPages - 1} />
>
Suivant ›
</Button>
</div> </div>
)} )}
+24 -14
View File
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { import {
User, User,
LogOut, LogOut,
@@ -53,10 +54,6 @@ export default function SettingsPage() {
} }
}; };
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return ( return (
<div className="p-4 space-y-6 pb-24"> <div className="p-4 space-y-6 pb-24">
{/* Appearance */} {/* Appearance */}
@@ -68,16 +65,29 @@ export default function SettingsPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between"> <RadioGroup value={theme} onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}>
<Label htmlFor="dark-mode" className="text-base"> <div className="flex items-center space-x-2 mb-3">
Mode sombre <RadioGroupItem value="light" id="light" />
</Label> <Label htmlFor="light" className="text-base font-normal cursor-pointer flex items-center gap-2">
<Switch <Sun className="h-4 w-4" />
id="dark-mode" Clair
checked={theme === "dark"} </Label>
onCheckedChange={toggleTheme} </div>
/> <div className="flex items-center space-x-2 mb-3">
</div> <RadioGroupItem value="dark" id="dark" />
<Label htmlFor="dark" className="text-base font-normal cursor-pointer flex items-center gap-2">
<Moon className="h-4 w-4" />
Sombre
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="system" id="system" />
<Label htmlFor="system" className="text-base font-normal cursor-pointer flex items-center gap-2">
<User className="h-4 w-4" />
Système
</Label>
</div>
</RadioGroup>
</CardContent> </CardContent>
</Card> </Card>
+89 -2
View File
@@ -6,6 +6,9 @@ interface Todo {
text: string; text: string;
completed: boolean; completed: boolean;
position?: number; position?: number;
due_date?: string;
parent_id?: number;
level?: number;
} }
interface Image { interface Image {
@@ -36,9 +39,9 @@ export interface Note {
} }
class NotesService { class NotesService {
async getNotes(): Promise<Note[]> { async getNotes(archived: boolean = false): Promise<Note[]> {
try { try {
const response = await fetch("/api/notes", { const response = await fetch(`/api/notes?archived=${archived}`, {
headers: AuthService.getHeaders() headers: AuthService.getHeaders()
}); });
@@ -219,6 +222,90 @@ class NotesService {
return false; return false;
} }
} }
// Todo management methods
async addTodo(noteId: number, text: string, dueDate?: string): Promise<Todo | null> {
try {
const payload: any = { text };
if (dueDate) {
payload.due_date = dueDate;
}
const response = await fetch(`/api/notes/${noteId}/todos`, {
method: "POST",
headers: AuthService.getHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error("Erreur lors de l'ajout de la tâche");
}
const result = await response.json();
showSuccess("Tâche ajoutée");
return result.todo || result;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return null;
}
}
async updateTodo(todoId: number, updates: Partial<Todo>): Promise<Todo | null> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "PUT",
headers: AuthService.getHeaders(),
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error("Erreur lors de la mise à jour de la tâche");
}
const result = await response.json();
return result.todo || result;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return null;
}
}
async toggleTodo(todoId: number, completed: boolean): Promise<boolean> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "PUT",
headers: AuthService.getHeaders(),
body: JSON.stringify({ completed })
});
if (!response.ok) {
throw new Error("Erreur lors de la mise à jour de la tâche");
}
return true;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return false;
}
}
async deleteTodo(todoId: number): Promise<boolean> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "DELETE",
headers: AuthService.getHeaders()
});
if (!response.ok) {
throw new Error("Erreur lors de la suppression de la tâche");
}
return true;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return false;
}
}
} }
export default new NotesService(); export default new NotesService();
+33 -4
View File
@@ -60,6 +60,8 @@ class OpenRouterService {
*/ */
async sendMessage(model: string, messages: Array<{ role: string; content: string }>): Promise<string> { async sendMessage(model: string, messages: Array<{ role: string; content: string }>): Promise<string> {
try { try {
console.log(`[OpenRouter] Envoi requête - model: ${model}, messages: ${messages.length}`);
const response = await fetch('/api/openrouter/chat', { const response = await fetch('/api/openrouter/chat', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -69,15 +71,42 @@ class OpenRouterService {
body: JSON.stringify({ model, messages }) body: JSON.stringify({ model, messages })
}); });
console.log(`[OpenRouter] Réponse reçue - status: ${response.status}`);
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Erreur inconnue' })); const errorData = await response.json().catch(() => ({ error: 'Erreur inconnue' }));
throw new Error(error.error || 'Erreur lors de la communication avec l\'IA'); const errorMessage = errorData.error || `Erreur HTTP ${response.status}`;
console.error('[OpenRouter] Erreur:', errorMessage);
// Messages d'erreur plus clairs selon le code HTTP
if (response.status === 400) {
throw new Error(errorMessage);
} else if (response.status === 401) {
throw new Error('Clé API invalide. Vérifiez la configuration dans Paramètres.');
} else if (response.status === 402) {
throw new Error('Crédits OpenRouter épuisés. Rechargez votre compte.');
} else if (response.status === 429) {
throw new Error('Trop de requêtes. Attendez quelques instants.');
} else if (response.status === 502 || response.status === 503) {
throw new Error('Service OpenRouter temporairement indisponible. Réessayez dans quelques instants.');
} else {
throw new Error(errorMessage);
}
} }
const data = await response.json(); const data = await response.json();
return data.choices?.[0]?.message?.content || 'Pas de réponse'; const content = data.choices?.[0]?.message?.content;
if (!content) {
console.error('[OpenRouter] Réponse vide:', data);
throw new Error('Aucune réponse générée par le modèle');
}
console.log(`[OpenRouter] Succès - ${content.length} caractères`);
return content;
} catch (error: any) { } catch (error: any) {
console.error('Erreur lors de l\'envoi du message:', error); console.error('[OpenRouter] Erreur exception:', error);
throw error; throw error;
} }
} }
+6 -2
View File
@@ -33,9 +33,13 @@ class TodosService {
} }
} }
async createTodo(text: string, priority?: boolean): Promise<Todo> { async createTodo(text: string, priority?: boolean, dueDate?: string): Promise<Todo> {
try { try {
const response = await axios.post(this.baseURL, { text, priority: priority || false, completed: false }, this.getAuthHeader()); const payload: any = { text, priority: priority || false, completed: false };
if (dueDate) {
payload.due_date = dueDate;
}
const response = await axios.post(this.baseURL, payload, this.getAuthHeader());
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error creating todo:', error); console.error('Error creating todo:', error);
+163
View File
@@ -0,0 +1,163 @@
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
export interface BackupData {
version: string;
exportDate: string;
notes: Note[];
globalTodos: Todo[];
settings?: Record<string, any>;
}
/**
* Export all app data to JSON
*/
export async function exportToJSON(
notes: Note[],
globalTodos: Todo[],
settings?: Record<string, any>
): Promise<string> {
const backup: BackupData = {
version: "1.0.0",
exportDate: new Date().toISOString(),
notes,
globalTodos,
settings
};
return JSON.stringify(backup, null, 2);
}
/**
* Download backup as JSON file
*/
export function downloadBackup(jsonData: string, filename?: string): void {
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const timestamp = new Date().toISOString().split('T')[0];
link.href = url;
link.download = filename || `noteflow-backup-${timestamp}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Parse and validate backup JSON
*/
export function parseBackupJSON(jsonString: string): BackupData | null {
try {
const data = JSON.parse(jsonString);
// Validate structure
if (!data.version || !data.exportDate || !Array.isArray(data.notes)) {
throw new Error('Format de backup invalide');
}
return data as BackupData;
} catch (error) {
console.error('Error parsing backup:', error);
return null;
}
}
/**
* Read file as text
*/
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => {
reject(new Error('File reading error'));
};
reader.readAsText(file);
});
}
/**
* Export notes to CSV format
*/
export function exportNotesToCSV(notes: Note[]): string {
const headers = ['ID', 'Title', 'Created', 'Updated', 'Archived', 'Priority', 'Tags', 'Todos Count'];
const rows = notes.map(note => [
note.id || '',
`"${(note.title || '').replace(/"/g, '""')}"`,
note.created_at || '',
note.updated_at || '',
note.archived ? 'Yes' : 'No',
note.priority ? 'Yes' : 'No',
`"${(note.tags || []).map(t => t.name).join(', ')}"`,
(note.todos || []).length
]);
const csv = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
return csv;
}
/**
* Download CSV file
*/
export function downloadCSV(csvData: string, filename?: string): void {
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const timestamp = new Date().toISOString().split('T')[0];
link.href = url;
link.download = filename || `noteflow-export-${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Get backup statistics
*/
export function getBackupStats(backup: BackupData): {
notesCount: number;
todosCount: number;
tagsCount: number;
imagesCount: number;
filesCount: number;
} {
const allTags = new Set<string>();
let imagesCount = 0;
let filesCount = 0;
backup.notes.forEach(note => {
note.tags?.forEach(tag => allTags.add(tag.name));
imagesCount += (note.images?.length || 0);
filesCount += (note.files?.length || 0);
});
return {
notesCount: backup.notes.length,
todosCount: backup.globalTodos?.length || 0,
tagsCount: allTags.size,
imagesCount,
filesCount
};
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Compresse une image en la redimensionnant et en réduisant sa qualité
* @param file - Le fichier image à compresser
* @param maxWidth - Largeur maximale (défaut: 1920px)
* @param maxHeight - Hauteur maximale (défaut: 1080px)
* @param quality - Qualité de compression JPEG (0-1, défaut: 0.8)
* @returns Promise<File> - Le fichier image compressé
*/
export async function compressImage(
file: File,
maxWidth: number = 1920,
maxHeight: number = 1080,
quality: number = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
// Vérifier que c'est bien une image
if (!file.type.startsWith('image/')) {
reject(new Error('Le fichier n\'est pas une image'));
return;
}
// Si l'image est déjà petite (< 1MB), on la retourne telle quelle
const oneMB = 1024 * 1024;
if (file.size < oneMB) {
resolve(file);
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier'));
reader.onload = (e) => {
const img = new Image();
img.onerror = () => reject(new Error('Erreur lors du chargement de l\'image'));
img.onload = () => {
try {
// Calculer les nouvelles dimensions en gardant le ratio
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
// Créer un canvas pour redimensionner l'image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Impossible de créer le contexte canvas'));
return;
}
// Dessiner l'image redimensionnée
ctx.drawImage(img, 0, 0, width, height);
// Déterminer le type de sortie et la qualité
let outputType = 'image/jpeg';
let outputQuality = quality;
// Si c'est un PNG avec transparence, on garde le format PNG
if (file.type === 'image/png') {
// Vérifier si l'image a de la transparence
const imageData = ctx.getImageData(0, 0, width, height);
const hasTransparency = imageData.data.some((_, i) => i % 4 === 3 && imageData.data[i] < 255);
if (hasTransparency) {
outputType = 'image/png';
outputQuality = 0.9; // PNG utilise une compression sans perte
}
}
// Convertir le canvas en Blob
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Erreur lors de la conversion de l\'image'));
return;
}
// Si l'image compressée fait toujours plus de 2MB, on réduit encore la qualité
const twoMB = 2 * 1024 * 1024;
if (blob.size > twoMB && outputType === 'image/jpeg' && outputQuality > 0.5) {
// Réessayer avec une qualité plus faible
canvas.toBlob(
(blob2) => {
if (!blob2) {
reject(new Error('Erreur lors de la deuxième compression'));
return;
}
// Créer un nouveau fichier avec le blob compressé
const compressedFile = new File(
[blob2],
file.name.replace(/\.[^/.]+$/, '.jpg'), // Remplacer l'extension par .jpg
{ type: 'image/jpeg' }
);
resolve(compressedFile);
},
'image/jpeg',
0.6 // Qualité réduite
);
return;
}
// Créer un nouveau fichier avec le blob compressé
const extension = outputType === 'image/png' ? '.png' : '.jpg';
const compressedFile = new File(
[blob],
file.name.replace(/\.[^/.]+$/, extension),
{ type: outputType }
);
resolve(compressedFile);
},
outputType,
outputQuality
);
} catch (error) {
reject(error);
}
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
}
/**
* Formate la taille d'un fichier en MB ou KB
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
+241
View File
@@ -0,0 +1,241 @@
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
/**
* Convertit le contenu HTML d'une note en Markdown
*/
function htmlToMarkdown(html: string): string {
let markdown = html;
// Enlever les balises HTML simples et les convertir en Markdown
markdown = markdown
// Headings
.replace(/<h1[^>]*>(.*?)<\/h1>/g, '# $1\n')
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '### $1\n')
// Bold and italic
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<b[^>]*>(.*?)<\/b>/g, '**$1**')
.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*')
.replace(/<i[^>]*>(.*?)<\/i>/g, '*$1*')
// Lists
.replace(/<ul[^>]*>/g, '\n')
.replace(/<\/ul>/g, '\n')
.replace(/<ol[^>]*>/g, '\n')
.replace(/<\/ol>/g, '\n')
.replace(/<li[^>]*>(.*?)<\/li>/g, '- $1\n')
// Paragraphs and breaks
.replace(/<p[^>]*>/g, '\n')
.replace(/<\/p>/g, '\n')
.replace(/<br\s*\/?>/g, '\n')
// Links
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/g, '[$2]($1)')
// Code
.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`')
.replace(/<pre[^>]*>(.*?)<\/pre>/g, '```\n$1\n```')
// Remove remaining HTML tags
.replace(/<[^>]*>/g, '')
// Decode HTML entities
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Clean up extra whitespace
.replace(/\n{3,}/g, '\n\n')
.trim();
return markdown;
}
/**
* Exporte une note au format Markdown
*/
export function exportNoteAsMarkdown(note: Note): string {
let markdown = '';
// Title
markdown += `# ${note.title || 'Sans titre'}\n\n`;
// Metadata
if (note.created_at) {
const date = new Date(note.created_at);
markdown += `*Créée le: ${date.toLocaleDateString('fr-FR')} à ${date.toLocaleTimeString('fr-FR')}*\n\n`;
}
// Tags
if (note.tags && note.tags.length > 0) {
markdown += `**Tags:** ${note.tags.map(tag => `#${tag.name}`).join(', ')}\n\n`;
}
// Priority
if (note.priority) {
markdown += `⭐ **Prioritaire**\n\n`;
}
// Separator
markdown += '---\n\n';
// Content
if (note.content) {
markdown += htmlToMarkdown(note.content);
markdown += '\n\n';
}
// Todos
if (note.todos && note.todos.length > 0) {
markdown += '## Tâches\n\n';
note.todos.forEach(todo => {
const checkbox = todo.completed ? '[x]' : '[ ]';
markdown += `- ${checkbox} ${todo.text}\n`;
});
markdown += '\n';
}
// Images
if (note.images && note.images.length > 0) {
markdown += '## Images\n\n';
note.images.forEach(image => {
markdown += `![${image.original_name || 'Image'}](/uploads/images/${image.filename})\n`;
});
markdown += '\n';
}
return markdown;
}
/**
* Télécharge une note au format Markdown
*/
export function downloadNoteAsMarkdown(note: Note): void {
const markdown = exportNoteAsMarkdown(note);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${note.title || 'note'}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Exporte toutes les notes au format Markdown (un fichier par note dans un zip)
*/
export function exportAllNotesAsMarkdown(notes: Note[]): string {
let combined = `# Mes Notes\n\n`;
combined += `*Exporté le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}*\n\n`;
combined += `---\n\n`;
notes.forEach((note, index) => {
if (index > 0) {
combined += `\n\n---\n\n`;
}
combined += exportNoteAsMarkdown(note);
});
return combined;
}
/**
* Télécharge toutes les notes dans un fichier Markdown
*/
export function downloadAllNotesAsMarkdown(notes: Note[]): void {
const markdown = exportAllNotesAsMarkdown(notes);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const filename = `noteflow-export-${new Date().toISOString().split('T')[0]}.md`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Exporte un todo au format Markdown
*/
export function exportTodoAsMarkdown(todo: Todo): string {
const checkbox = todo.completed ? '[x]' : '[ ]';
let markdown = `- ${checkbox} ${todo.text}`;
if (todo.priority) {
markdown += ' ⭐';
}
if (todo.in_progress) {
markdown += ' 🔄';
}
return markdown;
}
/**
* Exporte tous les todos au format Markdown
*/
export function exportTodosAsMarkdown(todos: Todo[]): string {
let markdown = '# Mes Tâches\n\n';
markdown += `*Exporté le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}*\n\n`;
// Tâches prioritaires
const priorityTodos = todos.filter(t => t.priority && !t.completed);
if (priorityTodos.length > 0) {
markdown += '## ⭐ Prioritaires\n\n';
priorityTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches en cours
const inProgressTodos = todos.filter(t => t.in_progress && !t.completed);
if (inProgressTodos.length > 0) {
markdown += '## 🔄 En cours\n\n';
inProgressTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches en attente
const pendingTodos = todos.filter(t => !t.completed && !t.priority && !t.in_progress);
if (pendingTodos.length > 0) {
markdown += '## 📋 En attente\n\n';
pendingTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches terminées
const completedTodos = todos.filter(t => t.completed);
if (completedTodos.length > 0) {
markdown += '## ✅ Terminées\n\n';
completedTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
}
return markdown;
}
/**
* Télécharge tous les todos au format Markdown
*/
export function downloadTodosAsMarkdown(todos: Todo[]): void {
const markdown = exportTodosAsMarkdown(todos);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const filename = `noteflow-todos-${new Date().toISOString().split('T')[0]}.md`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
+187
View File
@@ -0,0 +1,187 @@
import { Note } from "@/services/NotesService";
/**
* Pattern to match wiki-style links: [[Note Title]]
*/
const WIKI_LINK_PATTERN = /\[\[([^\]]+)\]\]/g;
/**
* Extract all note links from content
*/
export function extractNoteLinks(content: string): string[] {
if (!content) return [];
const matches = content.matchAll(WIKI_LINK_PATTERN);
const links: string[] = [];
for (const match of matches) {
if (match[1]) {
links.push(match[1].trim());
}
}
return [...new Set(links)]; // Remove duplicates
}
/**
* Find notes that match the given link text
*/
export function findLinkedNotes(linkText: string, allNotes: Note[]): Note[] {
const searchText = linkText.toLowerCase().trim();
return allNotes.filter(note => {
if (!note.title) return false;
return note.title.toLowerCase().includes(searchText);
});
}
/**
* Convert wiki-style links to HTML clickable links
*/
export function convertLinksToHTML(
content: string,
allNotes: Note[],
onLinkClick: (noteId: number) => void
): string {
if (!content) return '';
return content.replace(WIKI_LINK_PATTERN, (match, linkText) => {
const trimmedText = linkText.trim();
const matchedNotes = findLinkedNotes(trimmedText, allNotes);
if (matchedNotes.length > 0) {
// Found matching note - create clickable link
const note = matchedNotes[0];
return `<a
href="#"
class="note-link"
data-note-id="${note.id}"
style="color: #3b82f6; text-decoration: underline; cursor: pointer;"
title="Ouvrir: ${note.title}"
>${trimmedText}</a>`;
} else {
// No matching note - show as grayed out
return `<span
class="note-link-broken"
style="color: #9ca3af; text-decoration: line-through;"
title="Note introuvable: ${trimmedText}"
>[[${trimmedText}]]</span>`;
}
});
}
/**
* Get backlinks for a note (notes that link to this one)
*/
export function getBacklinks(note: Note, allNotes: Note[]): Note[] {
if (!note.title) return [];
const backlinks: Note[] = [];
const noteTitle = note.title.toLowerCase();
for (const otherNote of allNotes) {
if (otherNote.id === note.id || !otherNote.content) continue;
const links = extractNoteLinks(otherNote.content);
const hasLink = links.some(link =>
link.toLowerCase().includes(noteTitle) ||
noteTitle.includes(link.toLowerCase())
);
if (hasLink) {
backlinks.push(otherNote);
}
}
return backlinks;
}
/**
* Suggest note titles for autocomplete when typing [[
*/
export function suggestNoteTitles(
partialText: string,
allNotes: Note[],
limit = 10
): Note[] {
if (!partialText) {
// Return recent notes when no search text
return allNotes
.filter(note => note.title && !note.archived)
.sort((a, b) =>
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
)
.slice(0, limit);
}
const searchText = partialText.toLowerCase().trim();
// Find notes matching the partial text
const matches = allNotes
.filter(note => {
if (!note.title || note.archived) return false;
return note.title.toLowerCase().includes(searchText);
})
.sort((a, b) => {
// Prioritize exact matches and more recent notes
const aTitle = a.title?.toLowerCase() || '';
const bTitle = b.title?.toLowerCase() || '';
const aExact = aTitle === searchText ? 1 : 0;
const bExact = bTitle === searchText ? 1 : 0;
if (aExact !== bExact) return bExact - aExact;
const aStarts = aTitle.startsWith(searchText) ? 1 : 0;
const bStarts = bTitle.startsWith(searchText) ? 1 : 0;
if (aStarts !== bStarts) return bStarts - aStarts;
// Fall back to recency
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime();
});
return matches.slice(0, limit);
}
/**
* Parse content and detect cursor position for link autocomplete
* Returns the partial link text if cursor is inside a [[...]] pattern
*/
export function detectLinkAtCursor(content: string, cursorPosition: number): {
isInLink: boolean;
linkText: string;
startPos: number;
endPos: number;
} | null {
// Find all [[ positions before cursor
const beforeCursor = content.substring(0, cursorPosition);
const lastOpenBracket = beforeCursor.lastIndexOf('[[');
if (lastOpenBracket === -1) {
return null; // Not in a link
}
// Check if there's a closing ]] between lastOpenBracket and cursor
const betweenBrackets = content.substring(lastOpenBracket, cursorPosition);
if (betweenBrackets.includes(']]')) {
return null; // Link is already closed
}
// Find the closing ]] after cursor (if any)
const afterCursor = content.substring(cursorPosition);
const nextCloseBracket = afterCursor.indexOf(']]');
const endPos = nextCloseBracket !== -1
? cursorPosition + nextCloseBracket + 2
: cursorPosition;
// Extract the link text
const linkText = content.substring(lastOpenBracket + 2, cursorPosition).trim();
return {
isInLink: true,
linkText,
startPos: lastOpenBracket,
endPos
};
}
+363
View File
@@ -0,0 +1,363 @@
export interface NoteTemplate {
id: string;
name: string;
description: string;
icon: string;
category: string;
content: string;
todos?: Array<{ text: string; completed: boolean }>;
}
export const DEFAULT_TEMPLATES: NoteTemplate[] = [
{
id: 'blank',
name: 'Note vide',
description: 'Commencer avec une page blanche',
icon: '📄',
category: 'Général',
content: '',
todos: []
},
{
id: 'meeting',
name: 'Réunion',
description: 'Notes de réunion structurées',
icon: '🤝',
category: 'Travail',
content: `<h2>Réunion - [Titre]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Participants:</strong> </p>
<p><strong>Objectif:</strong> </p>
<h3>Points discutés</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>Décisions</h3>
<p>• </p>
<h3>Prochaines étapes</h3>
<p>→ </p>`,
todos: [
{ text: 'Envoyer le compte-rendu', completed: false },
{ text: 'Planifier le suivi', completed: false }
]
},
{
id: 'daily-journal',
name: 'Journal quotidien',
description: 'Réflexions et gratitude du jour',
icon: '📔',
category: 'Personnel',
content: `<h2>Journal - ${new Date().toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</h2>
<h3>🌅 Ce matin je me sens...</h3>
<p></p>
<h3>🎯 Mes priorités aujourd'hui</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>💡 Idées et réflexions</h3>
<p></p>
<h3>🙏 Gratitude</h3>
<p>Aujourd'hui je suis reconnaissant(e) pour:</p>
<p>• </p>
<h3>🌙 Bilan de la journée</h3>
<p></p>`,
todos: []
},
{
id: 'project-planning',
name: 'Planification de projet',
description: 'Organiser un nouveau projet',
icon: '🎯',
category: 'Travail',
content: `<h2>Projet - [Nom du projet]</h2>
<h3>📋 Vue d'ensemble</h3>
<p><strong>Objectif:</strong> </p>
<p><strong>Deadline:</strong> </p>
<p><strong>Responsable:</strong> </p>
<h3>🎯 Objectifs</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>📊 Phases du projet</h3>
<p><strong>Phase 1:</strong> Préparation</p>
<p>• </p>
<p><strong>Phase 2:</strong> Développement</p>
<p>• </p>
<p><strong>Phase 3:</strong> Finalisation</p>
<p>• </p>
<h3>👥 Équipe</h3>
<p>• </p>
<h3>⚠️ Risques identifiés</h3>
<p>• </p>
<h3>📌 Ressources nécessaires</h3>
<p>• </p>`,
todos: [
{ text: 'Définir le scope', completed: false },
{ text: 'Assembler l\'équipe', completed: false },
{ text: 'Créer le planning', completed: false }
]
},
{
id: 'brainstorming',
name: 'Brainstorming',
description: 'Session de génération d\'idées',
icon: '💭',
category: 'Créativité',
content: `<h2>💭 Brainstorming - [Sujet]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Question centrale:</strong> </p>
<h3>💡 Idées</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
<p>5. </p>
<h3>⭐ Idées à creuser</h3>
<p>• </p>
<h3>🚀 Actions possibles</h3>
<p>→ </p>
<h3>🔗 Connexions et insights</h3>
<p></p>`,
todos: []
},
{
id: 'recipe',
name: 'Recette de cuisine',
description: 'Noter une recette',
icon: '👨‍🍳',
category: 'Personnel',
content: `<h2>🍳 Recette - [Nom du plat]</h2>
<p><strong>⏱️ Temps de préparation:</strong> </p>
<p><strong>👥 Portions:</strong> </p>
<p><strong>😋 Difficulté:</strong> Facile / Moyen / Difficile</p>
<h3>📝 Ingrédients</h3>
<p>• </p>
<h3>👨‍🍳 Préparation</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>💡 Astuces</h3>
<p>• </p>
<h3>📌 Notes</h3>
<p></p>`,
todos: []
},
{
id: 'weekly-review',
name: 'Revue hebdomadaire',
description: 'Bilan et planification de la semaine',
icon: '📊',
category: 'Productivité',
content: `<h2>📊 Revue de la semaine</h2>
<p><strong>Semaine du:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<h3>✅ Accomplissements</h3>
<p>Cette semaine j'ai réussi à:</p>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>📈 Progrès</h3>
<p>• </p>
<h3>⚠️ Défis rencontrés</h3>
<p>• </p>
<h3>💡 Leçons apprises</h3>
<p>• </p>
<h3>🎯 Objectifs semaine prochaine</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>🔄 Habitudes à améliorer</h3>
<p>• </p>`,
todos: [
{ text: 'Planifier la semaine prochaine', completed: false }
]
},
{
id: 'bug-report',
name: 'Rapport de bug',
description: 'Documenter un problème technique',
icon: '🐛',
category: 'Développement',
content: `<h2>🐛 Bug Report</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Priorité:</strong> 🔴 Critique / 🟠 Haute / 🟡 Moyenne / 🟢 Basse</p>
<p><strong>Statut:</strong> Nouveau / En cours / Résolu</p>
<h3>📋 Description</h3>
<p></p>
<h3>🔄 Étapes pour reproduire</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>✅ Résultat attendu</h3>
<p></p>
<h3>❌ Résultat actuel</h3>
<p></p>
<h3>💻 Environnement</h3>
<p><strong>OS:</strong> </p>
<p><strong>Navigateur:</strong> </p>
<p><strong>Version:</strong> </p>
<h3>📌 Notes additionnelles</h3>
<p></p>
<h3>🔧 Solution proposée</h3>
<p></p>`,
todos: [
{ text: 'Reproduire le bug', completed: false },
{ text: 'Identifier la cause', completed: false },
{ text: 'Implémenter le fix', completed: false },
{ text: 'Tester la solution', completed: false }
]
},
{
id: 'learning-notes',
name: 'Notes d\'apprentissage',
description: 'Apprendre un nouveau sujet',
icon: '📚',
category: 'Éducation',
content: `<h2>📚 Notes d'apprentissage - [Sujet]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Source:</strong> </p>
<h3>🎯 Objectifs d'apprentissage</h3>
<p>• </p>
<h3>📝 Concepts clés</h3>
<p><strong>Concept 1:</strong> </p>
<p>• </p>
<p><strong>Concept 2:</strong> </p>
<p>• </p>
<h3>💡 Exemples</h3>
<p></p>
<h3>❓ Questions</h3>
<p>• </p>
<h3>🔗 Ressources</h3>
<p>• </p>
<h3>📌 À retenir</h3>
<p>• </p>
<h3>✍️ Pratique</h3>
<p>Exercices à faire:</p>
<p>1. </p>`,
todos: [
{ text: 'Réviser les notes', completed: false },
{ text: 'Faire les exercices', completed: false }
]
},
{
id: 'travel-planning',
name: 'Planification de voyage',
description: 'Organiser un déplacement',
icon: '✈️',
category: 'Personnel',
content: `<h2>✈️ Voyage - [Destination]</h2>
<p><strong>📅 Dates:</strong> du [date] au [date]</p>
<p><strong>👥 Voyageurs:</strong> </p>
<p><strong>💰 Budget:</strong> </p>
<h3>🎒 À emporter</h3>
<p>Vêtements:</p>
<p>• </p>
<p>Documents:</p>
<p>• Passeport</p>
<p>• Billets</p>
<p>• Assurance</p>
<h3>🏨 Hébergement</h3>
<p><strong>Hôtel/Airbnb:</strong> </p>
<p><strong>Adresse:</strong> </p>
<p><strong>Check-in:</strong> </p>
<h3>✈️ Transport</h3>
<p><strong>Vol aller:</strong> </p>
<p><strong>Vol retour:</strong> </p>
<h3>📍 Lieux à visiter</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>🍽️ Restaurants recommandés</h3>
<p>• </p>
<h3>💡 Conseils et infos pratiques</h3>
<p>• </p>`,
todos: [
{ text: 'Réserver les vols', completed: false },
{ text: 'Réserver l\'hébergement', completed: false },
{ text: 'Faire la valise', completed: false }
]
}
];
export function getTemplatesByCategory(): Map<string, NoteTemplate[]> {
const categorized = new Map<string, NoteTemplate[]>();
DEFAULT_TEMPLATES.forEach(template => {
const category = template.category;
if (!categorized.has(category)) {
categorized.set(category, []);
}
categorized.get(category)!.push(template);
});
return categorized;
}
export function getTemplateById(id: string): NoteTemplate | undefined {
return DEFAULT_TEMPLATES.find(t => t.id === id);
}
export function searchTemplates(query: string): NoteTemplate[] {
const lowerQuery = query.toLowerCase();
return DEFAULT_TEMPLATES.filter(template =>
template.name.toLowerCase().includes(lowerQuery) ||
template.description.toLowerCase().includes(lowerQuery) ||
template.category.toLowerCase().includes(lowerQuery)
);
}
+179
View File
@@ -0,0 +1,179 @@
import { Note } from "@/services/NotesService";
// Common French stop words to filter out
const FRENCH_STOP_WORDS = new Set([
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'et', 'ou', 'mais', 'donc',
'or', 'ni', 'car', 'est', 'sont', 'être', 'avoir', 'a', 'dans', 'sur', 'pour',
'par', 'avec', 'sans', 'sous', 'vers', 'chez', 'depuis', 'pendant', 'avant',
'après', 'entre', 'parmi', 'selon', 'dont', 'que', 'qui', 'quoi', 'où', 'quand',
'comment', 'pourquoi', 'ce', 'cet', 'cette', 'ces', 'mon', 'ton', 'son', 'ma',
'ta', 'sa', 'mes', 'tes', 'ses', 'notre', 'votre', 'leur', 'nos', 'vos', 'leurs',
'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on', 'me', 'te', 'se',
'lui', 'leur', 'y', 'en', 'ne', 'pas', 'plus', 'moins', 'très', 'trop', 'assez',
'beaucoup', 'peu', 'bien', 'mal', 'tout', 'toute', 'tous', 'toutes', 'même',
'autre', 'autres', 'tel', 'telle', 'quel', 'quelle', 'quelque', 'plusieurs',
'chaque', 'aucun', 'aucune', 'nul', 'nulle', 'certain', 'certaine'
]);
// Category keywords for auto-tagging
const CATEGORY_KEYWORDS: Record<string, string[]> = {
'travail': ['réunion', 'projet', 'équipe', 'client', 'présentation', 'deadline', 'objectif', 'tâche', 'mission', 'entreprise', 'bureau', 'collègue', 'manager'],
'personnel': ['maison', 'famille', 'ami', 'vacances', 'loisir', 'hobby', 'détente', 'week-end', 'anniversaire', 'fête'],
'développement': ['code', 'bug', 'feature', 'api', 'database', 'frontend', 'backend', 'git', 'deploy', 'test', 'debug', 'javascript', 'python', 'react', 'node'],
'design': ['ui', 'ux', 'interface', 'maquette', 'prototype', 'couleur', 'police', 'layout', 'wireframe', 'mockup', 'figma', 'sketch'],
'finance': ['budget', 'dépense', 'économie', 'investissement', 'facture', 'paiement', 'compte', 'banque', 'argent', 'revenu'],
'santé': ['sport', 'exercice', 'nutrition', 'médecin', 'santé', 'bien-être', 'fitness', 'régime', 'yoga', 'course'],
'formation': ['cours', 'apprentissage', 'étude', 'formation', 'certifica', 'examen', 'leçon', 'tutoriel', 'documentation', 'lecture'],
'idée': ['brainstorming', 'innovation', 'créativité', 'concept', 'inspiration', 'réflexion', 'hypothèse', 'proposition'],
'urgent': ['urgent', 'important', 'prioritaire', 'asap', 'deadline', 'critique', 'essentiel'],
'meeting': ['réunion', 'rendez-vous', 'appel', 'visio', 'conférence', 'présentation', 'démonstration']
};
/**
* Extract keywords from text content
*/
export function extractKeywords(text: string, limit = 10): string[] {
if (!text) return [];
// Remove HTML tags
const cleanText = text.replace(/<[^>]*>/g, ' ');
// Convert to lowercase and split into words
const words = cleanText
.toLowerCase()
.replace(/[^\w\sàâäæçéèêëïîôùûüÿœ]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 3) // Min 4 chars
.filter(word => !FRENCH_STOP_WORDS.has(word));
// Count word frequency
const frequency = new Map<string, number>();
words.forEach(word => {
frequency.set(word, (frequency.get(word) || 0) + 1);
});
// Sort by frequency and take top N
return Array.from(frequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([word]) => word);
}
/**
* Suggest category tags based on content
*/
export function suggestCategoryTags(text: string): string[] {
if (!text) return [];
const cleanText = text.toLowerCase().replace(/<[^>]*>/g, ' ');
const suggestions: string[] = [];
for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
// Check if any category keyword appears in the text
const matches = keywords.filter(keyword =>
cleanText.includes(keyword.toLowerCase())
);
// If multiple matches, suggest this category
if (matches.length >= 2) {
suggestions.push(category);
}
}
return suggestions;
}
/**
* Suggest tags based on existing tags from similar notes
*/
export function suggestFromSimilarNotes(
currentNote: Note,
allNotes: Note[],
limit = 5
): string[] {
if (!currentNote.title && !currentNote.content) return [];
const currentKeywords = extractKeywords(
`${currentNote.title || ''} ${currentNote.content || ''}`,
15
);
if (currentKeywords.length === 0) return [];
// Find notes with similar keywords
const noteSimilarity = allNotes
.filter(note => note.id !== currentNote.id && note.tags && note.tags.length > 0)
.map(note => {
const noteKeywords = extractKeywords(
`${note.title || ''} ${note.content || ''}`,
15
);
// Calculate similarity (common keywords / total unique keywords)
const commonKeywords = currentKeywords.filter(kw =>
noteKeywords.includes(kw)
).length;
const totalKeywords = new Set([...currentKeywords, ...noteKeywords]).size;
const similarity = totalKeywords > 0 ? commonKeywords / totalKeywords : 0;
return {
note,
similarity
};
})
.filter(item => item.similarity > 0.2) // Min 20% similarity
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5); // Top 5 similar notes
// Extract tags from similar notes
const tagFrequency = new Map<string, number>();
noteSimilarity.forEach(({ note }) => {
note.tags?.forEach(tag => {
const tagName = tag.name.toLowerCase();
tagFrequency.set(tagName, (tagFrequency.get(tagName) || 0) + 1);
});
});
// Sort by frequency and return top tags
return Array.from(tagFrequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([tag]) => tag);
}
/**
* Get all tag suggestions for a note
*/
export function getSmartTagSuggestions(
note: Note,
allNotes: Note[],
existingTags: string[] = []
): string[] {
const content = `${note.title || ''} ${note.content || ''}`;
// Get category suggestions
const categoryTags = suggestCategoryTags(content);
// Get suggestions from similar notes
const similarNoteTags = suggestFromSimilarNotes(note, allNotes);
// Get keyword-based suggestions
const keywords = extractKeywords(content, 5);
// Combine all suggestions
const allSuggestions = [
...categoryTags,
...similarNoteTags,
...keywords
];
// Filter out existing tags and duplicates
const existingTagsLower = existingTags.map(t => t.toLowerCase());
const uniqueSuggestions = Array.from(
new Set(allSuggestions.map(t => t.toLowerCase()))
).filter(tag => !existingTagsLower.includes(tag));
// Return top 8 suggestions
return uniqueSuggestions.slice(0, 8);
}
+11 -1
View File
@@ -6,7 +6,17 @@ import path from "path";
export default defineConfig(() => ({ export default defineConfig(() => ({
server: { server: {
host: "::", host: "::",
port: 2222, port: 5173,
proxy: {
'/api': {
target: 'http://localhost:2222',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:2222',
changeOrigin: true,
}
}
}, },
build: { build: {
outDir: "dist", outDir: "dist",