Add Todo module: SPA task manager with lists, priorities and due dates
Deploy HouseHub / deploy (push) Successful in 1s
Deploy HouseHub / deploy (push) Successful in 1s
- New todo.php SPA page with sidebar (smart views + user lists) - modules/todo/api.php: REST API for lists and todos (CRUD, filters, stats) - modules/todo/assets/todo.js: frontend (quick add, toggle done, modals) - modules/todo/assets/todo.css: full light/dark theme styles - pf_todo_lists + pf_todos tables added to schema_family.sql - Integrated in header nav, home page cards, settings module list - Translations added in fr/en/ca Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5a81c1e9f0
commit
b97cba6e75
@@ -317,3 +317,27 @@ CREATE TABLE IF NOT EXISTS pf_memo_attachments (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
|
FOREIGN KEY (note_id) REFERENCES pf_memo_notes(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- ─── Todo ─────────────────────────────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_todo_lists (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
color VARCHAR(20) DEFAULT '#3b82f6',
|
||||||
|
icon VARCHAR(10) DEFAULT '📋',
|
||||||
|
position INT DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pf_todos (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
list_id INT DEFAULT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
due_date DATE DEFAULT NULL,
|
||||||
|
priority ENUM('none','low','medium','high') DEFAULT 'none',
|
||||||
|
done TINYINT(1) DEFAULT 0,
|
||||||
|
done_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (list_id) REFERENCES pf_todo_lists(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-nav-link <?= $activePage === 'gift-list' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-nav-link <?= $activePage === 'garage' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-nav-link <?= $activePage === 'memo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-nav-link <?= $activePage === 'todo' ? 'pf-nav-link--active' : '' ?>"><?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ $currentLang = $_SESSION['app_lang'] ?? 'fr';
|
|||||||
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
|
<?php if (in_array('gifts', $mods)): ?><a href="/gift-list.php" class="pf-mobile-nav-link">🎁 <?= tr('menu_gifts') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
|
<?php if (in_array('garage', $mods)): ?><a href="/garage.php" class="pf-mobile-nav-link">🚗 <?= tr('menu_garage') ?></a><?php endif; ?>
|
||||||
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
|
<?php if (in_array('memo', $mods)): ?><a href="/memo.php" class="pf-mobile-nav-link">📝 <?= tr('menu_memo') ?></a><?php endif; ?>
|
||||||
|
<?php if (in_array('todo', $mods)): ?><a href="/todo.php" class="pf-mobile-nav-link">✅ <?= tr('menu_todo') ?></a><?php endif; ?>
|
||||||
<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>
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ return [
|
|||||||
'mod_memo_name' => 'Notes',
|
'mod_memo_name' => 'Notes',
|
||||||
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
|
'mod_memo_desc' => 'Els vostres memos, receptes, instruccions i informació diversa amb Markdown.',
|
||||||
'menu_memo' => 'Notes',
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gestioneu les vostres tasques, llistes de la compra i recordatoris familiars.',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
'cta_open' => 'Obrir',
|
'cta_open' => 'Obrir',
|
||||||
'cta_explore' => 'Explorar',
|
'cta_explore' => 'Explorar',
|
||||||
'cta_view_lists' => 'Veure les llistes',
|
'cta_view_lists' => 'Veure les llistes',
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ return [
|
|||||||
'mod_memo_name' => 'Notes',
|
'mod_memo_name' => 'Notes',
|
||||||
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
|
'mod_memo_desc' => 'Your memos, recipes, instructions and miscellaneous info with Markdown and attachments.',
|
||||||
'menu_memo' => 'Notes',
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Manage tasks, shopping lists and family reminders.',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
'cta_open' => 'Open',
|
'cta_open' => 'Open',
|
||||||
'cta_explore' => 'Explore',
|
'cta_explore' => 'Explore',
|
||||||
'cta_view_lists' => 'View lists',
|
'cta_view_lists' => 'View lists',
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ return [
|
|||||||
'mod_memo_name' => 'Notes',
|
'mod_memo_name' => 'Notes',
|
||||||
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
|
'mod_memo_desc' => 'Vos mémos, recettes, instructions et infos diverses avec Markdown et pièces jointes.',
|
||||||
'menu_memo' => 'Notes',
|
'menu_memo' => 'Notes',
|
||||||
|
'mod_todo_name' => 'Todo',
|
||||||
|
'mod_todo_desc' => 'Gérez vos tâches, listes de courses et rappels en famille.',
|
||||||
|
'menu_todo' => 'Todo',
|
||||||
'cta_open' => 'Ouvrir',
|
'cta_open' => 'Ouvrir',
|
||||||
'cta_explore' => 'Explorer',
|
'cta_explore' => 'Explorer',
|
||||||
'cta_view_lists' => 'Voir les listes',
|
'cta_view_lists' => 'Voir les listes',
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ if ($_has_custom_bg): ?>
|
|||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (in_array('todo', $mods)): ?>
|
||||||
|
<a href="/todo.php" class="pf-module-card">
|
||||||
|
<div class="pf-card-icon">✅</div>
|
||||||
|
<h3 class="pf-card-title"><?= tr('mod_todo_name') ?></h3>
|
||||||
|
<div class="pf-card-desc"><?= tr('mod_todo_desc') ?></div>
|
||||||
|
<span class="pf-card-cta"><?= tr('cta_open') ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?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';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
function tOk($d) { echo json_encode(['ok' => true, 'data' => $d], JSON_UNESCAPED_UNICODE); exit; }
|
||||||
|
function tErr($m, $c = 400) { http_response_code($c); echo json_encode(['ok' => false, 'error' => $m]); exit; }
|
||||||
|
function tBody() { return json_decode(file_get_contents('php://input'), true) ?? []; }
|
||||||
|
|
||||||
|
// ── LISTS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'lists') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT l.*, COUNT(t.id) as total,
|
||||||
|
SUM(CASE WHEN t.done=0 THEN 1 ELSE 0 END) as pending
|
||||||
|
FROM pf_todo_lists l
|
||||||
|
LEFT JOIN pf_todos t ON t.list_id = l.id
|
||||||
|
GROUP BY l.id ORDER BY l.position ASC, l.id ASC"
|
||||||
|
)->fetchAll();
|
||||||
|
tOk($rows);
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = tBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) tErr('Nom requis');
|
||||||
|
$pdo->prepare("INSERT INTO pf_todo_lists (name, color, icon) VALUES (?,?,?)")
|
||||||
|
->execute([$name, $d['color'] ?? '#3b82f6', $d['icon'] ?? '📋']);
|
||||||
|
tOk(['id' => (int)$pdo->lastInsertId()]);
|
||||||
|
}
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
$name = trim($d['name'] ?? ''); if (!$name) tErr('Nom requis');
|
||||||
|
$pdo->prepare("UPDATE pf_todo_lists SET name=?, color=?, icon=? WHERE id=?")
|
||||||
|
->execute([$name, $d['color'] ?? '#3b82f6', $d['icon'] ?? '📋', $id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("DELETE FROM pf_todo_lists WHERE id=?")->execute([$id]);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TODOS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'todos') {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$list_id = $_GET['list_id'] ?? null;
|
||||||
|
$show_done = ($_GET['show_done'] ?? '0') === '1';
|
||||||
|
$priority = $_GET['priority'] ?? null;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($list_id === 'all' || $list_id === null) {
|
||||||
|
// all
|
||||||
|
} elseif ($list_id === 'today') {
|
||||||
|
$where[] = 't.due_date = CURDATE()';
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} elseif ($list_id === 'upcoming') {
|
||||||
|
$where[] = 't.due_date >= CURDATE()';
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} else {
|
||||||
|
$where[] = 't.list_id = ?'; $params[] = $list_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$show_done && $list_id !== 'done') {
|
||||||
|
$where[] = 't.done = 0';
|
||||||
|
} elseif ($list_id === 'done') {
|
||||||
|
$where[] = 't.done = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priority) { $where[] = 't.priority = ?'; $params[] = $priority; }
|
||||||
|
|
||||||
|
$sql = "SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon
|
||||||
|
FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id";
|
||||||
|
if ($where) $sql .= ' WHERE ' . implode(' AND ', $where);
|
||||||
|
$sql .= ' ORDER BY t.done ASC, FIELD(t.priority,"high","medium","low","none") ASC, t.due_date ASC, t.created_at ASC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql); $stmt->execute($params);
|
||||||
|
tOk($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$d = tBody();
|
||||||
|
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
||||||
|
$pdo->prepare("INSERT INTO pf_todos (list_id, title, notes, due_date, priority) VALUES (?,?,?,?,?)")
|
||||||
|
->execute([
|
||||||
|
$d['list_id'] ?: null,
|
||||||
|
$title,
|
||||||
|
$d['notes'] ?? null,
|
||||||
|
$d['due_date'] ?: null,
|
||||||
|
$d['priority'] ?? 'none'
|
||||||
|
]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
$new = $pdo->prepare("SELECT t.*, l.name as list_name, l.color as list_color, l.icon as list_icon FROM pf_todos t LEFT JOIN pf_todo_lists l ON l.id = t.list_id WHERE t.id=?");
|
||||||
|
$new->execute([$id]); tOk($new->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'PUT') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$d = tBody();
|
||||||
|
|
||||||
|
// Toggle done
|
||||||
|
if (isset($d['done'])) {
|
||||||
|
$done = $d['done'] ? 1 : 0;
|
||||||
|
$pdo->prepare("UPDATE pf_todos SET done=?, done_at=?, updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$done, $done ? date('Y-m-d H:i:s') : null, $id]);
|
||||||
|
tOk(['done' => $done]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full update
|
||||||
|
$title = trim($d['title'] ?? ''); if (!$title) tErr('Titre requis');
|
||||||
|
$pdo->prepare("UPDATE pf_todos SET list_id=?, title=?, notes=?, due_date=?, priority=?, updated_at=NOW() WHERE id=?")
|
||||||
|
->execute([$d['list_id'] ?: null, $title, $d['notes'] ?? null, $d['due_date'] ?: null, $d['priority'] ?? 'none', $id]);
|
||||||
|
tOk(['updated' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$id = $_GET['id'] ?? null; if (!$id) tErr('ID manquant');
|
||||||
|
$pdo->prepare("DELETE FROM pf_todos WHERE id=?")->execute([$id]);
|
||||||
|
tOk(['deleted' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STATS ──────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'stats') {
|
||||||
|
tOk([
|
||||||
|
'total' => $pdo->query("SELECT COUNT(*) FROM pf_todos")->fetchColumn(),
|
||||||
|
'pending' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE done=0")->fetchColumn(),
|
||||||
|
'done' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE done=1")->fetchColumn(),
|
||||||
|
'today' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE due_date=CURDATE() AND done=0")->fetchColumn(),
|
||||||
|
'overdue' => $pdo->query("SELECT COUNT(*) FROM pf_todos WHERE due_date < CURDATE() AND done=0")->fetchColumn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tErr('Action inconnue', 404);
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
/* Todo module */
|
||||||
|
|
||||||
|
.todo-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.todo-sidebar {
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.todo-sidebar-top {
|
||||||
|
padding: .75rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
position: sticky; top: 0; background: var(--bg-panel); z-index: 1;
|
||||||
|
}
|
||||||
|
.todo-main { overflow-y: auto; background: var(--bg-page); }
|
||||||
|
|
||||||
|
/* Sidebar items */
|
||||||
|
.todo-nav-item {
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
padding: .5rem .75rem; cursor: pointer;
|
||||||
|
font-size: .875rem; color: var(--text-muted);
|
||||||
|
border-radius: 8px; margin: 1px .5rem;
|
||||||
|
transition: background .12s, color .12s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.todo-nav-item:hover { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
.todo-nav-item.active { background: #eff6ff; color: var(--primary); font-weight: 600; }
|
||||||
|
.todo-nav-badge {
|
||||||
|
margin-left: auto; font-size: .7rem; font-weight: 700;
|
||||||
|
background: var(--bg-page); color: var(--text-muted);
|
||||||
|
padding: 1px 6px; border-radius: 999px; min-width: 20px; text-align: center;
|
||||||
|
}
|
||||||
|
.todo-nav-item.active .todo-nav-badge { background: #dbeafe; color: var(--primary); }
|
||||||
|
.todo-nav-badge.urgent { background: #fee2e2; color: var(--danger); }
|
||||||
|
.todo-nav-section { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: .75rem .75rem .25rem; }
|
||||||
|
.todo-list-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Main header */
|
||||||
|
.todo-main-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 1.1rem 1.5rem; background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border-light); gap: .75rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.todo-main-header h2 { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||||
|
.todo-subtitle { font-size: .8rem; color: var(--text-muted); }
|
||||||
|
.todo-header-actions { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
|
||||||
|
/* Quick add */
|
||||||
|
.todo-quick-add {
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
margin: 1rem 1.5rem .75rem;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px; padding: .5rem .75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
.todo-quick-add:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
.todo-quick-add input {
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font-size: .9rem; color: var(--text-main); flex: 1;
|
||||||
|
}
|
||||||
|
.todo-quick-add input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Todo list */
|
||||||
|
.todo-list { padding: 0 1.5rem 2rem; }
|
||||||
|
.todo-group-label { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: .75rem 0 .35rem; }
|
||||||
|
.todo-item {
|
||||||
|
display: flex; align-items: flex-start; gap: .75rem;
|
||||||
|
padding: .65rem .9rem; margin-bottom: .35rem;
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px; cursor: pointer;
|
||||||
|
transition: border-color .15s, box-shadow .12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.todo-item:hover { border-color: #cbd5e1; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||||
|
.todo-item.done { opacity: .55; }
|
||||||
|
.todo-item.done .todo-title { text-decoration: line-through; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.todo-check {
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
border: 2px solid #cbd5e1; background: transparent;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; flex-shrink: 0; margin-top: 2px;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.todo-check:hover { border-color: var(--primary); background: #eff6ff; }
|
||||||
|
.todo-check.checked { background: var(--success); border-color: var(--success); color: #fff; font-size: .8rem; }
|
||||||
|
.todo-check.checked::after { content: '✓'; }
|
||||||
|
|
||||||
|
/* Priority colors for checkbox */
|
||||||
|
.todo-item[data-priority="high"] .todo-check { border-color: var(--danger); }
|
||||||
|
.todo-item[data-priority="high"] .todo-check:hover { background: #fef2f2; border-color: var(--danger); }
|
||||||
|
.todo-item[data-priority="medium"] .todo-check { border-color: var(--warning); }
|
||||||
|
.todo-item[data-priority="low"] .todo-check { border-color: var(--success); }
|
||||||
|
|
||||||
|
.todo-content { flex: 1; min-width: 0; }
|
||||||
|
.todo-title { font-size: .9rem; font-weight: 500; color: var(--text-main); line-height: 1.3; }
|
||||||
|
.todo-meta { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-top: .25rem; }
|
||||||
|
.todo-due { font-size: .75rem; color: var(--text-muted); display: flex; align-items: center; gap: .2rem; }
|
||||||
|
.todo-due.overdue { color: var(--danger); font-weight: 600; }
|
||||||
|
.todo-due.today { color: #d97706; font-weight: 600; }
|
||||||
|
.todo-list-badge { font-size: .7rem; padding: .1rem .45rem; border-radius: 999px; font-weight: 500; }
|
||||||
|
.todo-priority-badge { font-size: .7rem; font-weight: 700; padding: .1rem .4rem; border-radius: 999px; }
|
||||||
|
.pri-high { background: #fee2e2; color: #dc2626; }
|
||||||
|
.pri-medium { background: #fef3c7; color: #b45309; }
|
||||||
|
.pri-low { background: #dcfce7; color: #15803d; }
|
||||||
|
.todo-notes-preview { font-size: .78rem; color: var(--text-muted); margin-top: .2rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
|
||||||
|
.todo-item-actions { display: flex; gap: .25rem; opacity: 0; transition: opacity .12s; flex-shrink: 0; }
|
||||||
|
.todo-item:hover .todo-item-actions { opacity: 1; }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.todo-empty { text-align: center; padding: 4rem 2rem; color: var(--text-muted); }
|
||||||
|
.todo-empty .icon { font-size: 3rem; opacity: .3; margin-bottom: 1rem; }
|
||||||
|
.todo-empty p { margin: 0; }
|
||||||
|
|
||||||
|
/* Modals */
|
||||||
|
.todo-modal-backdrop {
|
||||||
|
display: none; position: fixed; inset: 0; z-index: 200;
|
||||||
|
background: rgba(15,23,42,.5); backdrop-filter: blur(4px);
|
||||||
|
align-items: center; justify-content: center; padding: 1rem;
|
||||||
|
}
|
||||||
|
.todo-modal-backdrop.show { display: flex; }
|
||||||
|
.todo-modal {
|
||||||
|
background: var(--bg-panel); border: 1px solid var(--border-light);
|
||||||
|
border-radius: 14px; width: 100%; max-width: 500px;
|
||||||
|
max-height: 90vh; display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,.15);
|
||||||
|
animation: modalIn .18s ease;
|
||||||
|
}
|
||||||
|
@keyframes modalIn { from{opacity:0;transform:scale(.96) translateY(-8px)} to{opacity:1;transform:none} }
|
||||||
|
.todo-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-light); }
|
||||||
|
.todo-modal-header h3 { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||||
|
.todo-modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.2rem; border-radius: 6px; padding: .2rem .4rem; }
|
||||||
|
.todo-modal-close:hover { background: var(--bg-page); }
|
||||||
|
.todo-modal-body { padding: 1.25rem; overflow-y: auto; display: flex; flex-direction: column; gap: .9rem; }
|
||||||
|
.todo-modal-footer { padding: 1rem 1.25rem; border-top: 1px solid var(--border-light); display: flex; justify-content: flex-end; gap: .5rem; }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: .35rem; }
|
||||||
|
.form-label { font-size: .8rem; font-weight: 600; color: var(--text-muted); }
|
||||||
|
.form-control { background: #fff; border: 1px solid var(--border-light); color: var(--text-main); border-radius: 8px; padding: .5rem .75rem; font-size: .875rem; width: 100%; outline: none; transition: border-color .15s, box-shadow .15s; font-family: inherit; }
|
||||||
|
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||||||
|
textarea.form-control { resize: vertical; min-height: 80px; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; }
|
||||||
|
|
||||||
|
/* Priority selector */
|
||||||
|
.priority-opts { display: flex; gap: .4rem; }
|
||||||
|
.priority-opt { display: flex; align-items: center; gap: .35rem; padding: .35rem .65rem; border-radius: 8px; border: 1.5px solid var(--border-light); cursor: pointer; font-size: .8rem; font-weight: 600; transition: all .12s; }
|
||||||
|
.priority-opt:hover { border-color: #94a3b8; }
|
||||||
|
.priority-opt.sel-none { border-color: #cbd5e1; }
|
||||||
|
.priority-opt.sel-low { border-color: var(--success); background: #dcfce7; color: #15803d; }
|
||||||
|
.priority-opt.sel-medium { border-color: var(--warning); background: #fef3c7; color: #b45309; }
|
||||||
|
.priority-opt.sel-high { border-color: var(--danger); background: #fee2e2; color: #dc2626; }
|
||||||
|
|
||||||
|
/* Color picker for list */
|
||||||
|
.color-swatches { display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||||
|
.color-swatch { width: 24px; height: 24px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform .12s; }
|
||||||
|
.color-swatch:hover { transform: scale(1.15); }
|
||||||
|
.color-swatch.selected { border-color: #0f172a; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: .4rem; padding: .4rem .85rem; border-radius: 8px; font-size: .875rem; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: all .15s; white-space: nowrap; }
|
||||||
|
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); }
|
||||||
|
.btn-secondary { background: var(--bg-panel); color: var(--text-main); border-color: var(--border-light); }
|
||||||
|
.btn-secondary:hover { background: var(--bg-page); }
|
||||||
|
.btn-danger { background: #fff; color: var(--danger); border-color: #fecaca; }
|
||||||
|
.btn-danger:hover { background: #fef2f2; }
|
||||||
|
.btn-sm { padding: .25rem .55rem; font-size: .78rem; }
|
||||||
|
.btn-icon { padding: .25rem; min-width: 28px; justify-content: center; }
|
||||||
|
.btn-ghost { background: transparent; border: none; color: var(--text-muted); padding: .2rem .4rem; }
|
||||||
|
.btn-ghost:hover { color: var(--text-main); background: var(--bg-page); border-radius: 6px; }
|
||||||
|
|
||||||
|
/* Stats bar */
|
||||||
|
.todo-stats { display: flex; gap: 1rem; padding: .5rem 1.5rem; background: var(--bg-panel); border-bottom: 1px solid var(--border-light); flex-wrap: wrap; }
|
||||||
|
.todo-stat-item { font-size: .78rem; color: var(--text-muted); display: flex; align-items: center; gap: .3rem; }
|
||||||
|
.todo-stat-item strong { color: var(--text-main); }
|
||||||
|
|
||||||
|
/* Done animation */
|
||||||
|
@keyframes doneCheck { from{transform:scale(1)} 40%{transform:scale(1.3)} to{transform:scale(1)} }
|
||||||
|
.todo-check.just-done { animation: doneCheck .3s ease; }
|
||||||
|
|
||||||
|
/* Show done toggle */
|
||||||
|
.show-done-btn { display: flex; align-items: center; gap: .4rem; font-size: .8rem; color: var(--text-muted); background: none; border: none; cursor: pointer; padding: .2rem .4rem; border-radius: 6px; }
|
||||||
|
.show-done-btn:hover { background: var(--bg-page); color: var(--text-main); }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.todo-toast-container { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999; display: flex; flex-direction: column; gap: .5rem; }
|
||||||
|
.todo-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; }
|
||||||
|
.todo-toast.error { border-left-color: var(--danger); }
|
||||||
|
@keyframes toastIn { from{opacity:0;transform:translateX(16px)} to{opacity:1;transform:none} }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.todo-layout { grid-template-columns: 1fr; height: auto; }
|
||||||
|
.todo-sidebar { display: none; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.todo-list { padding: 0 .75rem 1.5rem; }
|
||||||
|
.todo-main-header { padding: .75rem 1rem; }
|
||||||
|
.todo-quick-add { margin: .75rem 1rem .5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
[data-theme="dark"] .todo-sidebar,.todo-sidebar-top { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-nav-item:hover { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .todo-nav-item.active { background: rgba(59,130,246,.15); }
|
||||||
|
[data-theme="dark"] .todo-main { background: var(--bg-page); }
|
||||||
|
[data-theme="dark"] .todo-main-header { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-quick-add { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-quick-add input { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .todo-item { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-item:hover { border-color: #484f58; }
|
||||||
|
[data-theme="dark"] .todo-title { color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .todo-check { border-color: #484f58; }
|
||||||
|
[data-theme="dark"] .todo-check:hover { background: rgba(59,130,246,.15); border-color: var(--primary); }
|
||||||
|
[data-theme="dark"] .todo-modal { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-modal-header,.todo-modal-footer { border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .form-control { background: #1c2128; border-color: var(--border-light); color: var(--text-main); }
|
||||||
|
[data-theme="dark"] .btn-secondary { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .btn-secondary:hover { background: rgba(255,255,255,.06); }
|
||||||
|
[data-theme="dark"] .btn-danger { background: var(--bg-panel); }
|
||||||
|
[data-theme="dark"] .todo-stats { background: var(--bg-panel); border-color: var(--border-light); }
|
||||||
|
[data-theme="dark"] .todo-toast { background: var(--bg-panel); border-color: var(--border-light); color: var(--text-main); }
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// HouseHub — Todo module
|
||||||
|
const API = '/modules/todo/api.php';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s){const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML;}
|
||||||
|
function fmtDate(d){if(!d)return null;const dt=new Date(d+'T00:00:00');return dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'});}
|
||||||
|
function isToday(d){if(!d)return false;return d===new Date().toISOString().slice(0,10);}
|
||||||
|
function isOverdue(d){if(!d)return false;return d<new Date().toISOString().slice(0,10);}
|
||||||
|
function toast(msg,type='success'){
|
||||||
|
const c=document.getElementById('todo-toasts');
|
||||||
|
const t=document.createElement('div');t.className='todo-toast'+(type==='error'?' error':'');
|
||||||
|
t.textContent=msg;c.appendChild(t);setTimeout(()=>t.remove(),3000);
|
||||||
|
}
|
||||||
|
async function api(action,method='GET',data=null,extra=''){
|
||||||
|
const opts={method,headers:{}};
|
||||||
|
if(data){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
|
||||||
|
const r=await fetch(API+'?action='+action+extra,opts);
|
||||||
|
const j=await r.json();
|
||||||
|
if(!j.ok)throw new Error(j.error||'Erreur');
|
||||||
|
return j.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let currentFilter='all';
|
||||||
|
let showDone=false;
|
||||||
|
let lists=[];
|
||||||
|
let editTodoId=null;
|
||||||
|
let editListId=null;
|
||||||
|
let selectedPriority='none';
|
||||||
|
let selectedColor='#3b82f6';
|
||||||
|
|
||||||
|
const COLORS=['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899','#06b6d4','#84cc16','#f97316','#64748b'];
|
||||||
|
const ICONS=['📋','🏠','🛒','💼','💪','🎯','📚','✈️','🎮','❤️','⭐','🔧'];
|
||||||
|
|
||||||
|
// ─── Sidebar ─────────────────────────────────────────────────────────────────
|
||||||
|
async function loadSidebar(){
|
||||||
|
try{
|
||||||
|
const[data,stats]=await Promise.all([api('lists'),api('stats')]);
|
||||||
|
lists=data;
|
||||||
|
const el=document.getElementById('todo-sidebar-lists');
|
||||||
|
|
||||||
|
// Smart views
|
||||||
|
let smartHtml=`
|
||||||
|
<div class="todo-nav-section">Vue</div>
|
||||||
|
<div class="todo-nav-item${currentFilter==='all'?' active':''}" onclick="setFilter('all')">
|
||||||
|
📋 Toutes <span class="todo-nav-badge">${stats.pending}</span></div>
|
||||||
|
<div class="todo-nav-item${currentFilter==='today'?' active':''}" onclick="setFilter('today')">
|
||||||
|
📅 Aujourd'hui <span class="todo-nav-badge${parseInt(stats.today)>0?' urgent':''}">${stats.today}</span></div>
|
||||||
|
<div class="todo-nav-item${currentFilter==='upcoming'?' active':''}" onclick="setFilter('upcoming')">
|
||||||
|
🗓️ À venir</div>`;
|
||||||
|
if(parseInt(stats.overdue)>0){
|
||||||
|
smartHtml+=`<div class="todo-nav-item${currentFilter==='overdue'?' active':''}" onclick="setFilter('overdue')">
|
||||||
|
🔴 En retard <span class="todo-nav-badge urgent">${stats.overdue}</span></div>`;
|
||||||
|
}
|
||||||
|
smartHtml+=`<div class="todo-nav-item${currentFilter==='done'?' active':''}" onclick="setFilter('done')">
|
||||||
|
✅ Terminées <span class="todo-nav-badge">${stats.done}</span></div>`;
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
let listsHtml='<div class="todo-nav-section" style="display:flex;align-items:center;justify-content:space-between;padding-right:.75rem">Listes <button class="btn-ghost btn-sm" onclick="openListModal()" title="Nouvelle liste" style="font-size:.9rem">+</button></div>';
|
||||||
|
lists.forEach(l=>{
|
||||||
|
const act=currentFilter==='list_'+l.id?' active':'';
|
||||||
|
listsHtml+=`<div class="todo-nav-item${act}" onclick="setFilter('list_${l.id}')">
|
||||||
|
<span class="todo-list-dot" style="background:${escHtml(l.color)}"></span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(l.icon)} ${escHtml(l.name)}</span>
|
||||||
|
<span class="todo-nav-badge">${l.pending||0}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.innerHTML=smartHtml+listsHtml;
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(f){
|
||||||
|
currentFilter=f;showDone=f==='done';
|
||||||
|
loadSidebar();loadTodos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Todo list ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadTodos(){
|
||||||
|
try{
|
||||||
|
let extra='';
|
||||||
|
if(currentFilter==='all'){extra='';}
|
||||||
|
else if(currentFilter==='done'){extra='&show_done=1';}
|
||||||
|
else if(currentFilter==='today'){extra='&list_id=today';}
|
||||||
|
else if(currentFilter==='upcoming'){extra='&list_id=upcoming';}
|
||||||
|
else if(currentFilter==='overdue'){extra='&list_id=overdue';}
|
||||||
|
else if(currentFilter.startsWith('list_')){extra='&list_id='+currentFilter.slice(5);}
|
||||||
|
if(showDone&¤tFilter!=='done')extra+='&show_done=1';
|
||||||
|
|
||||||
|
const todos=await api('todos','GET',null,extra);
|
||||||
|
renderTodos(todos);
|
||||||
|
// Update quick-add list selector
|
||||||
|
updateQuickAddList();
|
||||||
|
// Header title
|
||||||
|
updateHeader();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeader(){
|
||||||
|
const el=document.getElementById('todo-header-title');
|
||||||
|
if(!el)return;
|
||||||
|
if(currentFilter==='all')el.textContent='Toutes les tâches';
|
||||||
|
else if(currentFilter==='today')el.textContent='Aujourd\'hui';
|
||||||
|
else if(currentFilter==='upcoming')el.textContent='À venir';
|
||||||
|
else if(currentFilter==='done')el.textContent='Tâches terminées';
|
||||||
|
else if(currentFilter==='overdue')el.textContent='En retard';
|
||||||
|
else if(currentFilter.startsWith('list_')){
|
||||||
|
const l=lists.find(x=>x.id==currentFilter.slice(5));
|
||||||
|
el.textContent=l?(l.icon+' '+l.name):'Liste';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuickAddList(){
|
||||||
|
const sel=document.getElementById('quick-add-list');
|
||||||
|
if(!sel)return;
|
||||||
|
sel.innerHTML='<option value="">Sans liste</option>'+
|
||||||
|
lists.map(l=>`<option value="${l.id}">${escHtml(l.icon)} ${escHtml(l.name)}</option>`).join('');
|
||||||
|
// Pre-select current list
|
||||||
|
if(currentFilter.startsWith('list_')) sel.value=currentFilter.slice(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTodos(todos){
|
||||||
|
const el=document.getElementById('todo-list');
|
||||||
|
if(!todos.length){
|
||||||
|
el.innerHTML=`<div class="todo-empty"><div class="icon">${currentFilter==='done'?'✅':'📋'}</div>
|
||||||
|
<p>${currentFilter==='done'?'Aucune tâche terminée.':'Aucune tâche. Ajoutez-en une !'}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Group: pending then done
|
||||||
|
const pending=todos.filter(t=>!t.done);
|
||||||
|
const done=todos.filter(t=>t.done);
|
||||||
|
let html='';
|
||||||
|
pending.forEach(t=>{ html+=todoItemHtml(t); });
|
||||||
|
if(done.length&&showDone){
|
||||||
|
html+=`<div class="todo-group-label" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
Terminées (${done.length}) <button class="show-done-btn" onclick="showDone=false;loadTodos()">Masquer ✕</button></div>`;
|
||||||
|
done.forEach(t=>{ html+=todoItemHtml(t); });
|
||||||
|
} else if(done.length&&!showDone){
|
||||||
|
html+=`<div style="padding:.5rem 0"><button class="show-done-btn" onclick="showDone=true;loadTodos()">
|
||||||
|
✅ Afficher ${done.length} tâche${done.length>1?'s':''} terminée${done.length>1?'s':''}</button></div>`;
|
||||||
|
}
|
||||||
|
el.innerHTML=html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todoItemHtml(t){
|
||||||
|
const checkClass='todo-check'+(t.done?' checked':'');
|
||||||
|
const itemClass='todo-item'+(t.done?' done':'');
|
||||||
|
const dueCls=isOverdue(t.due_date)&&!t.done?' overdue':isToday(t.due_date)&&!t.done?' today':'';
|
||||||
|
const dueLabel=t.due_date?(isToday(t.due_date)?'Aujourd\'hui':isOverdue(t.due_date)?'En retard '+fmtDate(t.due_date):fmtDate(t.due_date)):null;
|
||||||
|
const priBadge=t.priority&&t.priority!=='none'?
|
||||||
|
`<span class="todo-priority-badge pri-${t.priority}">${t.priority==='high'?'Urgent':t.priority==='medium'?'Normal':'Bas'}</span>`:'';
|
||||||
|
const listBadge=t.list_name?
|
||||||
|
`<span class="todo-list-badge" style="background:${escHtml(t.list_color||'#3b82f6')}22;color:${escHtml(t.list_color||'#3b82f6')}">${escHtml(t.list_icon||'')} ${escHtml(t.list_name)}</span>`:'';
|
||||||
|
return `<div class="${itemClass}" data-id="${t.id}" data-priority="${t.priority||'none'}">
|
||||||
|
<div class="${checkClass}" onclick="toggleDone(event,${t.id},${t.done?0:1})"></div>
|
||||||
|
<div class="todo-content" onclick="openEditTodo(${t.id})">
|
||||||
|
<div class="todo-title">${escHtml(t.title)}</div>
|
||||||
|
${t.notes?`<div class="todo-notes-preview">${escHtml(t.notes)}</div>`:''}
|
||||||
|
<div class="todo-meta">
|
||||||
|
${dueLabel?`<span class="todo-due${dueCls}">📅 ${escHtml(dueLabel)}</span>`:''}
|
||||||
|
${priBadge}${listBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-item-actions">
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="openEditTodo(${t.id})" title="Modifier">✏️</button>
|
||||||
|
<button class="btn btn-ghost btn-icon" onclick="deleteTodo(event,${t.id})" title="Supprimer">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDone(e,id,done){
|
||||||
|
e.stopPropagation();
|
||||||
|
try{
|
||||||
|
await api('todos','PUT',{done:done===1},'&id='+id);
|
||||||
|
const item=document.querySelector(`.todo-item[data-id="${id}"]`);
|
||||||
|
if(item){
|
||||||
|
const check=item.querySelector('.todo-check');
|
||||||
|
check.classList.add('just-done');
|
||||||
|
setTimeout(()=>{check.classList.remove('just-done');loadTodos();loadSidebar();},300);
|
||||||
|
}
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodo(e,id){
|
||||||
|
e.stopPropagation();
|
||||||
|
if(!confirm('Supprimer cette tâche ?'))return;
|
||||||
|
try{await api('todos','DELETE',null,'&id='+id);toast('Supprimée');loadTodos();loadSidebar();}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quick add ────────────────────────────────────────────────────────────────
|
||||||
|
async function quickAdd(e){
|
||||||
|
if(e.key!=='Enter')return;
|
||||||
|
const inp=document.getElementById('quick-add-input');
|
||||||
|
const title=inp.value.trim();
|
||||||
|
if(!title)return;
|
||||||
|
const listSel=document.getElementById('quick-add-list');
|
||||||
|
try{
|
||||||
|
await api('todos','POST',{title,list_id:listSel?.value||null,priority:'none'});
|
||||||
|
inp.value='';toast('Tâche ajoutée');loadTodos();loadSidebar();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Todo modal ───────────────────────────────────────────────────────────────
|
||||||
|
function openAddTodo(){
|
||||||
|
editTodoId=null;selectedPriority='none';
|
||||||
|
document.getElementById('todo-modal-title').textContent='Nouvelle tâche';
|
||||||
|
document.getElementById('todo-form-title').value='';
|
||||||
|
document.getElementById('todo-form-notes').value='';
|
||||||
|
document.getElementById('todo-form-due').value='';
|
||||||
|
document.getElementById('todo-delete-btn').style.display='none';
|
||||||
|
// List selector
|
||||||
|
const sel=document.getElementById('todo-form-list');
|
||||||
|
sel.innerHTML='<option value="">Sans liste</option>'+lists.map(l=>`<option value="${l.id}">${escHtml(l.icon)} ${escHtml(l.name)}</option>`).join('');
|
||||||
|
if(currentFilter.startsWith('list_')) sel.value=currentFilter.slice(5);
|
||||||
|
setPriority('none');
|
||||||
|
openModal('todo-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditTodo(id){
|
||||||
|
editTodoId=id;
|
||||||
|
try{
|
||||||
|
const todos=await api('todos','GET',null,'&action=todos');
|
||||||
|
// fetch single - use filter by all and find
|
||||||
|
const all=await api('todos','GET',null,'&show_done=1');
|
||||||
|
const t=all.find(x=>x.id==id);
|
||||||
|
if(!t)return;
|
||||||
|
document.getElementById('todo-modal-title').textContent='Modifier la tâche';
|
||||||
|
document.getElementById('todo-form-title').value=t.title;
|
||||||
|
document.getElementById('todo-form-notes').value=t.notes||'';
|
||||||
|
document.getElementById('todo-form-due').value=t.due_date||'';
|
||||||
|
document.getElementById('todo-delete-btn').style.display='';
|
||||||
|
const sel=document.getElementById('todo-form-list');
|
||||||
|
sel.innerHTML='<option value="">Sans liste</option>'+lists.map(l=>`<option value="${l.id}">${escHtml(l.icon)} ${escHtml(l.name)}</option>`).join('');
|
||||||
|
sel.value=t.list_id||'';
|
||||||
|
setPriority(t.priority||'none');
|
||||||
|
openModal('todo-modal');
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPriority(p){
|
||||||
|
selectedPriority=p;
|
||||||
|
document.querySelectorAll('.priority-opt').forEach(el=>{
|
||||||
|
el.className='priority-opt';
|
||||||
|
if(el.dataset.p===p) el.classList.add('sel-'+p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTodo(){
|
||||||
|
const title=document.getElementById('todo-form-title').value.trim();
|
||||||
|
if(!title){toast('Titre requis','error');return;}
|
||||||
|
const data={
|
||||||
|
title,
|
||||||
|
notes:document.getElementById('todo-form-notes').value||null,
|
||||||
|
due_date:document.getElementById('todo-form-due').value||null,
|
||||||
|
list_id:document.getElementById('todo-form-list').value||null,
|
||||||
|
priority:selectedPriority
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
if(editTodoId){await api('todos','PUT',data,'&id='+editTodoId);toast('Tâche mise à jour');}
|
||||||
|
else{await api('todos','POST',data);toast('Tâche ajoutée');}
|
||||||
|
closeModal('todo-modal');loadTodos();loadSidebar();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTodoFromModal(){
|
||||||
|
if(!editTodoId||!confirm('Supprimer cette tâche ?'))return;
|
||||||
|
try{await api('todos','DELETE',null,'&id='+editTodoId);closeModal('todo-modal');toast('Supprimée');loadTodos();loadSidebar();}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── List modal ───────────────────────────────────────────────────────────────
|
||||||
|
function openListModal(id=null){
|
||||||
|
editListId=id;selectedColor='#3b82f6';
|
||||||
|
let list=id?lists.find(l=>l.id==id):null;
|
||||||
|
document.getElementById('list-modal-title').textContent=id?'Modifier la liste':'Nouvelle liste';
|
||||||
|
document.getElementById('list-form-name').value=list?.name||'';
|
||||||
|
document.getElementById('list-delete-btn').style.display=id?'':'none';
|
||||||
|
selectedColor=list?.color||'#3b82f6';
|
||||||
|
// Render swatches
|
||||||
|
document.getElementById('list-color-swatches').innerHTML=COLORS.map(c=>
|
||||||
|
`<div class="color-swatch${c===selectedColor?' selected':''}" style="background:${c}" onclick="selectColor('${c}')"></div>`).join('');
|
||||||
|
// Render icons
|
||||||
|
document.getElementById('list-icon-opts').innerHTML=ICONS.map(i=>
|
||||||
|
`<button type="button" class="btn btn-sm btn-secondary" onclick="selectIcon(this,'${i}')" style="${list?.icon===i?'background:#eff6ff;border-color:var(--primary)':''}">${i}</button>`).join('');
|
||||||
|
openModal('list-modal');
|
||||||
|
}
|
||||||
|
function selectColor(c){
|
||||||
|
selectedColor=c;
|
||||||
|
document.querySelectorAll('.color-swatch').forEach(s=>{s.classList.toggle('selected',s.style.background===c||rgbToHex(s.style.background)===c);});
|
||||||
|
}
|
||||||
|
function rgbToHex(rgb){
|
||||||
|
const m=rgb.match(/\d+/g);if(!m||m.length<3)return rgb;
|
||||||
|
return '#'+m.slice(0,3).map(x=>parseInt(x).toString(16).padStart(2,'0')).join('');
|
||||||
|
}
|
||||||
|
let selectedIcon='📋';
|
||||||
|
function selectIcon(btn,icon){
|
||||||
|
selectedIcon=icon;
|
||||||
|
document.querySelectorAll('#list-icon-opts button').forEach(b=>{b.style.background='';b.style.borderColor='';});
|
||||||
|
btn.style.background='#eff6ff';btn.style.borderColor='var(--primary)';
|
||||||
|
}
|
||||||
|
async function saveList(){
|
||||||
|
const name=document.getElementById('list-form-name').value.trim();
|
||||||
|
if(!name){toast('Nom requis','error');return;}
|
||||||
|
const data={name,color:selectedColor,icon:selectedIcon};
|
||||||
|
try{
|
||||||
|
if(editListId){await api('lists','PUT',data,'&id='+editListId);}
|
||||||
|
else{await api('lists','POST',data);}
|
||||||
|
closeModal('list-modal');toast(editListId?'Liste mise à jour':'Liste créée');
|
||||||
|
loadSidebar();
|
||||||
|
}catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
async function deleteList(){
|
||||||
|
if(!editListId||!confirm('Supprimer la liste et toutes ses tâches ?'))return;
|
||||||
|
try{await api('lists','DELETE',null,'&id='+editListId);closeModal('list-modal');toast('Liste supprimée');setFilter('all');}
|
||||||
|
catch(e){toast(e.message,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modal helpers ────────────────────────────────────────────────────────────
|
||||||
|
function openModal(id){document.getElementById(id)?.classList.add('show');}
|
||||||
|
function closeModal(id){document.getElementById(id)?.classList.remove('show');}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded',()=>{
|
||||||
|
loadSidebar();
|
||||||
|
loadTodos();
|
||||||
|
// Quick add enter
|
||||||
|
document.getElementById('quick-add-input')?.addEventListener('keydown',quickAdd);
|
||||||
|
// Modal backdrops
|
||||||
|
document.querySelectorAll('.todo-modal-backdrop').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show');}));
|
||||||
|
});
|
||||||
+2
-1
@@ -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', 'memo'];
|
$all = ['calendar', 'budget', 'holidays', 'gifts', 'garage', 'memo', 'todo'];
|
||||||
$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.";
|
||||||
@@ -188,6 +188,7 @@ require __DIR__ . '/header.php';
|
|||||||
'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')],
|
'memo' => ['icon' => '📝', 'label' => tr('menu_memo')],
|
||||||
|
'todo' => ['icon' => '✅', 'label' => tr('menu_todo')],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/includes/auth.php';
|
||||||
|
require_login();
|
||||||
|
require_once __DIR__ . '/includes/i18n.php';
|
||||||
|
|
||||||
|
$pageTitle = 'Todo — HouseHub';
|
||||||
|
$activePage = 'todo';
|
||||||
|
require __DIR__ . '/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/modules/todo/assets/todo.css">
|
||||||
|
<div id="todo-toasts" class="todo-toast-container"></div>
|
||||||
|
|
||||||
|
<div class="todo-layout">
|
||||||
|
|
||||||
|
<!-- ── SIDEBAR ────────────────────────────────────────────────────────────── -->
|
||||||
|
<aside class="todo-sidebar" id="todo-sidebar">
|
||||||
|
<div class="todo-sidebar-top">
|
||||||
|
<button class="btn btn-primary" onclick="openAddTodo()" style="width:100%;justify-content:center">+ Nouvelle tâche</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Smart views -->
|
||||||
|
<div class="todo-nav-section">Vues</div>
|
||||||
|
<a class="todo-nav-item active" data-filter="all" onclick="setFilter('all')">
|
||||||
|
<span>📋</span> Toutes
|
||||||
|
<span class="todo-nav-badge" id="badge-all">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="todo-nav-item" data-filter="today" onclick="setFilter('today')">
|
||||||
|
<span>☀️</span> Aujourd'hui
|
||||||
|
<span class="todo-nav-badge urgent" id="badge-today">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="todo-nav-item" data-filter="upcoming" onclick="setFilter('upcoming')">
|
||||||
|
<span>📅</span> À venir
|
||||||
|
<span class="todo-nav-badge" id="badge-upcoming">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="todo-nav-item" data-filter="overdue" onclick="setFilter('overdue')">
|
||||||
|
<span>⚠️</span> En retard
|
||||||
|
<span class="todo-nav-badge urgent" id="badge-overdue">0</span>
|
||||||
|
</a>
|
||||||
|
<a class="todo-nav-item" data-filter="done" onclick="setFilter('done')">
|
||||||
|
<span>✅</span> Terminées
|
||||||
|
<span class="todo-nav-badge" id="badge-done">0</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- User lists -->
|
||||||
|
<div class="todo-nav-section" style="display:flex;align-items:center;justify-content:space-between;padding-right:.75rem">
|
||||||
|
Listes
|
||||||
|
<button class="btn btn-ghost btn-icon btn-sm" onclick="openListModal()" title="Nouvelle liste" style="padding:.1rem .3rem;font-size:.85rem">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="todo-lists-nav"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ── MAIN ───────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-main">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="todo-main-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="todo-view-title">Toutes les tâches</h2>
|
||||||
|
<div class="todo-subtitle" id="todo-view-subtitle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-header-actions">
|
||||||
|
<button class="show-done-btn" id="show-done-btn" onclick="toggleShowDone()">
|
||||||
|
<span id="show-done-icon">👁</span> <span id="show-done-label">Afficher terminées</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openAddTodo()">+ Tâche</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar -->
|
||||||
|
<div class="todo-stats" id="todo-stats-bar"></div>
|
||||||
|
|
||||||
|
<!-- Quick add -->
|
||||||
|
<div class="todo-quick-add">
|
||||||
|
<span style="color:var(--text-muted)">+</span>
|
||||||
|
<input type="text" id="quick-add-input" placeholder="Ajouter une tâche rapide… (Entrée pour valider)">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="quick-add-list-btn" onclick="openAddTodo(document.getElementById('quick-add-input').value)">Options</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Todo list -->
|
||||||
|
<div class="todo-list" id="todo-list"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : TÂCHE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-modal-backdrop" id="todo-modal">
|
||||||
|
<div class="todo-modal">
|
||||||
|
<div class="todo-modal-header">
|
||||||
|
<h3 id="todo-modal-title">Nouvelle tâche</h3>
|
||||||
|
<button class="todo-modal-close" onclick="closeTodoModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-body">
|
||||||
|
<input type="hidden" id="todo-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Titre *</label>
|
||||||
|
<input type="text" id="todo-title" class="form-control" placeholder="Ex : Appeler le plombier" autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea id="todo-notes" class="form-control" placeholder="Détails, liens, infos…"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Date limite</label>
|
||||||
|
<input type="date" id="todo-due" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Liste</label>
|
||||||
|
<select id="todo-list-select" class="form-control">
|
||||||
|
<option value="">— Aucune —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Priorité</label>
|
||||||
|
<div class="priority-opts">
|
||||||
|
<div class="priority-opt sel-none" data-p="none" onclick="selectPriority('none')">— Aucune</div>
|
||||||
|
<div class="priority-opt" data-p="low" onclick="selectPriority('low')">🟢 Basse</div>
|
||||||
|
<div class="priority-opt" data-p="medium" onclick="selectPriority('medium')">🟡 Moyenne</div>
|
||||||
|
<div class="priority-opt" data-p="high" onclick="selectPriority('high')">🔴 Haute</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="todo-priority" value="none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-footer">
|
||||||
|
<button class="btn btn-danger btn-sm" id="todo-delete-btn" onclick="deleteTodo()" style="display:none;margin-right:auto">Supprimer</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeTodoModal()">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveTodo()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL : LISTE ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="todo-modal-backdrop" id="list-modal">
|
||||||
|
<div class="todo-modal">
|
||||||
|
<div class="todo-modal-header">
|
||||||
|
<h3 id="list-modal-title">Nouvelle liste</h3>
|
||||||
|
<button class="todo-modal-close" onclick="closeListModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-body">
|
||||||
|
<input type="hidden" id="list-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nom *</label>
|
||||||
|
<input type="text" id="list-name" class="form-control" placeholder="Ex : Maison, Travail…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Icône</label>
|
||||||
|
<input type="text" id="list-icon" class="form-control" placeholder="📋" maxlength="4" style="font-size:1.2rem;width:80px">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Couleur</label>
|
||||||
|
<div class="color-swatches" id="list-color-swatches"></div>
|
||||||
|
<input type="hidden" id="list-color" value="#3b82f6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="todo-modal-footer">
|
||||||
|
<button class="btn btn-danger btn-sm" id="list-delete-btn" onclick="deleteList()" style="display:none;margin-right:auto">Supprimer</button>
|
||||||
|
<button class="btn btn-secondary" onclick="closeListModal()">Annuler</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveList()">Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/modules/todo/assets/todo.js"></script>
|
||||||
|
<?php require __DIR__ . '/footer.php'; ?>
|
||||||
Reference in New Issue
Block a user