mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
Merge branch 'main' into antigravity - Resolve conflict in NotesPage.tsx (Archive fix + Kanban)
This commit is contained in:
commit
ad3f415469
50 files changed
+4916
-359
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
Generated
+14
-20
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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'}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
||||||
|
|||||||
@@ -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})`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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');
|
||||||
|
|||||||
Executable
+92
@@ -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();
|
||||||
@@ -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 $$;
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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 += `\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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user