Module Memo : intégration PercoMemo dans HouseHub
Deploy HouseHub / deploy (push) Successful in 1s

- SPA PHP/MySQL avec rendu Markdown client-side (marked.js)
- Notes par famille : titre, contenu Markdown, tags, pièces jointes
- Attachements : images, fichiers, URLs avec galerie lightbox
- Recherche full-text (LIKE) + filtrage par tag dans la sidebar
- Preview live Markdown en split-view lors de l'édition
- Drag & drop images + paste depuis presse-papier
- Tables pf_memo_notes + pf_memo_attachments dans schema_family.sql
- Migration des 2 notes existantes depuis PercoMemo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
perco
2026-05-12 13:21:57 +02:00
co-authored by Claude Sonnet 4.6
parent 3e344f4c1c
commit 5a81c1e9f0
11 changed files with 1083 additions and 1 deletions
+24
View File
@@ -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 (vehicle_id) REFERENCES pf_vehicles(id) ON DELETE SET NULL,
FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL FOREIGN KEY (maintenance_id) REFERENCES pf_maintenances(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── 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;
+2
View File
@@ -53,6 +53,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?> <?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-nav-link <?= $activePage === 'holidays' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?> <?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
</nav> </nav>
<?php endif; ?> <?php endif; ?>
@@ -98,6 +99,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
<?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?> <?php if (in_array('holidays', $mods)): ?><a href="/holidays.php" class="pf-mobile-nav-link">🏖️ <?= tr('menu_holidays') ?></a><?php endif; ?>
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?> <?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?> <?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
<a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a> <a href="/settings.php" class="pf-mobile-nav-link">⚙️ Paramètres</a>
<?php if (!empty($_SESSION['user']['is_admin'])): ?> <?php if (!empty($_SESSION['user']['is_admin'])): ?>
<a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a> <a href="/admin/" class="pf-mobile-nav-link" style="color:#2563eb">🛡️ Admin</a>
+3
View File
@@ -90,6 +90,9 @@ return [
'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.', 'mod_budget_desc' => 'Seguiment de despeses fixes, ingressos i balanç del compte familiar.',
'mod_garage_name' => 'Garatge', 'mod_garage_name' => 'Garatge',
'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.', 'mod_garage_desc' => 'Seguiu el manteniment dels vostres vehicles, les peces i els costos.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
'menu_memo' => 'Notes',
'cta_open' => 'Obrir', 'cta_open' => 'Obrir',
'cta_explore' => 'Explorar', 'cta_explore' => 'Explorar',
'cta_view_lists' => 'Veure les llistes', 'cta_view_lists' => 'Veure les llistes',
+3
View File
@@ -91,6 +91,9 @@ return [
'mod_budget_desc' => 'Track fixed expenses, income and family account balance.', 'mod_budget_desc' => 'Track fixed expenses, income and family account balance.',
'mod_garage_name' => 'Garage', 'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.', 'mod_garage_desc' => 'Track vehicle maintenance, parts used and costs.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
'menu_memo' => 'Notes',
'cta_open' => 'Open', 'cta_open' => 'Open',
'cta_explore' => 'Explore', 'cta_explore' => 'Explore',
'cta_view_lists' => 'View lists', 'cta_view_lists' => 'View lists',
+3
View File
@@ -93,6 +93,9 @@ return [
'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.', 'mod_budget_desc' => 'Suivi des dépenses fixes, revenus et équilibre du compte familial.',
'mod_garage_name' => 'Garage', 'mod_garage_name' => 'Garage',
'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.', 'mod_garage_desc' => 'Suivez l\'entretien de vos véhicules, les pièces utilisées et les coûts.',
'mod_memo_name' => 'Notes',
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
'menu_memo' => 'Notes',
'cta_open' => 'Ouvrir', 'cta_open' => 'Ouvrir',
'cta_explore' => 'Explorer', 'cta_explore' => 'Explorer',
'cta_view_lists' => 'Voir les listes', 'cta_view_lists' => 'Voir les listes',
+9
View File
@@ -91,6 +91,15 @@ if ($_has_custom_bg): ?>
</a> </a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array('memo', $mods)): ?>
<a href="/memo.php" class="pf-module-card">
<div class="pf-card-icon">📝</div>
<h3 class="pf-card-title"><?= tr('mod_memo_name') ?></h3>
<div class="pf-card-desc"><?= tr('mod_memo_desc') ?></div>
<span class="pf-card-cta"><?= tr('cta_open') ?></span>
</a>
<?php endif; ?>
</div> </div>
</section> </section>
</div> </div>
+139
View File
@@ -0,0 +1,139 @@
<?php
require __DIR__ . '/includes/auth.php';
require_login();
require_once __DIR__ . '/includes/i18n.php';
$pageTitle = 'Notes — HouseHub';
$activePage = 'memo';
require __DIR__ . '/header.php';
?>
<link rel="stylesheet" href="/modules/memo/assets/memo.css">
<div id="memo-toasts" class="memo-toast-container"></div>
<div class="memo-layout">
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
<aside class="memo-sidebar" id="memo-sidebar">
<div class="memo-sidebar-top">
<button class="btn btn-primary" onclick="openCreate()" style="width:100%;justify-content:center">+ Nouvelle note</button>
<div class="memo-search">
<span style="color:var(--text-muted);font-size:.9rem">🔍</span>
<input type="text" id="memo-search-input" placeholder="Rechercher…">
</div>
</div>
<div class="memo-tags-list" id="memo-tags-list"></div>
</aside>
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
<div class="memo-main">
<!-- Page : liste ─────────────────────────────────────────────────────── -->
<div id="memo-page-list" class="memo-page active">
<div class="memo-list-header">
<div>
<div id="memo-list-title" style="font-size:1rem;font-weight:700">📝 Notes</div>
<div id="memo-list-subtitle" class="memo-list-subtitle"></div>
</div>
<button class="btn btn-primary btn-sm" onclick="openCreate()">+ Nouvelle note</button>
</div>
<div id="memo-notes-grid" class="notes-grid"></div>
<div id="memo-pagination" style="display:flex;justify-content:space-between;align-items:center;padding:0 1.5rem 1.5rem;gap:.5rem"></div>
</div>
<!-- Page : vue note ──────────────────────────────────────────────────── -->
<div id="memo-page-view" class="memo-page">
<div class="memo-view-header">
<div class="memo-view-title" id="view-title"></div>
<div class="memo-view-meta">
<div id="view-tags" style="display:flex;gap:.35rem;flex-wrap:wrap"></div>
<span id="view-date" style="margin-left:auto"></span>
<div class="memo-view-actions">
<button class="btn btn-secondary btn-sm" onclick="loadList(currentQ,currentTag)">← Retour</button>
<button class="btn btn-secondary btn-sm" onclick="openEdit(currentNoteId)">✏️ Modifier</button>
<button class="btn btn-danger btn-sm" onclick="deleteNote(currentNoteId)">🗑️</button>
</div>
</div>
</div>
<div class="memo-content-area">
<div class="md-body" id="view-md"></div>
</div>
<div class="memo-attachments" id="view-attachments" style="display:none"></div>
</div>
<!-- Page : édition ───────────────────────────────────────────────────── -->
<div id="memo-page-edit" class="memo-page">
<div class="memo-edit-area">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem;flex-wrap:wrap;gap:.5rem">
<h2 id="edit-page-title" style="font-size:1rem;font-weight:700;margin:0">Nouvelle note</h2>
<div style="display:flex;gap:.5rem">
<button class="btn btn-secondary" onclick="currentNoteId?viewNote(currentNoteId):loadList()">Annuler</button>
<button class="btn btn-primary" onclick="saveNote()">💾 Enregistrer</button>
</div>
</div>
<div class="memo-edit-form">
<!-- Titre -->
<div class="form-group">
<label class="form-label">Titre *</label>
<input type="text" id="edit-title" class="form-control" placeholder="Titre de la note…" required>
</div>
<!-- Contenu + preview -->
<div class="form-group">
<label class="form-label">Contenu <span style="font-size:.72rem;color:var(--text-muted)">(Markdown supporté)</span></label>
<div class="edit-split">
<textarea id="edit-content" class="form-control edit-textarea" placeholder="Écrivez ici en Markdown…"></textarea>
<div class="md-preview-box md-body" id="md-live-preview" style="min-height:320px"></div>
</div>
</div>
<!-- Tags -->
<div class="form-group">
<label class="form-label">Tags</label>
<div class="tags-input-wrap" onclick="document.getElementById('edit-tags-input').focus()">
<div id="edit-tags-chips" style="display:contents"></div>
<input type="text" id="edit-tags-input" placeholder="Ajouter un tag…" autocomplete="off">
</div>
<div style="font-size:.75rem;color:var(--text-muted);margin-top:.25rem">Appuyez sur Entrée ou virgule pour valider</div>
</div>
<!-- Fichiers -->
<div class="form-group">
<label class="form-label">Fichiers & Images</label>
<div id="edit-drop-zone" class="attach-drop">
📎 Glissez-déposez des fichiers ou cliquez pour choisir (images, PDF…)
<input type="file" id="edit-file-input" multiple accept="image/*,.pdf,.doc,.docx,.txt,.csv,.zip" style="display:none">
</div>
<div id="edit-attach-preview" class="attach-preview-list"></div>
<div id="edit-existing-attachments" style="margin-top:.75rem"></div>
</div>
<!-- URLs -->
<div class="form-group">
<label class="form-label">Liens / URLs</label>
<div id="edit-url-rows"></div>
</div>
</div>
</div>
</div>
</div><!-- memo-main -->
</div><!-- memo-layout -->
<!-- Lightbox -->
<div id="memo-lightbox" class="lightbox">
<img src="" alt="" onclick="event.stopPropagation()">
</div>
<!-- marked.js pour le rendu Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
if(window.marked){
marked.setOptions({breaks:true,gfm:true});
}
</script>
<script src="/modules/memo/assets/memo.js"></script>
<?php require __DIR__ . '/footer.php'; ?>
+209
View File
@@ -0,0 +1,209 @@
<?php
require_once dirname(__DIR__, 2) . '/includes/auth.php';
require_login();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
set_exception_handler(function(\Throwable $e) {
if (!headers_sent()) { header('Content-Type: application/json'); http_response_code(500); }
echo json_encode(['ok' => false, 'error' => $e->getMessage()]); exit;
});
require_once dirname(__DIR__, 2) . '/includes/db.php';
$UPLOAD_DIR = '/uploads/memo/';
if (!is_dir($UPLOAD_DIR)) @mkdir($UPLOAD_DIR, 0755, true);
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
function mOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); exit; }
function mErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
function mBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
function parseTags(string $raw): string {
$tags = array_unique(array_filter(array_map(function($t) {
return strtolower(trim(ltrim($t, '#'), " \t\n\r,"));
}, preg_split('/[\s,]+/', $raw))));
return implode(',', $tags);
}
function imgExts(): array { return ['jpg','jpeg','png','gif','webp','svg','bmp']; }
function isImage(string $fname): bool { return in_array(strtolower(pathinfo($fname, PATHINFO_EXTENSION)), imgExts()); }
function handleUpload(string $field, string $dir): ?array {
if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;
$orig = $_FILES[$field]['name'];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
$allowed = array_merge(imgExts(), ['pdf','doc','docx','txt','csv','zip','mp3','mp4']);
if (!in_array($ext, $allowed)) return null;
$fname = uniqid('m', true) . '.' . $ext;
if (!move_uploaded_file($_FILES[$field]['tmp_name'], $dir . $fname)) return null;
return ['filename' => $fname, 'original_name' => $orig, 'size' => filesize($dir . $fname)];
}
// ── NOTES ─────────────────────────────────────────────────────────────────────
if ($action === 'notes') {
if ($method === 'GET') {
$id = $_GET['id'] ?? null;
$q = trim($_GET['q'] ?? '');
$tag = trim($_GET['tag'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 24;
$offset = ($page - 1) * $perPage;
if ($id) {
$n = $pdo->prepare("SELECT * FROM pf_memo_notes WHERE id = ?"); $n->execute([$id]);
$note = $n->fetch(); if (!$note) mErr('Note introuvable', 404);
$a = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE note_id = ? ORDER BY created_at");
$a->execute([$id]); $note['attachments'] = $a->fetchAll();
mOk($note);
}
if ($q) {
$like = '%' . $q . '%';
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$like, $like, $like, $perPage, $offset]);
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE title LIKE ? OR content LIKE ? OR tags LIKE ?");
$total->execute([$like, $like, $like]);
} elseif ($tag) {
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0
ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$tag, $perPage, $offset]);
$total = $pdo->prepare("SELECT COUNT(*) FROM pf_memo_notes WHERE FIND_IN_SET(?, tags) > 0");
$total->execute([$tag]);
} else {
$stmt = $pdo->prepare("SELECT id, title, tags, updated_at, LEFT(content,200) as snippet
FROM pf_memo_notes ORDER BY updated_at DESC LIMIT ? OFFSET ?");
$stmt->execute([$perPage, $offset]);
$total = $pdo->query("SELECT COUNT(*) FROM pf_memo_notes");
}
mOk(['notes' => $stmt->fetchAll(), 'total' => (int)$total->fetchColumn(),
'page' => $page, 'per_page' => $perPage]);
}
if ($method === 'POST') {
$d = $_POST ?: mBody();
$title = trim($d['title'] ?? '');
if (!$title) mErr('Titre requis');
$tags = parseTags($d['tags'] ?? '');
$pdo->prepare("INSERT INTO pf_memo_notes (title, content, tags) VALUES (?,?,?)")
->execute([$title, $d['content'] ?? '', $tags]);
$id = $pdo->lastInsertId();
// Files
if (!empty($_FILES)) {
foreach ($_FILES as $key => $f) {
if ($f['error'] === UPLOAD_ERR_OK) {
$up = handleUpload($key, $UPLOAD_DIR);
if ($up) {
$type = isImage($up['filename']) ? 'image' : 'file';
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
}
}
}
}
// URLs from JSON body
foreach (($d['urls'] ?? []) as $urlItem) {
if (!empty($urlItem['url'])) {
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
->execute([$id, 'url', $urlItem['url'], $urlItem['label'] ?? '']);
}
}
mOk(['id' => (int)$id]);
}
if ($method === 'PUT') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$d = mBody();
$title = trim($d['title'] ?? '');
if (!$title) mErr('Titre requis');
$tags = parseTags($d['tags'] ?? '');
$pdo->prepare("UPDATE pf_memo_notes SET title=?, content=?, tags=?, updated_at=NOW() WHERE id=?")
->execute([$title, $d['content'] ?? '', $tags, $id]);
mOk(['updated' => true]);
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$rows = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE note_id=? AND filename IS NOT NULL");
$rows->execute([$id]);
foreach ($rows->fetchAll() as $r) @unlink($UPLOAD_DIR . $r['filename']);
$pdo->prepare("DELETE FROM pf_memo_notes WHERE id=?")->execute([$id]);
mOk(['deleted' => true]);
}
}
// ── TAGS ──────────────────────────────────────────────────────────────────────
if ($action === 'tags') {
$rows = $pdo->query("SELECT tags FROM pf_memo_notes WHERE tags != ''")->fetchAll();
$counts = [];
foreach ($rows as $r) {
foreach (explode(',', $r['tags']) as $t) {
$t = trim($t);
if ($t) $counts[$t] = ($counts[$t] ?? 0) + 1;
}
}
arsort($counts);
mOk(array_map(fn($t, $c) => ['tag' => $t, 'count' => $c], array_keys($counts), $counts));
}
// ── ATTACHMENTS ───────────────────────────────────────────────────────────────
if ($action === 'attachments') {
if ($method === 'POST') {
$id = $_POST['note_id'] ?? null; if (!$id) mErr('note_id manquant');
// File upload
if (!empty($_FILES['file']) && $_FILES['file']['error'] === UPLOAD_ERR_OK) {
$up = handleUpload('file', $UPLOAD_DIR);
if (!$up) mErr('Upload échoué ou format non autorisé');
$type = isImage($up['filename']) ? 'image' : 'file';
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,filename,original_name,size) VALUES (?,?,?,?,?)")
->execute([$id, $type, $up['filename'], $up['original_name'], $up['size']]);
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => $type,
'filename' => $up['filename'], 'original_name' => $up['original_name']]);
}
// URL
$url = trim($_POST['url'] ?? '');
if ($url) {
$label = trim($_POST['label'] ?? '');
$pdo->prepare("INSERT INTO pf_memo_attachments (note_id,type,url,label) VALUES (?,?,?,?)")
->execute([$id, 'url', $url, $label]);
mOk(['id' => (int)$pdo->lastInsertId(), 'type' => 'url', 'url' => $url, 'label' => $label]);
}
mErr('Aucun fichier ou URL fourni');
}
if ($method === 'DELETE') {
$id = $_GET['id'] ?? null; if (!$id) mErr('ID manquant');
$r = $pdo->prepare("SELECT filename FROM pf_memo_attachments WHERE id=?"); $r->execute([$id]);
$row = $r->fetch();
if ($row && $row['filename']) @unlink($UPLOAD_DIR . $row['filename']);
$pdo->prepare("DELETE FROM pf_memo_attachments WHERE id=?")->execute([$id]);
mOk(['deleted' => true]);
}
}
// ── FILE SERVE ────────────────────────────────────────────────────────────────
if ($action === 'file') {
$id = $_GET['id'] ?? null;
$row = null;
if ($id) { $s = $pdo->prepare("SELECT * FROM pf_memo_attachments WHERE id=?"); $s->execute([$id]); $row = $s->fetch(); }
if (!$row || !$row['filename'] || !file_exists($UPLOAD_DIR . $row['filename'])) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($row['filename'], PATHINFO_EXTENSION));
$mime = [
'jpg'=>'image/jpeg','jpeg'=>'image/jpeg','png'=>'image/png','gif'=>'image/gif',
'webp'=>'image/webp','svg'=>'image/svg+xml','pdf'=>'application/pdf',
'txt'=>'text/plain','mp4'=>'video/mp4','mp3'=>'audio/mpeg',
][$ext] ?? 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Disposition: inline; filename="' . rawurlencode($row['original_name'] ?? $row['filename']) . '"');
header('Cache-Control: private, max-age=3600');
readfile($UPLOAD_DIR . $row['filename']); exit;
}
mErr('Action inconnue', 404);
+279
View File
@@ -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); }
+410
View File
@@ -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===''&&currentQ==='';
let html=`<div class="memo-tag-item${allActive?' active':''}" onclick="filterTag('')">
📝 Toutes les notes <span class="memo-tag-count" id="memo-all-count">…</span></div>`;
allTagsList.forEach(t=>{
const act=currentTag===t.tag?' active':'';
html+=`<div class="memo-tag-item${act}" onclick="filterTag('${escHtml(t.tag)}')">
<span># ${escHtml(t.tag)}</span><span class="memo-tag-count">${t.count}</span></div>`;
});
el.innerHTML=html;
}catch(e){}
}
// ─── Note list ────────────────────────────────────────────────────────────────
async function loadList(q='',tag='',page=1){
currentQ=q;currentTag=tag;showPage('list');
loadSidebar();
try{
let extra='&page='+page;
if(q)extra+='&q='+encodeURIComponent(q);
if(tag)extra+='&tag='+encodeURIComponent(tag);
const data=await api('notes','GET',null,extra);
const el=document.getElementById('memo-notes-grid');
const count=document.getElementById('memo-all-count');
const title=document.getElementById('memo-list-title');
const subtitle=document.getElementById('memo-list-subtitle');
if(count)count.textContent=data.total;
if(title){
title.textContent = q?`🔍 "${q}"` : tag?`# ${tag}` : '📝 Notes';
}
if(subtitle)subtitle.textContent=data.total+' note'+(data.total!==1?'s':'');
if(!data.notes.length){
el.innerHTML='<div class="memo-empty"><div class="icon">📝</div><p>'+(q||tag?'Aucune note trouvée.':'Aucune note. Créez-en une !')+'</p></div>';
return;
}
el.innerHTML=data.notes.map(n=>noteCardHtml(n)).join('');
// Pagination
const total_pages=Math.ceil(data.total/data.per_page);
const pag=document.getElementById('memo-pagination');
if(pag){
pag.innerHTML=total_pages>1?
(page>1?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page-1})">← Précédent</button>`:'<span></span>')+
`<span style="color:var(--text-muted);font-size:.82rem">Page ${page} / ${total_pages}</span>`+
(page<total_pages?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page+1})">Suivant →</button>`:'<span></span>')
: '';
}
}catch(e){toast(e.message,'error');}
}
function noteCardHtml(n){
const tags=(n.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="event.stopPropagation();filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
const snippet=n.snippet?(escHtml(n.snippet.replace(/\n/g,' '))):'';
return `<div class="note-card" onclick="viewNote(${n.id})">
<div class="note-card-title">${escHtml(n.title)}</div>
${snippet?`<div class="note-card-snippet">${snippet}</div>`:''}
<div class="note-card-meta">${tags}<span class="note-card-date">${fmtDate(n.updated_at)}</span></div>
</div>`;
}
function filterTag(tag){
currentTag=tag;currentQ='';
document.getElementById('memo-search-input')?.value!=null&&(document.getElementById('memo-search-input').value='');
loadList('',tag);
}
function doSearch(){
const q=document.getElementById('memo-search-input')?.value.trim()||'';
loadList(q,'');
}
// ─── Note view ────────────────────────────────────────────────────────────────
async function viewNote(id){
currentNoteId=id;showPage('view');
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('view-title').textContent=note.title;
document.getElementById('view-date').textContent='Modifié '+fmtDate(note.updated_at);
// Tags
const tagEl=document.getElementById('view-tags');
tagEl.innerHTML=(note.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
// Markdown rendering
const mdEl=document.getElementById('view-md');
if(note.content&&window.marked){
mdEl.innerHTML=marked.parse(note.content);
mdEl.classList.remove('empty');
} else if(note.content){
mdEl.textContent=note.content;mdEl.classList.remove('empty');
} else {
mdEl.innerHTML='<em>Aucun contenu.</em>';mdEl.classList.add('empty');
}
// Attachments
renderAttachmentsView(note.attachments||[],id);
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsView(atts,noteId){
const images=atts.filter(a=>a.type==='image');
const files =atts.filter(a=>a.type==='file');
const urls =atts.filter(a=>a.type==='url');
const el=document.getElementById('view-attachments');
el.innerHTML='';
if(!atts.length){el.style.display='none';return;}
el.style.display='block';
if(images.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🖼️ Images</div>
<div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
<img src="${API}?action=file&id=${a.id}" loading="lazy" alt="${escHtml(a.original_name||'')}">
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
</div>`).join('')}</div></div>`;
}
if(files.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">📎 Fichiers</div>
<div class="att-file-list">${files.map(a=>`
<div class="att-file-item">
<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
</div>`).join('')}</div></div>`;
}
if(urls.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🔗 Liens</div>
<div class="att-url-list">${urls.map(a=>`
<div class="att-url-item">
<a href="${escHtml(a.url)}" target="_blank" rel="noopener">${escHtml(a.label||a.url)}</a>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
</div>`).join('')}</div></div>`;
}
}
async function deleteAttachment(attId,noteId){
if(!confirm('Supprimer cet attachement ?'))return;
try{ await api('attachments','DELETE',null,'&id='+attId); toast('Supprimé'); viewNote(noteId); }
catch(e){toast(e.message,'error');}
}
// Lightbox
function openLightbox(src){
const lb=document.getElementById('memo-lightbox');
const img=lb.querySelector('img');
img.src=src;lb.classList.add('show');
}
function closeLightbox(){ document.getElementById('memo-lightbox')?.classList.remove('show'); }
// ─── Edit / Create ────────────────────────────────────────────────────────────
let editingId=null;
let editTags=[];
let tagAutocomplete=[];
function openCreate(){
editingId=null;pendingFiles=[];pendingUrls=[];editTags=[];
document.getElementById('edit-page-title').textContent='Nouvelle note';
document.getElementById('edit-title').value='';
document.getElementById('edit-content').value='';
document.getElementById('md-live-preview').innerHTML='';
document.getElementById('edit-tags-chips').innerHTML='';
document.getElementById('edit-tags-input').value='';
document.getElementById('edit-attach-preview').innerHTML='';
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
renderTagChips();
showPage('edit');
document.getElementById('edit-title')?.focus();
}
async function openEdit(id){
editingId=id;pendingFiles=[];pendingUrls=[];editTags=[];
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('edit-page-title').textContent='Modifier la note';
document.getElementById('edit-title').value=note.title;
document.getElementById('edit-content').value=note.content||'';
editTags=(note.tags||'').split(',').filter(t=>t.trim());
renderTagChips();
updatePreview();
// existing attachments shown below form
renderAttachmentsEdit(note.attachments||[]);
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
showPage('edit');
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsEdit(atts){
const el=document.getElementById('edit-existing-attachments');
if(!atts.length){el.innerHTML='';return;}
el.innerHTML=`<div class="memo-att-title" style="margin-bottom:.5rem">Attachements existants</div>`+
atts.map(a=>`<div class="att-file-item">
${a.type==='url'?
`<a href="${escHtml(a.url)}" target="_blank">${escHtml(a.label||a.url)}</a>`:
`<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>`}
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${editingId})">✕</button>
</div>`).join('');
}
function updatePreview(){
const content=document.getElementById('edit-content')?.value||'';
const el=document.getElementById('md-live-preview');
if(!el)return;
el.innerHTML=window.marked?marked.parse(content):'<em style="color:var(--text-muted)">Preview chargement…</em>';
}
// Tags
function renderTagChips(){
const wrap=document.getElementById('edit-tags-chips');
wrap.innerHTML=editTags.map(t=>
`<span class="tag-chip">#${escHtml(t)}<button type="button" onclick="removeTag('${escHtml(t)}')" title="Retirer">×</button></span>`
).join('');
}
function removeTag(t){ editTags=editTags.filter(x=>x!==t);renderTagChips(); }
function addTagFromInput(){
const inp=document.getElementById('edit-tags-input');
const val=inp.value.trim().replace(/^#/,'').toLowerCase();
if(val&&!editTags.includes(val)){editTags.push(val);renderTagChips();}
inp.value='';
document.getElementById('tags-suggestions')?.remove();
}
function onTagKeydown(e){
if(e.key==='Enter'||e.key===','||e.key===' '){e.preventDefault();addTagFromInput();}
else if(e.key==='Backspace'&&!e.target.value&&editTags.length){
editTags.pop();renderTagChips();
} else showTagSuggestions(e.target.value);
}
function showTagSuggestions(q){
document.getElementById('tags-suggestions')?.remove();
if(!q||!allTagsList.length)return;
const matches=allTagsList.filter(t=>t.tag.includes(q.toLowerCase())).slice(0,6);
if(!matches.length)return;
const inp=document.getElementById('edit-tags-input');
const box=document.createElement('div');
box.id='tags-suggestions';box.className='tags-suggestions';
box.style.cssText=`position:absolute;top:${inp.offsetTop+inp.offsetHeight+4}px;left:${inp.offsetLeft}px`;
matches.forEach(t=>{const d=document.createElement('div');d.textContent='#'+t.tag;d.onclick=()=>{
if(!editTags.includes(t.tag)){editTags.push(t.tag);renderTagChips();}
inp.value='';box.remove();
};box.appendChild(d);});
inp.closest('.tags-input-wrap').style.position='relative';
inp.closest('.tags-input-wrap').appendChild(box);
}
// File drop
function initDropZone(){
const zone=document.getElementById('edit-drop-zone');
const inp=document.getElementById('edit-file-input');
if(!zone)return;
zone.addEventListener('click',()=>inp?.click());
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over');});
zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');handleFileSelect(e.dataTransfer.files);});
inp?.addEventListener('change',()=>handleFileSelect(inp.files));
document.getElementById('edit-content')?.addEventListener('paste',e=>{
const files=[...e.clipboardData.files].filter(f=>f.type.startsWith('image/'));
if(files.length){e.preventDefault();handleFileSelect(files);}
});
}
function handleFileSelect(files){
const preview=document.getElementById('edit-attach-preview');
[...files].forEach(f=>{
pendingFiles.push(f);
const item=document.createElement('div');item.className='attach-preview-item';
if(f.type.startsWith('image/')){
const img=document.createElement('img');
const reader=new FileReader();
reader.onload=ev=>{img.src=ev.target.result;};
reader.readAsDataURL(f);
item.appendChild(img);
}else{
item.innerHTML=`<div style="background:#f1f5f9;padding:.3rem .5rem;border-radius:6px;font-size:.75rem;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(f.name)}</div>`;
}
const rm=document.createElement('button');rm.className='rm';rm.textContent='✕';
rm.onclick=()=>{pendingFiles=pendingFiles.filter(x=>x!==f);item.remove();};
item.appendChild(rm);
preview.appendChild(item);
});
}
// URL rows
function addUrlRowHtml(){
return `<div class="url-row" style="margin-bottom:.4rem">
<input type="text" class="form-control url-input" placeholder="https://..." style="flex:1.5">
<input type="text" class="form-control url-label" placeholder="Libellé (optionnel)" style="flex:1">
<button type="button" class="btn btn-secondary btn-sm" onclick="addUrlRow()">+</button>
</div>`;
}
function addUrlRow(){
const wrap=document.getElementById('edit-url-rows');
wrap.insertAdjacentHTML('beforeend',addUrlRowHtml());
}
// Save
async function saveNote(){
const title=document.getElementById('edit-title')?.value.trim();
if(!title){toast('Titre requis','error');return;}
const content=document.getElementById('edit-content')?.value||'';
const tags=editTags.join(',');
// Collect URLs
const urlRows=document.querySelectorAll('#edit-url-rows .url-row');
const urls=[];
urlRows.forEach(row=>{
const u=row.querySelector('.url-input')?.value.trim();
const l=row.querySelector('.url-label')?.value.trim()||'';
if(u)urls.push({url:u,label:l});
});
try{
let noteId;
if(editingId){
await api('notes','PUT',{title,content,tags},'&id='+editingId);
noteId=editingId;toast('Note mise à jour');
}else{
const r=await api('notes','POST',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();});
});
+2 -1
View File
@@ -15,7 +15,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
if ($action === 'set_modules' && $family_id) { if ($action === 'set_modules' && $family_id) {
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage']; $all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo'];
$enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m]))); $enabled = array_values(array_filter($all, fn($m) => isset($_POST['mod_' . $m])));
if (empty($enabled)) { if (empty($enabled)) {
$error = "Vous devez garder au moins un module actif."; $error = "Vous devez garder au moins un module actif.";
@@ -187,6 +187,7 @@ require __DIR__ . '/header.php';
'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')], 'holidays' => ['icon' => '🏖️', 'label' => tr('menu_holidays')],
'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')], 'gifts' => ['icon' => '🎁', 'label' => tr('menu_gifts')],
'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')], 'garage' => ['icon' => '🚗', 'label' => tr('menu_garage')],
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
]; ];
?> ?>
<form method="post"> <form method="post">