Compare commits
12 Commits
937033ea07
...
83ba58ddcd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ba58ddcd | ||
|
|
596a233898 | ||
|
|
68a764c0df | ||
|
|
97649d9a98 | ||
|
|
513de7813f | ||
|
|
78d309ae4e | ||
|
|
afb7ef2a37 | ||
|
|
dbe02e52fd | ||
|
|
8dedb57692 | ||
|
|
f4f842d658 | ||
|
|
2a670d80d1 | ||
|
|
99f948ba5d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,4 @@
|
|||||||
# Don't Commit
|
# Don't Commit
|
||||||
uploads/*
|
uploads/*
|
||||||
database.sqlite
|
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
|||||||
BIN
database.sqlite
Normal file
BIN
database.sqlite
Normal file
Binary file not shown.
94
db.php
94
db.php
@@ -29,26 +29,108 @@ function get_user_groups($user_id) {
|
|||||||
return $stmt->fetchAll();
|
return $stmt->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify_user($user_id, $message) {
|
function compile_waha_template($template, $context) {
|
||||||
|
if (!$template) return "";
|
||||||
|
$action = $context['action'] ?? 'Update';
|
||||||
|
$module = $context['module'] ?? 'System';
|
||||||
|
$item = $context['item'] ?? '';
|
||||||
|
|
||||||
|
$user = $context['user'] ?? null;
|
||||||
|
if (!$user && isset($_SESSION['user_id'])) {
|
||||||
|
$u = get_user($_SESSION['user_id']);
|
||||||
|
$user = $u ? $u['username'] : 'System';
|
||||||
|
} elseif (!$user) {
|
||||||
|
$user = 'System';
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment_count = $context['attachment_count'] ?? '0';
|
||||||
|
$due_date = $context['due_date'] ?? 'None';
|
||||||
|
$created_date = $context['created_date'] ?? date('Y-m-d H:i');
|
||||||
|
$updated_date = $context['updated_date'] ?? date('Y-m-d H:i');
|
||||||
|
$assignor = $context['assignor'] ?? 'N/A';
|
||||||
|
$assignee = $context['assignee'] ?? 'N/A';
|
||||||
|
|
||||||
|
return str_replace(
|
||||||
|
['{action}', '{module}', '{item}', '{user}', '{attachment_count}', '{due_date}', '{created_date}', '{updated_date}', '{assignor}', '{assignee}'],
|
||||||
|
[$action, $module, $item, $user, $attachment_count, $due_date, $created_date, $updated_date, $assignor, $assignee],
|
||||||
|
$template
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatch_waha_message($endpoint, $chat_id, $session, $api_key, $text) {
|
||||||
|
try {
|
||||||
|
$payload = json_encode([
|
||||||
|
"chatId" => $chat_id,
|
||||||
|
"text" => $text,
|
||||||
|
"session" => $session ?: 'default'
|
||||||
|
]);
|
||||||
|
$ch = curl_init($endpoint);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||||
|
$headers = ['Content-Type: application/json'];
|
||||||
|
if (!empty($api_key)) $headers[] = 'X-Api-Key: ' . $api_key;
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
|
||||||
|
curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify_user($user_id, $message, $context = []) {
|
||||||
global $pdo;
|
global $pdo;
|
||||||
$stmt = $pdo->prepare("INSERT INTO notifications (user_id, message) VALUES (?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO notifications (user_id, message) VALUES (?, ?)");
|
||||||
$stmt->execute([$user_id, $message]);
|
$stmt->execute([$user_id, $message]);
|
||||||
|
|
||||||
// Virtual hook for Email/Telegram/WhatsApp based on user features
|
|
||||||
$user = get_user($user_id);
|
$user = get_user($user_id);
|
||||||
if($user && isset($user['notify_channels'])) {
|
if($user && isset($user['notify_channels'])) {
|
||||||
$channels = explode(',', $user['notify_channels']);
|
$channels = explode(',', $user['notify_channels']);
|
||||||
// Here we would actually send the message to the third party API.
|
if (in_array('whatsapp', $channels) && $user['waha_endpoint'] && $user['waha_chat_id']) {
|
||||||
// For MVP we just assume it's sent.
|
$module = $context['module'] ?? '';
|
||||||
|
$template = '';
|
||||||
|
if (stripos($module, 'Note') !== false) $template = $user['waha_template_note'];
|
||||||
|
elseif (stripos($module, 'Password') !== false) $template = $user['waha_template_password'];
|
||||||
|
elseif (stripos($module, 'File') !== false || stripos($module, 'Folder') !== false) $template = $user['waha_template_file'];
|
||||||
|
else $template = $user['waha_template_task'];
|
||||||
|
|
||||||
|
if (!$template) $template = "*Alert:* {action} on {module} '{item}' by {user}";
|
||||||
|
|
||||||
|
$text = compile_waha_template($template, $context);
|
||||||
|
dispatch_waha_message($user['waha_endpoint'], $user['waha_chat_id'], $user['waha_session'], $user['waha_api_key'], $text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify_group($group_id, $message) {
|
function notify_group($group_id, $message, $context = []) {
|
||||||
global $pdo;
|
global $pdo;
|
||||||
|
// Notify all individual group members in their UI notifications
|
||||||
$stmt = $pdo->prepare("SELECT user_id FROM user_groups WHERE group_id = ?");
|
$stmt = $pdo->prepare("SELECT user_id FROM user_groups WHERE group_id = ?");
|
||||||
$stmt->execute([$group_id]);
|
$stmt->execute([$group_id]);
|
||||||
$users = $stmt->fetchAll();
|
$users = $stmt->fetchAll();
|
||||||
foreach ($users as $u) {
|
foreach ($users as $u) {
|
||||||
notify_user($u['user_id'], $message);
|
$s = $pdo->prepare("INSERT INTO notifications (user_id, message) VALUES (?, ?)");
|
||||||
|
$s->execute([$u['user_id'], $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the group itself has WAHA configured
|
||||||
|
$stmt = $pdo->prepare("SELECT * FROM groups WHERE id = ?");
|
||||||
|
$stmt->execute([$group_id]);
|
||||||
|
$group = $stmt->fetch();
|
||||||
|
|
||||||
|
if($group && isset($group['notify_channels'])) {
|
||||||
|
$channels = explode(',', $group['notify_channels']);
|
||||||
|
if (in_array('whatsapp', $channels) && $group['waha_endpoint'] && $group['waha_chat_id']) {
|
||||||
|
$module = $context['module'] ?? '';
|
||||||
|
$template = '';
|
||||||
|
if (stripos($module, 'Note') !== false) $template = $group['waha_template_note'];
|
||||||
|
elseif (stripos($module, 'Password') !== false) $template = $group['waha_template_password'];
|
||||||
|
elseif (stripos($module, 'File') !== false || stripos($module, 'Folder') !== false) $template = $group['waha_template_file'];
|
||||||
|
else $template = $group['waha_template_task'];
|
||||||
|
|
||||||
|
if (!$template) $template = "*Group Alert:* {action} on {module} '{item}' by {user}";
|
||||||
|
|
||||||
|
$text = compile_waha_template($template, $context);
|
||||||
|
dispatch_waha_message($group['waha_endpoint'], $group['waha_chat_id'], $group['waha_session'], $group['waha_api_key'], $text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
288
index.php
288
index.php
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
|
date_default_timezone_set('Etc/GMT-7');
|
||||||
require_once 'db.php';
|
require_once 'db.php';
|
||||||
|
|
||||||
$action = (string)($_GET['action'] ?? 'dashboard');
|
$action = (string)($_GET['action'] ?? 'dashboard');
|
||||||
@@ -7,14 +8,295 @@ $action = (string)($_GET['action'] ?? 'dashboard');
|
|||||||
// API updates for task kanban drag n drop
|
// API updates for task kanban drag n drop
|
||||||
if ($action === 'api') {
|
if ($action === 'api') {
|
||||||
$api_action = $_GET['api_action'] ?? '';
|
$api_action = $_GET['api_action'] ?? '';
|
||||||
if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized']));
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Ensure newer task columns exist (API might be called without visiting the tasks page first).
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_type TEXT DEFAULT 'user'"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATETIME"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN custom_assignee_name TEXT"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN completed_by INTEGER DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN archived_by INTEGER DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_comments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, comment TEXT NOT NULL, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {}
|
||||||
|
|
||||||
|
$format_dt = function($value) {
|
||||||
|
$value = is_string($value) ? trim($value) : $value;
|
||||||
|
if(!$value) return null;
|
||||||
|
$ts = strtotime((string)$value);
|
||||||
|
if($ts === false) return null;
|
||||||
|
return date('Y-m-d H:i', $ts);
|
||||||
|
};
|
||||||
|
|
||||||
|
$format_status = function($value) {
|
||||||
|
if(!$value) return null;
|
||||||
|
$s = strtolower(trim((string)$value));
|
||||||
|
if($s === 'in_progress') return 'In Progress';
|
||||||
|
$s = str_replace('_', ' ', $s);
|
||||||
|
return ucfirst($s);
|
||||||
|
};
|
||||||
|
|
||||||
if($api_action === 'update_task_status') {
|
if($api_action === 'update_task_status') {
|
||||||
|
if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized']));
|
||||||
$task_id = $_POST['task_id'] ?? null;
|
$task_id = $_POST['task_id'] ?? null;
|
||||||
$status = $_POST['status'] ?? null;
|
$status = $_POST['status'] ?? null;
|
||||||
$stmt = $pdo->prepare("UPDATE tasks SET status = ?, status_updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$user_id = $_SESSION['user_id'];
|
||||||
$stmt->execute([$status, $task_id]);
|
|
||||||
|
$set_clause = "status = ?, status_updated_at = CURRENT_TIMESTAMP";
|
||||||
|
$params = [$status];
|
||||||
|
|
||||||
|
if ($status === 'completed') {
|
||||||
|
$set_clause .= ", completed_by = ?";
|
||||||
|
$params[] = $user_id;
|
||||||
|
} elseif ($status === 'archived') {
|
||||||
|
$set_clause .= ", archived_by = ?";
|
||||||
|
$params[] = $user_id;
|
||||||
|
}
|
||||||
|
$params[] = $task_id;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE tasks SET $set_clause WHERE id = ?");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$t = query("SELECT title, assignee_type, assignee_id FROM tasks WHERE id=?", [$task_id])->fetch();
|
||||||
|
if($t) {
|
||||||
|
$ctx = ['action' => 'Moved to '.$status, 'module' => 'Task', 'item' => $t['title']];
|
||||||
|
if($t['assignee_type'] === 'user' && $t['assignee_id']) {
|
||||||
|
notify_user($t['assignee_id'], "Task '{$t['title']}' moved to $status", $ctx);
|
||||||
|
} elseif($t['assignee_type'] === 'group' && $t['assignee_id']) {
|
||||||
|
notify_group($t['assignee_id'], "Group task '{$t['title']}' moved to $status", $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
echo json_encode(['status'=>'ok']);
|
echo json_encode(['status'=>'ok']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if($api_action === 'add_comment') {
|
||||||
|
if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized']));
|
||||||
|
$task_id = $_POST['task_id'] ?? null;
|
||||||
|
$comment = $_POST['comment'] ?? null;
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
if($task_id && $comment) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO task_comments (task_id, comment, created_by) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$task_id, $comment, $user_id]);
|
||||||
|
$t = query("SELECT title, assignee_type, assignee_id FROM tasks WHERE id=?", [$task_id])->fetch();
|
||||||
|
if($t) {
|
||||||
|
$ctx = ['action' => 'New Comment', 'module' => 'Task', 'item' => $t['title']];
|
||||||
|
if($t['assignee_type'] === 'user' && $t['assignee_id']) {
|
||||||
|
notify_user($t['assignee_id'], "New comment on '{$t['title']}'", $ctx);
|
||||||
|
} elseif($t['assignee_type'] === 'group' && $t['assignee_id']) {
|
||||||
|
notify_group($t['assignee_id'], "New comment on group task '{$t['title']}'", $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo json_encode(['status'=>'ok']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($api_action === 'delete_comment') {
|
||||||
|
if(!isset($_SESSION['user_id'])) die(json_encode(['status'=>'error','msg'=>'unauthorized']));
|
||||||
|
$comment_id = $_POST['comment_id'] ?? null;
|
||||||
|
if($comment_id) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM task_comments WHERE id = ?");
|
||||||
|
$stmt->execute([$comment_id]);
|
||||||
|
}
|
||||||
|
echo json_encode(['status'=>'ok']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($api_action === 'get_tasks_status') {
|
||||||
|
$task_id = $_GET['task_id'] ?? null;
|
||||||
|
|
||||||
|
// Correlated subquery gives attachment count per task.
|
||||||
|
$sql = "
|
||||||
|
SELECT
|
||||||
|
t.*,
|
||||||
|
u.username as assignor_user_name,
|
||||||
|
g.name as assignor_group_name,
|
||||||
|
au.username as assignee_user_name,
|
||||||
|
ag.name as assignee_group_name,
|
||||||
|
(SELECT COUNT(*) FROM task_attachments ta WHERE ta.task_id = t.id) AS attachments_count,
|
||||||
|
(SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comments_count,
|
||||||
|
(SELECT comment FROM task_comments tc WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_comment,
|
||||||
|
(SELECT created_at FROM task_comments tc WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_comment_date,
|
||||||
|
(SELECT cu.username FROM task_comments tc JOIN users cu ON tc.created_by = cu.id WHERE tc.task_id = t.id ORDER BY tc.created_at DESC LIMIT 1) AS last_commenter,
|
||||||
|
t.status_updated_at,
|
||||||
|
t.completed_by,
|
||||||
|
t.archived_by,
|
||||||
|
t.tags,
|
||||||
|
t.title
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN users u ON t.assignor_id = u.id
|
||||||
|
LEFT JOIN groups g ON t.assignor_group_id = g.id
|
||||||
|
LEFT JOIN users au ON (t.assignee_type='user' AND t.assignee_id = au.id)
|
||||||
|
LEFT JOIN groups ag ON (t.assignee_type='group' AND t.assignee_id = ag.id)
|
||||||
|
";
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if($task_id !== null && $task_id !== '') {
|
||||||
|
if(!ctype_digit((string)$task_id)) {
|
||||||
|
echo json_encode(['status'=>'error','msg'=>'Invalid task_id']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$sql .= " WHERE t.id = ? LIMIT 1";
|
||||||
|
$params[] = (int)$task_id;
|
||||||
|
} else {
|
||||||
|
$sql .= " ORDER BY t.created_at DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
if($task_id !== null && $task_id !== '') {
|
||||||
|
$row = $rows ? $rows[0] : null;
|
||||||
|
if(!$row) {
|
||||||
|
echo json_encode(['status'=>'error','msg'=>'Task not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = null;
|
||||||
|
if(($row['assignor_type'] ?? null) === 'group') $from = $row['assignor_group_name'] ?? null;
|
||||||
|
elseif(($row['assignor_type'] ?? null) === 'user') $from = $row['assignor_user_name'] ?? null;
|
||||||
|
|
||||||
|
$to = null;
|
||||||
|
if(($row['assignee_type'] ?? null) === 'user') $to = $row['assignee_user_name'] ?? null;
|
||||||
|
elseif(($row['assignee_type'] ?? null) === 'group') $to = $row['assignee_group_name'] ?? null;
|
||||||
|
elseif(($row['assignee_type'] ?? null) === 'custom') $to = $row['custom_assignee_name'] ?? null;
|
||||||
|
|
||||||
|
$desc = (string)($row['description'] ?? '');
|
||||||
|
$desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? '');
|
||||||
|
|
||||||
|
$tags = !empty($row['tags']) ? array_filter(array_map('trim', explode(',', $row['tags']))) : [];
|
||||||
|
|
||||||
|
// Logic for last_modified_by
|
||||||
|
// If it's archived, 'Archived by' user; if completed, 'Completed by' user; otherwise latest commenter or creator.
|
||||||
|
$last_mod_by = 'System';
|
||||||
|
if ($row['status'] === 'archived' && $row['archived_by']) {
|
||||||
|
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['archived_by']])->fetchColumn() ?: 'System';
|
||||||
|
} elseif ($row['status'] === 'completed' && $row['completed_by']) {
|
||||||
|
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['completed_by']])->fetchColumn() ?: 'System';
|
||||||
|
} else {
|
||||||
|
$last_mod_by = $row['last_commenter'] ?: $row['assignor_user_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'task_id' => (string)$row['id'],
|
||||||
|
'task_name' => (string)$row['title'],
|
||||||
|
'task_created_date' => $format_dt($row['created_at'] ? $row['created_at'] . ' UTC' : null),
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'task_description' => $desc,
|
||||||
|
'due_date' => $format_dt($row['due_date'] ?? null),
|
||||||
|
'current_status' => $format_status($row['status'] ?? null),
|
||||||
|
'attachments_count' => (int)($row['attachments_count'] ?? 0),
|
||||||
|
'tags_count' => count($tags),
|
||||||
|
'comments_count' => (int)($row['comments_count'] ?? 0),
|
||||||
|
'last_modified_by' => $last_mod_by,
|
||||||
|
'last_modified_date' => $format_dt(($row['status_updated_at'] ?: $row['created_at']) . ' UTC'),
|
||||||
|
'last_comment' => $row['last_comment'] ?: null,
|
||||||
|
'last_comment_date' => $format_dt($row['last_comment_date'] ? $row['last_comment_date'] . ' UTC' : null),
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode($payload);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach($rows as $row) {
|
||||||
|
$from = null;
|
||||||
|
if(($row['assignor_type'] ?? null) === 'group') $from = $row['assignor_group_name'] ?? null;
|
||||||
|
elseif(($row['assignor_type'] ?? null) === 'user') $from = $row['assignor_user_name'] ?? null;
|
||||||
|
|
||||||
|
$to = null;
|
||||||
|
if(($row['assignee_type'] ?? null) === 'user') $to = $row['assignee_user_name'] ?? null;
|
||||||
|
elseif(($row['assignee_type'] ?? null) === 'group') $to = $row['assignee_group_name'] ?? null;
|
||||||
|
elseif(($row['assignee_type'] ?? null) === 'custom') $to = $row['custom_assignee_name'] ?? null;
|
||||||
|
|
||||||
|
$desc = (string)($row['description'] ?? '');
|
||||||
|
$desc = trim($desc) !== '' ? $desc : (string)($row['title'] ?? '');
|
||||||
|
|
||||||
|
$tags = !empty($row['tags']) ? array_filter(array_map('trim', explode(',', $row['tags']))) : [];
|
||||||
|
|
||||||
|
$last_mod_by = 'System';
|
||||||
|
if ($row['status'] === 'archived' && $row['archived_by']) {
|
||||||
|
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['archived_by']])->fetchColumn() ?: 'System';
|
||||||
|
} elseif ($row['status'] === 'completed' && $row['completed_by']) {
|
||||||
|
$last_mod_by = query("SELECT username FROM users WHERE id=?", [$row['completed_by']])->fetchColumn() ?: 'System';
|
||||||
|
} else {
|
||||||
|
$last_mod_by = $row['last_commenter'] ?: $row['assignor_user_name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'task_id' => (string)$row['id'],
|
||||||
|
'task_name' => (string)$row['title'],
|
||||||
|
'task_created_date' => $format_dt($row['created_at'] ? $row['created_at'] . ' UTC' : null),
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'task_description' => $desc,
|
||||||
|
'due_date' => $format_dt($row['due_date'] ?? null),
|
||||||
|
'current_status' => $format_status($row['status'] ?? null),
|
||||||
|
'attachments_count' => (int)($row['attachments_count'] ?? 0),
|
||||||
|
'tags_count' => count($tags),
|
||||||
|
'comments_count' => (int)($row['comments_count'] ?? 0),
|
||||||
|
'last_modified_by' => $last_mod_by,
|
||||||
|
'last_modified_date' => $format_dt(($row['status_updated_at'] ?: $row['created_at']) . ' UTC'),
|
||||||
|
'last_comment' => $row['last_comment'] ?: null,
|
||||||
|
'last_comment_date' => $format_dt($row['last_comment_date'] ? $row['last_comment_date'] . ' UTC' : null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($result);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($api_action === 'add_task_tag') {
|
||||||
|
if(!isset($_SESSION['user_id'])) die(json_encode(['success'=>false,'error'=>'unauthorized']));
|
||||||
|
$task_id = $_POST['task_id'] ?? null;
|
||||||
|
$tag = trim($_POST['tag'] ?? '');
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
if($task_id && $tag) {
|
||||||
|
$stmt = $pdo->prepare("SELECT tags FROM tasks WHERE id = ?");
|
||||||
|
$stmt->execute([$task_id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
$current_tags = $row ? ($row['tags'] ?? '') : '';
|
||||||
|
|
||||||
|
$tags_array = $current_tags ? array_filter(array_map('trim', explode(',', $current_tags))) : [];
|
||||||
|
if(!in_array($tag, $tags_array)) {
|
||||||
|
$tags_array[] = $tag;
|
||||||
|
$new_tags = implode(',', $tags_array);
|
||||||
|
$pdo->prepare("UPDATE tasks SET tags = ? WHERE id = ?")->execute([$new_tags, $task_id]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success'=>true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success'=>false,'error'=>'Missing task_id or tag']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if($api_action === 'remove_task_tag') {
|
||||||
|
if(!isset($_SESSION['user_id'])) die(json_encode(['success'=>false,'error'=>'unauthorized']));
|
||||||
|
$task_id = $_POST['task_id'] ?? null;
|
||||||
|
$tag = trim($_POST['tag'] ?? '');
|
||||||
|
|
||||||
|
if($task_id && $tag) {
|
||||||
|
$stmt = $pdo->prepare("SELECT tags FROM tasks WHERE id = ?");
|
||||||
|
$stmt->execute([$task_id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
$current_tags = $row ? ($row['tags'] ?? '') : '';
|
||||||
|
|
||||||
|
$tags_array = $current_tags ? array_filter(array_map('trim', explode(',', $current_tags))) : [];
|
||||||
|
$tags_array = array_diff($tags_array, [$tag]);
|
||||||
|
$new_tags = implode(',', $tags_array);
|
||||||
|
$pdo->prepare("UPDATE tasks SET tags = ? WHERE id = ?")->execute([$new_tags, $task_id]);
|
||||||
|
echo json_encode(['success'=>true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success'=>false,'error'=>'Missing task_id or tag']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['status'=>'error','msg'=>'Unknown api_action']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,17 @@ a:hover { text-decoration: underline; }
|
|||||||
|
|
||||||
/* Toggle button pinned to right edge */
|
/* Toggle button pinned to right edge */
|
||||||
.sidebar-toggle-btn {
|
.sidebar-toggle-btn {
|
||||||
position: absolute; top: 1.5rem; right: -14px;
|
position: fixed; top: 1.5rem; left: 226px;
|
||||||
width: 28px; height: 28px; border-radius: 50%;
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
border: 1px solid var(--border); background: var(--surface);
|
border: 1px solid var(--border); background: var(--surface);
|
||||||
color: var(--text-light); font-size: 0.65rem;
|
color: var(--text-light); font-size: 0.65rem;
|
||||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
z-index: 20; transition: background 0.15s, color 0.15s, border-color 0.15s;
|
z-index: 20; transition: left 0.25s cubic-bezier(.4,0,.2,1), background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||||
}
|
}
|
||||||
.sidebar-toggle-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
|
.sidebar-toggle-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
|
||||||
|
|
||||||
/* Nav link: icon always visible, label collapses */
|
/* Nav link: icon always visible, label collapses */
|
||||||
.nav-link {
|
.nav-link {
|
||||||
padding: 0.6rem 0.75rem; border-radius: 0.5rem;
|
padding: 0.6rem 0.75rem; border-radius: 0.5rem;
|
||||||
@@ -75,6 +76,7 @@ body.sidebar-collapsed .sidebar { width: 64px; }
|
|||||||
body.sidebar-collapsed .sidebar-label { opacity: 0; width: 0; pointer-events: none; }
|
body.sidebar-collapsed .sidebar-label { opacity: 0; width: 0; pointer-events: none; }
|
||||||
body.sidebar-collapsed .nav-link { justify-content: center; padding: 0.65rem; gap: 0; }
|
body.sidebar-collapsed .nav-link { justify-content: center; padding: 0.65rem; gap: 0; }
|
||||||
body.sidebar-collapsed .nav-link .badge { position: absolute; top: 2px; right: 2px; padding: 1px 4px; font-size: 0.6rem; }
|
body.sidebar-collapsed .nav-link .badge { position: absolute; top: 2px; right: 2px; padding: 1px 4px; font-size: 0.6rem; }
|
||||||
|
body.sidebar-collapsed .sidebar-toggle-btn { left: 50px; }
|
||||||
body.sidebar-collapsed .sidebar-toggle-btn .fa-chevron-left { display: block; }
|
body.sidebar-collapsed .sidebar-toggle-btn .fa-chevron-left { display: block; }
|
||||||
|
|
||||||
/* Tooltip on collapsed nav links */
|
/* Tooltip on collapsed nav links */
|
||||||
@@ -219,8 +221,17 @@ body.dark-mode .project-item:hover { background: rgba(255,255,255,0.03); }
|
|||||||
.note-editor-toolbar { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem; padding: 0.35rem; background: var(--bg); border-radius: 0.5rem; border: 1px solid var(--border); }
|
.note-editor-toolbar { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.5rem; padding: 0.35rem; background: var(--bg); border-radius: 0.5rem; border: 1px solid var(--border); }
|
||||||
.note-toolbar-btn { background: none; border: none; cursor: pointer; padding: 4px 7px; border-radius: 4px; color: var(--text-light); font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
.note-toolbar-btn { background: none; border: none; cursor: pointer; padding: 4px 7px; border-radius: 4px; color: var(--text-light); font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||||
.note-toolbar-btn:hover { background: rgba(var(--primary-rgb),0.1); color: var(--primary); }
|
.note-toolbar-btn:hover { background: rgba(var(--primary-rgb),0.1); color: var(--primary); }
|
||||||
.note-content-area { min-height: 160px; border: 1px solid var(--border); border-radius: 0.5rem; padding: 0.75rem; outline: none; font-size: 0.9rem; line-height: 1.6; background: var(--input-bg); color: var(--text); resize: vertical; }
|
.note-content-area { width: 100%; min-height: 160px; border: 1px solid var(--border); border-radius: 0.5rem; padding: 0.75rem; outline: none; font-size: 0.9rem; line-height: 1.6; background: var(--input-bg); color: var(--text); resize: vertical; }
|
||||||
.note-content-area:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(var(--primary-rgb),0.1); }
|
.note-content-area:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(var(--primary-rgb),0.1); }
|
||||||
|
.note-modal-compact { padding: 1rem 1.1rem; }
|
||||||
|
.note-modal-compact .form-group { margin-bottom: 0.75rem; }
|
||||||
|
.note-modal-compact .note-editor-toolbar { margin-bottom: 0.45rem; padding: 0.28rem; }
|
||||||
|
.note-modal-compact .note-toolbar-btn { padding: 3px 6px; font-size: 0.82rem; }
|
||||||
|
.note-modal-compact .form-control { padding: 0.55rem 0.68rem; font-size: 0.88rem; }
|
||||||
|
.note-modal-compact .note-content-area { min-height: 200px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.note-modal-top-fields { grid-template-columns: 1fr !important; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Note color palette */
|
/* Note color palette */
|
||||||
.note-color-palette { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
.note-color-palette { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||||
|
|||||||
447
views/admin.php
447
views/admin.php
@@ -3,6 +3,25 @@ if(empty($_SESSION['is_admin'])) die("Access Denied");
|
|||||||
|
|
||||||
// ── DB Migrations ─────────────────────────────────────────────────────────────
|
// ── DB Migrations ─────────────────────────────────────────────────────────────
|
||||||
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_endpoint TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_chat_id TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_session TEXT DEFAULT 'default'"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_api_key TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_template_task TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_template_note TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_template_password TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN waha_template_file TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN notify_channels TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_endpoint TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_chat_id TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_api_key TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_session TEXT DEFAULT 'default'"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_template_task TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_template_note TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_template_password TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN waha_template_file TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE groups ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try {
|
try {
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)");
|
$pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)");
|
||||||
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('completed_days', '7')");
|
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('completed_days', '7')");
|
||||||
@@ -48,11 +67,18 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if(isset($_POST['create_group'])) {
|
if(isset($_POST['create_group'])) {
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare("INSERT INTO groups (name) VALUES (?)");
|
$name = trim($_POST['group_name']);
|
||||||
$stmt->execute([$_POST['group_name']]);
|
$pic_path = null;
|
||||||
$success_msg = "Group '{$_POST['group_name']}' created!";
|
if(isset($_FILES['group_pic']) && $_FILES['group_pic']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$ext = pathinfo($_FILES['group_pic']['name'], PATHINFO_EXTENSION);
|
||||||
|
$pic_path = 'uploads/group_' . time() . '_' . uniqid() . '.' . $ext;
|
||||||
|
move_uploaded_file($_FILES['group_pic']['tmp_name'], $pic_path);
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO groups (name, profile_pic) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$name, $pic_path]);
|
||||||
|
$success_msg = "Group '{$name}' created!";
|
||||||
} catch(Exception $e) {
|
} catch(Exception $e) {
|
||||||
$error_msg = "Group name may already exist.";
|
$error_msg = "Error: Group name may already exist.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,24 +102,77 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if(isset($_POST['update_user_channels'])) {
|
if(isset($_POST['update_user_channels'])) {
|
||||||
$ch = $_POST['channels'] ?? [];
|
$ch = $_POST['channels'] ?? [];
|
||||||
$ch_str = is_array($ch) ? implode(',', $ch) : '';
|
$ch_str = is_array($ch) ? implode(',', $ch) : '';
|
||||||
$stmt = $pdo->prepare("UPDATE users SET notify_channels=?, telegram_id=?, email=?, whatsapp_number=? WHERE id=?");
|
$waha_ep = trim($_POST['waha_endpoint'] ?? '');
|
||||||
$stmt->execute([$ch_str, $_POST['telegram_id'], $_POST['email'], $_POST['whatsapp_number'], $_POST['user_id']]);
|
$waha_cid = trim($_POST['waha_chat_id'] ?? '');
|
||||||
|
$waha_ses = trim($_POST['waha_session'] ?? '') ?: 'default';
|
||||||
|
$waha_key = trim($_POST['waha_api_key'] ?? '');
|
||||||
|
$tpl_task = trim($_POST['waha_template_task'] ?? '');
|
||||||
|
$tpl_note = trim($_POST['waha_template_note'] ?? '');
|
||||||
|
$tpl_pass = trim($_POST['waha_template_password'] ?? '');
|
||||||
|
$tpl_file = trim($_POST['waha_template_file'] ?? '');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE users SET notify_channels=?, telegram_id=?, email=?, whatsapp_number=?, waha_endpoint=?, waha_chat_id=?, waha_template_task=?, waha_template_note=?, waha_template_password=?, waha_template_file=?, waha_session=?, waha_api_key=? WHERE id=?");
|
||||||
|
$stmt->execute([$ch_str, $_POST['telegram_id'], $_POST['email'], $_POST['whatsapp_number'], $waha_ep ?: null, $waha_cid ?: null, $tpl_task ?: null, $tpl_note ?: null, $tpl_pass ?: null, $tpl_file ?: null, $waha_ses, $waha_key ?: null, $_POST['user_id']]);
|
||||||
$success_msg = "User notification preferences updated!";
|
$success_msg = "User notification preferences updated!";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['update_group_channels'])) {
|
||||||
|
$ch = $_POST['channels'] ?? [];
|
||||||
|
$ch_str = is_array($ch) ? implode(',', $ch) : '';
|
||||||
|
$waha_ep = trim($_POST['waha_endpoint'] ?? '');
|
||||||
|
$waha_cid = trim($_POST['waha_chat_id'] ?? '');
|
||||||
|
$waha_ses = trim($_POST['waha_session'] ?? '') ?: 'default';
|
||||||
|
$waha_key = trim($_POST['waha_api_key'] ?? '');
|
||||||
|
$tpl_task = trim($_POST['waha_template_task'] ?? '');
|
||||||
|
$tpl_note = trim($_POST['waha_template_note'] ?? '');
|
||||||
|
$tpl_pass = trim($_POST['waha_template_password'] ?? '');
|
||||||
|
$tpl_file = trim($_POST['waha_template_file'] ?? '');
|
||||||
|
$group_id = $_POST['group_id'];
|
||||||
|
$name = trim($_POST['group_name'] ?? '');
|
||||||
|
|
||||||
|
// Handle profile pic update
|
||||||
|
$pic_sql = "";
|
||||||
|
$pic_params = [];
|
||||||
|
if(isset($_POST['remove_pic']) && $_POST['remove_pic'] == '1') {
|
||||||
|
$old = query("SELECT profile_pic FROM groups WHERE id=?", [$group_id])->fetch();
|
||||||
|
if($old && $old['profile_pic'] && file_exists($old['profile_pic'])) @unlink($old['profile_pic']);
|
||||||
|
$pic_sql = ", profile_pic = NULL";
|
||||||
|
} elseif(isset($_FILES['group_pic']) && $_FILES['group_pic']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$old = query("SELECT profile_pic FROM groups WHERE id=?", [$group_id])->fetch();
|
||||||
|
if($old && $old['profile_pic'] && file_exists($old['profile_pic'])) @unlink($old['profile_pic']);
|
||||||
|
$ext = pathinfo($_FILES['group_pic']['name'], PATHINFO_EXTENSION);
|
||||||
|
$new_path = 'uploads/group_' . time() . '_' . uniqid() . '.' . $ext;
|
||||||
|
move_uploaded_file($_FILES['group_pic']['tmp_name'], $new_path);
|
||||||
|
$pic_sql = ", profile_pic = ?";
|
||||||
|
$pic_params[] = $new_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "UPDATE groups SET notify_channels=?, waha_endpoint=?, waha_chat_id=?, waha_template_task=?, waha_template_note=?, waha_template_password=?, waha_template_file=?, waha_session=?, waha_api_key=?, name=? $pic_sql WHERE id=?";
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute(array_merge([$ch_str, $waha_ep ?: null, $waha_cid ?: null, $tpl_task ?: null, $tpl_note ?: null, $tpl_pass ?: null, $tpl_file ?: null, $waha_ses, $waha_key ?: null, $name], $pic_params, [$group_id]));
|
||||||
|
$success_msg = "Group settings updated!";
|
||||||
|
} catch(Exception $e) {
|
||||||
|
$error_msg = "Error: Group name may already exist.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(isset($_POST['update_sys_config'])) {
|
if(isset($_POST['update_sys_config'])) {
|
||||||
$stmt = $pdo->prepare("UPDATE system_config SET v=? WHERE k=?");
|
$stmt = $pdo->prepare("UPDATE system_config SET v=? WHERE k=?");
|
||||||
$stmt->execute([$_POST['completed_days'], 'completed_days']);
|
$stmt->execute([$_POST['completed_days'] ?? 7, 'completed_days']);
|
||||||
$stmt->execute([$_POST['archived_days'], 'archived_days']);
|
$stmt->execute([$_POST['archived_days'] ?? 90, 'archived_days']);
|
||||||
$success_msg = "System configuration saved!";
|
$success_msg = "System configuration saved!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch Data ────────────────────────────────────────────────────────────────
|
// ── Fetch Data ────────────────────────────────────────────────────────────────
|
||||||
$users = query("SELECT id, username, is_admin, notify_channels, telegram_id, email, whatsapp_number, profile_pic FROM users ORDER BY is_admin DESC, username ASC")->fetchAll();
|
$users = query("SELECT id, username, is_admin, notify_channels, telegram_id, email, whatsapp_number, profile_pic, waha_endpoint, waha_chat_id, waha_template_task, waha_template_note, waha_template_password, waha_template_file, waha_session, waha_api_key FROM users ORDER BY is_admin DESC, username ASC")->fetchAll();
|
||||||
$all_groups = query("SELECT * FROM groups ORDER BY name")->fetchAll();
|
$all_groups = query("SELECT * FROM groups ORDER BY name")->fetchAll();
|
||||||
$conf_comp = query("SELECT v FROM system_config WHERE k='completed_days'")->fetchColumn() ?: 7;
|
$conf_comp = query("SELECT v FROM system_config WHERE k='completed_days'")->fetchColumn() ?: 7;
|
||||||
$conf_arch = query("SELECT v FROM system_config WHERE k='archived_days'")->fetchColumn() ?: 90;
|
$conf_arch = query("SELECT v FROM system_config WHERE k='archived_days'")->fetchColumn() ?: 90;
|
||||||
|
$waha_endpoint = query("SELECT v FROM system_config WHERE k='waha_endpoint'")->fetchColumn() ?: '';
|
||||||
|
$waha_chat_id = query("SELECT v FROM system_config WHERE k='waha_chat_id'")->fetchColumn() ?: '';
|
||||||
|
$waha_template = query("SELECT v FROM system_config WHERE k='waha_template'")->fetchColumn() ?: '';
|
||||||
|
|
||||||
// Activity stats for admin
|
// Activity stats for admin
|
||||||
$total_users = count($users);
|
$total_users = count($users);
|
||||||
@@ -256,7 +335,7 @@ $active_tab = $_GET['tab'] ?? 'overview';
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<form method="POST" style="display:inline;" onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>?')">
|
<form method="POST" style="display:inline;" onsubmit="event.preventDefault(); appConfirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>?').then(y => { if(y) { this.onsubmit=null; this.submit(); } })">
|
||||||
<input type="hidden" name="del_user_id" value="<?= $u['id'] ?>">
|
<input type="hidden" name="del_user_id" value="<?= $u['id'] ?>">
|
||||||
<button type="submit" name="delete_user" class="btn btn-sm btn-danger" title="Delete user">
|
<button type="submit" name="delete_user" class="btn btn-sm btn-danger" title="Delete user">
|
||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
@@ -279,10 +358,17 @@ $active_tab = $_GET['tab'] ?? 'overview';
|
|||||||
<!-- Create Group + Assign -->
|
<!-- Create Group + Assign -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-folder-plus"></i> Create Group</h3>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1.25rem;"><i class="fa fa-folder-plus"></i> Create Group</h3>
|
||||||
<form method="POST" style="display:flex; gap:0.5rem;">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<input type="text" name="group_name" class="form-control" placeholder="Group name" required>
|
<div class="form-group">
|
||||||
<button type="submit" name="create_group" class="btn btn-sm"><i class="fa fa-plus"></i> Create</button>
|
<label>Group Name</label>
|
||||||
|
<input type="text" name="group_name" class="form-control" placeholder="Enter group name" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Picture</label>
|
||||||
|
<input type="file" name="group_pic" class="form-control" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="create_group" class="btn" style="width:100%"><i class="fa fa-plus"></i> Create Group</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -320,18 +406,51 @@ $active_tab = $_GET['tab'] ?? 'overview';
|
|||||||
$g_users = $stmt->fetchAll();
|
$g_users = $stmt->fetchAll();
|
||||||
?>
|
?>
|
||||||
<div class="card" style="margin-bottom:0;">
|
<div class="card" style="margin-bottom:0;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<div>
|
<input type="hidden" name="update_group_channels" value="1">
|
||||||
<div style="font-weight:700; font-size:1rem; color:var(--primary);">
|
<input type="hidden" name="group_id" value="<?= $g['id'] ?>">
|
||||||
<i class="fa fa-people-group"></i> <?= htmlspecialchars($g['name']) ?>
|
<input type="hidden" name="group_name" value="<?= htmlspecialchars($g['name']) ?>">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.75rem;">
|
||||||
|
<div style="display:flex; align-items:center; gap:0.75rem;">
|
||||||
|
<div class="admin-user-avatar" style="width:42px; height:42px; font-size:1rem;">
|
||||||
|
<?php if($g['profile_pic'] && file_exists($g['profile_pic'])): ?>
|
||||||
|
<img src="<?= htmlspecialchars($g['profile_pic']) ?>" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= strtoupper(mb_substr($g['name'],0,1)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="group_name" value="<?= htmlspecialchars($g['name']) ?>" class="form-control" style="font-weight:700; font-size:1rem; color:var(--primary); background:transparent; border:none; padding:0; height:auto; width:100%;" onchange="this.form.submit()">
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-light);"><?= count($g_users) ?> member<?= count($g_users)!=1?'s':'' ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.4rem;">
|
||||||
|
<label class="btn btn-sm btn-secondary" style="cursor:pointer;" title="Change Picture">
|
||||||
|
<i class="fa fa-camera"></i>
|
||||||
|
<input type="file" name="group_pic" style="display:none;" onchange="this.form.submit()">
|
||||||
|
</label>
|
||||||
|
<?php if($g['profile_pic']): ?>
|
||||||
|
<button type="submit" name="remove_pic" value="1" class="btn btn-sm btn-secondary" title="Remove Picture" onclick="return confirm('Remove profile picture?')"><i class="fa fa-image-slash"></i></button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" onclick="appConfirm('Delete group <?= htmlspecialchars(addslashes($g['name'])) ?>?').then(y => { if(y) { document.getElementById('del-group-<?= $g['id'] ?>').submit(); } })"><i class="fa fa-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.75rem; color:var(--text-light);"><?= count($g_users) ?> member<?= count($g_users)!=1?'s':'' ?></div>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" onsubmit="return confirm('Delete group <?= htmlspecialchars(addslashes($g['name'])) ?>?')">
|
<!-- Hidden inputs for templates to preserve them when updating from here -->
|
||||||
<input type="hidden" name="del_group_id" value="<?= $g['id'] ?>">
|
<div style="display:none;">
|
||||||
<button type="submit" name="delete_group" class="btn btn-sm btn-danger"><i class="fa fa-trash"></i></button>
|
<input type="text" name="waha_endpoint" value="<?= htmlspecialchars($g['waha_endpoint']??'') ?>">
|
||||||
</form>
|
<input type="text" name="waha_chat_id" value="<?= htmlspecialchars($g['waha_chat_id']??'') ?>">
|
||||||
</div>
|
<input type="text" name="waha_session" value="<?= htmlspecialchars($g['waha_session']??'default') ?>">
|
||||||
|
<input type="text" name="waha_api_key" value="<?= htmlspecialchars($g['waha_api_key']??'') ?>">
|
||||||
|
<textarea name="waha_template_task"><?= htmlspecialchars($g['waha_template_task']??'') ?></textarea>
|
||||||
|
<textarea name="waha_template_note"><?= htmlspecialchars($g['waha_template_note']??'') ?></textarea>
|
||||||
|
<textarea name="waha_template_password"><?= htmlspecialchars($g['waha_template_password']??'') ?></textarea>
|
||||||
|
<textarea name="waha_template_file"><?= htmlspecialchars($g['waha_template_file']??'') ?></textarea>
|
||||||
|
<?php $channels = explode(',', $g['notify_channels']??''); foreach($channels as $c): if(!$c) continue; ?>
|
||||||
|
<input type="checkbox" name="channels[]" value="<?= $c ?>" checked>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="POST" id="del-group-<?= $g['id'] ?>" style="display:none;"><input type="hidden" name="del_group_id" value="<?= $g['id'] ?>"><input type="hidden" name="delete_group" value="1"></form>
|
||||||
<?php if($g_users): ?>
|
<?php if($g_users): ?>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:0.5rem;">
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem;">
|
||||||
<?php foreach($g_users as $gu): ?>
|
<?php foreach($g_users as $gu): ?>
|
||||||
@@ -359,60 +478,207 @@ $active_tab = $_GET['tab'] ?? 'overview';
|
|||||||
<div class="admin-tab-pane <?= $active_tab==='channels'?'active':'' ?>" id="tab-channels">
|
<div class="admin-tab-pane <?= $active_tab==='channels'?'active':'' ?>" id="tab-channels">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem;"><i class="fa fa-bell"></i> User Notification Channels</h3>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem;"><i class="fa fa-bell"></i> User Notification Channels</h3>
|
||||||
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1.25rem;">Configure notification channels (Native, Telegram, Email, WhatsApp) per user.</p>
|
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1.25rem;">Configure notification channels per user. WAHA settings appear when <strong>WhatsApp</strong> is enabled.</p>
|
||||||
<div style="overflow-x:auto;">
|
|
||||||
<table>
|
<?php foreach($users as $u):
|
||||||
<thead>
|
$chs = explode(',', $u['notify_channels'] ?? '');
|
||||||
<tr>
|
$uid = $u['id'];
|
||||||
<th>User</th>
|
$hasWA = in_array('whatsapp', $chs);
|
||||||
<th>Role</th>
|
?>
|
||||||
<th>Channels</th>
|
<div class="card" style="margin-bottom:1rem; padding:1rem; border:1px solid var(--border);">
|
||||||
<th>Telegram ID</th>
|
<form method="POST">
|
||||||
<th>Email</th>
|
<input type="hidden" name="user_id" value="<?= $uid ?>">
|
||||||
<th>WhatsApp</th>
|
|
||||||
<th>Save</th>
|
<!-- User header row -->
|
||||||
</tr>
|
<div style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
|
||||||
</thead>
|
<!-- Avatar -->
|
||||||
<tbody>
|
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:0.85rem;font-weight:700;flex-shrink:0;overflow:hidden;">
|
||||||
<?php foreach($users as $u):
|
<?php if(($u['profile_pic']??null) && file_exists($u['profile_pic'])): ?>
|
||||||
$chs = explode(',', $u['notify_channels'] ?? '');
|
<img src="<?= htmlspecialchars($u['profile_pic']) ?>" style="width:100%;height:100%;object-fit:cover;">
|
||||||
?>
|
<?php else: ?>
|
||||||
<tr>
|
<?= strtoupper(mb_substr($u['username'],0,1)) ?>
|
||||||
<form method="POST">
|
<?php endif; ?>
|
||||||
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
|
</div>
|
||||||
<td>
|
|
||||||
<div style="display:flex; align-items:center; gap:0.5rem;">
|
<!-- Name & role -->
|
||||||
<div style="width:28px;height:28px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:0.75rem;font-weight:700;flex-shrink:0;overflow:hidden;">
|
<div style="flex:1;">
|
||||||
<?php if(($u['profile_pic']??null) && file_exists($u['profile_pic'])): ?>
|
<div style="font-weight:700; font-size:0.95rem;"><?= htmlspecialchars($u['username']) ?></div>
|
||||||
<img src="<?= htmlspecialchars($u['profile_pic']) ?>" style="width:100%;height:100%;object-fit:cover;">
|
<div><span class="<?= $u['is_admin']?'admin-badge-admin':'admin-badge-user' ?>" style="font-size:0.7rem;"><?= $u['is_admin']?'Admin':'User' ?></span></div>
|
||||||
<?php else: ?>
|
</div>
|
||||||
<?= strtoupper(mb_substr($u['username'],0,1)) ?>
|
|
||||||
<?php endif; ?>
|
<!-- Save button -->
|
||||||
</div>
|
<button type="submit" name="update_user_channels" class="btn btn-sm"><i class="fa fa-save"></i> Save</button>
|
||||||
<strong><?= htmlspecialchars($u['username']) ?></strong>
|
</div>
|
||||||
</div>
|
|
||||||
</td>
|
<!-- Channel checkboxes -->
|
||||||
<td><span class="<?= $u['is_admin']?'admin-badge-admin':'admin-badge-user' ?>"><?= $u['is_admin']?'Admin':'User' ?></span></td>
|
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-top:0.85rem; padding-top:0.75rem; border-top:1px dashed var(--border);">
|
||||||
<td>
|
<?php foreach(['native','email','telegram','whatsapp'] as $ch): ?>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<label style="display:flex; align-items:center; gap:5px; font-size:0.85rem; cursor:pointer; font-weight:500;">
|
||||||
<?php foreach(['native','email','telegram','whatsapp'] as $ch): ?>
|
<input type="checkbox" name="channels[]" value="<?= $ch ?>"
|
||||||
<label style="display:flex; align-items:center; gap:3px; font-size:0.8rem; cursor:pointer; white-space:nowrap;">
|
<?= in_array($ch,$chs)?'checked':'' ?>
|
||||||
<input type="checkbox" name="channels[]" value="<?= $ch ?>" <?= in_array($ch,$chs)?'checked':'' ?>>
|
<?= $ch==='whatsapp' ? "onchange=\"toggleWAHA({$uid}, this.checked)\"" : '' ?>>
|
||||||
<?= ucfirst($ch) ?>
|
<?php
|
||||||
</label>
|
$icons = ['native'=>'🔔','email'=>'📧','telegram'=>'✈️','whatsapp'=>'📱'];
|
||||||
<?php endforeach; ?>
|
echo $icons[$ch] . ' ' . ucfirst($ch);
|
||||||
</div>
|
?>
|
||||||
</td>
|
</label>
|
||||||
<td><input type="text" name="telegram_id" value="<?= htmlspecialchars($u['telegram_id']??'') ?>" class="form-control" style="width:100px; padding:0.35rem;"></td>
|
|
||||||
<td><input type="email" name="email" value="<?= htmlspecialchars($u['email']??'') ?>" class="form-control" style="width:160px; padding:0.35rem;"></td>
|
|
||||||
<td><input type="text" name="whatsapp_number" value="<?= htmlspecialchars($u['whatsapp_number']??'') ?>" class="form-control" style="width:120px; padding:0.35rem;"></td>
|
|
||||||
<td><button type="submit" name="update_user_channels" class="btn btn-sm"><i class="fa fa-save"></i></button></td>
|
|
||||||
</form>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
|
<!-- Extra fields row -->
|
||||||
|
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-top:0.75rem; align-items:flex-end;">
|
||||||
|
<div class="form-group" style="margin:0; min-width:130px;">
|
||||||
|
<label style="font-size:0.75rem;">Telegram ID</label>
|
||||||
|
<input type="text" name="telegram_id" value="<?= htmlspecialchars($u['telegram_id']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="@username or ID">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; min-width:180px;">
|
||||||
|
<label style="font-size:0.75rem;">Email</label>
|
||||||
|
<input type="email" name="email" value="<?= htmlspecialchars($u['email']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="user@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; min-width:140px;">
|
||||||
|
<label style="font-size:0.75rem;">WhatsApp Number</label>
|
||||||
|
<input type="text" name="whatsapp_number" value="<?= htmlspecialchars($u['whatsapp_number']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="6281234567890">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WAHA expandable section -->
|
||||||
|
<div id="waha-section-<?= $uid ?>" style="<?= $hasWA ? '' : 'display:none;' ?> margin-top:0.75rem; padding:0.85rem; background:rgba(37,211,102,0.06); border:1px solid rgba(37,211,102,0.25); border-radius:8px;">
|
||||||
|
<div style="font-size:0.78rem; font-weight:700; color:#25D366; margin-bottom:0.65rem;"><i class="fab fa-whatsapp"></i> WAHA WhatsApp API Settings</div>
|
||||||
|
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end;">
|
||||||
|
<div class="form-group" style="margin:0; flex:2; min-width:200px;">
|
||||||
|
<label style="font-size:0.75rem;">WAHA Endpoint URL</label>
|
||||||
|
<input type="text" name="waha_endpoint" value="<?= htmlspecialchars($u['waha_endpoint']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="http://localhost:3000/api/sendText">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:120px;">
|
||||||
|
<label style="font-size:0.75rem;">Session Name</label>
|
||||||
|
<input type="text" name="waha_session" value="<?= htmlspecialchars($u['waha_session'] ?? 'default') ?>" class="form-control" style="padding:0.35rem;" placeholder="default">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:160px;">
|
||||||
|
<label style="font-size:0.75rem;">Chat ID</label>
|
||||||
|
<input type="text" name="waha_chat_id" value="<?= htmlspecialchars($u['waha_chat_id']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="6281234567890@c.us">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:160px;">
|
||||||
|
<label style="font-size:0.75rem;">API Key <span style="font-weight:400; color:var(--text-light);">(X-Api-Key)</span></label>
|
||||||
|
<input type="password" name="waha_api_key" value="<?= htmlspecialchars($u['waha_api_key']??'') ?>" class="form-control" style="padding:0.35rem; font-family:monospace;" placeholder="Leave blank if not required">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:0.6rem;">
|
||||||
|
<label style="font-size:0.75rem;">Message Templates</label>
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-light); margin-bottom:8px;">Variables: <code>{action}</code> <code>{module}</code> <code>{item}</code> <code>{user}</code> <code>{attachment_count}</code> <code>{due_date}</code> <code>{created_date}</code> <code>{updated_date}</code> <code>{assignor}</code> <code>{assignee}</code></div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Task</label>
|
||||||
|
<textarea name="waha_template_task" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Task Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($u['waha_template_task']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Note</label>
|
||||||
|
<textarea name="waha_template_note" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Note Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($u['waha_template_note']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Password</label>
|
||||||
|
<textarea name="waha_template_password" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Password Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($u['waha_template_password']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">File/Folder</label>
|
||||||
|
<textarea name="waha_template_file" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*File Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($u['waha_template_file']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Notification Channels -->
|
||||||
|
<div class="card" style="margin-top:2rem;">
|
||||||
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem;"><i class="fa fa-users"></i> Group Notification Channels</h3>
|
||||||
|
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1.25rem;">Configure notification channels for an entire group. WAHA settings appear when <strong>WhatsApp</strong> is enabled.</p>
|
||||||
|
|
||||||
|
<?php foreach($all_groups as $g):
|
||||||
|
$chs = explode(',', $g['notify_channels'] ?? '');
|
||||||
|
$gid = $g['id'];
|
||||||
|
$hasWA = in_array('whatsapp', $chs);
|
||||||
|
?>
|
||||||
|
<div class="card" style="margin-bottom:1rem; padding:1rem; border:1px solid var(--border);">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="update_group_channels" value="1">
|
||||||
|
<input type="hidden" name="group_id" value="<?= $gid ?>">
|
||||||
|
<input type="hidden" name="group_name" value="<?= htmlspecialchars($g['name']) ?>">
|
||||||
|
|
||||||
|
<!-- Group header row -->
|
||||||
|
<div style="display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap;">
|
||||||
|
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:0.85rem;font-weight:700;flex-shrink:0;overflow:hidden;">
|
||||||
|
<?php if(!empty($g['profile_pic']) && file_exists($g['profile_pic'])): ?>
|
||||||
|
<img src="<?= htmlspecialchars($g['profile_pic']) ?>" style="width:100%;height:100%;object-fit:cover;">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= strtoupper(mb_substr($g['name'],0,1)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-weight:700; font-size:0.95rem;"><?= htmlspecialchars($g['name']) ?></div>
|
||||||
|
<div><span style="font-size:0.7rem; color:var(--text-light);">Group</span></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="update_group_channels" class="btn btn-sm"><i class="fa fa-save"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel checkboxes -->
|
||||||
|
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin-top:0.85rem; padding-top:0.75rem; border-top:1px dashed var(--border);">
|
||||||
|
<label style="display:flex; align-items:center; gap:5px; font-size:0.85rem; cursor:pointer; font-weight:500;">
|
||||||
|
<input type="checkbox" name="channels[]" value="whatsapp" <?= $hasWA?'checked':'' ?> onchange="toggleWAHAGroup(<?= $gid ?>, this.checked)">
|
||||||
|
📱 WhatsApp
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WAHA expandable section -->
|
||||||
|
<div id="waha-group-section-<?= $gid ?>" style="<?= $hasWA ? '' : 'display:none;' ?> margin-top:0.75rem; padding:0.85rem; background:rgba(37,211,102,0.06); border:1px solid rgba(37,211,102,0.25); border-radius:8px;">
|
||||||
|
<div style="font-size:0.78rem; font-weight:700; color:#25D366; margin-bottom:0.65rem;"><i class="fab fa-whatsapp"></i> WAHA WhatsApp API Settings</div>
|
||||||
|
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:flex-end;">
|
||||||
|
<div class="form-group" style="margin:0; flex:2; min-width:200px;">
|
||||||
|
<label style="font-size:0.75rem;">WAHA Endpoint URL</label>
|
||||||
|
<input type="text" name="waha_endpoint" value="<?= htmlspecialchars($g['waha_endpoint']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="http://localhost:3000/api/sendText">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:120px;">
|
||||||
|
<label style="font-size:0.75rem;">Session Name</label>
|
||||||
|
<input type="text" name="waha_session" value="<?= htmlspecialchars($g['waha_session'] ?? 'default') ?>" class="form-control" style="padding:0.35rem;" placeholder="default">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:160px;">
|
||||||
|
<label style="font-size:0.75rem;">Chat ID</label>
|
||||||
|
<input type="text" name="waha_chat_id" value="<?= htmlspecialchars($g['waha_chat_id']??'') ?>" class="form-control" style="padding:0.35rem;" placeholder="12345678@g.us">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0; flex:1; min-width:160px;">
|
||||||
|
<label style="font-size:0.75rem;">API Key <span style="font-weight:400; color:var(--text-light);">(X-Api-Key)</span></label>
|
||||||
|
<input type="password" name="waha_api_key" value="<?= htmlspecialchars($g['waha_api_key']??'') ?>" class="form-control" style="padding:0.35rem; font-family:monospace;" placeholder="Leave blank if not required">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:0.6rem;">
|
||||||
|
<label style="font-size:0.75rem;">Message Templates</label>
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-light); margin-bottom:8px;">Variables: <code>{action}</code> <code>{module}</code> <code>{item}</code> <code>{user}</code> <code>{attachment_count}</code> <code>{due_date}</code> <code>{created_date}</code> <code>{updated_date}</code> <code>{assignor}</code> <code>{assignee}</code></div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem;">
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Task</label>
|
||||||
|
<textarea name="waha_template_task" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Task Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($g['waha_template_task']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Note</label>
|
||||||
|
<textarea name="waha_template_note" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Note Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($g['waha_template_note']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">Password</label>
|
||||||
|
<textarea name="waha_template_password" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*Password Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($g['waha_template_password']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;">
|
||||||
|
<label style="font-size:0.7rem; color:var(--text-light);">File/Folder</label>
|
||||||
|
<textarea name="waha_template_file" class="form-control" rows="2" style="padding:0.35rem; font-size:0.75rem;" placeholder="*File Alert:* {action} on '{item}' by {user}"><?= htmlspecialchars($g['waha_template_file']??'') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,10 +709,10 @@ $active_tab = $_GET['tab'] ?? 'overview';
|
|||||||
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem; color:#EF4444;"><i class="fa fa-triangle-exclamation"></i> Danger Zone</h3>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem; color:#EF4444;"><i class="fa fa-triangle-exclamation"></i> Danger Zone</h3>
|
||||||
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1rem;">These actions are irreversible. Proceed with extreme caution.</p>
|
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1rem;">These actions are irreversible. Proceed with extreme caution.</p>
|
||||||
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
|
||||||
<button class="btn btn-danger" onclick="if(confirm('Really purge all archive tasks?')) alert('Not implemented in this demo.')">
|
<button class="btn btn-danger" onclick="appConfirm('Really purge all archive tasks?').then(y => { if(y) appAlert('Not implemented in this demo.'); })">
|
||||||
<i class="fa fa-trash"></i> Purge Archived Tasks
|
<i class="fa fa-trash"></i> Purge Archived Tasks
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick="if(confirm('Really clear all activity logs?')) alert('Not implemented in this demo.')">
|
<button class="btn btn-danger" onclick="appConfirm('Really clear all activity logs?').then(y => { if(y) appAlert('Not implemented in this demo.'); })">
|
||||||
<i class="fa fa-eraser"></i> Clear Activity Logs
|
<i class="fa fa-eraser"></i> Clear Activity Logs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -459,9 +725,32 @@ function switchTab(name) {
|
|||||||
document.querySelectorAll('.admin-tab-pane').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('.admin-tab-pane').forEach(p => p.classList.remove('active'));
|
||||||
document.querySelector(`[onclick="switchTab('${name}')"]`).classList.add('active');
|
document.querySelector(`[onclick="switchTab('${name}')"]`).classList.add('active');
|
||||||
document.getElementById('tab-'+name).classList.add('active');
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
// Update URL without reload
|
|
||||||
const url = new URL(window.location);
|
const url = new URL(window.location);
|
||||||
url.searchParams.set('tab', name);
|
url.searchParams.set('tab', name);
|
||||||
history.replaceState({}, '', url);
|
history.replaceState({}, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleWAHA(uid, enabled) {
|
||||||
|
const section = document.getElementById('waha-section-' + uid);
|
||||||
|
if (!section) return;
|
||||||
|
if (enabled) {
|
||||||
|
section.style.display = 'block';
|
||||||
|
section.style.animation = 'fadeIn 0.25s ease';
|
||||||
|
} else {
|
||||||
|
section.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleWAHAGroup(gid, enabled) {
|
||||||
|
const section = document.getElementById('waha-group-section-' + gid);
|
||||||
|
if (!section) return;
|
||||||
|
if (enabled) {
|
||||||
|
section.style.display = 'block';
|
||||||
|
section.style.animation = 'fadeIn 0.25s ease';
|
||||||
|
} else {
|
||||||
|
section.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
@keyframes fadeIn { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:none; } }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -52,61 +52,69 @@ try {
|
|||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
// ── Current user mood ─────────────────────────────────────────────────────────
|
// ── Current user mood ─────────────────────────────────────────────────────────
|
||||||
$my_mood = $current_user['mood'] ?? null;
|
$me = query("SELECT username, profile_pic, mood, mood_updated_at FROM users WHERE id=?", [$user_id])->fetch();
|
||||||
|
$my_mood = $me['mood'] ?? null;
|
||||||
|
|
||||||
// ── Team moods (all users + their mood) ──────────────────────────────────────
|
// ── Team moods (all users + their mood) ──────────────────────────────────────
|
||||||
$team_moods = query("SELECT id, username, mood, mood_updated_at FROM users WHERE mood IS NOT NULL AND id != ? ORDER BY mood_updated_at DESC", [$user_id])->fetchAll();
|
$team_moods = query("SELECT id, username, mood, mood_updated_at, profile_pic FROM users WHERE mood IS NOT NULL AND id != ? ORDER BY mood_updated_at DESC", [$user_id])->fetchAll();
|
||||||
|
|
||||||
// ── Activity log ──────────────────────────────────────────────────────────────
|
// ── Activity log ──────────────────────────────────────────────────────────────
|
||||||
// Admin: sees ALL activity across all users & groups
|
// Admin: No longer sees all activity by default.
|
||||||
// Standard user: sees own activity + group members activity
|
// Standard user/Admin: sees own activity + group members activity, BUT we hide admin activity from non-self.
|
||||||
$is_admin_user = !empty($_SESSION['is_admin']);
|
$is_admin_user = !empty($_SESSION['is_admin']);
|
||||||
|
|
||||||
|
$peer_ids = [$user_id];
|
||||||
|
if ($my_group_ids) {
|
||||||
|
$gin = implode(',', array_fill(0, count($my_group_ids), '?'));
|
||||||
|
$peers = $pdo->prepare("SELECT DISTINCT user_id FROM user_groups WHERE group_id IN ($gin)");
|
||||||
|
$peers->execute($my_group_ids);
|
||||||
|
$peer_ids = array_unique(array_merge($peer_ids, array_column($peers->fetchAll(), 'user_id')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure at least the user themselves is present
|
||||||
|
if(empty($peer_ids)) { $peer_ids = [$user_id]; }
|
||||||
|
|
||||||
|
// Get all admins except the current user
|
||||||
|
$other_admins = [];
|
||||||
|
try {
|
||||||
|
$other_admins = $pdo->prepare("SELECT id FROM users WHERE is_admin = 1 AND id != ?");
|
||||||
|
$other_admins->execute([$user_id]);
|
||||||
|
$other_admins = $other_admins->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
|
// Filter out other admins from peer ids
|
||||||
|
$peer_ids = array_filter($peer_ids, function($id) use ($other_admins) {
|
||||||
|
return !in_array($id, $other_admins);
|
||||||
|
});
|
||||||
|
|
||||||
|
$uid_in = implode(',', array_fill(0, count($peer_ids), '?'));
|
||||||
$recent_logs = [];
|
$recent_logs = [];
|
||||||
try {
|
try {
|
||||||
if ($is_admin_user) {
|
$stmt = $pdo->prepare("SELECT al.*, u.username FROM activity_log al LEFT JOIN users u ON al.user_id=u.id WHERE al.user_id IN ($uid_in) ORDER BY al.created_at DESC LIMIT 30");
|
||||||
// Admin: fetch all activity
|
$stmt->execute(array_values($peer_ids));
|
||||||
$stmt = $pdo->prepare("SELECT al.*, u.username FROM activity_log al LEFT JOIN users u ON al.user_id=u.id ORDER BY al.created_at DESC LIMIT 40");
|
|
||||||
$stmt->execute();
|
|
||||||
} else {
|
|
||||||
// Standard: only own + group peers
|
|
||||||
$peer_ids = [$user_id];
|
|
||||||
if ($my_group_ids) {
|
|
||||||
$gin = implode(',', array_fill(0, count($my_group_ids), '?'));
|
|
||||||
$peers = $pdo->prepare("SELECT DISTINCT user_id FROM user_groups WHERE group_id IN ($gin)");
|
|
||||||
$peers->execute($my_group_ids);
|
|
||||||
$peer_ids = array_unique(array_merge($peer_ids, array_column($peers->fetchAll(), 'user_id')));
|
|
||||||
}
|
|
||||||
$uid_in = implode(',', array_fill(0, count($peer_ids), '?'));
|
|
||||||
$stmt = $pdo->prepare("SELECT al.*, u.username FROM activity_log al LEFT JOIN users u ON al.user_id=u.id WHERE al.user_id IN ($uid_in) ORDER BY al.created_at DESC LIMIT 20");
|
|
||||||
$stmt->execute($peer_ids);
|
|
||||||
}
|
|
||||||
$recent_logs = $stmt->fetchAll();
|
$recent_logs = $stmt->fetchAll();
|
||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
// ── Task logs ─────────────────────────────────────────────────────────────────
|
// ── Task logs ─────────────────────────────────────────────────────────────────
|
||||||
$task_logs = [];
|
$task_logs = [];
|
||||||
try {
|
try {
|
||||||
if ($is_admin_user) {
|
$group_in_tl = $my_group_ids ? implode(',', array_fill(0, count($my_group_ids), '?')) : '0';
|
||||||
// Admin: all recent task status changes
|
$tl_params = array_merge([$user_id, $user_id], $my_group_ids, $my_group_ids);
|
||||||
$stmt = $pdo->prepare("SELECT t.title, t.status, t.status_updated_at, u.username as assignor
|
|
||||||
FROM tasks t LEFT JOIN users u ON t.assignor_id=u.id
|
$admin_excl = $other_admins ? "AND t.assignor_id NOT IN (" . implode(',', array_fill(0, count($other_admins), '?')) . ")" : "";
|
||||||
WHERE t.status_updated_at IS NOT NULL
|
if($other_admins) { $tl_params = array_merge($tl_params, $other_admins); }
|
||||||
ORDER BY t.status_updated_at DESC LIMIT 40");
|
|
||||||
$stmt->execute();
|
$stmt = $pdo->prepare("SELECT t.title, t.status, t.status_updated_at, u.username as assignor
|
||||||
} else {
|
FROM tasks t LEFT JOIN users u ON t.assignor_id=u.id
|
||||||
$group_in_tl = $my_group_ids ? implode(',', array_fill(0, count($my_group_ids), '?')) : '0';
|
WHERE t.status_updated_at IS NOT NULL
|
||||||
$tl_params = array_merge([$user_id, $user_id], $my_group_ids, $my_group_ids);
|
AND (
|
||||||
$stmt = $pdo->prepare("SELECT t.title, t.status, t.status_updated_at, u.username as assignor
|
t.assignee_id=? OR t.assignor_id=?
|
||||||
FROM tasks t LEFT JOIN users u ON t.assignor_id=u.id
|
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in_tl))
|
||||||
WHERE t.status_updated_at IS NOT NULL
|
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in_tl))
|
||||||
AND (
|
)
|
||||||
t.assignee_id=? OR t.assignor_id=?
|
$admin_excl
|
||||||
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in_tl))
|
ORDER BY t.status_updated_at DESC LIMIT 30");
|
||||||
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in_tl))
|
$stmt->execute($tl_params);
|
||||||
)
|
|
||||||
ORDER BY t.status_updated_at DESC LIMIT 30");
|
|
||||||
$stmt->execute($tl_params);
|
|
||||||
}
|
|
||||||
$task_logs = $stmt->fetchAll();
|
$task_logs = $stmt->fetchAll();
|
||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
?>
|
?>
|
||||||
@@ -268,11 +276,11 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
<div class="db-stat-link">Open Vault →</div>
|
<div class="db-stat-link">Open Vault →</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Projects -->
|
<!-- Files -->
|
||||||
<a href="?action=projects" class="db-stat-card" style="border-top:3px solid #F59E0B;">
|
<a href="?action=projects" class="db-stat-card" style="border-top:3px solid #F59E0B;">
|
||||||
<div class="db-stat-label">
|
<div class="db-stat-label">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||||
Projects & Files
|
Files
|
||||||
</div>
|
</div>
|
||||||
<div class="db-stat-value" style="color:#F59E0B;"><?= $projects_count ?></div>
|
<div class="db-stat-value" style="color:#F59E0B;"><?= $projects_count ?></div>
|
||||||
<div class="db-stat-sub">
|
<div class="db-stat-sub">
|
||||||
@@ -293,47 +301,88 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="db-stat-link">Open Notes →</div>
|
<div class="db-stat-link">Open Notes →</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Tools -->
|
||||||
|
<a href="?action=tools" class="db-stat-card" style="border-top:3px solid #EC4899;">
|
||||||
|
<div class="db-stat-label">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 9.36l-7.1 7.1a1 1 0 0 1-1.4 0l-2.8-2.8a1 1 0 0 1 0-1.4l7.1-7.1a6 6 0 0 1 9.36-7.94z"></path></svg>
|
||||||
|
Tools
|
||||||
|
</div>
|
||||||
|
<div class="db-stat-value" style="color:#EC4899;">3</div>
|
||||||
|
<div class="db-stat-sub">
|
||||||
|
<span style="background:rgba(236,72,153,0.12); color:#EC4899;">🛠 Utilities</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-stat-link">Open Tools →</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Team Moods + Activity Log ───────────────────────────────────────────── -->
|
<!-- ── Team Moods ───────────────────────────────────────────── -->
|
||||||
<div class="two-col">
|
<div class="card" style="margin-bottom:1.5rem;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
<!-- Team Moods -->
|
<h3 style="font-size:1rem; font-weight:700;">👥 Team Mood</h3>
|
||||||
<div class="card">
|
<span style="font-size:0.75rem; color:var(--text-light); font-style:italic;">Live from colleagues</span>
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
|
||||||
<h3 style="font-size:1rem; font-weight:700;">👥 Team Mood</h3>
|
|
||||||
<span style="font-size:0.75rem; color:var(--text-light); font-style:italic;">Live from colleagues</span>
|
|
||||||
</div>
|
|
||||||
<?php if(empty($team_moods)): ?>
|
|
||||||
<p style="color:var(--text-light); font-size:0.85rem;">No one else has set a mood yet.</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="team-mood-row">
|
|
||||||
<?php foreach($team_moods as $tm): ?>
|
|
||||||
<div class="team-mood-chip" title="Updated: <?= date('M j, H:i', strtotime($tm['mood_updated_at'])) ?>">
|
|
||||||
<span class="em"><?= $tm['mood'] ?></span>
|
|
||||||
<span><?= htmlspecialchars($tm['username']) ?></span>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if($my_mood): ?>
|
|
||||||
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px dashed var(--border); font-size:0.8rem; color:var(--text-light);">
|
|
||||||
Your mood is visible to your team: <strong style="color:var(--text);"><?= $my_mood ?></strong>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
|
<?php if(empty($team_moods) && empty($my_mood)): ?>
|
||||||
|
<p style="color:var(--text-light); font-size:0.85rem;">No team mood activity yet.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="team-mood-row" style="display:flex; flex-wrap:nowrap; overflow-x:auto; gap:1rem; padding-bottom:0.5rem;">
|
||||||
|
<?php
|
||||||
|
$avatar_colors_tm = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
|
||||||
|
|
||||||
|
if(!empty($my_mood)):
|
||||||
|
$m_initial = strtoupper(mb_substr($me['username'], 0, 1));
|
||||||
|
$m_color = $avatar_colors_tm[crc32($me['username']) % count($avatar_colors_tm)];
|
||||||
|
?>
|
||||||
|
<div style="position:relative; flex-shrink:0; cursor:pointer;" title="You (Updated: <?= date('M j, H:i', strtotime($me['mood_updated_at'])) ?>)">
|
||||||
|
<div style="width:40px; height:40px; border-radius:50%; background:<?= $m_color ?>; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; overflow:hidden;">
|
||||||
|
<?php if(!empty($me['profile_pic']) && file_exists($me['profile_pic'])): ?>
|
||||||
|
<img src="<?= htmlspecialchars($me['profile_pic']) ?>?v=<?= time() ?>" style="width:100%; height:100%; object-fit:cover;">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $m_initial ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute; top:-4px; right:-4px; background:var(--surface); border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<?= $my_mood ?>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.65rem; color:var(--text-light); text-align:center; margin-top:4px; max-width:40px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-style:italic;">
|
||||||
|
You
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Activity Log -->
|
<?php
|
||||||
<div class="card">
|
foreach($team_moods as $tm):
|
||||||
|
$initial = strtoupper(mb_substr($tm['username'], 0, 1));
|
||||||
|
$color = $avatar_colors_tm[crc32($tm['username']) % count($avatar_colors_tm)];
|
||||||
|
?>
|
||||||
|
<div style="position:relative; flex-shrink:0; cursor:pointer;" title="<?= htmlspecialchars($tm['username']) ?> (Updated: <?= date('M j, H:i', strtotime($tm['mood_updated_at'])) ?>)">
|
||||||
|
<div style="width:40px; height:40px; border-radius:50%; background:<?= $color ?>; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; overflow:hidden;">
|
||||||
|
<?php if(!empty($tm['profile_pic']) && file_exists($tm['profile_pic'])): ?>
|
||||||
|
<img src="<?= htmlspecialchars($tm['profile_pic']) ?>?v=<?= time() ?>" style="width:100%; height:100%; object-fit:cover;">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= $initial ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute; top:-4px; right:-4px; background:var(--surface); border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||||
|
<?= $tm['mood'] ?>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.65rem; color:var(--text-light); text-align:center; margin-top:4px; max-width:40px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||||
|
<?= htmlspecialchars($tm['username']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Activity Log ───────────────────────────────────────────── -->
|
||||||
|
<div class="card" style="flex:1; display:flex; flex-direction:column; min-height:0;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
<div style="display:flex; align-items:center; gap:0.65rem;">
|
<div style="display:flex; align-items:center; gap:0.65rem;">
|
||||||
<h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3>
|
<h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3>
|
||||||
<?php if($is_admin_user): ?>
|
|
||||||
<span class="admin-activity-badge"><i class="fa fa-shield-halved" style="font-size:0.65rem;"></i> All Users</span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.75rem; color:var(--text-light);">
|
<span style="font-size:0.75rem; color:var(--text-light);">
|
||||||
<?= $is_admin_user ? 'All activity across platform' : 'You & your groups' ?>
|
You & your groups
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
@@ -393,7 +442,6 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div><!-- .db-wrapper -->
|
</div><!-- .db-wrapper -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -401,7 +449,7 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
function updateTime() {
|
function updateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const el = document.getElementById('current-datetime');
|
const el = document.getElementById('current-datetime');
|
||||||
if (el) el.textContent = now.toLocaleString('id-ID', {
|
if (el) el.textContent = now.toLocaleString('en-US', {
|
||||||
weekday:'long', year:'numeric', month:'long', day:'numeric',
|
weekday:'long', year:'numeric', month:'long', day:'numeric',
|
||||||
hour:'2-digit', minute:'2-digit', second:'2-digit'
|
hour:'2-digit', minute:'2-digit', second:'2-digit'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,61 @@
|
|||||||
setTimeout(() => t.classList.remove('show'), 3000);
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Custom Alert / Confirm ─────────────────────────────────────────────
|
||||||
|
function appAlert(msg) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let modal = document.getElementById('app-alert-modal');
|
||||||
|
if(!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'app-alert-modal';
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.style.zIndex = '10005';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width:320px; text-align:center;">
|
||||||
|
<h3 style="margin-bottom:1rem; font-size:1.1rem;">Notice</h3>
|
||||||
|
<p style="margin-bottom:1.5rem; font-size:0.9rem; color:var(--text);" id="app-alert-msg"></p>
|
||||||
|
<button class="btn" style="width:100%;" id="app-alert-ok">OK</button>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
document.getElementById('app-alert-msg').textContent = msg;
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.getElementById('app-alert-ok').onclick = () => { modal.classList.remove('active'); resolve(true); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.alert = function(msg) { appAlert(msg); };
|
||||||
|
|
||||||
|
function appConfirm(msg) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let modal = document.getElementById('app-confirm-modal');
|
||||||
|
if(!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'app-confirm-modal';
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.style.zIndex = '10005';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width:350px;">
|
||||||
|
<h3 style="margin-bottom:0.75rem; font-size:1.1rem; text-align:center;">Confirmation</h3>
|
||||||
|
<p style="margin-bottom:1.5rem; font-size:0.95rem; color:var(--text); text-align:center; line-height:1.4;" id="app-confirm-msg"></p>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button class="btn btn-secondary" style="flex:1;" id="app-confirm-no">Cancel</button>
|
||||||
|
<button class="btn btn-danger" style="flex:1;" id="app-confirm-yes">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
document.getElementById('app-confirm-msg').textContent = msg;
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.getElementById('app-confirm-no').onclick = () => { modal.classList.remove('active'); resolve(false); };
|
||||||
|
document.getElementById('app-confirm-yes').onclick = () => { modal.classList.remove('active'); resolve(true); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function appConfirmUrl(event, url, msg) {
|
||||||
|
event.preventDefault();
|
||||||
|
appConfirm(msg).then(yes => { if(yes) window.location.href = url; });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sidebar Collapse ──────────────────────────────────────────────────
|
// ── Sidebar Collapse ──────────────────────────────────────────────────
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const collapsed = document.body.classList.toggle('sidebar-collapsed');
|
const collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||||
@@ -87,6 +142,19 @@
|
|||||||
document.getElementById('change-pw-modal').classList.add('active');
|
document.getElementById('change-pw-modal').classList.add('active');
|
||||||
});
|
});
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
// ── Tagify globally ───────────────────────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if(typeof Tagify !== 'undefined' && window.APP_GLOBAL_TAGS) {
|
||||||
|
document.querySelectorAll('.tagify-input').forEach(el => {
|
||||||
|
new Tagify(el, {
|
||||||
|
whitelist: window.APP_GLOBAL_TAGS,
|
||||||
|
dropdown: { maxItems: 20, classname: "tags-look", enabled: 0, closeOnSelect: false },
|
||||||
|
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(',')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,6 +7,38 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="stylesheet" href="public/css/style.css">
|
<link rel="stylesheet" href="public/css/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/themes/dark.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||||
|
<style>
|
||||||
|
.tags-look .tagify__dropdown__item{ display: inline-block; border-radius: 3px; padding: .3em .5em; border: 1px solid var(--border); background: var(--surface); margin: .2em; font-size: .85em; transition: 0s; color: var(--text); }
|
||||||
|
.tags-look .tagify__dropdown__item--active{ color: white; background: var(--primary); border-color: var(--primary); }
|
||||||
|
.tags-look .tagify__dropdown__item:hover{ background: var(--primary); color: white; border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* Premium Dark Mode Tagify Sync */
|
||||||
|
.tagify { --tag-bg: rgba(var(--primary-rgb), 0.2); --tag-text-color: var(--text); --tag-hover: rgba(var(--primary-rgb), 0.3); --placeholder-color: var(--text-light); }
|
||||||
|
body.dark-mode .tagify { --tag-bg: #2d3748; --tag-text-color: #e2e8f0; --tag-hover: #4a5568; }
|
||||||
|
.tagify__tag > div { background: var(--tag-bg); color: var(--tag-text-color); border-radius: 6px; }
|
||||||
|
.tagify__tag:hover:not([readonly]) > div::before { background: var(--tag-hover); }
|
||||||
|
.tagify__dropdown { background: rgba(30, 41, 59, 0.95); backdrop-filter: blur(8px); border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.tagify__dropdown__wrapper { background: transparent; border: none; }
|
||||||
|
</style>
|
||||||
|
<?php
|
||||||
|
date_default_timezone_set('Etc/GMT-7');
|
||||||
|
$all_app_tags = [];
|
||||||
|
try {
|
||||||
|
$rows = $pdo->query("SELECT label_name as tag FROM notes WHERE label_name IS NOT NULL AND label_name != ''")->fetchAll();
|
||||||
|
foreach($rows as $r) { foreach(explode(',', $r['tag']) as $p) { $all_app_tags[trim($p)] = 1; } }
|
||||||
|
$rows = $pdo->query("SELECT tags as tag FROM tasks WHERE tags IS NOT NULL AND tags != ''")->fetchAll();
|
||||||
|
foreach($rows as $r) { foreach(explode(',', $r['tag']) as $p) { $all_app_tags[trim($p)] = 1; } }
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
$global_tags = array_keys($all_app_tags);
|
||||||
|
?>
|
||||||
|
<script>
|
||||||
|
window.APP_GLOBAL_TAGS = <?= json_encode(array_filter($global_tags)) ?>;
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
@@ -45,7 +77,7 @@
|
|||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="delete_profile_pic" value="1">
|
<input type="hidden" name="delete_profile_pic" value="1">
|
||||||
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
|
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
|
||||||
<button type="submit" class="btn btn-danger" style="width:100%;" onclick="return confirm('Delete profile picture?')">Delete Picture</button>
|
<button type="button" class="btn btn-danger" style="width:100%;" onclick="appConfirm('Delete profile picture?').then(y => { if(y) this.form.submit(); })">Delete Picture</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<button class="btn btn-secondary" style="width:100%; margin-top:0.5rem;" onclick="document.getElementById('profile-pic-modal').classList.remove('active')">Close</button>
|
<button class="btn btn-secondary" style="width:100%; margin-top:0.5rem;" onclick="document.getElementById('profile-pic-modal').classList.remove('active')">Close</button>
|
||||||
@@ -163,9 +195,13 @@
|
|||||||
<i class="fa fa-lock nav-icon"></i>
|
<i class="fa fa-lock nav-icon"></i>
|
||||||
<span class="sidebar-label">Passwords</span>
|
<span class="sidebar-label">Passwords</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="?action=projects" class="nav-link <?= $action=='projects'?'active':'' ?>" title="Projects">
|
<a href="?action=projects" class="nav-link <?= $action=='projects'?'active':'' ?>" title="Files">
|
||||||
<i class="fa fa-folder-open nav-icon"></i>
|
<i class="fa fa-folder-open nav-icon"></i>
|
||||||
<span class="sidebar-label">Projects</span>
|
<span class="sidebar-label">Files</span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=tools" class="nav-link <?= $action=='tools'?'active':'' ?>" title="Tools">
|
||||||
|
<i class="fa fa-toolbox nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Tools</span>
|
||||||
</a>
|
</a>
|
||||||
<?php if(!empty($_SESSION['is_admin'])): ?>
|
<?php if(!empty($_SESSION['is_admin'])): ?>
|
||||||
<a href="?action=admin" class="nav-link <?= $action=='admin'?'active':'' ?>" title="Admin Panel">
|
<a href="?action=admin" class="nav-link <?= $action=='admin'?'active':'' ?>" title="Admin Panel">
|
||||||
@@ -230,7 +266,7 @@
|
|||||||
<i class="fa fa-key"></i> Change Password
|
<i class="fa fa-key"></i> Change Password
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown-divider"></div>
|
<div class="user-dropdown-divider"></div>
|
||||||
<a href="?action=logout" class="user-dropdown-item user-dropdown-logout" onclick="return confirm('Logout?')">
|
<a href="?action=logout" class="user-dropdown-item user-dropdown-logout" onclick="appConfirmUrl(event, this.href, 'Logout?')">
|
||||||
<i class="fa fa-right-from-bracket"></i> Logout
|
<i class="fa fa-right-from-bracket"></i> Logout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<h1>Welcome Back</h1>
|
<h1>Login</h1>
|
||||||
<?php if($error): ?><div class="alert"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
<?php if($error): ?><div class="alert"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -23,7 +23,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn" style="width:100%;">Sign In</button>
|
<button type="submit" class="btn" style="width:100%;">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
<!--
|
||||||
<p style="text-align:center; margin-top:1rem; font-size:0.875rem; color:var(--text-light);">First time? Run <a href="?action=setup">setup</a> to install Database.</p>
|
<p style="text-align:center; margin-top:1rem; font-size:0.875rem; color:var(--text-light);">First time? Run <a href="?action=setup">setup</a> to install Database.</p>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
288
views/notes.php
288
views/notes.php
@@ -3,6 +3,7 @@
|
|||||||
try { $pdo->exec("ALTER TABLE notes ADD COLUMN color TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE notes ADD COLUMN color TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE notes ADD COLUMN image_path TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE notes ADD COLUMN image_path TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE notes ADD COLUMN updated_at DATETIME DEFAULT NULL"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE notes ADD COLUMN updated_at DATETIME DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE notes ADD COLUMN label_name TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try {
|
try {
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS notes (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -40,8 +41,10 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if(move_uploaded_file($_FILES['note_image']['tmp_name'], $dest)) $image_path = $dest;
|
if(move_uploaded_file($_FILES['note_image']['tmp_name'], $dest)) $image_path = $dest;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$stmt = $pdo->prepare("INSERT INTO notes (title, content, owner_id, color, image_path) VALUES (?, ?, ?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO notes (title, content, owner_id, color, image_path, label_name) VALUES (?, ?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$_POST['title'] ?? 'Untitled', $_POST['content'], $user_id, $_POST['color'] ?? null, $image_path]);
|
$stmt->execute([$_POST['title'] ?? 'Untitled', $_POST['content'], $user_id, $_POST['color'] ?? null, $image_path, trim($_POST['label_name'] ?? '') ?: null]);
|
||||||
|
// Note creation only notifies owner in UI, but we can trigger a WAHA self-alert if they have it enabled
|
||||||
|
notify_user($user_id, "You created a new note: " . ($_POST['title'] ?? 'Untitled'), ['action'=>'Created', 'module'=>'Note', 'item'=>$_POST['title']??'Untitled']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isset($_POST['update_note'])) {
|
if(isset($_POST['update_note'])) {
|
||||||
@@ -64,16 +67,34 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if($image_path && file_exists($image_path)) @unlink($image_path);
|
if($image_path && file_exists($image_path)) @unlink($image_path);
|
||||||
$image_path = null;
|
$image_path = null;
|
||||||
}
|
}
|
||||||
$stmt = $pdo->prepare("UPDATE notes SET title=?, content=?, color=?, image_path=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND owner_id=?");
|
$stmt = $pdo->prepare("UPDATE notes SET title=?, content=?, color=?, image_path=?, label_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND owner_id=?");
|
||||||
$stmt->execute([$_POST['title'] ?? 'Untitled', $_POST['content'], $_POST['color'] ?? null, $image_path, $_POST['note_id'], $user_id]);
|
$stmt->execute([$_POST['title'] ?? 'Untitled', $_POST['content'], $_POST['color'] ?? null, $image_path, trim($_POST['label_name'] ?? '') ?: null, $_POST['note_id'], $user_id]);
|
||||||
|
|
||||||
|
$ctx = ['action'=>'Updated', 'module'=>'Note', 'item'=>$_POST['title']??'Untitled'];
|
||||||
|
// Notify owner
|
||||||
|
notify_user($user_id, "Note '{$_POST['title']}' updated.", $ctx);
|
||||||
|
// Notify anyone this note is shared with
|
||||||
|
$shares = query("SELECT share_type, share_id FROM note_shares WHERE note_id=?", [$_POST['note_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared note '{$_POST['title']}' updated.", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group note '{$_POST['title']}' updated.", $ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isset($_POST['delete_note'])) {
|
if(isset($_POST['delete_note'])) {
|
||||||
$n = query("SELECT owner_id, image_path FROM notes WHERE id=?", [$_POST['note_id']])->fetch();
|
$n = query("SELECT owner_id, image_path, title FROM notes WHERE id=?", [$_POST['note_id']])->fetch();
|
||||||
if($n && $n['owner_id'] == $user_id) {
|
if($n && $n['owner_id'] == $user_id) {
|
||||||
if($n['image_path'] && file_exists($n['image_path'])) @unlink($n['image_path']);
|
if($n['image_path'] && file_exists($n['image_path'])) @unlink($n['image_path']);
|
||||||
|
$ctx = ['action'=>'Deleted', 'module'=>'Note', 'item'=>$n['title']??'Untitled'];
|
||||||
|
// Notify anyone it was shared with before deleting
|
||||||
|
$shares = query("SELECT share_type, share_id FROM note_shares WHERE note_id=?", [$_POST['note_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared note '{$n['title']}' has been deleted.", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group note '{$n['title']}' has been deleted.", $ctx);
|
||||||
|
}
|
||||||
query("DELETE FROM notes WHERE id=? AND owner_id=?", [$_POST['note_id'], $user_id]);
|
query("DELETE FROM notes WHERE id=? AND owner_id=?", [$_POST['note_id'], $user_id]);
|
||||||
|
notify_user($user_id, "Note '{$n['title']}' has been deleted.", $ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +107,17 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$stmt->execute([$n_id, $parts[0], $parts[1]]);
|
$stmt->execute([$n_id, $parts[0], $parts[1]]);
|
||||||
$n = query("SELECT title FROM notes WHERE id=?", [$n_id])->fetch();
|
$n = query("SELECT title FROM notes WHERE id=?", [$n_id])->fetch();
|
||||||
$msg = "A note '{$n['title']}' has been shared with you.";
|
$msg = "A note '{$n['title']}' has been shared with you.";
|
||||||
if($parts[0] === 'user') notify_user($parts[1], $msg);
|
$ctx = ['action'=>'Shared', 'module'=>'Note', 'item'=>$n['title']??'Untitled', 'user'=>$_SESSION['username']??'System'];
|
||||||
else notify_group($parts[1], $msg);
|
if($parts[0] === 'user') notify_user($parts[1], $msg, $ctx);
|
||||||
|
else notify_group($parts[1], $msg, $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['remove_note_share'])) {
|
||||||
|
$share_id = (int)$_POST['share_id'];
|
||||||
|
$chk = query("SELECT ns.id FROM note_shares ns JOIN notes n ON ns.note_id=n.id WHERE ns.id=? AND n.owner_id=?", [$share_id, $user_id])->fetch();
|
||||||
|
if($chk) {
|
||||||
|
$pdo->prepare("DELETE FROM note_shares WHERE id=?")->execute([$share_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +141,15 @@ $notes = $stmt->fetchAll();
|
|||||||
|
|
||||||
$all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->fetchAll();
|
$all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->fetchAll();
|
||||||
$all_groups = query("SELECT * FROM groups")->fetchAll();
|
$all_groups = query("SELECT * FROM groups")->fetchAll();
|
||||||
|
$label_names = [];
|
||||||
|
foreach($notes as $n) {
|
||||||
|
$lbls = explode(',', (string)($n['label_name'] ?? ''));
|
||||||
|
foreach($lbls as $lbl) {
|
||||||
|
$lbl = trim($lbl);
|
||||||
|
if($lbl !== '') $label_names[strtolower($lbl)] = $lbl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
natcasesort($label_names);
|
||||||
|
|
||||||
// Define note colors — Google Keep inspired
|
// Define note colors — Google Keep inspired
|
||||||
$note_colors = [
|
$note_colors = [
|
||||||
@@ -122,31 +161,54 @@ $note_colors = [
|
|||||||
'#b3e5fc' => ['bg'=>'#b3e5fc', 'label'=>'Sky', 'hex'=>'#b3e5fc'],
|
'#b3e5fc' => ['bg'=>'#b3e5fc', 'label'=>'Sky', 'hex'=>'#b3e5fc'],
|
||||||
'#cce5ff' => ['bg'=>'#cce5ff', 'label'=>'Blue', 'hex'=>'#cce5ff'],
|
'#cce5ff' => ['bg'=>'#cce5ff', 'label'=>'Blue', 'hex'=>'#cce5ff'],
|
||||||
'#e2d9f3' => ['bg'=>'#e2d9f3', 'label'=>'Lavender', 'hex'=>'#e2d9f3'],
|
'#e2d9f3' => ['bg'=>'#e2d9f3', 'label'=>'Lavender', 'hex'=>'#e2d9f3'],
|
||||||
|
'#f3e5f5' => ['bg'=>'#f3e5f5', 'label'=>'Orchid', 'hex'=>'#f3e5f5'],
|
||||||
'#f8d7da' => ['bg'=>'#f8d7da', 'label'=>'Rose', 'hex'=>'#f8d7da'],
|
'#f8d7da' => ['bg'=>'#f8d7da', 'label'=>'Rose', 'hex'=>'#f8d7da'],
|
||||||
'#ef9a9a' => ['bg'=>'#ef9a9a', 'label'=>'Red', 'hex'=>'#ef9a9a'],
|
'#ef9a9a' => ['bg'=>'#ef9a9a', 'label'=>'Red', 'hex'=>'#ef9a9a'],
|
||||||
'#ffcc80' => ['bg'=>'#ffcc80', 'label'=>'Peach', 'hex'=>'#ffcc80'],
|
'#ffcc80' => ['bg'=>'#ffcc80', 'label'=>'Peach', 'hex'=>'#ffcc80'],
|
||||||
'#fde8d8' => ['bg'=>'#fde8d8', 'label'=>'Orange', 'hex'=>'#fde8d8'],
|
'#fde8d8' => ['bg'=>'#fde8d8', 'label'=>'Orange', 'hex'=>'#fde8d8'],
|
||||||
'#d7ccc8' => ['bg'=>'#d7ccc8', 'label'=>'Sand', 'hex'=>'#d7ccc8'],
|
'#d7ccc8' => ['bg'=>'#d7ccc8', 'label'=>'Sand', 'hex'=>'#d7ccc8'],
|
||||||
|
'#f5f5f5' => ['bg'=>'#f5f5f5', 'label'=>'Stone', 'hex'=>'#f5f5f5'],
|
||||||
|
'#e8f5e9' => ['bg'=>'#e8f5e9', 'label'=>'Mint', 'hex'=>'#e8f5e9'],
|
||||||
|
'#fff3e0' => ['bg'=>'#fff3e0', 'label'=>'Apricot', 'hex'=>'#fff3e0'],
|
||||||
|
'#fce4ec' => ['bg'=>'#fce4ec', 'label'=>'Pink', 'hex'=>'#fce4ec'],
|
||||||
|
'#ede7f6' => ['bg'=>'#ede7f6', 'label'=>'Violet', 'hex'=>'#ede7f6'],
|
||||||
|
'#e0f7fa' => ['bg'=>'#e0f7fa', 'label'=>'Cyan', 'hex'=>'#e0f7fa'],
|
||||||
|
'#fffde7' => ['bg'=>'#fffde7', 'label'=>'Butter', 'hex'=>'#fffde7'],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div style="display:flex; justify-content:flex-end; align-items:center; margin-bottom:1rem;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; gap:0.75rem; flex-wrap:wrap;">
|
||||||
<button class="btn" onclick="document.getElementById('new-note-modal').classList.add('active')">
|
<button class="btn" onclick="document.getElementById('new-note-modal').classList.add('active')">
|
||||||
<i class="fa fa-plus"></i> New Note
|
<i class="fa fa-plus"></i> New Note
|
||||||
</button>
|
</button>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<input type="text" id="notes-search-input" class="form-control" placeholder="Search notes..." style="width:200px; padding:0.45rem 0.6rem; font-size:0.84rem;" oninput="filterNotes()">
|
||||||
|
<select id="notes-label-filter" class="form-control" style="width:180px; padding:0.45rem 0.6rem; font-size:0.84rem;" onchange="filterNotes()">
|
||||||
|
<option value="">All labels</option>
|
||||||
|
<?php foreach($label_names as $lbl): ?>
|
||||||
|
<option value="<?= htmlspecialchars(strtolower($lbl)) ?>"><?= htmlspecialchars($lbl) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── New Note Modal ─────────────────────────────────────────────────────── -->
|
<!-- ── New Note Modal ─────────────────────────────────────────────────────── -->
|
||||||
<div class="modal-overlay" id="new-note-modal">
|
<div class="modal-overlay" id="new-note-modal">
|
||||||
<div class="modal-content" style="max-width:580px; max-height:92vh; overflow-y:auto; padding:1.5rem;">
|
<div class="modal-content note-modal-compact" style="max-width:760px; max-height:92vh; overflow-y:auto;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
<h3 style="font-size:1rem; font-weight:700;">New Note</h3>
|
<h3 style="font-size:1rem; font-weight:700;">New Note</h3>
|
||||||
<button type="button" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.2rem;" onclick="document.getElementById('new-note-modal').classList.remove('active')">×</button>
|
<button type="button" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.2rem;" onclick="document.getElementById('new-note-modal').classList.remove('active')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" enctype="multipart/form-data" id="new-note-form">
|
<form method="POST" enctype="multipart/form-data" id="new-note-form">
|
||||||
<input type="hidden" name="create_note" value="1">
|
<input type="hidden" name="create_note" value="1">
|
||||||
<div class="form-group">
|
<div class="note-modal-top-fields" style="display:grid; grid-template-columns:2fr 1fr; gap:0.75rem;">
|
||||||
<input type="text" name="title" class="form-control" placeholder="Title..." id="new-note-title" style="font-weight:600; font-size:1rem;">
|
<div class="form-group">
|
||||||
|
<input type="text" name="title" class="form-control" placeholder="Title..." id="new-note-title" style="font-weight:600; font-size:1rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.25rem; display:block;">Tags</label>
|
||||||
|
<input type="text" name="label_name" class="form-control tagify-input" id="new-note-label" placeholder="Add tags...">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- WYSIWYG Toolbar -->
|
<!-- WYSIWYG Toolbar -->
|
||||||
<div class="note-editor-toolbar" id="new-note-toolbar">
|
<div class="note-editor-toolbar" id="new-note-toolbar">
|
||||||
@@ -194,15 +256,54 @@ $note_colors = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── View Note Modal ────────────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="view-note-modal">
|
||||||
|
<div class="modal-content note-modal-compact" style="max-width:760px; max-height:92vh; overflow-y:auto; padding:0; background:var(--bg);">
|
||||||
|
<div id="view-note-header" style="padding:1.5rem; border-bottom:1px solid var(--border); background:var(--surface); position:sticky; top:0; z-index:10;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:1rem;">
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<h2 id="view-note-title" style="margin:0 0 0.5rem 0; font-size:1.4rem; font-weight:800; line-height:1.3; color:var(--text); word-wrap:break-word;"></h2>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<span id="view-note-label-container" style="display:none; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.75rem; font-weight:600;">
|
||||||
|
<i class="fa fa-tag" style="font-size:0.7rem;"></i> <span id="view-note-label"></span>
|
||||||
|
</span>
|
||||||
|
<span id="view-note-date" style="font-size:0.8rem; color:var(--text-light);"></span>
|
||||||
|
<span id="view-note-shared-badge" class="note-card-shared-badge" style="display:none;">Shared</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-shrink:0;">
|
||||||
|
<button class="btn btn-sm btn-secondary" id="view-note-edit-btn" onclick="transToEditNote()"><i class="fa fa-pencil"></i> Edit</button>
|
||||||
|
<button type="button" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.6rem; line-height:1; padding:0 4px;" onclick="document.getElementById('view-note-modal').classList.remove('active')">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="view-note-image-container" style="display:none; margin-top:1.25rem; border-radius:0.75rem; overflow:hidden; border:1px solid var(--border);">
|
||||||
|
<img id="view-note-image" src="" style="width:100%; max-height:450px; object-fit:cover; display:block;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1.5rem;">
|
||||||
|
<div id="view-note-content" class="markdown-body" style="font-size:1rem; line-height:1.7; color:var(--text);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Edit Note Modal ────────────────────────────────────────────────────── -->
|
<!-- ── Edit Note Modal ────────────────────────────────────────────────────── -->
|
||||||
<div class="modal-overlay" id="edit-note-modal">
|
<div class="modal-overlay" id="edit-note-modal">
|
||||||
<div class="modal-content" style="max-width:600px; max-height:90vh; overflow-y:auto;">
|
<div class="modal-content note-modal-compact" style="max-width:760px; max-height:90vh; overflow-y:auto;">
|
||||||
<h3 style="margin-bottom:1rem;">Edit Note</h3>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
|
<h3 style="margin:0;">Edit Note</h3>
|
||||||
|
<button type="button" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.2rem;" onclick="document.getElementById('edit-note-modal').classList.remove('active')">×</button>
|
||||||
|
</div>
|
||||||
<form method="POST" enctype="multipart/form-data" id="edit-note-form">
|
<form method="POST" enctype="multipart/form-data" id="edit-note-form">
|
||||||
<input type="hidden" name="update_note" value="1">
|
<input type="hidden" name="update_note" value="1">
|
||||||
<input type="hidden" name="note_id" id="edit-note-id">
|
<input type="hidden" name="note_id" id="edit-note-id">
|
||||||
<div class="form-group">
|
<div class="note-modal-top-fields" style="display:grid; grid-template-columns:2fr 1fr; gap:0.75rem;">
|
||||||
<input type="text" name="title" id="edit-note-title" class="form-control" placeholder="Title..." style="font-weight:600; font-size:1rem;">
|
<div class="form-group">
|
||||||
|
<input type="text" name="title" id="edit-note-title" class="form-control" placeholder="Title..." style="font-weight:600; font-size:1rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.25rem; display:block;">Tags</label>
|
||||||
|
<input type="text" name="label_name" id="edit-note-label" class="form-control tagify-input" placeholder="Add tags...">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-editor-toolbar">
|
<div class="note-editor-toolbar">
|
||||||
<button type="button" class="note-toolbar-btn" onclick="formatNote('edit-note-content','bold')"><b>B</b></button>
|
<button type="button" class="note-toolbar-btn" onclick="formatNote('edit-note-content','bold')"><b>B</b></button>
|
||||||
@@ -250,7 +351,8 @@ $note_colors = [
|
|||||||
|
|
||||||
<!-- Share section (only for owner) -->
|
<!-- Share section (only for owner) -->
|
||||||
<div id="edit-share-section" style="border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;">
|
<div id="edit-share-section" style="border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;">
|
||||||
<label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.4rem; display:block;">Share this note</label>
|
<label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.4rem; display:block;">Shared with</label>
|
||||||
|
<div id="note-shares-list" style="display:flex; flex-wrap:wrap; gap:0.35rem; margin-bottom:0.75rem;"></div>
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||||
<select name="share_target" id="edit-share-target" class="form-control" style="flex:1; min-width:150px; padding:0.4rem;">
|
<select name="share_target" id="edit-share-target" class="form-control" style="flex:1; min-width:150px; padding:0.4rem;">
|
||||||
<?php foreach($all_users as $u): ?>
|
<?php foreach($all_users as $u): ?>
|
||||||
@@ -290,6 +392,11 @@ $note_colors = [
|
|||||||
<input type="hidden" name="share_target" id="share-note-standalone-target">
|
<input type="hidden" name="share_target" id="share-note-standalone-target">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" id="remove-note-share-form" style="display:none;">
|
||||||
|
<input type="hidden" name="remove_note_share" value="1">
|
||||||
|
<input type="hidden" name="share_id" id="remove-note-share-id">
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- Markdown Libs -->
|
<!-- Markdown Libs -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
|
||||||
@@ -306,22 +413,40 @@ $note_colors = [
|
|||||||
$bg = $n['color'] ?: 'var(--surface)';
|
$bg = $n['color'] ?: 'var(--surface)';
|
||||||
$preview = strip_tags(isset($n['content']) ? (function_exists('marked') ? $n['content'] : $n['content']) : '');
|
$preview = strip_tags(isset($n['content']) ? (function_exists('marked') ? $n['content'] : $n['content']) : '');
|
||||||
$preview = mb_substr($preview, 0, 120) . (mb_strlen($preview) > 120 ? '...' : '');
|
$preview = mb_substr($preview, 0, 120) . (mb_strlen($preview) > 120 ? '...' : '');
|
||||||
|
$note_shares = [];
|
||||||
|
if($n['is_mine']) {
|
||||||
|
$note_shares = query("SELECT ns.id, ns.share_type, ns.share_id, u.username, u.profile_pic as upic, g.name as gname, g.profile_pic as gpic FROM note_shares ns LEFT JOIN users u ON (ns.share_type='user' AND ns.share_id=u.id) LEFT JOIN groups g ON (ns.share_type='group' AND ns.share_id=g.id) WHERE ns.note_id=?", [$n['id']])->fetchAll();
|
||||||
|
}
|
||||||
|
$shares_json = htmlspecialchars(json_encode($note_shares), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<div class="note-card"
|
<div class="note-card note-filter-item"
|
||||||
style="background:<?= htmlspecialchars($bg) ?>;"
|
style="background:<?= htmlspecialchars($bg) ?>;"
|
||||||
data-id="<?= $n['id'] ?>"
|
data-id="<?= $n['id'] ?>"
|
||||||
data-title="<?= htmlspecialchars(addslashes((string)$n['title'])) ?>"
|
data-title="<?= htmlspecialchars(addslashes((string)$n['title'])) ?>"
|
||||||
data-content="<?= htmlspecialchars(addslashes((string)$n['content'])) ?>"
|
data-content="<?= htmlspecialchars(addslashes((string)$n['content'])) ?>"
|
||||||
|
data-label="<?= htmlspecialchars(strtolower(trim((string)($n['label_name'] ?? '')))) ?>"
|
||||||
|
data-label-raw="<?= htmlspecialchars(trim((string)($n['label_name'] ?? ''))) ?>"
|
||||||
data-color="<?= htmlspecialchars((string)$n['color']) ?>"
|
data-color="<?= htmlspecialchars((string)$n['color']) ?>"
|
||||||
data-image="<?= htmlspecialchars((string)($n['image_path'] ?? '')) ?>"
|
data-image="<?= htmlspecialchars((string)($n['image_path'] ?? '')) ?>"
|
||||||
|
data-shares="<?= $shares_json ?>"
|
||||||
data-mine="<?= $n['is_mine'] ?>"
|
data-mine="<?= $n['is_mine'] ?>"
|
||||||
onclick="openNote(this)">
|
data-updated="<?= date('M j, Y, H:i', strtotime($n['updated_at'] ?: $n['created_at'])) ?>"
|
||||||
|
onclick="viewNote(this)">
|
||||||
<?php if($n['image_path'] && file_exists($n['image_path'])): ?>
|
<?php if($n['image_path'] && file_exists($n['image_path'])): ?>
|
||||||
<img src="<?= htmlspecialchars($n['image_path']) ?>" alt="Note image" class="note-card-img">
|
<img src="<?= htmlspecialchars($n['image_path']) ?>" alt="Note image" class="note-card-img">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if($n['title']): ?>
|
<?php if($n['title']): ?>
|
||||||
<div class="note-card-title"><?= htmlspecialchars($n['title']) ?></div>
|
<div class="note-card-title"><?= htmlspecialchars($n['title']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if(!empty($n['label_name'])): ?>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.35rem; margin-bottom:0.5rem;">
|
||||||
|
<?php foreach(explode(',', $n['label_name']) as $lbl): $lbl=trim($lbl); if(!$lbl) continue; ?>
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.7rem; font-weight:600;">
|
||||||
|
<i class="fa fa-tag" style="font-size:0.65rem;"></i> <?= htmlspecialchars($lbl) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="note-card-preview"><?= htmlspecialchars($preview) ?></div>
|
<div class="note-card-preview"><?= htmlspecialchars($preview) ?></div>
|
||||||
<div class="note-card-footer">
|
<div class="note-card-footer">
|
||||||
<div style="display:flex; align-items:center; gap:0.4rem;">
|
<div style="display:flex; align-items:center; gap:0.4rem;">
|
||||||
@@ -403,16 +528,18 @@ function openNote(card) {
|
|||||||
const title = card.dataset.title;
|
const title = card.dataset.title;
|
||||||
const content = card.dataset.content;
|
const content = card.dataset.content;
|
||||||
const color = card.dataset.color;
|
const color = card.dataset.color;
|
||||||
|
const label = card.dataset.labelRaw || '';
|
||||||
const image = card.dataset.image;
|
const image = card.dataset.image;
|
||||||
const isMine = card.dataset.mine === '1';
|
const isMine = card.dataset.mine === '1';
|
||||||
|
|
||||||
document.getElementById('edit-note-id').value = id;
|
document.getElementById('edit-note-id').value = id;
|
||||||
document.getElementById('edit-note-title').value = title;
|
document.getElementById('edit-note-title').value = title;
|
||||||
document.getElementById('edit-note-content').value = content;
|
document.getElementById('edit-note-content').value = content;
|
||||||
|
document.getElementById('edit-note-label').value = label;
|
||||||
document.getElementById('edit-note-color-input').value = color;
|
document.getElementById('edit-note-color-input').value = color;
|
||||||
document.getElementById('edit-note-preview').style.display = 'none';
|
document.getElementById('edit-note-preview').style.display = 'none';
|
||||||
document.getElementById('edit-note-content').style.display = 'block';
|
document.getElementById('edit-note-content').style.display = 'block';
|
||||||
document.getElementById('edit-preview-btn').textContent = '👁 Preview';
|
document.getElementById('edit-preview-btn').innerHTML = '👁 Preview';
|
||||||
|
|
||||||
// Color swatches
|
// Color swatches
|
||||||
document.querySelectorAll('#edit-note-colors .color-swatch').forEach(s => {
|
document.querySelectorAll('#edit-note-colors .color-swatch').forEach(s => {
|
||||||
@@ -433,6 +560,9 @@ function openNote(card) {
|
|||||||
|
|
||||||
// Share section
|
// Share section
|
||||||
document.getElementById('edit-share-section').style.display = isMine ? 'block' : 'none';
|
document.getElementById('edit-share-section').style.display = isMine ? 'block' : 'none';
|
||||||
|
if(isMine) {
|
||||||
|
renderNoteShares(card.dataset.shares || '[]');
|
||||||
|
}
|
||||||
|
|
||||||
// Wire up share button
|
// Wire up share button
|
||||||
const shareBtn = document.querySelector('#edit-note-form button[name="share_note"]');
|
const shareBtn = document.querySelector('#edit-note-form button[name="share_note"]');
|
||||||
@@ -449,10 +579,124 @@ function openNote(card) {
|
|||||||
document.getElementById('edit-note-modal').classList.add('active');
|
document.getElementById('edit-note-modal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentViewedCard = null;
|
||||||
|
|
||||||
|
function viewNote(card) {
|
||||||
|
currentViewedCard = card;
|
||||||
|
const title = card.dataset.title || 'Untitled Note';
|
||||||
|
const content = card.dataset.content || '';
|
||||||
|
const label = card.dataset.labelRaw || '';
|
||||||
|
const image = card.dataset.image;
|
||||||
|
const updatedAt = card.dataset.updated;
|
||||||
|
const isMine = card.dataset.mine === '1';
|
||||||
|
|
||||||
|
document.getElementById('view-note-title').textContent = title;
|
||||||
|
|
||||||
|
|
||||||
|
// Clear old tags
|
||||||
|
document.getElementById('view-note-label-container').innerHTML = '';
|
||||||
|
|
||||||
|
if(label) {
|
||||||
|
document.getElementById('view-note-label-container').style.display = 'flex';
|
||||||
|
const lbls = label.split(',');
|
||||||
|
lbls.forEach(l => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.style.cssText = 'display:inline-flex; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.75rem; font-weight:600;';
|
||||||
|
span.innerHTML = `<i class="fa fa-tag" style="font-size:0.7rem;"></i> ${l.trim()}`;
|
||||||
|
document.getElementById('view-note-label-container').appendChild(span);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.getElementById('view-note-label-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('view-note-date').textContent = 'Updated ' + updatedAt;
|
||||||
|
document.getElementById('view-note-shared-badge').style.display = !isMine ? 'inline-block' : 'none';
|
||||||
|
|
||||||
|
if(image) {
|
||||||
|
document.getElementById('view-note-image-container').style.display = 'block';
|
||||||
|
document.getElementById('view-note-image').src = image;
|
||||||
|
} else {
|
||||||
|
document.getElementById('view-note-image-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('view-note-content').innerHTML = marked.parse(content);
|
||||||
|
|
||||||
|
document.getElementById('view-note-edit-btn').style.display = isMine ? 'inline-flex' : 'none';
|
||||||
|
|
||||||
|
document.getElementById('view-note-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function transToEditNote() {
|
||||||
|
if(currentViewedCard) {
|
||||||
|
document.getElementById('view-note-modal').classList.remove('active');
|
||||||
|
openNote(currentViewedCard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoteShares(jsonShares) {
|
||||||
|
const list = document.getElementById('note-shares-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
let shares = [];
|
||||||
|
try { shares = JSON.parse(jsonShares || '[]'); } catch(e) { shares = []; }
|
||||||
|
if(shares.length === 0) return;
|
||||||
|
shares.forEach(sh => {
|
||||||
|
const pic = sh.share_type === 'user' ? sh.upic : sh.gpic;
|
||||||
|
const name = sh.share_type === 'user' ? (sh.username || 'User') : (sh.gname || 'Group');
|
||||||
|
const icon = sh.share_type === 'user' ? '👤' : '👥';
|
||||||
|
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'share-chip';
|
||||||
|
chip.style.display = 'inline-flex';
|
||||||
|
chip.style.alignItems = 'center';
|
||||||
|
chip.style.gap = '0.4rem';
|
||||||
|
chip.style.fontSize = '0.72rem';
|
||||||
|
chip.style.padding = '2px 8px 2px 2px';
|
||||||
|
chip.style.borderRadius = '999px';
|
||||||
|
chip.style.background = 'rgba(var(--primary-rgb),0.1)';
|
||||||
|
chip.style.color = 'var(--primary)';
|
||||||
|
chip.style.border = '1px solid rgba(var(--primary-rgb),0.2)';
|
||||||
|
|
||||||
|
let avatarHtml = `<div style="width:20px; height:20px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.6rem; font-weight:700; overflow:hidden;">${name.charAt(0).toUpperCase()}</div>`;
|
||||||
|
if(pic) {
|
||||||
|
avatarHtml = `<img src="${pic}" style="width:20px; height:20px; border-radius:50%; object-fit:cover;">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.innerHTML = `${avatarHtml}<span style="font-weight:600;">${name}</span><button type="button" style="border:none;background:transparent;color:inherit;cursor:pointer;line-height:1;margin-left:2px;font-size:0.9rem;" title="Unshare" onclick="removeNoteShare(${Number(sh.id)})">×</button>`;
|
||||||
|
list.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNoteShare(shareId) {
|
||||||
|
if(!shareId) return;
|
||||||
|
appConfirm('Unshare this note access?').then(y => {
|
||||||
|
if(y) {
|
||||||
|
document.getElementById('remove-note-share-id').value = String(shareId);
|
||||||
|
document.getElementById('remove-note-share-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterNotes() {
|
||||||
|
const q = (document.getElementById('notes-search-input')?.value || '').toLowerCase().trim();
|
||||||
|
const lbl = (document.getElementById('notes-label-filter')?.value || '').trim();
|
||||||
|
document.querySelectorAll('.note-filter-item').forEach(card => {
|
||||||
|
const t = (card.dataset.title || '').toLowerCase();
|
||||||
|
const c = (card.dataset.content || '').toLowerCase();
|
||||||
|
// data-label is now comma separated
|
||||||
|
const cardLabels = (card.dataset.label || '').split(',').map(s=>s.trim());
|
||||||
|
const okText = !q || t.includes(q) || c.includes(q) || cardLabels.join(' ').includes(q);
|
||||||
|
const okLabel = !lbl || cardLabels.includes(lbl);
|
||||||
|
card.style.display = (okText && okLabel) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deleteNote(id) {
|
function deleteNote(id) {
|
||||||
if(!confirm('Delete this note permanently?')) return;
|
appConfirm('Delete this note permanently?').then(y => {
|
||||||
document.getElementById('delete-note-id').value = id;
|
if(y) {
|
||||||
document.getElementById('delete-note-form').submit();
|
document.getElementById('delete-note-id').value = id;
|
||||||
|
document.getElementById('delete-note-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix note card colors in dark mode
|
// Fix note card colors in dark mode
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
// ── DB migrations ─────────────────────────────────────────────────────────────
|
// ── DB migrations ─────────────────────────────────────────────────────────────
|
||||||
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN folder_id INTEGER DEFAULT NULL"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN folder_id INTEGER DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN tags TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try {
|
try {
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS password_folders (
|
$pdo->exec("CREATE TABLE IF NOT EXISTS password_folders (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -25,23 +26,39 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if(isset($_POST['create_password'])) {
|
if(isset($_POST['create_password'])) {
|
||||||
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id, folder_id) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id, folder_id, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id, $_POST['folder_id'] ?: null]);
|
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id, $_POST['folder_id'] ?: null, trim($_POST['tags'] ?? '')]);
|
||||||
|
notify_user($user_id, "You added a new password: " . $_POST['title'], ['action'=>'Created', 'module'=>'Password', 'item'=>$_POST['title']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isset($_POST['update_password'])) {
|
if(isset($_POST['update_password'])) {
|
||||||
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
||||||
if($p && $p['owner_id'] == $user_id) {
|
if($p && $p['owner_id'] == $user_id) {
|
||||||
$stmt = $pdo->prepare("UPDATE passwords SET title=?, username=?, password=?, url=?, notes=?, folder_id=? WHERE id=? AND owner_id=?");
|
$stmt = $pdo->prepare("UPDATE passwords SET title=?, username=?, password=?, url=?, notes=?, folder_id=?, tags=? WHERE id=? AND owner_id=?");
|
||||||
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $_POST['folder_id'] ?: null, $_POST['password_id'], $user_id]);
|
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $_POST['folder_id'] ?: null, trim($_POST['tags'] ?? ''), $_POST['password_id'], $user_id]);
|
||||||
|
|
||||||
|
$ctx = ['action'=>'Updated', 'module'=>'Password', 'item'=>$_POST['title']];
|
||||||
|
notify_user($user_id, "Password '{$_POST['title']}' updated.", $ctx);
|
||||||
|
// Notify shared users/groups
|
||||||
|
$shares = query("SELECT share_type, share_id FROM password_shares WHERE password_id=?", [$_POST['password_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared password '{$_POST['title']}' updated.", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group password '{$_POST['title']}' updated.", $ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(isset($_POST['delete_password'])) {
|
if(isset($_POST['delete_password'])) {
|
||||||
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
$p = query("SELECT owner_id, title FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
||||||
if($p && $p['owner_id'] == $user_id) {
|
$ctx = ['action'=>'Deleted', 'module'=>'Password', 'item'=>$p['title']];
|
||||||
|
// Notify shared recipients before deletion
|
||||||
|
$shares = query("SELECT share_type, share_id FROM password_shares WHERE password_id=?", [$_POST['password_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared password '{$p['title']}' has been deleted.", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group password '{$p['title']}' has been deleted.", $ctx);
|
||||||
|
}
|
||||||
query("DELETE FROM passwords WHERE id=? AND owner_id=?", [$_POST['password_id'], $user_id]);
|
query("DELETE FROM passwords WHERE id=? AND owner_id=?", [$_POST['password_id'], $user_id]);
|
||||||
}
|
notify_user($user_id, "Password '{$p['title']}' deleted.", $ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unshare a specific share record
|
// Unshare a specific share record
|
||||||
@@ -56,8 +73,11 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$action = $_POST['bulk_action'];
|
$action = $_POST['bulk_action'];
|
||||||
if($action === 'delete') {
|
if($action === 'delete') {
|
||||||
foreach($selected as $p_id) {
|
foreach($selected as $p_id) {
|
||||||
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$p_id])->fetch();
|
$p = query("SELECT owner_id, title FROM passwords WHERE id=?", [$p_id])->fetch();
|
||||||
if($p && $p['owner_id']==$user_id) query("DELETE FROM passwords WHERE id=?", [$p_id]);
|
if($p && $p['owner_id']==$user_id) {
|
||||||
|
query("DELETE FROM passwords WHERE id=?", [$p_id]);
|
||||||
|
notify_waha('Deleted', 'Password Entry', $p['title']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} elseif($action === 'share') {
|
} elseif($action === 'share') {
|
||||||
$share_target = $_POST['share_target'];
|
$share_target = $_POST['share_target'];
|
||||||
@@ -69,8 +89,9 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$stmt = $pdo->prepare("INSERT INTO password_shares (password_id, share_type, share_id) VALUES (?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO password_shares (password_id, share_type, share_id) VALUES (?, ?, ?)");
|
||||||
$stmt->execute([$p_id, $parts[0], $parts[1]]);
|
$stmt->execute([$p_id, $parts[0], $parts[1]]);
|
||||||
$msg = "A password '{$p['title']}' has been shared with you.";
|
$msg = "A password '{$p['title']}' has been shared with you.";
|
||||||
if($parts[0] === 'user') notify_user($parts[1], $msg);
|
$ctx = ['action'=>'Shared', 'module'=>'Password', 'item'=>$p['title'], 'user'=>$_SESSION['username']??'System'];
|
||||||
else notify_group($parts[1], $msg);
|
if($parts[0] === 'user') notify_user($parts[1], $msg, $ctx);
|
||||||
|
else notify_group($parts[1], $msg, $ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +165,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pw-view-notes" style="font-size:0.8rem; color:var(--text-light); margin-top:0.5rem; white-space:pre-wrap;"></div>
|
<div id="pw-view-notes" style="font-size:0.8rem; color:var(--text-light); margin-top:0.5rem; white-space:pre-wrap;"></div>
|
||||||
|
<div id="pw-view-tags-container" style="display:none; flex-wrap:wrap; gap:0.25rem; margin-top:1rem; padding-top:0.5rem; border-top:1px dashed var(--border);"></div>
|
||||||
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem;">
|
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem;">
|
||||||
<button class="btn btn-secondary" onclick="document.getElementById('password-view-modal').classList.remove('active')">Close</button>
|
<button class="btn btn-secondary" onclick="document.getElementById('password-view-modal').classList.remove('active')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,6 +216,10 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea name="notes" id="pw-edit-notes" class="form-control" rows="2" placeholder="Optional notes..."></textarea>
|
<textarea name="notes" id="pw-edit-notes" class="form-control" rows="2" placeholder="Optional notes..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Tags</label>
|
||||||
|
<input type="text" name="tags" id="pw-edit-tags" class="form-control tagify-input" placeholder="Add tags...">
|
||||||
|
</div>
|
||||||
<!-- Shares management section (populated by JS) -->
|
<!-- Shares management section (populated by JS) -->
|
||||||
<div id="pw-shares-section" style="display:none; border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;">
|
<div id="pw-shares-section" style="display:none; border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;">
|
||||||
<label style="font-size:0.8rem; color:var(--text-light); display:block; margin-bottom:0.4rem;">Shared with</label>
|
<label style="font-size:0.8rem; color:var(--text-light); display:block; margin-bottom:0.4rem;">Shared with</label>
|
||||||
@@ -217,11 +243,10 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
<input type="hidden" name="delete_password" value="1">
|
<input type="hidden" name="delete_password" value="1">
|
||||||
<input type="hidden" name="password_id" id="delete-pw-id">
|
<input type="hidden" name="password_id" id="delete-pw-id">
|
||||||
</form>
|
</form>
|
||||||
|
<form method="POST" id="remove-share-form" style="display:none;">
|
||||||
<div style="display:flex; justify-content:flex-end; gap:0.5rem; flex-wrap:wrap; margin-bottom:1rem;">
|
<input type="hidden" name="remove_password_share" value="1">
|
||||||
<button class="btn btn-secondary" onclick="showAddFolderPrompt()"><i class="fa fa-folder-plus"></i> New Folder</button>
|
<input type="hidden" name="share_id" id="remove-share-id">
|
||||||
<button class="btn" onclick="openAddPassword()"><i class="fa fa-plus"></i> Add Entry</button>
|
</form>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add folder form (hidden) -->
|
<!-- Add folder form (hidden) -->
|
||||||
<form method="POST" id="add-folder-form" style="display:none;">
|
<form method="POST" id="add-folder-form" style="display:none;">
|
||||||
@@ -234,7 +259,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div style="background:var(--surface); border-radius:1rem; box-shadow:0 4px 6px -1px rgba(0,0,0,0.07); border:1px solid var(--border); overflow:hidden;">
|
<div style="background:var(--surface); border-radius:1rem; box-shadow:0 4px 6px -1px rgba(0,0,0,0.07); border:1px solid var(--border); overflow:hidden;">
|
||||||
<div class="vault-layout" style="min-height:calc(100vh - 200px);">
|
<div class="vault-layout" style="min-height:calc(100vh - 130px);">
|
||||||
<!-- Vault Sidebar (Folders) -->
|
<!-- Vault Sidebar (Folders) -->
|
||||||
<div class="vault-sidebar">
|
<div class="vault-sidebar">
|
||||||
<div style="font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-light); font-weight:700; padding:0.25rem 0.65rem; margin-bottom:0.25rem;">Folders</div>
|
<div style="font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-light); font-weight:700; padding:0.25rem 0.65rem; margin-bottom:0.25rem;">Folders</div>
|
||||||
@@ -265,6 +290,10 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
|
|
||||||
<!-- Vault Main -->
|
<!-- Vault Main -->
|
||||||
<div class="vault-main">
|
<div class="vault-main">
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:0.5rem; flex-wrap:wrap; margin-bottom:1rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="showAddFolderPrompt()"><i class="fa fa-folder-plus"></i> New Folder</button>
|
||||||
|
<button class="btn" onclick="openAddPassword()"><i class="fa fa-plus"></i> Add Entry</button>
|
||||||
|
</div>
|
||||||
<!-- Search + Bulk Actions -->
|
<!-- Search + Bulk Actions -->
|
||||||
<div class="vault-search">
|
<div class="vault-search">
|
||||||
<div style="position:relative; flex:1;">
|
<div style="position:relative; flex:1;">
|
||||||
@@ -295,7 +324,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" name="bulk_action" value="move_folder" class="btn btn-sm btn-secondary"><i class="fa fa-folder-arrow-down"></i> Move</button>
|
<button type="submit" name="bulk_action" value="move_folder" class="btn btn-sm btn-secondary"><i class="fa fa-folder-arrow-down"></i> Move</button>
|
||||||
<button type="submit" name="bulk_action" value="delete" class="btn btn-sm btn-danger" onclick="return confirm('Delete selected entries?')"><i class="fa fa-trash"></i> Delete</button>
|
<button type="button" class="btn btn-sm btn-danger" onclick="appConfirm('Delete selected entries?').then(y => { if(y) { const el = document.createElement('input'); el.type='hidden'; el.name='bulk_action'; el.value='delete'; this.form.appendChild(el); this.form.submit(); } })"><i class="fa fa-trash"></i> Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entries List -->
|
<!-- Entries List -->
|
||||||
@@ -347,11 +376,21 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
|
|||||||
$domain = preg_replace('/^www\./', '', $domain);
|
$domain = preg_replace('/^www\./', '', $domain);
|
||||||
}
|
}
|
||||||
$favicon = $domain ? "https://www.google.com/s2/favicons?domain={$domain}&sz=32" : '';
|
$favicon = $domain ? "https://www.google.com/s2/favicons?domain={$domain}&sz=32" : '';
|
||||||
|
$pw_shares = [];
|
||||||
|
if($p['is_mine']) {
|
||||||
|
$pw_shares = query("SELECT ps.id, ps.share_type, ps.share_id, u.username, u.profile_pic as upic, g.name as gname, g.profile_pic as gpic FROM password_shares ps LEFT JOIN users u ON (ps.share_type='user' AND ps.share_id=u.id) LEFT JOIN groups g ON (ps.share_type='group' AND ps.share_id=g.id) WHERE ps.password_id=?", [$p['id']])->fetchAll();
|
||||||
|
}
|
||||||
|
$shares_json = htmlspecialchars(json_encode($pw_shares), ENT_QUOTES, 'UTF-8');
|
||||||
?>
|
?>
|
||||||
<div class="vault-entry pw-row"
|
<div class="vault-entry pw-row"
|
||||||
data-pw="<?= htmlspecialchars(addslashes($p['password'])) ?>"
|
data-pw="<?= htmlspecialchars(addslashes($p['password'])) ?>"
|
||||||
data-usr="<?= htmlspecialchars((string)$p['username']) ?>"
|
data-usr="<?= htmlspecialchars((string)$p['username']) ?>"
|
||||||
data-id="<?= $p['id'] ?>"
|
data-id="<?= $p['id'] ?>"
|
||||||
|
data-url="<?= htmlspecialchars((string)($p['url'] ?? '')) ?>"
|
||||||
|
data-notes="<?= htmlspecialchars(addslashes((string)($p['notes'] ?? ''))) ?>"
|
||||||
|
data-folder-id="<?= htmlspecialchars((string)($p['folder_id'] ?? '')) ?>"
|
||||||
|
data-shares="<?= $shares_json ?>"
|
||||||
|
data-tags="<?= htmlspecialchars((string)($p['tags'] ?? '')) ?>"
|
||||||
onclick="openViewEntry(this)">
|
onclick="openViewEntry(this)">
|
||||||
<!-- Checkbox (only for owned items) -->
|
<!-- Checkbox (only for owned items) -->
|
||||||
<?php if($p['is_mine']): ?>
|
<?php if($p['is_mine']): ?>
|
||||||
@@ -461,10 +500,33 @@ function openViewEntry(row) {
|
|||||||
document.getElementById('pw-view-pw').value = row.dataset.pw;
|
document.getElementById('pw-view-pw').value = row.dataset.pw;
|
||||||
document.getElementById('pw-view-pw').type = 'password';
|
document.getElementById('pw-view-pw').type = 'password';
|
||||||
document.getElementById('pw-view-toggle').innerHTML = '<i class="fa fa-eye"></i>';
|
document.getElementById('pw-view-toggle').innerHTML = '<i class="fa fa-eye"></i>';
|
||||||
const urlEl = document.getElementById('pw-view-url');
|
const urlEl = document.createElement('div');
|
||||||
urlEl.textContent = '';
|
document.getElementById('pw-view-url').innerHTML = ''; // Clear existing
|
||||||
// try to show url from data attribute if we add it - just show username for now
|
if(row.dataset.url) {
|
||||||
document.getElementById('pw-view-notes').textContent = '';
|
urlEl.innerHTML = `<a href="${row.dataset.url}" target="_blank" style="color:var(--primary);">${row.dataset.url}</a>`;
|
||||||
|
document.getElementById('pw-view-url').appendChild(urlEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pw-view-notes').textContent = row.dataset.notes || '';
|
||||||
|
|
||||||
|
// Tag rendering
|
||||||
|
const tagsContainer = document.getElementById('pw-view-tags-container');
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
const tagsStr = row.dataset.tags || '';
|
||||||
|
if(tagsStr) {
|
||||||
|
tagsContainer.style.display = 'flex';
|
||||||
|
tagsStr.split(',').forEach(t => {
|
||||||
|
t = t.trim();
|
||||||
|
if(!t) return;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.style.cssText = 'font-size:0.65rem; padding:2px 8px; border-radius:12px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-weight:600; display:inline-flex; align-items:center; gap:0.2rem;';
|
||||||
|
span.innerHTML = `<i class="fa fa-tag" style="font-size:0.6rem;"></i> ${t}`;
|
||||||
|
tagsContainer.appendChild(span);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tagsContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('password-view-modal').classList.add('active');
|
document.getElementById('password-view-modal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,6 +554,9 @@ function openAddPassword() {
|
|||||||
document.getElementById('pw-edit-url').value = '';
|
document.getElementById('pw-edit-url').value = '';
|
||||||
document.getElementById('pw-edit-notes').value = '';
|
document.getElementById('pw-edit-notes').value = '';
|
||||||
document.getElementById('pw-edit-folder').value = '';
|
document.getElementById('pw-edit-folder').value = '';
|
||||||
|
document.getElementById('pw-edit-tags').value = '';
|
||||||
|
document.getElementById('pw-shares-section').style.display = 'none';
|
||||||
|
document.getElementById('pw-shares-list').innerHTML = '';
|
||||||
document.getElementById('pw-edit-delete-wrap').style.display = 'none';
|
document.getElementById('pw-edit-delete-wrap').style.display = 'none';
|
||||||
document.getElementById('pw-edit-pw').dispatchEvent(new Event('input'));
|
document.getElementById('pw-edit-pw').dispatchEvent(new Event('input'));
|
||||||
document.getElementById('password-edit-modal').classList.add('active');
|
document.getElementById('password-edit-modal').classList.add('active');
|
||||||
@@ -517,17 +582,68 @@ function openEditEntry(row) {
|
|||||||
document.getElementById('pw-edit-uname').value = row.dataset.usr;
|
document.getElementById('pw-edit-uname').value = row.dataset.usr;
|
||||||
document.getElementById('pw-edit-pw').value = row.dataset.pw;
|
document.getElementById('pw-edit-pw').value = row.dataset.pw;
|
||||||
document.getElementById('pw-edit-pw').type = 'password';
|
document.getElementById('pw-edit-pw').type = 'password';
|
||||||
document.getElementById('pw-edit-url').value = '';
|
document.getElementById('pw-edit-url').value = row.dataset.url || '';
|
||||||
document.getElementById('pw-edit-notes').value = '';
|
document.getElementById('pw-edit-notes').value = row.dataset.notes || '';
|
||||||
document.getElementById('pw-edit-folder').value = '';
|
document.getElementById('pw-edit-folder').value = row.dataset.folderId || '';
|
||||||
|
document.getElementById('pw-edit-tags').value = row.dataset.tags || '';
|
||||||
document.getElementById('pw-edit-delete-wrap').style.display = 'block';
|
document.getElementById('pw-edit-delete-wrap').style.display = 'block';
|
||||||
|
renderPasswordShares(row.dataset.shares || '[]');
|
||||||
document.getElementById('password-edit-modal').classList.add('active');
|
document.getElementById('password-edit-modal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPasswordShares(jsonShares) {
|
||||||
|
const sec = document.getElementById('pw-shares-section');
|
||||||
|
const list = document.getElementById('pw-shares-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
let shares = [];
|
||||||
|
try { shares = JSON.parse(jsonShares || '[]'); } catch(e) { shares = []; }
|
||||||
|
if(!shares || shares.length === 0) {
|
||||||
|
sec.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shares.forEach(sh => {
|
||||||
|
const pic = sh.share_type === 'user' ? sh.upic : sh.gpic;
|
||||||
|
const name = sh.share_type === 'user' ? (sh.username || 'User') : (sh.gname || 'Group');
|
||||||
|
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.style.display = 'inline-flex';
|
||||||
|
chip.style.alignItems = 'center';
|
||||||
|
chip.style.gap = '0.4rem';
|
||||||
|
chip.style.fontSize = '0.72rem';
|
||||||
|
chip.style.padding = '2px 8px 2px 2px';
|
||||||
|
chip.style.borderRadius = '999px';
|
||||||
|
chip.style.background = 'rgba(var(--primary-rgb),0.1)';
|
||||||
|
chip.style.color = 'var(--primary)';
|
||||||
|
chip.style.border = '1px solid rgba(var(--primary-rgb),0.2)';
|
||||||
|
|
||||||
|
let avatarHtml = `<div style="width:20px; height:20px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.6rem; font-weight:700; overflow:hidden;">${name.charAt(0).toUpperCase()}</div>`;
|
||||||
|
if(pic) {
|
||||||
|
avatarHtml = `<img src="${pic}" style="width:20px; height:20px; border-radius:50%; object-fit:cover;">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.innerHTML = `${avatarHtml}<span style="font-weight:600;">${name}</span><button type="button" style="border:none;background:transparent;color:inherit;cursor:pointer;line-height:1;margin-left:2px;font-size:0.9rem;" title="Unshare" onclick="removePasswordShare(${Number(sh.id)})">×</button>`;
|
||||||
|
list.appendChild(chip);
|
||||||
|
});
|
||||||
|
sec.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePasswordShare(shareId) {
|
||||||
|
if(!shareId) return;
|
||||||
|
appConfirm('Unshare this password access?').then(y => {
|
||||||
|
if(y) {
|
||||||
|
document.getElementById('remove-share-id').value = String(shareId);
|
||||||
|
document.getElementById('remove-share-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function deletePassword() {
|
function deletePassword() {
|
||||||
if(!confirm('Delete this entry permanently?')) return;
|
appConfirm('Delete this entry permanently?').then(y => {
|
||||||
document.getElementById('delete-pw-id').value = document.getElementById('pw-edit-id').value;
|
if(y) {
|
||||||
document.getElementById('delete-pw-form').submit();
|
document.getElementById('delete-pw-id').value = document.getElementById('pw-edit-id').value;
|
||||||
|
document.getElementById('delete-pw-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggles ───────────────────────────────────────────────────────────────────
|
// ── Toggles ───────────────────────────────────────────────────────────────────
|
||||||
@@ -539,7 +655,8 @@ function filterVault(q) {
|
|||||||
q = q.toLowerCase();
|
q = q.toLowerCase();
|
||||||
document.querySelectorAll('.vault-entry').forEach(row => {
|
document.querySelectorAll('.vault-entry').forEach(row => {
|
||||||
const text = row.textContent.toLowerCase();
|
const text = row.textContent.toLowerCase();
|
||||||
row.style.display = text.includes(q) ? '' : 'none';
|
const tags = (row.dataset.tags || '').toLowerCase();
|
||||||
|
row.style.display = (text.includes(q) || tags.includes(q)) ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,9 +670,12 @@ function showAddFolderPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteFolder(id, name) {
|
function deleteFolder(id, name) {
|
||||||
if(!confirm(`Delete folder "${name}"? Entries will not be deleted.`)) return;
|
appConfirm(`Delete folder "${name}"? Entries will not be deleted.`).then(y => {
|
||||||
document.getElementById('del-folder-id').value = id;
|
if(y) {
|
||||||
document.getElementById('del-folder-form').submit();
|
document.getElementById('del-folder-id').value = id;
|
||||||
|
document.getElementById('del-folder-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hotkeys on hovered row ────────────────────────────────────────────────────
|
// ── Hotkeys on hovered row ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,16 +4,35 @@ $parent_id = $_GET['folder'] ?? null;
|
|||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_folder'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_folder'])) {
|
||||||
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, owner_id) VALUES (?, 1, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, owner_id) VALUES (?, 1, ?, ?)");
|
||||||
$stmt->execute([$_POST['name'], $parent_id, $user_id]);
|
$stmt->execute([$_POST['name'], $parent_id, $user_id]);
|
||||||
|
notify_user($user_id, "New folder created: {$_POST['name']}", ['action'=>'Created', 'module'=>'Folder', 'item'=>$_POST['name']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_item'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_item'])) {
|
||||||
|
$item = query("SELECT name FROM projects WHERE id=?", [$_POST['item_id']])->fetch();
|
||||||
|
$ctx = ['action'=>'Deleted', 'module'=>'File/Folder', 'item'=>$item['name']??'Item'];
|
||||||
|
// Notify shared recipients before deletion
|
||||||
|
$shares = query("SELECT share_type, share_id FROM project_shares WHERE project_id=?", [$_POST['item_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared item '{$item['name']}' deleted.", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group item '{$item['name']}' deleted.", $ctx);
|
||||||
|
}
|
||||||
$stmt = $pdo->prepare("DELETE FROM projects WHERE id=? AND owner_id=?");
|
$stmt = $pdo->prepare("DELETE FROM projects WHERE id=? AND owner_id=?");
|
||||||
$stmt->execute([$_POST['item_id'], $user_id]);
|
$stmt->execute([$_POST['item_id'], $user_id]);
|
||||||
|
notify_user($user_id, "Item deleted: " . ($item['name']??'Item'), $ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_item'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_item'])) {
|
||||||
$stmt = $pdo->prepare("UPDATE projects SET name=? WHERE id=? AND owner_id=?");
|
$stmt = $pdo->prepare("UPDATE projects SET name=? WHERE id=? AND owner_id=?");
|
||||||
$stmt->execute([$_POST['new_name'], $_POST['item_id'], $user_id]);
|
$stmt->execute([$_POST['new_name'], $_POST['item_id'], $user_id]);
|
||||||
|
|
||||||
|
$ctx = ['action'=>'Renamed', 'module'=>'File/Folder', 'item'=>$_POST['new_name']];
|
||||||
|
notify_user($user_id, "Item renamed to '{$_POST['new_name']}'", $ctx);
|
||||||
|
// Notify shared
|
||||||
|
$shares = query("SELECT share_type, share_id FROM project_shares WHERE project_id=?", [$_POST['item_id']])->fetchAll();
|
||||||
|
foreach($shares as $sh) {
|
||||||
|
if($sh['share_type'] === 'user') notify_user($sh['share_id'], "Shared item renamed to '{$_POST['new_name']}'", $ctx);
|
||||||
|
else notify_group($sh['share_id'], "Shared group item renamed to '{$_POST['new_name']}'", $ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isset($_FILES['file'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isset($_FILES['file'])) {
|
||||||
@@ -24,6 +43,7 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isse
|
|||||||
move_uploaded_file($tmp_name, $path);
|
move_uploaded_file($tmp_name, $path);
|
||||||
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, file_path, owner_id) VALUES (?, 0, ?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO projects (name, is_folder, parent_id, file_path, owner_id) VALUES (?, 0, ?, ?, ?)");
|
||||||
$stmt->execute([$name, $parent_id, $path, $user_id]);
|
$stmt->execute([$name, $parent_id, $path, $user_id]);
|
||||||
|
notify_user($user_id, "File uploaded: $name", ['action'=>'Uploaded', 'module'=>'File', 'item'=>$name]);
|
||||||
echo "OK"; exit;
|
echo "OK"; exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +56,17 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_project'])) {
|
|||||||
$stmt->execute([$pr_id, $parts[0], $parts[1]]);
|
$stmt->execute([$pr_id, $parts[0], $parts[1]]);
|
||||||
$pr = query("SELECT name FROM projects WHERE id=?", [$pr_id])->fetch();
|
$pr = query("SELECT name FROM projects WHERE id=?", [$pr_id])->fetch();
|
||||||
$msg = "A project/file '{$pr['name']}' has been shared with you.";
|
$msg = "A project/file '{$pr['name']}' has been shared with you.";
|
||||||
if($parts[0] === 'user') notify_user($parts[1], $msg);
|
$ctx = ['action'=>'Shared', 'module'=>'File/Folder', 'item'=>$pr['name'], 'user'=>$_SESSION['username']??'System'];
|
||||||
else notify_group($parts[1], $msg);
|
if($parts[0] === 'user') notify_user($parts[1], $msg, $ctx);
|
||||||
|
else notify_group($parts[1], $msg, $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['remove_project_share'])) {
|
||||||
|
$share_id = (int)$_POST['share_id'];
|
||||||
|
$chk = query("SELECT ps.id FROM project_shares ps JOIN projects p ON ps.project_id=p.id WHERE ps.id=? AND p.owner_id=?", [$share_id, $user_id])->fetch();
|
||||||
|
if($chk) {
|
||||||
|
$pdo->prepare("DELETE FROM project_shares WHERE id=?")->execute([$share_id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,16 +167,10 @@ function get_file_icon($name, $is_folder) {
|
|||||||
<input type="hidden" name="item_id" id="delete-item-id">
|
<input type="hidden" name="item_id" id="delete-item-id">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="header" style="margin-bottom:0.75rem;">
|
<form method="POST" id="remove-share-form" style="display:none;">
|
||||||
<h1 style="font-size:1.5rem; font-weight:700;">📁 Project Explorer</h1>
|
<input type="hidden" name="remove_project_share" value="1">
|
||||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
<input type="hidden" name="share_id" id="remove-share-id">
|
||||||
<label class="btn btn-secondary" style="cursor:pointer;">
|
</form>
|
||||||
<i class="fa fa-arrow-up-from-bracket"></i> Upload File
|
|
||||||
<input type="file" id="file-upload-input" style="display:none;" onchange="handleFileSelect(this)">
|
|
||||||
</label>
|
|
||||||
<button class="btn" onclick="showNewFolderDialog()"><i class="fa fa-folder-plus"></i> New Folder</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden folder create form -->
|
<!-- Hidden folder create form -->
|
||||||
<form method="POST" id="new-folder-form" style="display:none;">
|
<form method="POST" id="new-folder-form" style="display:none;">
|
||||||
@@ -156,7 +179,7 @@ function get_file_icon($name, $is_folder) {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Explorer Toolbar / Breadcrumb -->
|
<!-- Explorer Toolbar / Breadcrumb -->
|
||||||
<div class="explorer-toolbar">
|
<div class="explorer-toolbar" style="margin-bottom:0.75rem;">
|
||||||
<a href="?action=projects" class="btn btn-secondary btn-sm" title="Home">
|
<a href="?action=projects" class="btn btn-secondary btn-sm" title="Home">
|
||||||
<i class="fa fa-house"></i>
|
<i class="fa fa-house"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -173,21 +196,24 @@ function get_file_icon($name, $is_folder) {
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<input type="text" id="explorer-search" placeholder="Search..." class="form-control" style="width:180px; padding:0.4rem 0.6rem; font-size:0.85rem;" oninput="filterExplorer(this.value)">
|
<input type="text" id="explorer-search" placeholder="Search..." class="form-control" style="width:180px; padding:0.4rem 0.6rem; font-size:0.85rem;" oninput="filterExplorer(this.value)">
|
||||||
|
<label class="btn btn-secondary btn-sm" style="cursor:pointer;">
|
||||||
|
<i class="fa fa-arrow-up-from-bracket"></i> Upload File
|
||||||
|
<input type="file" id="file-upload-input" style="display:none;" onchange="handleFileSelect(this)">
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-sm" onclick="showNewFolderDialog()"><i class="fa fa-folder-plus"></i> New Folder</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drop Zone -->
|
<div id="drag-upload-overlay" style="position:fixed; inset:0; z-index:1200; display:none; align-items:center; justify-content:center; pointer-events:none; background:rgba(15,23,42,0.45); backdrop-filter:blur(2px);">
|
||||||
<div id="dropzone" class="dropzone" style="padding:1.5rem; margin-bottom:0.75rem;"
|
<div style="background:var(--surface); border:2px dashed var(--primary); border-radius:1rem; padding:2rem 2.5rem; color:var(--text); text-align:center; box-shadow:0 12px 30px rgba(0,0,0,0.2);">
|
||||||
ondragover="this.classList.add('dragover'); return false;"
|
<i class="fa fa-cloud-arrow-up" style="font-size:2rem; margin-bottom:0.6rem; display:block; color:var(--primary);"></i>
|
||||||
ondragleave="this.classList.remove('dragover');"
|
<div style="font-weight:700; margin-bottom:0.2rem;">Drop files to upload</div>
|
||||||
ondrop="handleDrop(event)">
|
<div style="font-size:0.84rem; color:var(--text-light);">Release your file anywhere on this page</div>
|
||||||
<i class="fa fa-cloud-arrow-up" style="font-size:1.5rem; margin-bottom:0.5rem; display:block;"></i>
|
</div>
|
||||||
Drag & drop files here to upload
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detail Table -->
|
<!-- Detail Table -->
|
||||||
<div class="card" style="padding:0; overflow:hidden;">
|
<div class="card" style="padding:0; overflow:hidden; min-height:calc(100vh - 190px);">
|
||||||
<table class="explorer-table" id="explorer-table">
|
<table class="explorer-table" id="explorer-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -235,10 +261,22 @@ function get_file_icon($name, $is_folder) {
|
|||||||
<td style="font-size:0.8rem; color:var(--text-light);">
|
<td style="font-size:0.8rem; color:var(--text-light);">
|
||||||
<?php
|
<?php
|
||||||
// Show shares
|
// Show shares
|
||||||
$shares = query("SELECT ps.share_type, ps.share_id, u.username, g.name as gname FROM project_shares ps LEFT JOIN users u ON (ps.share_type='user' AND ps.share_id=u.id) LEFT JOIN groups g ON (ps.share_type='group' AND ps.share_id=g.id) WHERE ps.project_id=?", [$item['id']])->fetchAll();
|
$shares = query("SELECT ps.id, ps.share_type, ps.share_id, u.username, u.profile_pic as upic, g.name as gname, g.profile_pic as gpic FROM project_shares ps LEFT JOIN users u ON (ps.share_type='user' AND ps.share_id=u.id) LEFT JOIN groups g ON (ps.share_type='group' AND ps.share_id=g.id) WHERE ps.project_id=?", [$item['id']])->fetchAll();
|
||||||
foreach($shares as $sh) {
|
foreach($shares as $sh) {
|
||||||
echo '<span style="font-size:0.72rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 5px; border-radius:8px; margin:1px; display:inline-block;">';
|
$pic = ($sh['share_type'] === 'user' ? ($sh['upic']??'') : ($sh['gpic']??''));
|
||||||
echo $sh['share_type']==='user' ? ('👤 '.htmlspecialchars($sh['username']??'')) : ('👥 '.htmlspecialchars($sh['gname']??''));
|
$name = ($sh['share_type'] === 'user' ? ($sh['username']??'User') : ($sh['gname']??'Group'));
|
||||||
|
$init = strtoupper(mb_substr($name, 0, 1));
|
||||||
|
|
||||||
|
echo '<span style="font-size:0.72rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 5px 1px 1px; border-radius:12px; margin:1px; display:inline-flex; align-items:center; gap:3px; border:1px solid rgba(var(--primary-rgb),0.15);">';
|
||||||
|
if($pic && file_exists($pic)) {
|
||||||
|
echo '<img src="'.htmlspecialchars($pic).'" style="width:16px; height:16px; border-radius:50%; object-fit:cover;">';
|
||||||
|
} else {
|
||||||
|
echo '<div style="width:16px; height:16px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.55rem; font-weight:700;">'.$init.'</div>';
|
||||||
|
}
|
||||||
|
echo '<span>'.htmlspecialchars($name).'</span>';
|
||||||
|
if($item['is_mine']) {
|
||||||
|
echo '<button type="button" style="border:none;background:transparent;color:inherit;cursor:pointer;line-height:1;margin-left:2px;font-size:0.8rem;" title="Unshare" onclick="removeProjectShare('.$sh['id'].')">×</button>';
|
||||||
|
}
|
||||||
echo '</span>';
|
echo '</span>';
|
||||||
}
|
}
|
||||||
if(!$shares) echo '<span style="color:var(--text-light); font-size:0.8rem;">—</span>';
|
if(!$shares) echo '<span style="color:var(--text-light); font-size:0.8rem;">—</span>';
|
||||||
@@ -281,9 +319,12 @@ function renameItem(id, currName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function deleteItem(id, name) {
|
function deleteItem(id, name) {
|
||||||
if(!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
appConfirm(`Delete "${name}"? This cannot be undone.`).then(y => {
|
||||||
document.getElementById('delete-item-id').value = id;
|
if(y) {
|
||||||
document.getElementById('delete-item-form').submit();
|
document.getElementById('delete-item-id').value = id;
|
||||||
|
document.getElementById('delete-item-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShare(id) {
|
function openShare(id) {
|
||||||
@@ -291,6 +332,15 @@ function openShare(id) {
|
|||||||
document.getElementById('share-modal').classList.add('active');
|
document.getElementById('share-modal').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeProjectShare(shareId) {
|
||||||
|
appConfirm('Unshare this item?').then(y => {
|
||||||
|
if(y) {
|
||||||
|
document.getElementById('remove-share-id').value = shareId;
|
||||||
|
document.getElementById('remove-share-form').submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showNewFolderDialog() {
|
function showNewFolderDialog() {
|
||||||
const name = prompt('New folder name:');
|
const name = prompt('New folder name:');
|
||||||
if(name && name.trim()) {
|
if(name && name.trim()) {
|
||||||
@@ -305,7 +355,7 @@ function handleFileSelect(input) {
|
|||||||
|
|
||||||
function handleDrop(e) {
|
function handleDrop(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.classList.remove('dragover');
|
hideDragOverlay();
|
||||||
if(e.dataTransfer.files.length > 0) uploadFile(e.dataTransfer.files[0]);
|
if(e.dataTransfer.files.length > 0) uploadFile(e.dataTransfer.files[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +368,35 @@ function uploadFile(file) {
|
|||||||
.then(() => window.location.reload());
|
.then(() => window.location.reload());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dragCounter = 0;
|
||||||
|
const dragOverlay = document.getElementById('drag-upload-overlay');
|
||||||
|
function showDragOverlay() {
|
||||||
|
if(dragOverlay) dragOverlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
function hideDragOverlay() {
|
||||||
|
if(dragOverlay) dragOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
document.addEventListener('dragenter', (e) => {
|
||||||
|
if(!e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
|
||||||
|
dragCounter++;
|
||||||
|
showDragOverlay();
|
||||||
|
});
|
||||||
|
document.addEventListener('dragover', (e) => {
|
||||||
|
if(!e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
document.addEventListener('dragleave', (e) => {
|
||||||
|
if(!e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
|
||||||
|
dragCounter = Math.max(0, dragCounter - 1);
|
||||||
|
if(dragCounter === 0) hideDragOverlay();
|
||||||
|
});
|
||||||
|
document.addEventListener('drop', (e) => {
|
||||||
|
if(!e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
handleDrop(e);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Select all ────────────────────────────────────────────────────────────────
|
// ── Select all ────────────────────────────────────────────────────────────────
|
||||||
function explorerSelectAll(chk) {
|
function explorerSelectAll(chk) {
|
||||||
document.querySelectorAll('.explorer-check').forEach(c => c.checked = chk.checked);
|
document.querySelectorAll('.explorer-check').forEach(c => c.checked = chk.checked);
|
||||||
|
|||||||
521
views/tasks.php
521
views/tasks.php
@@ -3,7 +3,8 @@
|
|||||||
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_type TEXT DEFAULT 'user'"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_type TEXT DEFAULT 'user'"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {}
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATETIME"); } catch(Exception $e) {}
|
||||||
|
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {}
|
try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {}
|
||||||
try {
|
try {
|
||||||
$pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)");
|
$pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)");
|
||||||
@@ -37,7 +38,16 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_task'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if($can_delete) {
|
if($can_delete) {
|
||||||
|
$task_to_del = query("SELECT title, assignee_type, assignee_id FROM tasks WHERE id=?", [$del_id])->fetch();
|
||||||
query("DELETE FROM tasks WHERE id=?", [$del_id]);
|
query("DELETE FROM tasks WHERE id=?", [$del_id]);
|
||||||
|
if($task_to_del) {
|
||||||
|
$ctx = ['action' => 'Deleted', 'module' => 'Task', 'item' => $task_to_del['title']];
|
||||||
|
if($task_to_del['assignee_type'] === 'user' && $task_to_del['assignee_id']) {
|
||||||
|
notify_user($task_to_del['assignee_id'], "Task '{$task_to_del['title']}' has been deleted.", $ctx);
|
||||||
|
} elseif($task_to_del['assignee_type'] === 'group' && $task_to_del['assignee_id']) {
|
||||||
|
notify_group($task_to_del['assignee_id'], "Group task '{$task_to_del['title']}' has been deleted.", $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +127,9 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_task'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO tasks (title, description, assignor_id, assignor_type, assignor_group_id, assignee_type, assignee_id, custom_assignee_name, status_updated_at, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)");
|
$tags = trim($_POST['tags'] ?? '');
|
||||||
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date])) {
|
$stmt = $pdo->prepare("INSERT INTO tasks (title, description, assignor_id, assignor_type, assignor_group_id, assignee_type, assignee_id, custom_assignee_name, status_updated_at, due_date, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)");
|
||||||
|
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date, $tags])) {
|
||||||
if(isset($_POST['ajax_submit'])) { echo json_encode(['success'=>false, 'error'=>'Task insert failed']); exit; }
|
if(isset($_POST['ajax_submit'])) { echo json_encode(['success'=>false, 'error'=>'Task insert failed']); exit; }
|
||||||
}
|
}
|
||||||
$new_task_id = $pdo->lastInsertId();
|
$new_task_id = $pdo->lastInsertId();
|
||||||
@@ -144,12 +155,13 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_task'])) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
|
$ctx = ['action' => 'Created', 'module' => 'Task', 'item' => $title, 'assignor' => $_SESSION['username'] ?? 'System', 'due_date' => $due_date ?: 'None'];
|
||||||
foreach($to_list as $rec) {
|
foreach($to_list as $rec) {
|
||||||
$u = query("SELECT id FROM users WHERE username=?", [$rec])->fetch();
|
$u = query("SELECT id FROM users WHERE username=?", [$rec])->fetch();
|
||||||
if($u) notify_user($u['id'], "You have been assigned a new task: $title");
|
if($u) notify_user($u['id'], "You have been assigned a new task: $title", $ctx);
|
||||||
else {
|
else {
|
||||||
$g = query("SELECT id FROM groups WHERE name=?", [$rec])->fetch();
|
$g = query("SELECT id FROM groups WHERE name=?", [$rec])->fetch();
|
||||||
if($g) notify_group($g['id'], "Your group has been assigned a new task: $title");
|
if($g) notify_group($g['id'], "Your group has been assigned a new task: $title", $ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,18 +188,43 @@ $all_g = query("SELECT id, name FROM groups")->fetchAll();
|
|||||||
|
|
||||||
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
||||||
|
|
||||||
$params = array_merge([$user_id], $my_group_ids, $my_group_ids, [$user_id]);
|
$custom_tokens = [];
|
||||||
|
if(!empty($current_user['username'])) $custom_tokens[] = (string)$current_user['username'];
|
||||||
|
foreach($user_groups as $g) {
|
||||||
|
if(!empty($g['name'])) $custom_tokens[] = (string)$g['name'];
|
||||||
|
}
|
||||||
|
$custom_tokens = array_values(array_unique(array_filter(array_map('trim', $custom_tokens), fn($v) => $v !== '')));
|
||||||
|
|
||||||
$sql = "SELECT t.*, u.username as assignor_user_name, g.name as assignor_group_name, au.username as assignee_user_name, ag.name as assignee_group_name
|
$custom_in = implode(',', array_fill(0, count($custom_tokens), '?')) ?: '0';
|
||||||
|
$custom_like = $custom_tokens ? implode(' OR ', array_fill(0, count($custom_tokens), "(','||lower(replace(t.custom_assignee_name,' ',''))||',') LIKE ('%,'||lower(replace(?,' ',''))||',%')")) : '0';
|
||||||
|
|
||||||
|
$params = array_merge([$user_id], $my_group_ids, $my_group_ids, [$user_id], $custom_tokens, $custom_tokens);
|
||||||
|
|
||||||
|
$sql = "SELECT t.*,
|
||||||
|
u.username as assignor_user_name, u.profile_pic as assignor_user_pic,
|
||||||
|
g.name as assignor_group_name, g.profile_pic as assignor_group_pic,
|
||||||
|
au.username as assignee_user_name, au.profile_pic as assignee_user_pic,
|
||||||
|
ag.name as assignee_group_name, ag.profile_pic as assignee_group_pic,
|
||||||
|
cu.username as completed_by_username, aru.username as archived_by_username
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN users u ON t.assignor_id = u.id
|
LEFT JOIN users u ON t.assignor_id = u.id
|
||||||
LEFT JOIN groups g ON t.assignor_group_id = g.id
|
LEFT JOIN groups g ON t.assignor_group_id = g.id
|
||||||
LEFT JOIN users au ON (t.assignee_type='user' AND t.assignee_id = au.id)
|
LEFT JOIN users au ON (t.assignee_type='user' AND t.assignee_id = au.id)
|
||||||
LEFT JOIN groups ag ON (t.assignee_type='group' AND t.assignee_id = ag.id)
|
LEFT JOIN groups ag ON (t.assignee_type='group' AND t.assignee_id = ag.id)
|
||||||
|
LEFT JOIN users cu ON t.completed_by = cu.id
|
||||||
|
LEFT JOIN users aru ON t.archived_by = aru.id
|
||||||
WHERE (t.assignee_type='user' AND t.assignee_id = ?)
|
WHERE (t.assignee_type='user' AND t.assignee_id = ?)
|
||||||
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in))
|
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in))
|
||||||
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in))
|
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in))
|
||||||
OR (t.assignor_type='user' AND t.assignor_id = ?)
|
OR (t.assignor_type='user' AND t.assignor_id = ?)
|
||||||
|
OR (
|
||||||
|
COALESCE(t.assignee_type,'custom')='custom'
|
||||||
|
AND t.custom_assignee_name IS NOT NULL
|
||||||
|
AND (
|
||||||
|
t.custom_assignee_name IN ($custom_in)
|
||||||
|
OR ($custom_like)
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY t.created_at DESC";
|
ORDER BY t.created_at DESC";
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
@@ -195,6 +232,7 @@ $tasks = $stmt->fetchAll();
|
|||||||
|
|
||||||
$t_ids = array_column($tasks, 'id');
|
$t_ids = array_column($tasks, 'id');
|
||||||
$attachments = [];
|
$attachments = [];
|
||||||
|
$comments = [];
|
||||||
if($t_ids) {
|
if($t_ids) {
|
||||||
$in = str_repeat('?,', count($t_ids) - 1) . '?';
|
$in = str_repeat('?,', count($t_ids) - 1) . '?';
|
||||||
$stmt = $pdo->prepare("SELECT ta.*, u.username as owner_name FROM task_attachments ta LEFT JOIN users u ON ta.owner_id = u.id WHERE ta.task_id IN ($in) ORDER BY ta.created_at ASC");
|
$stmt = $pdo->prepare("SELECT ta.*, u.username as owner_name FROM task_attachments ta LEFT JOIN users u ON ta.owner_id = u.id WHERE ta.task_id IN ($in) ORDER BY ta.created_at ASC");
|
||||||
@@ -202,6 +240,47 @@ if($t_ids) {
|
|||||||
foreach($stmt->fetchAll() as $row) {
|
foreach($stmt->fetchAll() as $row) {
|
||||||
$attachments[$row['task_id']][] = $row;
|
$attachments[$row['task_id']][] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stmt2 = $pdo->prepare("SELECT tc.*, cu.username as created_by_username FROM task_comments tc LEFT JOIN users cu ON tc.created_by = cu.id WHERE tc.task_id IN ($in) ORDER BY tc.created_at ASC");
|
||||||
|
$stmt2->execute($t_ids);
|
||||||
|
foreach($stmt2->fetchAll() as $row) {
|
||||||
|
$comments[$row['task_id']][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fetch profile pics for users
|
||||||
|
$all_usernames = [];
|
||||||
|
foreach($tasks as $t) {
|
||||||
|
if($t['assignee_type'] == 'user' || $t['assignee_type'] == 'custom') {
|
||||||
|
$names = array_filter(array_map('trim', explode(',', $t['assignee_user_name'] ?? $t['custom_assignee_name'] ?? '')));
|
||||||
|
foreach($names as $n) $all_usernames[] = $n;
|
||||||
|
}
|
||||||
|
if($t['assignor_type'] == 'user') $all_usernames[] = $t['assignor_user_name'];
|
||||||
|
}
|
||||||
|
$user_pics = [];
|
||||||
|
if($all_usernames) {
|
||||||
|
$all_usernames = array_unique($all_usernames);
|
||||||
|
$in_u = str_repeat('?,', count($all_usernames) - 1) . '?';
|
||||||
|
$stmt_u = $pdo->prepare("SELECT username, profile_pic FROM users WHERE username IN ($in_u)");
|
||||||
|
$stmt_u->execute(array_values($all_usernames));
|
||||||
|
foreach($stmt_u->fetchAll() as $row) {
|
||||||
|
$user_pics[$row['username']] = $row['profile_pic'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pre-fetch group pics
|
||||||
|
$all_groupnames = [];
|
||||||
|
foreach($tasks as $t) {
|
||||||
|
if($t['assignee_type'] == 'group') $all_groupnames[] = $t['assignee_group_name'];
|
||||||
|
if($t['assignor_type'] == 'group') $all_groupnames[] = $t['assignor_group_name'];
|
||||||
|
}
|
||||||
|
if($all_groupnames) {
|
||||||
|
$all_groupnames = array_unique($all_groupnames);
|
||||||
|
$in_g = str_repeat('?,', count($all_groupnames) - 1) . '?';
|
||||||
|
$stmt_g = $pdo->prepare("SELECT name, profile_pic FROM groups WHERE name IN ($in_g)");
|
||||||
|
$stmt_g->execute(array_values($all_groupnames));
|
||||||
|
foreach($stmt_g->fetchAll() as $row) {
|
||||||
|
$user_pics[$row['name']] = $row['profile_pic'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []];
|
$kanban = ['pending' => [], 'in_progress' => [], 'completed' => [], 'archived' => []];
|
||||||
@@ -213,7 +292,7 @@ foreach($tasks as $t) {
|
|||||||
usort($kanban['pending'], function($a, $b) {
|
usort($kanban['pending'], function($a, $b) {
|
||||||
$da = $a['due_date'] ? strtotime($a['due_date']) : PHP_INT_MAX;
|
$da = $a['due_date'] ? strtotime($a['due_date']) : PHP_INT_MAX;
|
||||||
$db = $b['due_date'] ? strtotime($b['due_date']) : PHP_INT_MAX;
|
$db = $b['due_date'] ? strtotime($b['due_date']) : PHP_INT_MAX;
|
||||||
if($da == $db) return strtotime($b['created_at']) - strtotime($a['created_at']);
|
if($da == $db) return strtotime($b['created_at'] . ' UTC') - strtotime($a['created_at'] . ' UTC');
|
||||||
return $da - $db;
|
return $da - $db;
|
||||||
});
|
});
|
||||||
?>
|
?>
|
||||||
@@ -242,7 +321,7 @@ usort($kanban['pending'], function($a, $b) {
|
|||||||
.kanban-task {
|
.kanban-task {
|
||||||
background: var(--surface); padding: 1rem; border-radius: 10px;
|
background: var(--surface); padding: 1rem; border-radius: 10px;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);
|
||||||
border: 1px solid rgba(0,0,0,0.05); cursor: grab; transition: transform 0.2s, box-shadow 0.2s;
|
border: 1px solid rgba(0,0,0,0.05); transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||||
border-left: 4px solid var(--border);
|
border-left: 4px solid var(--border);
|
||||||
}
|
}
|
||||||
body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-left: 4px solid var(--primary); }
|
body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-left: 4px solid var(--primary); }
|
||||||
@@ -253,8 +332,9 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
|
|
||||||
.kanban-task.expanded { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
|
.kanban-task.expanded { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
|
||||||
.kanban-task:hover:not(.expanded) { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
|
.kanban-task:hover:not(.expanded) { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0,0,0,0.05); }
|
||||||
|
.kanban-task.dragging { opacity: 0.4; transform: scale(1.02); box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04); cursor: grabbing; z-index: 100; }
|
||||||
|
|
||||||
.kanban-task-header { cursor: pointer; display: flex; flex-direction: column; gap: 0.5rem; }
|
.kanban-task-header { cursor: grab; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.kanban-task-title {
|
.kanban-task-title {
|
||||||
font-weight: 700; font-size: 1.05rem; color: var(--text);
|
font-weight: 700; font-size: 1.05rem; color: var(--text);
|
||||||
/* Clamp to 2 lines when collapsed */
|
/* Clamp to 2 lines when collapsed */
|
||||||
@@ -272,7 +352,7 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
-webkit-line-clamp: unset;
|
-webkit-line-clamp: unset;
|
||||||
}
|
}
|
||||||
.kanban-task-body {
|
.kanban-task-body {
|
||||||
display: none; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px dashed var(--border);
|
display: none; padding-top: 0.75rem; margin-top: 0.5rem; border-top: 1px dashed var(--border); position: relative; padding-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
.kanban-task.expanded .kanban-task-body { display: block; }
|
.kanban-task.expanded .kanban-task-body { display: block; }
|
||||||
.chevron { transition: transform 0.2s; font-size: 0.8rem; color: var(--text-light); }
|
.chevron { transition: transform 0.2s; font-size: 0.8rem; color: var(--text-light); }
|
||||||
@@ -288,6 +368,12 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
.role-from { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
|
.role-from { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
|
||||||
.role-to { background: rgba(16, 185, 129, 0.1); color: #10B981; }
|
.role-to { background: rgba(16, 185, 129, 0.1); color: #10B981; }
|
||||||
|
|
||||||
|
.kanban-task:not(.expanded) .expanded-only { display: none !important; }
|
||||||
|
.kanban-task.expanded .avatar-collapsed-only { display: none !important; }
|
||||||
|
.kanban-task.expanded .expanded-only { display: inline-flex !important; }
|
||||||
|
/* Hide header metadata row when expanded to avoid duplication */
|
||||||
|
.kanban-task.expanded .kanban-task-header > div:last-child { display: none !important; }
|
||||||
|
|
||||||
/* Custom Autocomplete Dropdown */
|
/* Custom Autocomplete Dropdown */
|
||||||
.autocomplete-dropdown {
|
.autocomplete-dropdown {
|
||||||
position: absolute; top: 100%; left: 0; right: 0; background: var(--surface);
|
position: absolute; top: 100%; left: 0; right: 0; background: var(--surface);
|
||||||
@@ -405,17 +491,27 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="position:relative; width:30px; height:30px;" title="Due Date">
|
<div style="position:relative;" title="Due Date & Time">
|
||||||
<div style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:100%; height:100%; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s; position:absolute; top:0; left:0; z-index:1; pointer-events:none;">
|
<div id="due-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:all 0.2s;" onclick="duePicker.open();">
|
||||||
<i class="fas fa-calendar-alt" style="font-size:0.85rem;"></i>
|
<i class="fas fa-calendar-alt" style="font-size:0.85rem;"></i>
|
||||||
</div>
|
</div>
|
||||||
<input type="date" name="due_date" style="position:absolute; top:0; left:0; width:100%; height:100%; opacity:0; cursor:pointer; z-index:2;" title="Set Due Date" onchange="handleDueDateChange(this.value)">
|
<input type="text" name="due_date" id="due-picker-input" style="visibility:hidden; position:absolute; width:1px; height:1px;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label title="Attach Files" id="attach-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;">
|
<label title="Attach Files" id="attach-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;">
|
||||||
<i class="fas fa-paperclip" style="font-size:0.85rem;"></i>
|
<i class="fas fa-paperclip" style="font-size:0.85rem;"></i>
|
||||||
<input type="file" multiple style="display:none;" onchange="handleTaskFilesSelect(this)">
|
<input type="file" multiple style="display:none;" onchange="handleTaskFilesSelect(this)">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div style="position:relative;" title="Tags">
|
||||||
|
<div id="tag-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;" onclick="document.getElementById('tags-dropdown-container').style.display='block';">
|
||||||
|
<i class="fas fa-tag" style="font-size:0.85rem;"></i>
|
||||||
|
</div>
|
||||||
|
<div id="tags-dropdown-container" style="display:none; position:absolute; top:35px; right:-20px; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:0.75rem; z-index:100; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); width:280px; align-items:stretch;">
|
||||||
|
<label style="font-size:0.75rem; font-weight:600; color:var(--text); margin-bottom:0.4rem; display:block; text-align:left;">Tags</label>
|
||||||
|
<input type="text" name="tags" class="form-control tagify-input" style="font-size:0.8rem; border:1px solid var(--border); border-radius:4px; padding:4px; max-width:100%;" placeholder="Add tags...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn" style="padding:0.3rem 1rem; border-radius:20px; font-weight:600; font-size:0.8rem;" onclick="submitCreateTaskForm()">Create</button>
|
<button type="button" class="btn" style="padding:0.3rem 1rem; border-radius:20px; font-weight:600; font-size:0.8rem;" onclick="submitCreateTaskForm()">Create</button>
|
||||||
<button type="submit" id="hidden-create-btn" name="create_task" style="display:none;"></button>
|
<button type="submit" id="hidden-create-btn" name="create_task" style="display:none;"></button>
|
||||||
@@ -451,9 +547,9 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
</div>
|
</div>
|
||||||
<div class="kanban-tasks" id="col-<?= $col_id ?>">
|
<div class="kanban-tasks" id="col-<?= $col_id ?>">
|
||||||
<?php foreach($kanban[$col_id] as $t): ?>
|
<?php foreach($kanban[$col_id] as $t): ?>
|
||||||
<div class="kanban-task status-<?= $col_id ?>" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at']) ?>">
|
<div class="kanban-task status-<?= $col_id ?>" id="task-<?= $t['id'] ?>" data-due="<?= $t['due_date'] ? strtotime($t['due_date']) : PHP_INT_MAX ?>" data-created="<?= strtotime($t['created_at'] . ' UTC') ?>">
|
||||||
|
|
||||||
<div class="kanban-task-header" onclick="toggleTaskExpand(this)">
|
<div class="kanban-task-header" draggable="true" ondragstart="drag(event, <?= $t['id'] ?>)" ondragend="dragEnd(event)" onclick="toggleTaskExpand(this)">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%; min-width:0; gap:0.5rem;">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%; min-width:0; gap:0.5rem;">
|
||||||
<span class="kanban-task-title"><?= htmlspecialchars($t['title']) ?></span>
|
<span class="kanban-task-title"><?= htmlspecialchars($t['title']) ?></span>
|
||||||
<div style="display:flex; align-items:center; gap:0.5rem; flex-shrink:0;">
|
<div style="display:flex; align-items:center; gap:0.5rem; flex-shrink:0;">
|
||||||
@@ -476,25 +572,99 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem;">
|
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;">
|
||||||
<?php
|
<?php
|
||||||
echo '<span class="badge-role role-to" style="padding:2px 8px;">';
|
// Determine assignor info
|
||||||
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
|
$from_name = '';
|
||||||
elseif($t['assignee_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignee_group_name']);
|
if($t['assignor_type'] == 'group') $from_name = $t['assignor_group_name'];
|
||||||
else echo '👤 '.htmlspecialchars($t['assignee_user_name'] ?? '');
|
else $from_name = $t['assignor_user_name'] ?? 'System';
|
||||||
echo '</span>';
|
$from_pic = ($t['assignor_type'] == 'user') ? ($user_pics[$from_name] ?? '') : '';
|
||||||
|
$from_initial = strtoupper(mb_substr($from_name, 0, 1));
|
||||||
|
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
|
||||||
|
$from_bg = $bg_colors[crc32($from_name) % 8];
|
||||||
|
|
||||||
|
$to_names_raw = '';
|
||||||
|
if($t['assignee_type'] == 'custom') $to_names_raw = (string)$t['custom_assignee_name'];
|
||||||
|
elseif($t['assignee_type'] == 'group') $to_names_raw = htmlspecialchars($t['assignee_group_name']);
|
||||||
|
else $to_names_raw = htmlspecialchars($t['assignee_user_name'] ?? '');
|
||||||
|
|
||||||
echo '<div style="display:flex; align-items:center; gap:0.5rem;">';
|
$to_names = array_filter(array_map('trim', explode(',', $to_names_raw)));
|
||||||
|
|
||||||
|
$to_names_str = implode(', ', $to_names);
|
||||||
|
$assignment_title = "Task Assigned from {$from_name} to {$to_names_str}";
|
||||||
|
|
||||||
|
// Collapsed View: {assignor} => {assignee}
|
||||||
|
echo '<div class="avatar-collapsed-only" style="display:flex; align-items:center; gap:0.4rem;" title="'.htmlspecialchars($assignment_title).'">';
|
||||||
|
|
||||||
|
// From Avatar
|
||||||
|
if ($from_pic && file_exists($from_pic)) {
|
||||||
|
echo '<img src="'.htmlspecialchars($from_pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface);">';
|
||||||
|
} else {
|
||||||
|
echo '<div style="width:24px; height:24px; border-radius:50%; background:'.$from_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.75rem; border:2px solid var(--surface);">'.$from_initial.'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow style separator
|
||||||
|
echo '<i class="fas fa-chevron-right" style="font-size:0.6rem; color:var(--text-light); opacity:0.6;"></i>';
|
||||||
|
|
||||||
|
// To Avatar(s) stack
|
||||||
|
echo '<div style="display:flex; align-items:center;">';
|
||||||
|
foreach($to_names as $idx => $name) {
|
||||||
|
$initial = strtoupper(mb_substr($name, 0, 1));
|
||||||
|
$col_idx = crc32($name) % 8;
|
||||||
|
$av_bg = $bg_colors[$col_idx];
|
||||||
|
$pic = $user_pics[$name] ?? '';
|
||||||
|
$margin = ($idx === 0) ? '0' : '-8px';
|
||||||
|
|
||||||
|
if ($pic && file_exists($pic)) {
|
||||||
|
echo '<img src="'.htmlspecialchars($pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface); margin-left:'.$margin.'; z-index:'.(10 - $idx).';">';
|
||||||
|
} else {
|
||||||
|
echo '<div style="width:24px; height:24px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.75rem; border:2px solid var(--surface); margin-left:'.$margin.'; z-index:'.(10 - $idx).';">'.$initial.'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
echo '</div>';
|
||||||
|
|
||||||
|
// Expanded View Badges
|
||||||
|
echo '<div class="expanded-only" style="display:flex; flex-wrap:wrap; gap:0.4rem;">';
|
||||||
|
foreach($to_names as $name) {
|
||||||
|
$initial = strtoupper(mb_substr($name, 0, 1));
|
||||||
|
$col_idx = crc32($name) % 8;
|
||||||
|
$av_bg = $bg_colors[$col_idx];
|
||||||
|
$pic = $user_pics[$name] ?? '';
|
||||||
|
|
||||||
|
echo '<span class="badge-role role-to" style="padding:2px 8px; align-items:center; gap:0.4rem; display:inline-flex;">';
|
||||||
|
if ($pic && file_exists($pic)) {
|
||||||
|
echo '<img src="'.htmlspecialchars($pic).'" style="width:18px; height:18px; border-radius:50%; object-fit:cover;">';
|
||||||
|
} else {
|
||||||
|
echo '<div style="width:18px; height:18px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.6rem;">'.$initial.'</div>';
|
||||||
|
}
|
||||||
|
echo '<span>'.htmlspecialchars($name).'</span>';
|
||||||
|
echo '</span>';
|
||||||
|
}
|
||||||
|
echo '</div>';
|
||||||
|
|
||||||
|
echo '<div style="display:flex; align-items:center; gap:0.5rem; margin-left:auto;">';
|
||||||
|
|
||||||
|
$tag_count = !empty($t['tags']) ? count(array_filter(array_map('trim', explode(',', $t['tags'])))) : 0;
|
||||||
|
if($tag_count > 0) {
|
||||||
|
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$tag_count.' tag(s)"><i class="fas fa-tag" style="font-size:0.75rem;"></i> '.$tag_count.'</span>';
|
||||||
|
}
|
||||||
|
|
||||||
$att_count = empty($attachments[$t['id']]) ? 0 : count($attachments[$t['id']]);
|
$att_count = empty($attachments[$t['id']]) ? 0 : count($attachments[$t['id']]);
|
||||||
if($att_count > 0) {
|
if($att_count > 0) {
|
||||||
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$att_count.' attachment(s)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg> '.$att_count.'</span>';
|
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$att_count.' attachment(s)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg> '.$att_count.'</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We could show comments count too on collapsed state
|
||||||
|
$comment_count = empty($comments[$t['id']]) ? 0 : count($comments[$t['id']]);
|
||||||
|
if($comment_count > 0) {
|
||||||
|
echo '<span style="color:var(--text-light); font-weight:600; display:flex; align-items:center; gap:0.15rem;" title="'.$comment_count.' comment(s)"><i class="fas fa-comment-dots" style="font-size:0.75rem;"></i> '.$comment_count.'</span>';
|
||||||
|
}
|
||||||
|
|
||||||
if($t['due_date']) {
|
if($t['due_date']) {
|
||||||
$days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400);
|
$days_diff = (int)round((strtotime($t['due_date']) - strtotime(date('Y-m-d'))) / 86400);
|
||||||
if($days_diff < 0) {
|
if($days_diff < 0) {
|
||||||
echo '<span class="badge" style="background:#EF4444; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Overdue by '.abs($days_diff).'d</span>';
|
echo '<span class="badge" style="background:#EF4444; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Overdue '.abs($days_diff).'d ago</span>';
|
||||||
} elseif($days_diff == 0) {
|
} elseif($days_diff == 0) {
|
||||||
echo '<span class="badge" style="background:#F59E0B; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Due Today</span>';
|
echo '<span class="badge" style="background:#F59E0B; color:white; padding:2px 8px; border-radius:12px; font-weight:600;">Due Today</span>';
|
||||||
} else {
|
} else {
|
||||||
@@ -511,38 +681,118 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
<div class="kanban-task-desc"><?= nl2br(htmlspecialchars((string)$t['description'])) ?></div>
|
<div class="kanban-task-desc"><?= nl2br(htmlspecialchars((string)$t['description'])) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div style="font-size:0.75rem; color:var(--text-light); margin-bottom:0.75rem;">
|
<div style="font-size:0.72rem; color:var(--text-light); margin-bottom:0.75rem; display:flex; justify-content:space-between; align-items:center;">
|
||||||
<div>📅 Created: <?= date('M j, Y', strtotime($t['created_at'])) ?></div>
|
<div title="Created At"><i class="fas fa-calendar-plus" style="opacity:0.7; margin-right:4px;"></i> <?= date('M j, Y H:i', strtotime($t['created_at'] . ' UTC')) ?></div>
|
||||||
<?php if($t['due_date']): ?>
|
<?php if($t['due_date']): ?>
|
||||||
<div style="margin-top:2px;">⏰ Due Date: <strong style="color:#EF4444;"><?= date('M j, Y', strtotime($t['due_date'])) ?></strong></div>
|
<div title="Due Date"><i class="fas fa-clock" style="color:#EF4444; margin-right:4px;"></i> <strong style="color:#EF4444;"><?= date('M j, Y H:i', strtotime($t['due_date'])) ?></strong></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; flex-direction:column; gap:0.5rem;">
|
<div style="display:flex; flex-direction:column; gap:0.5rem;">
|
||||||
|
<?php
|
||||||
|
// Calculate FROM and TO names
|
||||||
|
$from_name = '';
|
||||||
|
if($t['assignor_type'] == 'group') $from_name = $t['assignor_group_name'];
|
||||||
|
else $from_name = $t['assignor_user_name'] ?? 'System';
|
||||||
|
|
||||||
|
if (!function_exists('get_avatar_html_local')) {
|
||||||
|
function get_avatar_html_local($name, $profile_pic = '') {
|
||||||
|
if (!$name) return '';
|
||||||
|
if($profile_pic && file_exists($profile_pic)) {
|
||||||
|
return '<img src="'.htmlspecialchars($profile_pic).'" style="width:24px; height:24px; border-radius:50%; object-fit:cover; border:2px solid var(--surface); cursor:help;" title="'.htmlspecialchars($name).'">';
|
||||||
|
}
|
||||||
|
$initial = strtoupper(mb_substr($name, 0, 1));
|
||||||
|
$col_idx = crc32($name) % 8;
|
||||||
|
$bg_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
|
||||||
|
$av_bg = $bg_colors[$col_idx];
|
||||||
|
return '<div style="width:24px; height:24px; border-radius:50%; background:'.$av_bg.'; color:white; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:0.75rem; border:2px solid var(--surface); cursor:help;" title="'.htmlspecialchars($name).'">'.$initial.'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$from_profile_pic = $user_pics[$from_name] ?? '';
|
||||||
|
?>
|
||||||
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
|
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span style="color:var(--text-light);">From:</span>
|
<span style="color:var(--text-light);">Assignments:</span>
|
||||||
<span class="badge-role role-from">
|
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||||
<?php
|
<div style="display:flex; align-items:center; gap:0.4rem;">
|
||||||
if($t['assignor_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignor_group_name']);
|
<span style="font-size:0.65rem; color:var(--text-light);">From</span>
|
||||||
else echo '👤 '.htmlspecialchars($t['assignor_user_name'] ?? '');
|
<?= get_avatar_html_local($from_name, $from_profile_pic) ?>
|
||||||
?>
|
</div>
|
||||||
|
<div style="color:var(--border);">|</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; justify-content:flex-end; max-width:200px;">
|
||||||
|
<span style="font-size:0.65rem; color:var(--text-light);">To</span>
|
||||||
|
<?php
|
||||||
|
foreach($to_names as $name) {
|
||||||
|
$p = $user_pics[$name] ?? '';
|
||||||
|
echo get_avatar_html_local($name, $p);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if($t['status'] === 'completed'): ?>
|
||||||
|
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span style="color:var(--text-light);">Completed by:</span>
|
||||||
|
<span style="color:#10B981; font-weight:600;">
|
||||||
|
<?= htmlspecialchars($t['completed_by_username'] ?? 'System') ?>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if($t['status'] === 'archived'): ?>
|
||||||
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
|
<div style="font-size:0.8rem; display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span style="color:var(--text-light);">To:</span>
|
<span style="color:var(--text-light);">Archived by:</span>
|
||||||
<span class="badge-role role-to">
|
<span style="color:#6B7280; font-weight:600;">
|
||||||
<?php
|
<?= htmlspecialchars($t['archived_by_username'] ?? 'System (Auto)') ?>
|
||||||
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
|
|
||||||
elseif($t['assignee_type'] == 'group') echo '👥 '.htmlspecialchars($t['assignee_group_name']);
|
|
||||||
else echo '👤 '.htmlspecialchars($t['assignee_user_name'] ?? '');
|
|
||||||
?>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
|
||||||
|
<div style="font-size:0.75rem; font-weight:600; color:var(--text-light); margin-bottom:0.5rem;">Comments</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-bottom:0.5rem;" id="comments-container-<?= $t['id'] ?>">
|
||||||
|
<?php if(!empty($comments[$t['id']])): ?>
|
||||||
|
<?php foreach($comments[$t['id']] as $cm):
|
||||||
|
$commenter_name = $cm['created_by_username'] ?? 'User';
|
||||||
|
$commenter_pic = $user_pics[$commenter_name] ?? '';
|
||||||
|
?>
|
||||||
|
<div style="display:flex; gap:0.4rem; align-items:flex-start;">
|
||||||
|
<?= get_avatar_html_local($commenter_name, $commenter_pic) ?>
|
||||||
|
<div style="background:var(--bg); border:1px solid var(--border); border-radius:12px; border-top-left-radius:0; padding:0.4rem 0.6rem; font-size:0.75rem; color:var(--text); flex:1;">
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:0.2rem;">
|
||||||
|
<strong style="font-size:0.65rem; color:var(--primary);"><?= htmlspecialchars($cm['created_by_username'] ?? 'User') ?></strong>
|
||||||
|
<span style="font-size:0.6rem; color:var(--text-light);"><?= date('M j, Y H:i', strtotime($cm['created_at'] . ' UTC')) ?>
|
||||||
|
<?php if($cm['created_by'] == $user_id || !empty($_SESSION['is_admin'])): ?>
|
||||||
|
<i class="fas fa-trash" style="cursor:pointer; color:#EF4444;" onclick="deleteComment(<?= $cm['id'] ?>)"></i>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<?= nl2br(htmlspecialchars($cm['comment'])) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-light); font-style:italic;">No comments yet.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<form method="POST" onsubmit="event.preventDefault(); addComment(<?= $t['id'] ?>, this);" style="display:flex; gap:0.4rem;">
|
||||||
|
<input type="text" name="comment" placeholder="Write a comment..." required style="flex:1; border:1px solid var(--border); background:var(--bg); color:var(--text); border-radius:20px; padding:0.4rem 0.75rem; font-size:0.75rem; min-width:0;">
|
||||||
|
<button type="submit" style="background:var(--primary); color:white; border:none; border-radius:20px; padding:0.4rem 0.8rem; font-size:0.75rem; cursor:pointer;" title="Send"><i class="fas fa-paper-plane"></i></button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
|
<div style="margin-top:0.75rem; border-top:1px dashed var(--border); padding-top:0.75rem;">
|
||||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem;">
|
<div style="position:absolute; bottom:-0.25rem; right:-0.25rem; display:flex; align-items:center; gap:0.5rem; padding:0.5rem;">
|
||||||
<div style="font-size:0.75rem; font-weight:600; color:var(--text-light);">Attachments</div>
|
<!-- Tag icon styled like attachment icon -->
|
||||||
|
<?php $task_tags = !empty($t['tags']) ? array_filter(array_map('trim', explode(',', $t['tags']))) : []; ?>
|
||||||
|
<button type="button" onclick="showAddTagPopup(<?= $t['id'] ?>, '<?= htmlspecialchars(implode(',', $task_tags), ENT_QUOTES) ?>')" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:4px; background:var(--border); color:var(--text); border:none; transition:background 0.2s; position:relative;" title="Manage Tags">
|
||||||
|
<i class="fas fa-tag" style="font-size:0.6rem;"></i>
|
||||||
|
<?php if(!empty($task_tags)): ?>
|
||||||
|
<span style="position:absolute; top:-4px; right:-4px; background:#EF4444; color:white; border-radius:50%; width:12px; height:12px; font-size:0.5rem; display:flex; align-items:center; justify-content:center; font-weight:700; border:1px solid var(--surface);"><?= count($task_tags) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Attachment icon -->
|
||||||
<label title="Attach files" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:4px; background:var(--border); color:var(--text); transition:background 0.2s;">
|
<label title="Attach files" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:22px; height:22px; border-radius:4px; background:var(--border); color:var(--text); transition:background 0.2s;">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
||||||
<input type="file" multiple style="display:none;" onchange="uploadFilesAjax(<?= $t['id'] ?>, this.files)">
|
<input type="file" multiple style="display:none;" onchange="uploadFilesAjax(<?= $t['id'] ?>, this.files)">
|
||||||
@@ -590,7 +840,13 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div title="Uploaded by <?= htmlspecialchars((string)$att['owner_name']) ?>" style="position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:700; z-index:10; box-shadow:0 1px 2px rgba(0,0,0,0.3);"><?= $initial ?></div>
|
<?php
|
||||||
|
$att_owner_pic = $user_pics[$att['owner_name']] ?? '';
|
||||||
|
if($att_owner_pic && file_exists($att_owner_pic)): ?>
|
||||||
|
<img src="<?= htmlspecialchars($att_owner_pic) ?>" title="Uploaded by <?= htmlspecialchars((string)$att['owner_name']) ?>" style="position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; object-fit:cover; z-index:10; box-shadow:0 1px 2px rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.2);">
|
||||||
|
<?php else: ?>
|
||||||
|
<div title="Uploaded by <?= htmlspecialchars((string)$att['owner_name']) ?>" style="position:absolute; top:2px; left:2px; width:16px; height:16px; border-radius:50%; background:var(--primary); color:white; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:700; z-index:10; box-shadow:0 1px 2px rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.2);"><?= $initial ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<a href="<?= htmlspecialchars((string)$att['file_path']) ?>" target="_blank" style="background:rgba(0,0,0,0.6); color:white; font-size:0.5rem; padding:2px 3px; text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:absolute; bottom:0; left:0; right:0; text-align:center;" title="<?= htmlspecialchars((string)$att['file_name']) ?>"><?= htmlspecialchars((string)$att['file_name']) ?></a>
|
<a href="<?= htmlspecialchars((string)$att['file_path']) ?>" target="_blank" style="background:rgba(0,0,0,0.6); color:white; font-size:0.5rem; padding:2px 3px; text-decoration:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; position:absolute; bottom:0; left:0; right:0; text-align:center;" title="<?= htmlspecialchars((string)$att['file_name']) ?>"><?= htmlspecialchars((string)$att['file_name']) ?></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -598,6 +854,8 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -669,6 +927,13 @@ function allowDrop(ev) {
|
|||||||
|
|
||||||
function drag(ev, taskId) {
|
function drag(ev, taskId) {
|
||||||
ev.dataTransfer.setData("text", taskId);
|
ev.dataTransfer.setData("text", taskId);
|
||||||
|
// Get the task element from the header
|
||||||
|
let taskEl = ev.target.closest('.kanban-task');
|
||||||
|
setTimeout(() => taskEl.classList.add('dragging'), 0);
|
||||||
|
}
|
||||||
|
function dragEnd(ev) {
|
||||||
|
let taskEl = ev.target.closest('.kanban-task');
|
||||||
|
if(taskEl) taskEl.classList.remove('dragging');
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortPendingTasks() {
|
function sortPendingTasks() {
|
||||||
@@ -723,7 +988,33 @@ function drop(ev, newStatus) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
body: `task_id=${taskId}&status=${newStatus}`
|
body: `task_id=${taskId}&status=${newStatus}`
|
||||||
}).then(() => showToast('Task updated.'));
|
}).then(() => {
|
||||||
|
showToast('Task updated.');
|
||||||
|
// Soft reload to show updated completions/archives visually
|
||||||
|
setTimeout(() => window.location.reload(), 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addComment(taskId, form) {
|
||||||
|
let comment = form.comment.value;
|
||||||
|
if(!comment) return;
|
||||||
|
fetch('?action=api&api_action=add_comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `task_id=${taskId}&comment=${encodeURIComponent(comment)}`
|
||||||
|
}).then(() => {
|
||||||
|
form.reset();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function deleteComment(commentId) {
|
||||||
|
if(!confirm('Delete this comment?')) return;
|
||||||
|
fetch('?action=api&api_action=delete_comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `comment_id=${commentId}`
|
||||||
|
}).then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTaskExpand(header) {
|
function toggleTaskExpand(header) {
|
||||||
@@ -755,9 +1046,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
document.addEventListener('mousedown', function(e) {
|
document.addEventListener('mousedown', function(e) {
|
||||||
let container = document.getElementById('to-dropdown-container');
|
let container = document.getElementById('to-dropdown-container');
|
||||||
if(container && !container.contains(e.target) && e.target.id !== 'to-input' && !e.target.closest('label')) {
|
if(container && !container.contains(e.target) && e.target.id !== 'to-input' && !e.target.closest('#to-icon-btn')) {
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tagContainer = document.getElementById('tags-dropdown-container');
|
||||||
|
if(tagContainer && !tagContainer.contains(e.target) && !e.target.closest('#tag-icon-btn') && !e.target.closest('.tagify')) {
|
||||||
|
tagContainer.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let pendingTaskFiles = [];
|
let pendingTaskFiles = [];
|
||||||
@@ -904,11 +1200,22 @@ function handleDueDateChange(val) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearDueDate() {
|
function clearDueDate() {
|
||||||
let input = document.querySelector('input[name="due_date"]');
|
duePicker.clear();
|
||||||
input.value = '';
|
|
||||||
handleDueDateChange('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let duePicker;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
duePicker = flatpickr("#due-picker-input", {
|
||||||
|
enableTime: true,
|
||||||
|
dateFormat: "Y-m-d H:i",
|
||||||
|
altInput: false,
|
||||||
|
disableMobile: "true",
|
||||||
|
onChange: function(selectedDates, dateStr) {
|
||||||
|
handleDueDateChange(dateStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let selectedAssignees = [];
|
let selectedAssignees = [];
|
||||||
|
|
||||||
function handleToSelection(val) {
|
function handleToSelection(val) {
|
||||||
@@ -1062,8 +1369,124 @@ function confirmCustomDelete(form, msg) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showAddTagPopup(taskId, existingTags) {
|
||||||
|
let tags = existingTags ? existingTags.split(',').filter(t => t.trim()) : [];
|
||||||
|
|
||||||
|
let overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed; top:0; left:0; width:100vw; height:100vh; background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center; z-index:9999; opacity:0; transition:opacity 0.2s;';
|
||||||
|
|
||||||
|
let modal = document.createElement('div');
|
||||||
|
modal.style.cssText = 'background:var(--surface); padding:1.5rem; border-radius:1rem; min-width:300px; max-width:90vw; box-shadow:0 10px 25px rgba(0,0,0,0.2); transform:scale(0.9); transition:transform 0.2s;';
|
||||||
|
|
||||||
|
let tagsHtml = tags.map(tag => {
|
||||||
|
let escaped = tag.replace(/'/g, "\\'");
|
||||||
|
return `<span style="font-size:0.7rem; padding:3px 10px; border-radius:12px; background:rgba(var(--primary-rgb),0.15); color:var(--primary); font-weight:600; display:inline-flex; align-items:center; gap:0.3rem; margin:0.2rem;">
|
||||||
|
<i class="fas fa-tag" style="font-size:0.6rem;"></i> ${tag}
|
||||||
|
<i class="fas fa-times" style="cursor:pointer; opacity:0.6; font-size:0.65rem;" onclick="removeTagFromPopup('${escaped}', ${taskId})" title="Remove tag"></i>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<h3 style="margin-bottom:1rem; font-size:1rem; text-align:center;"><i class="fas fa-tags" style="color:var(--primary);"></i> Manage Tags</h3>
|
||||||
|
<div id="popup-tags-list" style="margin-bottom:1rem; min-height:30px; ${tags.length ? '' : 'display:none;'}">${tagsHtml}</div>
|
||||||
|
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
|
||||||
|
<input type="text" id="popup-tag-input" placeholder="Add tags (comma separated)..." style="flex:1; border:1px solid var(--border); background:var(--bg); color:var(--text); border-radius:8px; padding:0.5rem; font-size:0.875rem; box-sizing:border-box;" onkeypress="if(event.key==='Enter'){event.preventDefault();addTagsFromPopup(${taskId});">
|
||||||
|
<button type="button" onclick="addTagsFromPopup(${taskId})" style="background:var(--primary); color:white; border:none; border-radius:8px; padding:0.5rem 1rem; font-size:0.8rem; cursor:pointer;"><i class="fas fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<button onclick="closeTagPopup()" style="background:var(--border); color:var(--text); border:none; border-radius:8px; padding:0.4rem 1.5rem; font-size:0.8rem; cursor:pointer;">Close</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
window.currentTagPopup = overlay;
|
||||||
|
window.currentTaskId = taskId;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.opacity = '1';
|
||||||
|
modal.style.transform = 'scale(1)';
|
||||||
|
document.getElementById('popup-tag-input').focus();
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
overlay.onclick = function(e) {
|
||||||
|
if(e.target === overlay) closeTagPopup();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagPopup() {
|
||||||
|
if(window.currentTagPopup) {
|
||||||
|
window.currentTagPopup.style.opacity = '0';
|
||||||
|
window.currentTagPopup.querySelector('div').style.transform = 'scale(0.9)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if(window.currentTagPopup) document.body.removeChild(window.currentTagPopup);
|
||||||
|
window.currentTagPopup = null;
|
||||||
|
window.location.reload();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTagFromPopup(tag, taskId) {
|
||||||
|
fetch('?action=api&api_action=remove_task_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
|
||||||
|
}).then(r => r.json()).then(res => {
|
||||||
|
if(res.success) {
|
||||||
|
// Refresh popup
|
||||||
|
closeTagPopup();
|
||||||
|
fetch('?action=api&api_action=get_tasks_status&task_id=' + taskId)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if(data && !data.status) {
|
||||||
|
showAddTagPopup(taskId, data.tags || '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagsFromPopup(taskId) {
|
||||||
|
let input = document.getElementById('popup-tag-input');
|
||||||
|
let tags = input.value.split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
if(!tags.length) return;
|
||||||
|
|
||||||
|
let promises = tags.map(tag =>
|
||||||
|
fetch('?action=api&api_action=add_task_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
|
||||||
|
}).then(r => r.json())
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
input.value = '';
|
||||||
|
// Refresh popup
|
||||||
|
closeTagPopup();
|
||||||
|
setTimeout(() => showAddTagPopup(taskId, ''), 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTaskTag(taskId) {
|
||||||
|
showAddTagPopup(taskId, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTaskTag(taskId, tag) {
|
||||||
|
fetch('?action=api&api_action=remove_task_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
body: `task_id=${taskId}&tag=${encodeURIComponent(tag)}`
|
||||||
|
}).then(r => r.json()).then(res => {
|
||||||
|
if(res.success) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showToast('Error: ' + (res.error || 'Failed to remove tag'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function uploadFilesAjax(taskId, files) {
|
function uploadFilesAjax(taskId, files) {
|
||||||
if(files.length === 0) return;
|
|
||||||
|
|
||||||
let container = document.getElementById('progress-container-' + taskId);
|
let container = document.getElementById('progress-container-' + taskId);
|
||||||
let fill = document.getElementById('progress-fill-' + taskId);
|
let fill = document.getElementById('progress-fill-' + taskId);
|
||||||
|
|||||||
74
views/tools.php
Normal file
74
views/tools.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
// Tools page
|
||||||
|
?>
|
||||||
|
<div class="header">
|
||||||
|
<h1 style="font-size:1.5rem; font-weight:700;"><i class="fa fa-toolbox" style="color:var(--primary);"></i> Tools</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-grid">
|
||||||
|
<!-- Base64 encode/decode -->
|
||||||
|
<a href="#" class="db-stat-card" style="border-top:3px solid #6366F1;" onclick="openTool('base64'); return false;">
|
||||||
|
<div class="db-stat-label"><i class="fa fa-code"></i> Base64 En/Decode</div>
|
||||||
|
<div class="db-stat-sub" style="margin-top:auto;"><span style="background:rgba(99,102,241,0.12); color:#6366F1;">Text utility</span></div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- UUID Generator -->
|
||||||
|
<a href="#" class="db-stat-card" style="border-top:3px solid #10B981;" onclick="openTool('uuid'); return false;">
|
||||||
|
<div class="db-stat-label"><i class="fa fa-fingerprint"></i> UUID Generator</div>
|
||||||
|
<div class="db-stat-sub" style="margin-top:auto;"><span style="background:rgba(16,185,129,0.12); color:#10B981;">ID utility</span></div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- JSON Formatter -->
|
||||||
|
<a href="#" class="db-stat-card" style="border-top:3px solid #F59E0B;" onclick="openTool('json'); return false;">
|
||||||
|
<div class="db-stat-label"><i class="fa fa-code"></i> JSON Formatter</div>
|
||||||
|
<div class="db-stat-sub" style="margin-top:auto;"><span style="background:rgba(245,158,11,0.12); color:#F59E0B;">Dev utility</span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for tools -->
|
||||||
|
<div class="modal-overlay" id="tool-modal">
|
||||||
|
<div class="modal-content" style="max-width:600px; display:flex; flex-direction:column;">
|
||||||
|
<h3 id="tool-title" style="margin-bottom:1rem; font-size:1.1rem;"></h3>
|
||||||
|
<div id="tool-body" style="flex:1;"></div>
|
||||||
|
<button class="btn btn-secondary" style="margin-top:1rem; width:100%;" onclick="document.getElementById('tool-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function openTool(tool) {
|
||||||
|
const title = document.getElementById('tool-title');
|
||||||
|
const body = document.getElementById('tool-body');
|
||||||
|
|
||||||
|
if (tool === 'base64') {
|
||||||
|
title.innerHTML = '<i class="fa fa-code"></i> Base64 Encoder/Decoder';
|
||||||
|
body.innerHTML = `
|
||||||
|
<textarea id="b64-input" class="form-control" rows="4" placeholder="Enter text here..."></textarea>
|
||||||
|
<div style="display:flex; gap:0.5rem; margin:1rem 0;">
|
||||||
|
<button class="btn" onclick="document.getElementById('b64-output').value = btoa(unescape(encodeURIComponent(document.getElementById('b64-input').value)))">Encode</button>
|
||||||
|
<button class="btn" onclick="try{document.getElementById('b64-output').value = decodeURIComponent(escape(atob(document.getElementById('b64-input').value)))}catch(e){alert('Invalid base64')}">Decode</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="b64-output" class="form-control" rows="4" placeholder="Result..." readonly></textarea>
|
||||||
|
`;
|
||||||
|
} else if (tool === 'uuid') {
|
||||||
|
title.innerHTML = '<i class="fa fa-fingerprint"></i> UUID Generator';
|
||||||
|
body.innerHTML = `
|
||||||
|
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
|
||||||
|
<input type="text" id="uuid-output" class="form-control" readonly style="font-family:monospace; font-size:1.1rem; text-align:center;">
|
||||||
|
<button class="btn" onclick="navigator.clipboard.writeText(document.getElementById('uuid-output').value); showToast('Copied!')"><i class="fa fa-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
<button class="btn" style="width:100%;" onclick="document.getElementById('uuid-output').value = crypto.randomUUID()">Generate New UUID</button>
|
||||||
|
`;
|
||||||
|
setTimeout(() => document.getElementById('uuid-output').value = crypto.randomUUID(), 10);
|
||||||
|
} else if (tool === 'json') {
|
||||||
|
title.innerHTML = '<i class="fa fa-code"></i> JSON Formatter';
|
||||||
|
body.innerHTML = `
|
||||||
|
<textarea id="json-input" class="form-control" rows="8" placeholder="Paste JSON here..." style="font-family:monospace;"></textarea>
|
||||||
|
<div style="margin:1rem 0; text-align:right;">
|
||||||
|
<button class="btn" onclick="try{document.getElementById('json-input').value = JSON.stringify(JSON.parse(document.getElementById('json-input').value), null, 4)}catch(e){alert('Invalid JSON')}">Format JSON</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tool-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user