diff --git a/docker/schema_family.sql b/docker/schema_family.sql
index 0a7ce87..7816274 100644
--- a/docker/schema_family.sql
+++ b/docker/schema_family.sql
@@ -293,3 +293,27 @@ CREATE TABLE IF NOT EXISTS pf_parts (
FOREIGN KEY (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- ─── Notes / Memo ─────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS pf_memo_notes (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(255) NOT NULL,
+ content LONGTEXT DEFAULT '',
+ tags VARCHAR(1000) DEFAULT '',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FULLTEXT KEY ft_notes (title, content, tags)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS pf_memo_attachments (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ note_id INT NOT NULL,
+ type ENUM('image','file','url') NOT NULL DEFAULT 'file',
+ filename VARCHAR(255) DEFAULT NULL,
+ original_name VARCHAR(255) DEFAULT NULL,
+ url TEXT DEFAULT NULL,
+ label VARCHAR(255) DEFAULT NULL,
+ size INT DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/header.php b/header.php
index 74c84e8..ed8f0ca 100644
--- a/header.php
+++ b/header.php
@@ -53,6 +53,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
= tr('menu_holidays') ?>
= tr('menu_gifts') ?>
= tr('menu_garage') ?>
+ = tr('menu_memo') ?>
@@ -98,6 +99,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
🏖️ = tr('menu_holidays') ?>
🎁 = tr('menu_gifts') ?>
🚗 = tr('menu_garage') ?>
+ 📝 = tr('menu_memo') ?>
⚙️ Paramètres
🛡️ Admin
diff --git a/includes/lang/ca.php b/includes/lang/ca.php
index 5a563ca..04e195a 100644
--- a/includes/lang/ca.php
+++ b/includes/lang/ca.php
@@ -90,6 +90,9 @@ return [
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
'mod_garage_name' => 'Garatge',
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
+ 'mod_memo_name' => 'Notes',
+ 'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
+ 'menu_memo' => 'Notes',
'cta_open' => 'Obrir',
'cta_explore' => 'Explorar',
'cta_view_lists' => 'Veure les llistes',
diff --git a/includes/lang/en.php b/includes/lang/en.php
index 08190dd..ebee33f 100644
--- a/includes/lang/en.php
+++ b/includes/lang/en.php
@@ -91,6 +91,9 @@ return [
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
+ 'mod_memo_name' => 'Notes',
+ 'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
+ 'menu_memo' => 'Notes',
'cta_open' => 'Open',
'cta_explore' => 'Explore',
'cta_view_lists' => 'View lists',
diff --git a/includes/lang/fr.php b/includes/lang/fr.php
index 59b9c8e..a0dcb76 100644
--- a/includes/lang/fr.php
+++ b/includes/lang/fr.php
@@ -93,6 +93,9 @@ return [
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
+ 'mod_memo_name' => 'Notes',
+ 'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
+ 'menu_memo' => 'Notes',
'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer',
'cta_view_lists' => 'Voir les listes',
diff --git a/index.php b/index.php
index f817291..72c7acb 100644
--- a/index.php
+++ b/index.php
@@ -91,6 +91,15 @@ if ($_has_custom_bg): ?>
+
+
+ 📝
+ = tr('mod_memo_name') ?>
+ = tr('mod_memo_desc') ?>
+ = tr('cta_open') ?>
+
+
+
diff --git a/memo.php b/memo.php
new file mode 100644
index 0000000..2b8392c
--- /dev/null
+++ b/memo.php
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nouvelle note
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
+
diff --git a/modules/memo/api.php b/modules/memo/api.php
new file mode 100644
index 0000000..f6e8d22
--- /dev/null
+++ b/modules/memo/api.php
@@ -0,0 +1,209 @@
+ false, 'error' => $e->getMessage()]); exit;
+});
+
+require_once dirname(__DIR__, 2) . '/includes/db.php';
+
+$UPLOAD_DIR = '/uploads/memo/';
+if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
+
+$method = $_SERVER['REQUEST_METHOD'];
+$action = $_GET['action'] ?? '';
+
+function mOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; }
+function mErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
+function mBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
+
+function parseTags(string $raw): string {
+ $tags = array_unique(array_filter(array_map(function($t) {
+ return strtolower(trim(ltrim($t, '#'), " \t\n\r,"));
+ }, preg_split('/[\s,]+/', $raw))));
+ return implode(',', $tags);
+}
+
+function imgExts(): array { return ['jpg','jpeg','png','gif','webp','svg','bmp']; }
+function isImage(string $fname): bool { return in_array(strtolower(pathinfo($fname, PATHINFO_EXTENSION)), imgExts()); }
+
+function handleUpload(string $field, string $dir): ?array {
+ if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
+ $orig = $_FILES[$field]['name'];
+ $ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
+ $allowed = array_merge(imgExts(), ['pdf','doc','docx','txt','csv','zip','mp3','mp4']);
+ if (!in_array($ext, $allowed)) return null;
+ $fname = uniqid('m', true) . '.' . $ext;
+ if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
+ return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
+}
+
+// ── NOTES ─────────────────────────────────────────────────────────────────────
+if ($action === 'notes') {
+ if ($method === 'GET') {
+ $id = $_GET['id'] ?? null;
+ $q = trim($_GET['q'] ?? '');
+ $tag = trim($_GET['tag'] ?? '');
+ $page = max(1, (int)($_GET['page'] ?? 1));
+ $perPage = 24;
+ $offset = ($page - 1) * $perPage;
+
+ if ($id) {
+ $n = $pdo->prepare("SELECT * FROM pf_memo_notes WHERE id = ?"); $n->execute([$id]);
+ $note = $n->fetch(); if (!$note) mErr('Note introuvable', 404);
+ $a = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE note_id = ? ORDER BY created_at");
+ $a->execute([$id]); $note['attachments'] = $a->fetchAll();
+ mOk($note);
+ }
+
+ if ($q) {
+ $like = '%' . $q . '%';
+ $stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
+ FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?
+ ORDER BY updated_at DESC LIMIT ? OFFSET ?");
+ $stmt->execute([$like, $like, $like, $perPage, $offset]);
+ $total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?");
+ $total->execute([$like, $like, $like]);
+ } elseif ($tag) {
+ $stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
+ FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0
+ ORDER BY updated_at DESC LIMIT ? OFFSET ?");
+ $stmt->execute([$tag, $perPage, $offset]);
+ $total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0");
+ $total->execute([$tag]);
+ } else {
+ $stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
+ FROM pf_memo_notes ORDER BY updated_at DESC LIMIT ? OFFSET ?");
+ $stmt->execute([$perPage, $offset]);
+ $total = $pdo->query("SELECT COUNT(*) FROM pf_memo_notes");
+ }
+
+ mOk(['notes' => $stmt->fetchAll(), 'total' => (int)$total->fetchColumn(),
+ 'page' => $page, 'per_page' => $perPage]);
+ }
+
+ if ($method === 'POST') {
+ $d = $_POST ?: mBody();
+ $title = trim($d['title'] ?? '');
+ if (!$title) mErr('Titre requis');
+ $tags = parseTags($d['tags'] ?? '');
+ $pdo->prepare("INSERT INTO pf_memo_notes (title, content, tags) VALUES (?,?,?)")
+ ->execute([$title, $d['content'] ?? '', $tags]);
+ $id = $pdo->lastInsertId();
+
+ // Files
+ if (!empty($_FILES)) {
+ foreach ($_FILES as $key => $f) {
+ if ($f['error'] === UPLOAD_ERR_OK) {
+ $up = handleUpload($key, $UPLOAD_DIR);
+ if ($up) {
+ $type = isImage($up['filename']) ? 'image' : 'file';
+ $pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
+ ->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
+ }
+ }
+ }
+ }
+ // URLs from JSON body
+ foreach (($d['urls'] ?? []) as $urlItem) {
+ if (!empty($urlItem['url'])) {
+ $pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
+ ->execute([$id, 'url', $urlItem['url'], $urlItem['label'] ?? '']);
+ }
+ }
+ mOk(['id' => (int)$id]);
+ }
+
+ if ($method === 'PUT') {
+ $id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
+ $d = mBody();
+ $title = trim($d['title'] ?? '');
+ if (!$title) mErr('Titre requis');
+ $tags = parseTags($d['tags'] ?? '');
+ $pdo->prepare("UPDATE pf_memo_notes SET title=?, content=?, tags=?, updated_at=NOW() WHERE id=?")
+ ->execute([$title, $d['content'] ?? '', $tags, $id]);
+ mOk(['updated' => true]);
+ }
+
+ if ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
+ $rows = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE note_id=? AND filename IS NOT NULL");
+ $rows->execute([$id]);
+ foreach ($rows->fetchAll() as $r) @unlink($UPLOAD_DIR . $r['filename']);
+ $pdo->prepare("DELETE FROM pf_memo_notes WHERE id=?")->execute([$id]);
+ mOk(['deleted' => true]);
+ }
+}
+
+// ── TAGS ──────────────────────────────────────────────────────────────────────
+if ($action === 'tags') {
+ $rows = $pdo->query("SELECT tags FROM pf_memo_notes WHERE tags != ''")->fetchAll();
+ $counts = [];
+ foreach ($rows as $r) {
+ foreach (explode(',', $r['tags']) as $t) {
+ $t = trim($t);
+ if ($t) $counts[$t] = ($counts[$t] ?? 0) + 1;
+ }
+ }
+ arsort($counts);
+ mOk(array_map(fn($t, $c) => ['tag' => $t, 'count' => $c], array_keys($counts), $counts));
+}
+
+// ── ATTACHMENTS ───────────────────────────────────────────────────────────────
+if ($action === 'attachments') {
+ if ($method === 'POST') {
+ $id = $_POST['note_id'] ?? null; if (!$id) mErr('note_id manquant');
+ // File upload
+ if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
+ $up = handleUpload('file', $UPLOAD_DIR);
+ if (!$up) mErr('Upload échoué ou format non autorisé');
+ $type = isImage($up['filename']) ? 'image' : 'file';
+ $pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
+ ->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
+ mOk(['id' => (int)$pdo->lastInsertId(), 'type' => $type,
+ 'filename' => $up['filename'], 'original_name' => $up['original_name']]);
+ }
+ // URL
+ $url = trim($_POST['url'] ?? '');
+ if ($url) {
+ $label = trim($_POST['label'] ?? '');
+ $pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
+ ->execute([$id, 'url', $url, $label]);
+ mOk(['id' => (int)$pdo->lastInsertId(), 'type' => 'url', 'url' => $url, 'label' => $label]);
+ }
+ mErr('Aucun fichier ou URL fourni');
+ }
+ if ($method === 'DELETE') {
+ $id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
+ $r = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE id=?"); $r->execute([$id]);
+ $row = $r->fetch();
+ if ($row && $row['filename']) @unlink($UPLOAD_DIR . $row['filename']);
+ $pdo->prepare("DELETE FROM pf_memo_attachments WHERE id=?")->execute([$id]);
+ mOk(['deleted' => true]);
+ }
+}
+
+// ── FILE SERVE ────────────────────────────────────────────────────────────────
+if ($action === 'file') {
+ $id = $_GET['id'] ?? null;
+ $row = null;
+ if ($id) { $s = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE id=?"); $s->execute([$id]); $row = $s->fetch(); }
+ if (!$row || !$row['filename'] || !file_exists($UPLOAD_DIR . $row['filename'])) { http_response_code(404); exit; }
+ $ext = strtolower(pathinfo($row['filename'], PATHINFO_EXTENSION));
+ $mime = [
+ 'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif',
+ 'webp'=>'image/webp','svg'=>'image/svg+xml','pdf'=>'application/pdf',
+ 'txt'=>'text/plain','mp4'=>'video/mp4','mp3'=>'audio/mpeg',
+ ][$ext] ?? 'application/octet-stream';
+ header('Content-Type: ' . $mime);
+ header('Content-Disposition: inline; filename="' . rawurlencode($row['original_name'] ?? $row['filename']) . '"');
+ header('Cache-Control: private, max-age=3600');
+ readfile($UPLOAD_DIR . $row['filename']); exit;
+}
+
+mErr('Action inconnue', 404);
diff --git a/modules/memo/assets/memo.css b/modules/memo/assets/memo.css
new file mode 100644
index 0000000..9e87467
--- /dev/null
+++ b/modules/memo/assets/memo.css
@@ -0,0 +1,279 @@
+/* Memo module */
+
+.memo-layout {
+ display: grid;
+ grid-template-columns: 220px 1fr;
+ height: calc(100vh - 64px);
+ overflow: hidden;
+}
+.memo-sidebar {
+ border-right: 1px solid var(--border-light);
+ background: var(--bg-panel);
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+.memo-sidebar-top {
+ padding: 1rem;
+ border-bottom: 1px solid var(--border-light);
+ display: flex;
+ flex-direction: column;
+ gap: .6rem;
+ position: sticky;
+ top: 0;
+ background: var(--bg-panel);
+ z-index: 1;
+}
+.memo-search {
+ display: flex;
+ align-items: center;
+ gap: .4rem;
+ background: var(--bg-page);
+ border: 1px solid var(--border-light);
+ border-radius: 8px;
+ padding: .4rem .6rem;
+}
+.memo-search input {
+ border: none;
+ background: transparent;
+ color: var(--text-main);
+ font-size: .875rem;
+ outline: none;
+ flex: 1;
+ min-width: 0;
+}
+.memo-search input::placeholder { color: var(--text-muted); }
+.memo-tags-list { padding: .5rem 0; }
+.memo-tag-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: .4rem 1rem;
+ cursor: pointer;
+ font-size: .82rem;
+ color: var(--text-muted);
+ border-radius: 0;
+ transition: background .12s;
+ text-decoration: none;
+}
+.memo-tag-item:hover { background: var(--bg-page); color: var(--text-main); }
+.memo-tag-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
+.memo-tag-count {
+ font-size: .72rem;
+ background: var(--bg-page);
+ color: var(--text-muted);
+ padding: 1px 6px;
+ border-radius: 999px;
+ min-width: 20px;
+ text-align: center;
+}
+.memo-tag-item.active .memo-tag-count { background: #dbeafe; color: var(--primary); }
+
+/* Main area */
+.memo-main { overflow-y: auto; background: var(--bg-page); }
+
+/* Pages */
+.memo-page { display: none; }
+.memo-page.active { display: block; }
+
+/* Note list */
+.memo-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem;
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border-light);
+ gap: .75rem;
+}
+.memo-list-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
+.memo-list-subtitle { font-size: .82rem; color: var(--text-muted); }
+.notes-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+ padding: 1.25rem 1.5rem;
+}
+.note-card {
+ background: var(--bg-panel);
+ border: 1px solid var(--border-light);
+ border-radius: 10px;
+ padding: 1rem 1.1rem;
+ cursor: pointer;
+ transition: border-color .15s, box-shadow .15s, transform .1s;
+ display: flex;
+ flex-direction: column;
+ gap: .4rem;
+ text-decoration: none;
+}
+.note-card:hover { border-color: var(--primary); box-shadow: 0 4px 12px rgba(59,130,246,.1); transform: translateY(-1px); }
+.note-card-title { font-size: .95rem; font-weight: 700; color: var(--text-main); }
+.note-card-snippet { font-size: .8rem; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.note-card-meta { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-top: .25rem; }
+.note-card-date { font-size: .72rem; color: var(--text-muted); margin-left: auto; }
+.note-tag-chip {
+ font-size: .7rem; font-weight: 600;
+ background: #eff6ff; color: var(--primary);
+ padding: .15rem .45rem; border-radius: 999px;
+ cursor: pointer;
+}
+.note-tag-chip:hover { background: #dbeafe; }
+
+/* Note view */
+.memo-view-header {
+ padding: 1.5rem 1.75rem 1rem;
+ border-bottom: 1px solid var(--border-light);
+ background: var(--bg-panel);
+}
+.memo-view-title { font-size: 1.6rem; font-weight: 800; color: var(--text-main); margin-bottom: .5rem; line-height: 1.2; }
+.memo-view-meta { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; font-size: .8rem; color: var(--text-muted); }
+.memo-view-actions { display: flex; gap: .5rem; margin-left: auto; }
+.memo-content-area {
+ padding: 1.5rem 1.75rem;
+ background: var(--bg-panel);
+ margin: 1rem 1.5rem;
+ border: 1px solid var(--border-light);
+ border-radius: 12px;
+}
+.memo-content-area.empty { color: var(--text-muted); font-style: italic; }
+
+/* Markdown rendered content */
+.md-body { color: var(--text-main); line-height: 1.7; font-size: .95rem; }
+.md-body h1,.md-body h2,.md-body h3,.md-body h4 { font-weight: 700; margin: 1.25em 0 .5em; line-height: 1.3; color: var(--text-main); }
+.md-body h1 { font-size: 1.5rem; }
+.md-body h2 { font-size: 1.25rem; }
+.md-body h3 { font-size: 1.05rem; }
+.md-body p { margin: .75em 0; }
+.md-body ul,.md-body ol { padding-left: 1.5rem; margin: .75em 0; }
+.md-body li { margin: .25em 0; }
+.md-body code { font-family: monospace; background: #f1f5f9; color: #c7254e; padding: .1em .35em; border-radius: 4px; font-size: .88em; }
+.md-body pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 1em 0; }
+.md-body pre code { background: none; color: inherit; padding: 0; }
+.md-body blockquote { border-left: 3px solid var(--primary); margin: 1em 0; padding: .5em 1em; background: #eff6ff; color: var(--text-muted); border-radius: 0 6px 6px 0; }
+.md-body a { color: var(--primary); }
+.md-body hr { border: none; border-top: 1px solid var(--border-light); margin: 1.5em 0; }
+.md-body table { width: 100%; border-collapse: collapse; margin: 1em 0; }
+.md-body table th { background: #f8fafc; text-align: left; padding: .5rem .75rem; border: 1px solid var(--border-light); font-weight: 600; }
+.md-body table td { padding: .5rem .75rem; border: 1px solid var(--border-light); }
+.md-body mark { background: #fef3c7; color: #92400e; padding: .1em .2em; border-radius: 3px; }
+.md-body img { max-width: 100%; border-radius: 8px; margin: .5em 0; }
+
+/* Attachments */
+.memo-attachments { padding: 0 1.5rem 1.5rem; }
+.memo-att-section { margin-bottom: 1.25rem; }
+.memo-att-title { font-size: .75rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin-bottom: .6rem; }
+.att-img-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .6rem; }
+.att-img-item { position: relative; border-radius: 8px; overflow: hidden; aspect-ratio: 1; background: #f1f5f9; cursor: pointer; }
+.att-img-item img { width: 100%; height: 100%; object-fit: cover; }
+.att-img-item .att-del { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,.5); color: #fff; border: none; border-radius: 50%; width: 22px; height: 22px; font-size: .75rem; cursor: pointer; display: none; line-height: 1; }
+.att-img-item:hover .att-del { display: flex; align-items: center; justify-content: center; }
+.att-file-list { display: flex; flex-direction: column; gap: .4rem; }
+.att-file-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
+.att-file-item a { color: var(--primary); flex: 1; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.att-file-item a:hover { text-decoration: underline; }
+.att-file-size { color: var(--text-muted); font-size: .72rem; white-space: nowrap; }
+.att-url-list { display: flex; flex-direction: column; gap: .4rem; }
+.att-url-item { display: flex; align-items: center; gap: .6rem; padding: .4rem .7rem; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; font-size: .82rem; }
+.att-url-item a { color: var(--primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+/* Edit form */
+.memo-edit-area { padding: 1.25rem 1.5rem; }
+.memo-edit-form { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 12px; padding: 1.5rem; }
+.form-group { display: flex; flex-direction: column; gap: .35rem; margin-bottom: 1.1rem; }
+.form-label { font-size: .82rem; font-weight: 600; color: var(--text-muted); }
+.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s, box-shadow .15s; font-family: inherit; }
+.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
+.form-control::placeholder { color: #cbd5e1; }
+.edit-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
+.edit-textarea { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: .82rem; min-height: 320px; resize: vertical; line-height: 1.6; }
+.md-preview-box { min-height: 320px; padding: .75rem 1rem; background: #fafafa; border: 1px solid var(--border-light); border-radius: 8px; overflow-y: auto; }
+.tags-input-wrap { display: flex; flex-wrap: wrap; gap: .35rem; padding: .35rem .6rem; border: 1px solid var(--border-light); border-radius: 8px; background: #fff; min-height: 42px; align-items: center; cursor: text; transition: border-color .15s; }
+.tags-input-wrap:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
+.tags-input-wrap input { border: none; outline: none; background: transparent; font-size: .875rem; color: var(--text-main); min-width: 80px; flex: 1; }
+.tag-chip { display: inline-flex; align-items: center; gap: .25rem; background: #eff6ff; color: var(--primary); font-size: .78rem; font-weight: 600; padding: .2rem .5rem; border-radius: 999px; }
+.tag-chip button { background: none; border: none; cursor: pointer; font-size: .8rem; color: var(--primary); line-height: 1; padding: 0; }
+.attach-drop { border: 2px dashed var(--border-light); border-radius: 10px; padding: 1.25rem; text-align: center; cursor: pointer; transition: border-color .15s, background .15s; color: var(--text-muted); font-size: .875rem; }
+.attach-drop:hover,.attach-drop.drag-over { border-color: var(--primary); background: #eff6ff; color: var(--primary); }
+.attach-preview-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
+.attach-preview-item { position: relative; }
+.attach-preview-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border-light); }
+.attach-preview-item .rm { position: absolute; top: -6px; right: -6px; background: var(--danger); color: #fff; border: none; border-radius: 50%; width: 18px; height: 18px; font-size: .65rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
+.url-row { display: flex; gap: .5rem; }
+.url-row input:first-child { flex: 1.5; }
+.url-row input:last-child { flex: 1; }
+
+/* Tags autocomplete */
+.tags-suggestions { position: absolute; z-index: 100; background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.1); max-height: 160px; overflow-y: auto; min-width: 160px; }
+.tags-suggestions div { padding: .4rem .75rem; cursor: pointer; font-size: .82rem; color: var(--text-main); }
+.tags-suggestions div:hover { background: var(--bg-page); }
+
+/* Lightbox */
+.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.88); z-index: 10000; display: none; align-items: center; justify-content: center; cursor: zoom-out; }
+.lightbox.show { display: flex; }
+.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; }
+
+/* Empty state */
+.memo-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
+.memo-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
+
+/* Btns */
+.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .45rem .9rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
+.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
+.btn-primary:hover { background: var(--primary-dark); }
+.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
+.btn-secondary:hover { background: var(--bg-page); }
+.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
+.btn-danger:hover { background: #fef2f2; }
+.btn-sm { padding: .28rem .65rem; font-size: .8rem; }
+
+/* Toast */
+.memo-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
+.memo-toast { background: var(--bg-panel); border: 1px solid var(--border-light); border-radius: 10px; padding: .7rem 1rem; font-size: .875rem; min-width: 200px; color: var(--text-main); box-shadow: 0 4px 16px rgba(0,0,0,.12); border-left: 4px solid var(--primary); animation: toastIn .2s ease; }
+.memo-toast.error { border-left-color: var(--danger); }
+@keyframes toastIn { from { opacity:0;transform:translateX(16px); } to { opacity:1;transform:none; } }
+
+/* Responsive */
+@media (max-width: 768px) {
+ .memo-layout { grid-template-columns: 1fr; height: auto; }
+ .memo-sidebar { display: none; }
+ .memo-sidebar.open { display: flex; position: fixed; inset: 0; z-index: 200; width: 260px; height: 100vh; box-shadow: 4px 0 20px rgba(0,0,0,.2); }
+ .edit-split { grid-template-columns: 1fr; }
+ .notes-grid { grid-template-columns: 1fr; padding: 1rem; }
+ .memo-view-header { padding: 1rem; }
+ .memo-content-area { margin: .75rem; }
+ .memo-attachments { padding: 0 .75rem .75rem; }
+ .memo-edit-area { padding: .75rem; }
+}
+
+/* Dark mode */
+[data-theme="dark"] .memo-sidebar { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .memo-sidebar-top { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .memo-search { background: var(--bg-page); border-color: var(--border-light); }
+[data-theme="dark"] .memo-search input { color: var(--text-main); }
+[data-theme="dark"] .memo-tag-item { color: var(--text-muted); }
+[data-theme="dark"] .memo-tag-item:hover { background: var(--bg-page); }
+[data-theme="dark"] .memo-tag-item.active { background: rgba(59,130,246,.15); color: var(--primary); }
+[data-theme="dark"] .memo-main { background: var(--bg-page); }
+[data-theme="dark"] .memo-list-header { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .note-card { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .note-card:hover { border-color: var(--primary); }
+[data-theme="dark"] .note-card-title { color: var(--text-main); }
+[data-theme="dark"] .memo-view-header { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .memo-content-area { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .md-body code { background: #1c2128; color: #f87171; }
+[data-theme="dark"] .md-body blockquote { background: rgba(59,130,246,.08); }
+[data-theme="dark"] .md-body table th { background: #1c2128; border-color: var(--border-light); }
+[data-theme="dark"] .md-body table td { border-color: var(--border-light); }
+[data-theme="dark"] .att-file-item,.att-url-item { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .memo-edit-form { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
+[data-theme="dark"] .tags-input-wrap { background: #1c2128; border-color: var(--border-light); }
+[data-theme="dark"] .md-preview-box { background: #1c2128; border-color: var(--border-light); }
+[data-theme="dark"] .attach-drop { border-color: var(--border-light); }
+[data-theme="dark"] .attach-drop:hover,.attach-drop.drag-over { background: rgba(59,130,246,.1); }
+[data-theme="dark"] .btn-secondary { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
+[data-theme="dark"] .tags-suggestions { background: var(--bg-panel); border-color: var(--border-light); }
+[data-theme="dark"] .tags-suggestions div:hover { background: var(--bg-page); }
+[data-theme="dark"] .memo-toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
diff --git a/modules/memo/assets/memo.js b/modules/memo/assets/memo.js
new file mode 100644
index 0000000..3bdf097
--- /dev/null
+++ b/modules/memo/assets/memo.js
@@ -0,0 +1,410 @@
+// HouseHub — Memo module
+const API = '/modules/memo/api.php';
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+function escHtml(s){ const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML; }
+function fmtDate(d){ if(!d)return'--'; try{return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return d;} }
+function fmtSize(b){ if(!b)return''; if(b<1024)return b+'o'; if(b<1048576)return Math.round(b/1024)+'ko'; return (b/1048576).toFixed(1)+'Mo'; }
+function toast(msg,type='success'){
+ const c=document.getElementById('memo-toasts')||document.body;
+ const t=document.createElement('div');t.className='memo-toast'+(type==='error'?' error':'');t.textContent=msg;
+ c.appendChild(t);setTimeout(()=>t.remove(),3000);
+}
+async function api(action,method='GET',data=null,extra=''){
+ const opts={method,headers:{}};
+ if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
+ else if(data instanceof FormData){opts.body=data;}
+ const r=await fetch(API+'?action='+action+extra,opts);
+ const j=await r.json();
+ if(!j.ok)throw new Error(j.error||'Erreur API');
+ return j.data;
+}
+
+// ─── State ───────────────────────────────────────────────────────────────────
+let currentNoteId = null;
+let currentPage = 'list';
+let currentQ = '';
+let currentTag = '';
+let allTagsList = [];
+let pendingFiles = [];
+let pendingUrls = [];
+
+// ─── Navigation ──────────────────────────────────────────────────────────────
+function showPage(name){
+ document.querySelectorAll('.memo-page').forEach(p=>p.classList.remove('active'));
+ const el=document.getElementById('memo-page-'+name);
+ if(el)el.classList.add('active');
+ currentPage=name;
+}
+
+// ─── Sidebar ─────────────────────────────────────────────────────────────────
+async function loadSidebar(){
+ try{
+ allTagsList=await api('tags');
+ const el=document.getElementById('memo-tags-list');
+ if(!el)return;
+ const allActive=currentTag===''&¤tQ==='';
+ let html=`
+ 📝 Toutes les notes …
`;
+ allTagsList.forEach(t=>{
+ const act=currentTag===t.tag?' active':'';
+ html+=`
+ # ${escHtml(t.tag)}${t.count}
`;
+ });
+ el.innerHTML=html;
+ }catch(e){}
+}
+
+// ─── Note list ────────────────────────────────────────────────────────────────
+async function loadList(q='',tag='',page=1){
+ currentQ=q;currentTag=tag;showPage('list');
+ loadSidebar();
+ try{
+ let extra='&page='+page;
+ if(q)extra+='&q='+encodeURIComponent(q);
+ if(tag)extra+='&tag='+encodeURIComponent(tag);
+ const data=await api('notes','GET',null,extra);
+ const el=document.getElementById('memo-notes-grid');
+ const count=document.getElementById('memo-all-count');
+ const title=document.getElementById('memo-list-title');
+ const subtitle=document.getElementById('memo-list-subtitle');
+ if(count)count.textContent=data.total;
+ if(title){
+ title.textContent = q?`🔍 "${q}"` : tag?`# ${tag}` : '📝 Notes';
+ }
+ if(subtitle)subtitle.textContent=data.total+' note'+(data.total!==1?'s':'');
+ if(!data.notes.length){
+ el.innerHTML='📝
'+(q||tag?'Aucune note trouvée.':'Aucune note. Créez-en une !')+'
';
+ return;
+ }
+ el.innerHTML=data.notes.map(n=>noteCardHtml(n)).join('');
+ // Pagination
+ const total_pages=Math.ceil(data.total/data.per_page);
+ const pag=document.getElementById('memo-pagination');
+ if(pag){
+ pag.innerHTML=total_pages>1?
+ (page>1?``:'')+
+ `Page ${page} / ${total_pages}`+
+ (pageSuivant →`:'')
+ : '';
+ }
+ }catch(e){toast(e.message,'error');}
+}
+
+function noteCardHtml(n){
+ const tags=(n.tags||'').split(',').filter(t=>t.trim()).map(t=>
+ `#${escHtml(t.trim())}`).join('');
+ const snippet=n.snippet?(escHtml(n.snippet.replace(/\n/g,' '))):'';
+ return `
+
${escHtml(n.title)}
+ ${snippet?`
${snippet}
`:''}
+
${tags}${fmtDate(n.updated_at)}
+
`;
+}
+
+function filterTag(tag){
+ currentTag=tag;currentQ='';
+ document.getElementById('memo-search-input')?.value!=null&&(document.getElementById('memo-search-input').value='');
+ loadList('',tag);
+}
+
+function doSearch(){
+ const q=document.getElementById('memo-search-input')?.value.trim()||'';
+ loadList(q,'');
+}
+
+// ─── Note view ────────────────────────────────────────────────────────────────
+async function viewNote(id){
+ currentNoteId=id;showPage('view');
+ try{
+ const note=await api('notes','GET',null,'&id='+id);
+ document.getElementById('view-title').textContent=note.title;
+ document.getElementById('view-date').textContent='Modifié '+fmtDate(note.updated_at);
+ // Tags
+ const tagEl=document.getElementById('view-tags');
+ tagEl.innerHTML=(note.tags||'').split(',').filter(t=>t.trim()).map(t=>
+ `#${escHtml(t.trim())}`).join('');
+ // Markdown rendering
+ const mdEl=document.getElementById('view-md');
+ if(note.content&&window.marked){
+ mdEl.innerHTML=marked.parse(note.content);
+ mdEl.classList.remove('empty');
+ } else if(note.content){
+ mdEl.textContent=note.content;mdEl.classList.remove('empty');
+ } else {
+ mdEl.innerHTML='Aucun contenu.';mdEl.classList.add('empty');
+ }
+ // Attachments
+ renderAttachmentsView(note.attachments||[],id);
+ }catch(e){toast(e.message,'error');}
+}
+
+function renderAttachmentsView(atts,noteId){
+ const images=atts.filter(a=>a.type==='image');
+ const files =atts.filter(a=>a.type==='file');
+ const urls =atts.filter(a=>a.type==='url');
+ const el=document.getElementById('view-attachments');
+ el.innerHTML='';
+ if(!atts.length){el.style.display='none';return;}
+ el.style.display='block';
+
+ if(images.length){
+ el.innerHTML+=`
+
🖼️ Images
+
${images.map(a=>`
+
+

+
+
`).join('')}
`;
+ }
+ if(files.length){
+ el.innerHTML+=`
+
📎 Fichiers
+
${files.map(a=>`
+
`).join('')}
`;
+ }
+ if(urls.length){
+ el.innerHTML+=`
+
🔗 Liens
+
${urls.map(a=>`
+
`).join('')}
`;
+ }
+}
+
+async function deleteAttachment(attId,noteId){
+ if(!confirm('Supprimer cet attachement ?'))return;
+ try{ await api('attachments','DELETE',null,'&id='+attId); toast('Supprimé'); viewNote(noteId); }
+ catch(e){toast(e.message,'error');}
+}
+
+// Lightbox
+function openLightbox(src){
+ const lb=document.getElementById('memo-lightbox');
+ const img=lb.querySelector('img');
+ img.src=src;lb.classList.add('show');
+}
+function closeLightbox(){ document.getElementById('memo-lightbox')?.classList.remove('show'); }
+
+// ─── Edit / Create ────────────────────────────────────────────────────────────
+let editingId=null;
+let editTags=[];
+let tagAutocomplete=[];
+
+function openCreate(){
+ editingId=null;pendingFiles=[];pendingUrls=[];editTags=[];
+ document.getElementById('edit-page-title').textContent='Nouvelle note';
+ document.getElementById('edit-title').value='';
+ document.getElementById('edit-content').value='';
+ document.getElementById('md-live-preview').innerHTML='';
+ document.getElementById('edit-tags-chips').innerHTML='';
+ document.getElementById('edit-tags-input').value='';
+ document.getElementById('edit-attach-preview').innerHTML='';
+ document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
+ renderTagChips();
+ showPage('edit');
+ document.getElementById('edit-title')?.focus();
+}
+
+async function openEdit(id){
+ editingId=id;pendingFiles=[];pendingUrls=[];editTags=[];
+ try{
+ const note=await api('notes','GET',null,'&id='+id);
+ document.getElementById('edit-page-title').textContent='Modifier la note';
+ document.getElementById('edit-title').value=note.title;
+ document.getElementById('edit-content').value=note.content||'';
+ editTags=(note.tags||'').split(',').filter(t=>t.trim());
+ renderTagChips();
+ updatePreview();
+ // existing attachments shown below form
+ renderAttachmentsEdit(note.attachments||[]);
+ document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
+ showPage('edit');
+ }catch(e){toast(e.message,'error');}
+}
+
+function renderAttachmentsEdit(atts){
+ const el=document.getElementById('edit-existing-attachments');
+ if(!atts.length){el.innerHTML='';return;}
+ el.innerHTML=`Attachements existants
`+
+ atts.map(a=>``).join('');
+}
+
+function updatePreview(){
+ const content=document.getElementById('edit-content')?.value||'';
+ const el=document.getElementById('md-live-preview');
+ if(!el)return;
+ el.innerHTML=window.marked?marked.parse(content):'Preview chargement…';
+}
+
+// Tags
+function renderTagChips(){
+ const wrap=document.getElementById('edit-tags-chips');
+ wrap.innerHTML=editTags.map(t=>
+ `#${escHtml(t)}`
+ ).join('');
+}
+function removeTag(t){ editTags=editTags.filter(x=>x!==t);renderTagChips(); }
+function addTagFromInput(){
+ const inp=document.getElementById('edit-tags-input');
+ const val=inp.value.trim().replace(/^#/,'').toLowerCase();
+ if(val&&!editTags.includes(val)){editTags.push(val);renderTagChips();}
+ inp.value='';
+ document.getElementById('tags-suggestions')?.remove();
+}
+function onTagKeydown(e){
+ if(e.key==='Enter'||e.key===','||e.key===' '){e.preventDefault();addTagFromInput();}
+ else if(e.key==='Backspace'&&!e.target.value&&editTags.length){
+ editTags.pop();renderTagChips();
+ } else showTagSuggestions(e.target.value);
+}
+function showTagSuggestions(q){
+ document.getElementById('tags-suggestions')?.remove();
+ if(!q||!allTagsList.length)return;
+ const matches=allTagsList.filter(t=>t.tag.includes(q.toLowerCase())).slice(0,6);
+ if(!matches.length)return;
+ const inp=document.getElementById('edit-tags-input');
+ const box=document.createElement('div');
+ box.id='tags-suggestions';box.className='tags-suggestions';
+ box.style.cssText=`position:absolute;top:${inp.offsetTop+inp.offsetHeight+4}px;left:${inp.offsetLeft}px`;
+ matches.forEach(t=>{const d=document.createElement('div');d.textContent='#'+t.tag;d.onclick=()=>{
+ if(!editTags.includes(t.tag)){editTags.push(t.tag);renderTagChips();}
+ inp.value='';box.remove();
+ };box.appendChild(d);});
+ inp.closest('.tags-input-wrap').style.position='relative';
+ inp.closest('.tags-input-wrap').appendChild(box);
+}
+
+// File drop
+function initDropZone(){
+ const zone=document.getElementById('edit-drop-zone');
+ const inp=document.getElementById('edit-file-input');
+ if(!zone)return;
+ zone.addEventListener('click',()=>inp?.click());
+ zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over');});
+ zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
+ zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');handleFileSelect(e.dataTransfer.files);});
+ inp?.addEventListener('change',()=>handleFileSelect(inp.files));
+ document.getElementById('edit-content')?.addEventListener('paste',e=>{
+ const files=[...e.clipboardData.files].filter(f=>f.type.startsWith('image/'));
+ if(files.length){e.preventDefault();handleFileSelect(files);}
+ });
+}
+function handleFileSelect(files){
+ const preview=document.getElementById('edit-attach-preview');
+ [...files].forEach(f=>{
+ pendingFiles.push(f);
+ const item=document.createElement('div');item.className='attach-preview-item';
+ if(f.type.startsWith('image/')){
+ const img=document.createElement('img');
+ const reader=new FileReader();
+ reader.onload=ev=>{img.src=ev.target.result;};
+ reader.readAsDataURL(f);
+ item.appendChild(img);
+ }else{
+ item.innerHTML=`${escHtml(f.name)}
`;
+ }
+ const rm=document.createElement('button');rm.className='rm';rm.textContent='✕';
+ rm.onclick=()=>{pendingFiles=pendingFiles.filter(x=>x!==f);item.remove();};
+ item.appendChild(rm);
+ preview.appendChild(item);
+ });
+}
+
+// URL rows
+function addUrlRowHtml(){
+ return `
+
+
+
+
`;
+}
+function addUrlRow(){
+ const wrap=document.getElementById('edit-url-rows');
+ wrap.insertAdjacentHTML('beforeend',addUrlRowHtml());
+}
+
+// Save
+async function saveNote(){
+ const title=document.getElementById('edit-title')?.value.trim();
+ if(!title){toast('Titre requis','error');return;}
+ const content=document.getElementById('edit-content')?.value||'';
+ const tags=editTags.join(',');
+
+ // Collect URLs
+ const urlRows=document.querySelectorAll('#edit-url-rows .url-row');
+ const urls=[];
+ urlRows.forEach(row=>{
+ const u=row.querySelector('.url-input')?.value.trim();
+ const l=row.querySelector('.url-label')?.value.trim()||'';
+ if(u)urls.push({url:u,label:l});
+ });
+
+ try{
+ let noteId;
+ if(editingId){
+ await api('notes','PUT',{title,content,tags},'&id='+editingId);
+ noteId=editingId;toast('Note mise à jour');
+ }else{
+ const r=await api('notes','POST',JSON.stringify({title,content,tags,urls}));
+ noteId=r.id;toast('Note créée');
+ }
+
+ // Upload pending files
+ for(const f of pendingFiles){
+ const fd=new FormData();fd.append('file',f);fd.append('note_id',noteId);
+ try{await api('attachments','POST',fd);}catch(e){toast('Erreur upload: '+f.name,'error');}
+ }
+ // Add URLs (for edit mode)
+ if(editingId){
+ for(const u of urls){
+ const fd=new FormData();fd.append('note_id',noteId);fd.append('url',u.url);fd.append('label',u.label);
+ try{await api('attachments','POST',fd);}catch{}
+ }
+ }
+
+ loadList(currentQ,currentTag);
+ viewNote(noteId);
+ }catch(e){toast(e.message,'error');}
+}
+
+async function deleteNote(id){
+ if(!confirm('Supprimer cette note définitivement ?'))return;
+ try{
+ await api('notes','DELETE',null,'&id='+id);
+ toast('Note supprimée');loadList(currentQ,currentTag);
+ }catch(e){toast(e.message,'error');}
+}
+
+// ─── Init ─────────────────────────────────────────────────────────────────────
+document.addEventListener('DOMContentLoaded',()=>{
+ loadList();
+ loadSidebar();
+ initDropZone();
+ // Search
+ const sinp=document.getElementById('memo-search-input');
+ if(sinp){
+ let t;sinp.addEventListener('input',()=>{clearTimeout(t);t=setTimeout(()=>doSearch(),300);});
+ sinp.addEventListener('keydown',e=>{if(e.key==='Enter')doSearch();});
+ }
+ // Content live preview
+ document.getElementById('edit-content')?.addEventListener('input',updatePreview);
+ // Lightbox close
+ document.getElementById('memo-lightbox')?.addEventListener('click',closeLightbox);
+ // Tags input
+ document.getElementById('edit-tags-input')?.addEventListener('keydown',onTagKeydown);
+ document.getElementById('edit-tags-input')?.addEventListener('blur',()=>setTimeout(()=>document.getElementById('tags-suggestions')?.remove(),150));
+ document.addEventListener('click',e=>{if(!e.target.closest('.tags-input-wrap'))document.getElementById('tags-suggestions')?.remove();});
+});
diff --git a/settings.php b/settings.php
index 5d49d7f..875c397 100644
--- a/settings.php
+++ b/settings.php
@@ -15,7 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) {
- $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage'];
+ $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif.";
@@ -187,6 +187,7 @@ require __DIR__ . '/header.php';
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
+ 'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
];
?>