Compare commits
2 Commits
9b1d5753cb
...
937033ea07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
937033ea07 | ||
|
|
f89fcbee1c |
71
index.php
71
index.php
@@ -66,7 +66,6 @@ if ($action === 'login') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle POST actions that need a redirect BEFORE layout outputs any HTML.
|
// Handle POST actions that need a redirect BEFORE layout outputs any HTML.
|
||||||
// Adding new redirect-based POST keys here keeps header() calls safe.
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $user_id) {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $user_id) {
|
||||||
// Mood setter
|
// Mood setter
|
||||||
if (isset($_POST['set_mood'])) {
|
if (isset($_POST['set_mood'])) {
|
||||||
@@ -86,19 +85,81 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $user_id) {
|
|||||||
header('Location: ?action=dashboard');
|
header('Location: ?action=dashboard');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile picture upload
|
||||||
|
if (isset($_POST['upload_profile_pic'])) {
|
||||||
|
// Ensure column exists
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
if (isset($_FILES['profile_pic']) && $_FILES['profile_pic']['error'] === 0) {
|
||||||
|
$allowed_types = ['image/jpeg','image/png','image/gif','image/webp'];
|
||||||
|
if (in_array($_FILES['profile_pic']['type'], $allowed_types)) {
|
||||||
|
$ext = pathinfo($_FILES['profile_pic']['name'], PATHINFO_EXTENSION);
|
||||||
|
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $ext;
|
||||||
|
$dest = 'uploads/' . $filename;
|
||||||
|
if (move_uploaded_file($_FILES['profile_pic']['tmp_name'], $dest)) {
|
||||||
|
// Delete old pic
|
||||||
|
$old = $pdo->prepare("SELECT profile_pic FROM users WHERE id=?");
|
||||||
|
$old->execute([$user_id]);
|
||||||
|
$old_pic = $old->fetchColumn();
|
||||||
|
if ($old_pic && file_exists($old_pic)) @unlink($old_pic);
|
||||||
|
$pdo->prepare("UPDATE users SET profile_pic=? WHERE id=?")->execute([$dest, $user_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: ?action=' . ($_POST['redirect_to'] ?? 'dashboard'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete profile picture
|
||||||
|
if (isset($_POST['delete_profile_pic'])) {
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
$old = $pdo->prepare("SELECT profile_pic FROM users WHERE id=?");
|
||||||
|
$old->execute([$user_id]);
|
||||||
|
$old_pic = $old->fetchColumn();
|
||||||
|
if ($old_pic && file_exists($old_pic)) @unlink($old_pic);
|
||||||
|
$pdo->prepare("UPDATE users SET profile_pic=NULL WHERE id=?")->execute([$user_id]);
|
||||||
|
header('Location: ?action=' . ($_POST['redirect_to'] ?? 'dashboard'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all notifications as read
|
||||||
|
if (isset($_POST['mark_all_read'])) {
|
||||||
|
$pdo->prepare("UPDATE notifications SET is_read=1 WHERE user_id=?")->execute([$user_id]);
|
||||||
|
header('Location: ?action=' . ($_POST['redirect_to'] ?? 'dashboard'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
if (isset($_POST['change_password'])) {
|
||||||
|
$cur = trim($_POST['current_password'] ?? '');
|
||||||
|
$new = trim($_POST['new_password'] ?? '');
|
||||||
|
$cnf = trim($_POST['confirm_password'] ?? '');
|
||||||
|
$user = get_user($user_id);
|
||||||
|
$pw_error = '';
|
||||||
|
if (!password_verify($cur, $user['password_hash'])) {
|
||||||
|
$pw_error = 'Current password is incorrect.';
|
||||||
|
} elseif (strlen($new) < 6) {
|
||||||
|
$pw_error = 'New password must be at least 6 characters.';
|
||||||
|
} elseif ($new !== $cnf) {
|
||||||
|
$pw_error = 'Passwords do not match.';
|
||||||
|
} else {
|
||||||
|
$pdo->prepare("UPDATE users SET password_hash=? WHERE id=?")
|
||||||
|
->execute([password_hash($new, PASSWORD_DEFAULT), $user_id]);
|
||||||
|
}
|
||||||
|
$_SESSION['pw_change_msg'] = $pw_error ?: 'success';
|
||||||
|
header('Location: ?action=' . ($_POST['redirect_to'] ?? 'dashboard'));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$view_file = 'views/' . basename($action) . '.php';
|
$view_file = 'views/' . basename($action) . '.php';
|
||||||
if (file_exists($view_file)) {
|
if (file_exists($view_file)) {
|
||||||
// AJAX-only POST requests must not have the HTML layout wrapped around them,
|
|
||||||
// otherwise the JSON response gets embedded inside the HTML document and
|
|
||||||
// JSON.parse() fails on the client side.
|
|
||||||
$is_ajax_post = isset($_POST['ajax_submit'])
|
$is_ajax_post = isset($_POST['ajax_submit'])
|
||||||
|| isset($_POST['delete_attachment_ajax'])
|
|| isset($_POST['delete_attachment_ajax'])
|
||||||
|| isset($_POST['upload_attachment_ajax']);
|
|| isset($_POST['upload_attachment_ajax']);
|
||||||
|
|
||||||
if ($is_ajax_post) {
|
if ($is_ajax_post) {
|
||||||
include $view_file; // view handles its own output + exit
|
include $view_file;
|
||||||
} else {
|
} else {
|
||||||
include 'views/layout_top.php';
|
include 'views/layout_top.php';
|
||||||
include $view_file;
|
include $view_file;
|
||||||
|
|||||||
@@ -30,29 +30,83 @@ a { text-decoration: none; color: var(--primary); }
|
|||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.app-container { display: flex; min-height: 100vh; }
|
.app-container { display: flex; min-height: 100vh; }
|
||||||
.sidebar { width: 250px; background: var(--surface); border-right: 1px solid var(--border); padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
|
|
||||||
.sidebar h2 { font-size: 1.2rem; margin-bottom: 2rem; color: var(--primary); }
|
|
||||||
.nav-link { padding: 0.75rem 1rem; border-radius: 0.5rem; text-decoration: none !important; color: var(--text); font-weight: 500; transition: background 0.2s; display: flex; justify-content: space-between; align-items: center; }
|
|
||||||
.nav-link:hover, .nav-link.active { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
|
|
||||||
.nav-link .badge { background: #EF4444; color: white; padding: 2px 6px; border-radius: 12px; font-size: 0.75rem; }
|
|
||||||
|
|
||||||
.main-content { flex: 1; padding: 2rem; overflow-y: auto; }
|
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
.sidebar {
|
||||||
|
width: 240px; background: var(--surface); border-right: 1px solid var(--border);
|
||||||
|
padding: 1.25rem 1rem; display: flex; flex-direction: column; gap: 0.75rem;
|
||||||
|
position: sticky; top: 0; height: 100vh; overflow-y: auto; overflow-x: hidden;
|
||||||
|
transition: width 0.25s cubic-bezier(.4,0,.2,1); flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex; align-items: center; gap: 8px; font-size: 1.1rem;
|
||||||
|
font-weight: 700; color: var(--primary); margin-bottom: 0.75rem;
|
||||||
|
white-space: nowrap; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle button pinned to right edge */
|
||||||
|
.sidebar-toggle-btn {
|
||||||
|
position: absolute; top: 1.5rem; right: -14px;
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
border: 1px solid var(--border); background: var(--surface);
|
||||||
|
color: var(--text-light); font-size: 0.65rem;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 20; transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.sidebar-toggle-btn:hover { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* Nav link: icon always visible, label collapses */
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.6rem 0.75rem; border-radius: 0.5rem;
|
||||||
|
text-decoration: none !important; color: var(--text); font-weight: 500;
|
||||||
|
transition: background 0.2s; display: flex; align-items: center;
|
||||||
|
gap: 0.65rem; font-size: 0.875rem; white-space: nowrap; overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-link:hover, .nav-link.active { background: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
|
||||||
|
.nav-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 0.95rem; }
|
||||||
|
.sidebar-label { overflow: hidden; transition: opacity 0.2s; opacity: 1; flex: 1; }
|
||||||
|
.nav-link .badge { background: #EF4444; color: white; padding: 2px 6px; border-radius: 12px; font-size: 0.72rem; margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Collapsed State ────────────────────────────────────────────────────── */
|
||||||
|
body.sidebar-collapsed .sidebar { width: 64px; }
|
||||||
|
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 .badge { position: absolute; top: 2px; right: 2px; padding: 1px 4px; font-size: 0.6rem; }
|
||||||
|
body.sidebar-collapsed .sidebar-toggle-btn .fa-chevron-left { display: block; }
|
||||||
|
|
||||||
|
/* Tooltip on collapsed nav links */
|
||||||
|
body.sidebar-collapsed .nav-link { position: relative; }
|
||||||
|
body.sidebar-collapsed .nav-link::after {
|
||||||
|
content: attr(title); position: absolute; left: calc(100% + 12px); top: 50%;
|
||||||
|
transform: translateY(-50%); background: var(--text); color: var(--surface);
|
||||||
|
padding: 4px 10px; border-radius: 6px; font-size: 0.78rem; white-space: nowrap;
|
||||||
|
opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 100;
|
||||||
|
}
|
||||||
|
body.sidebar-collapsed .nav-link:hover::after { opacity: 1; }
|
||||||
|
|
||||||
|
.main-content { flex: 1; padding: 1.5rem 2rem; overflow-y: auto; max-height: 100vh; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||||
.card { background: var(--surface); padding: 1.5rem; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-bottom: 1.5rem; }
|
.card { background: var(--surface); padding: 1.5rem; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); margin-bottom: 1.5rem; }
|
||||||
|
|
||||||
.btn { display: inline-flex; align-items: center; gap: 0.5rem; justify-content:center; padding: 0.5rem 1rem; background: var(--primary); color: white !important; border-radius: 0.5rem; border: none; cursor: pointer; text-decoration: none !important; font-weight: 500; transition: opacity 0.2s; }
|
.btn { display: inline-flex; align-items: center; gap: 0.5rem; justify-content:center; padding: 0.5rem 1rem; background: var(--primary); color: white !important; border-radius: 0.5rem; border: none; cursor: pointer; text-decoration: none !important; font-weight: 500; transition: opacity 0.2s; font-size: 0.875rem; }
|
||||||
.btn:hover { opacity: 0.9; }
|
.btn:hover { opacity: 0.9; text-decoration: none !important; }
|
||||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
|
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
|
||||||
.btn-secondary { background: var(--text-light); }
|
.btn-secondary { background: var(--text-light); }
|
||||||
.btn-danger { background: #EF4444; }
|
.btn-danger { background: #EF4444; }
|
||||||
|
|
||||||
.form-group { margin-bottom: 1rem; }
|
.form-group { margin-bottom: 1rem; }
|
||||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
|
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
|
||||||
.form-control { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; font-family: inherit; background: var(--input-bg); color: var(--text); }
|
.form-control { width: 100%; padding: 0.65rem 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; font-family: inherit; background: var(--input-bg); color: var(--text); font-size: 0.9rem; }
|
||||||
|
.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(var(--primary-rgb),0.12); }
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
table { width: 100%; border-collapse: collapse; margin-top: 1rem; font-size: 0.875rem; }
|
||||||
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
|
th, td { padding: 0.65rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
th { color: var(--text-light); font-weight: 500; }
|
th { color: var(--text-light); font-weight: 600; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(0,0,0,0.02); }
|
||||||
|
body.dark-mode th { background: rgba(255,255,255,0.02); }
|
||||||
|
tr:hover td { background: rgba(var(--primary-rgb),0.03); }
|
||||||
|
|
||||||
.kanban-board { display: flex; gap: 1.5rem; overflow-x: auto; padding-bottom: 1rem; }
|
.kanban-board { display: flex; gap: 1.5rem; overflow-x: auto; padding-bottom: 1rem; }
|
||||||
.kanban-col { background: rgba(0,0,0,0.03); border-radius: 1rem; width: 320px; min-width: 320px; display: flex; flex-direction: column; }
|
.kanban-col { background: rgba(0,0,0,0.03); border-radius: 1rem; width: 320px; min-width: 320px; display: flex; flex-direction: column; }
|
||||||
@@ -62,22 +116,214 @@ body.dark-mode .kanban-col { background: rgba(255,255,255,0.03); }
|
|||||||
.kanban-task { background: var(--surface); padding: 1rem; border-radius: 0.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid var(--border); cursor: grab; }
|
.kanban-task { background: var(--surface); padding: 1rem; border-radius: 0.5rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); border: 1px solid var(--border); cursor: grab; }
|
||||||
.kanban-task:active { cursor: grabbing; }
|
.kanban-task:active { cursor: grabbing; }
|
||||||
|
|
||||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
|
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; pointer-events: none; transition: opacity 0.2s; backdrop-filter: blur(3px); }
|
||||||
.modal-overlay.active { opacity: 1; pointer-events: auto; }
|
.modal-overlay.active { opacity: 1; pointer-events: auto; }
|
||||||
.modal-content { background: var(--surface); padding: 2rem; border-radius: 1rem; width: 100%; max-width: 400px; transform: translateY(-20px); transition: transform 0.2s; }
|
.modal-content { background: var(--surface); padding: 2rem; border-radius: 1rem; width: 100%; max-width: 500px; transform: translateY(-20px); transition: transform 0.2s; box-shadow: 0 25px 50px rgba(0,0,0,0.3); }
|
||||||
.modal-overlay.active .modal-content { transform: translateY(0); }
|
.modal-overlay.active .modal-content { transform: translateY(0); }
|
||||||
|
|
||||||
#toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--surface); padding: 1rem; border-radius: 0.5rem; z-index: 9999; transform: translateY(100px); opacity: 0; transition: all 0.3s; }
|
#toast { position: fixed; bottom: 20px; right: 20px; background: var(--text); color: var(--surface); padding: 1rem 1.25rem; border-radius: 0.5rem; z-index: 9999; transform: translateY(100px); opacity: 0; transition: all 0.3s; font-size: 0.875rem; }
|
||||||
#toast.show { transform: translateY(0); opacity: 1; }
|
#toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
|
||||||
.dropzone { border: 2px dashed var(--border); padding: 3rem; text-align: center; border-radius: 1rem; cursor: pointer; transition: background 0.2s; }
|
.dropzone { border: 2px dashed var(--border); padding: 2rem; text-align: center; border-radius: 1rem; cursor: pointer; transition: background 0.2s; color: var(--text-light); }
|
||||||
.dropzone.dragover { background: rgba(var(--primary-rgb), 0.1); border-color: var(--primary); }
|
.dropzone.dragover { background: rgba(var(--primary-rgb), 0.1); border-color: var(--primary); }
|
||||||
|
|
||||||
.project-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; border-bottom: 1px solid var(--border); }
|
.project-item { display: flex; justify-content: space-between; align-items: center; padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); transition: background 0.15s; }
|
||||||
.project-item:hover { background: rgba(0,0,0,0.02); }
|
.project-item:hover { background: rgba(var(--primary-rgb),0.04); }
|
||||||
body.dark-mode .project-item:hover { background: rgba(255,255,255,0.02); }
|
body.dark-mode .project-item:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
.login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #4F46E5 0%, #10B981 100%); }
|
.login-container { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #4F46E5 0%, #10B981 100%); }
|
||||||
.login-card { background: var(--surface); padding: 2.5rem; border-radius: 1rem; width: 100%; max-width: 400px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); }
|
.login-card { background: var(--surface); padding: 2.5rem; border-radius: 1rem; width: 100%; max-width: 400px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); }
|
||||||
.login-card h1 { text-align: center; margin-bottom: 1.5rem; color: var(--text); }
|
.login-card h1 { text-align: center; margin-bottom: 1.5rem; color: var(--text); }
|
||||||
.alert { padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; background: #FEF2F2; color: #991B1B; }
|
.alert { padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; background: #FEF2F2; color: #991B1B; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
/* ── User Avatar Dropdown ─────────────────────────────────────────────────── */
|
||||||
|
.user-dropdown-wrap { position: relative; }
|
||||||
|
.user-avatar-btn {
|
||||||
|
width: 40px; height: 40px; border-radius: 50%;
|
||||||
|
background: var(--primary); color: #fff;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1rem; font-weight: 700; overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s, border-color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.user-avatar-btn:hover { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(var(--primary-rgb),0.2); }
|
||||||
|
/* Notification count badge overlaid on avatar */
|
||||||
|
.avatar-notif-badge {
|
||||||
|
position: absolute; top: -3px; right: -3px;
|
||||||
|
background: #EF4444; color: #fff; font-size: 0.6rem; font-weight: 700;
|
||||||
|
min-width: 17px; height: 17px; border-radius: 10px; border: 2px solid var(--bg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; z-index: 10; padding: 0 3px; line-height: 1;
|
||||||
|
animation: badge-pop 0.3s cubic-bezier(.17,.67,.83,.67);
|
||||||
|
}
|
||||||
|
@keyframes badge-pop { 0%{transform:scale(0)}70%{transform:scale(1.2)}100%{transform:scale(1)} }
|
||||||
|
.user-dropdown-menu {
|
||||||
|
display: none; position: absolute; top: calc(100% + 8px); right: 0;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.15); min-width: 200px; z-index: 500;
|
||||||
|
overflow: hidden; animation: dropdown-in 0.15s ease;
|
||||||
|
}
|
||||||
|
.user-dropdown-menu.open { display: block; }
|
||||||
|
@keyframes dropdown-in { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
.user-dropdown-header { padding: 0.9rem 1rem; background: rgba(var(--primary-rgb),0.06); }
|
||||||
|
.user-dropdown-name { font-weight: 700; font-size: 0.9rem; color: var(--text); }
|
||||||
|
.user-dropdown-role { font-size: 0.75rem; color: var(--text-light); margin-top: 2px; }
|
||||||
|
.user-dropdown-divider { height: 1px; background: var(--border); margin: 0; }
|
||||||
|
.user-dropdown-item {
|
||||||
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
|
padding: 0.65rem 1rem; width: 100%; border: none;
|
||||||
|
background: none; color: var(--text); font-size: 0.875rem;
|
||||||
|
cursor: pointer; text-decoration: none !important; font-family: inherit;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.user-dropdown-item:hover { background: rgba(var(--primary-rgb),0.07); color: var(--primary); }
|
||||||
|
.user-dropdown-item i { width: 16px; text-align: center; color: var(--text-light); }
|
||||||
|
.user-dropdown-item:hover i { color: var(--primary); }
|
||||||
|
.user-dropdown-logout { color: #EF4444 !important; }
|
||||||
|
.user-dropdown-logout:hover { background: rgba(239,68,68,0.07) !important; }
|
||||||
|
.user-dropdown-logout i { color: #EF4444 !important; }
|
||||||
|
|
||||||
|
/* ── Activity log scrollbar fix ────────────────────────────────────────── */
|
||||||
|
.log-list {
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
overflow-y: auto; flex: 1; max-height: 360px;
|
||||||
|
padding-right: 8px; /* prevent scrollbar from eating time */
|
||||||
|
scrollbar-gutter: stable; /* modern browsers reserve scrollbar space */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
.log-list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.log-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
.log-list::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ── Notes / Google Keep Style ───────────────────────────────────────────── */
|
||||||
|
.notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; }
|
||||||
|
.note-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem; padding: 1rem; cursor: pointer;
|
||||||
|
transition: box-shadow 0.2s, transform 0.15s; position: relative;
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem; min-height: 120px;
|
||||||
|
}
|
||||||
|
.note-card:hover { box-shadow: 0 8px 20px rgba(0,0,0,0.12); transform: translateY(-2px); }
|
||||||
|
.note-card-title { font-weight: 600; font-size: 0.95rem; color: var(--text); word-break: break-word; }
|
||||||
|
.note-card-preview { font-size: 0.8rem; color: var(--text-light); overflow: hidden; max-height: 80px; line-height: 1.5; word-break: break-word; }
|
||||||
|
.note-card-img { width: 100%; height: 120px; object-fit: cover; border-radius: 0.5rem; margin-bottom: 0.25rem; }
|
||||||
|
.note-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 0.5rem; border-top: 1px solid var(--border); gap: 0.5rem; }
|
||||||
|
.note-card-actions { display: none; gap: 0.25rem; }
|
||||||
|
.note-card:hover .note-card-actions { display: flex; }
|
||||||
|
.note-color-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.note-card-shared-badge { background: #10B981; color: #fff; font-size: 0.65rem; padding: 2px 6px; border-radius: 10px; font-weight: 600; letter-spacing: 0.03em; }
|
||||||
|
|
||||||
|
/* Note editor toolbar */
|
||||||
|
.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: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:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(var(--primary-rgb),0.1); }
|
||||||
|
|
||||||
|
/* Note color palette */
|
||||||
|
.note-color-palette { display: flex; gap: 0.4rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||||
|
.color-swatch { width: 24px; height: 24px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: transform 0.15s, border-color 0.15s; }
|
||||||
|
.color-swatch:hover, .color-swatch.selected { transform: scale(1.2); border-color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Passwords Vault / Bitwarden Style ───────────────────────────────────── */
|
||||||
|
.vault-layout { display: grid; grid-template-columns: 220px 1fr; gap: 0; min-height: 500px; }
|
||||||
|
.vault-sidebar {
|
||||||
|
background: rgba(var(--primary-rgb),0.05); border-right: 1px solid var(--border);
|
||||||
|
border-radius: 1rem 0 0 1rem; padding: 1rem; display: flex; flex-direction: column; gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.vault-folder-item {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.65rem;
|
||||||
|
border-radius: 0.5rem; cursor: pointer; font-size: 0.875rem; color: var(--text);
|
||||||
|
border: none; background: none; width: 100%; text-align: left; transition: background 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.vault-folder-item:hover, .vault-folder-item.active { background: rgba(var(--primary-rgb),0.12); color: var(--primary); font-weight: 600; }
|
||||||
|
.vault-folder-item i { width: 16px; color: var(--text-light); }
|
||||||
|
.vault-folder-item.active i { color: var(--primary); }
|
||||||
|
.vault-main { padding: 1.5rem; }
|
||||||
|
.vault-search { display: flex; align-items:center; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.vault-entry { display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-radius: 0.5rem; border: 1px solid transparent; transition: background 0.15s, border-color 0.15s; cursor: pointer; }
|
||||||
|
.vault-entry:hover { background: rgba(var(--primary-rgb),0.04); border-color: var(--border); }
|
||||||
|
.vault-entry-icon { width: 36px; height: 36px; border-radius: 8px; background: rgba(var(--primary-rgb),0.12); color: var(--primary); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; flex-shrink: 0; overflow: hidden; }
|
||||||
|
.vault-entry-icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.vault-entry-info { flex: 1; min-width: 0; }
|
||||||
|
.vault-entry-title { font-weight: 600; font-size: 0.9rem; color: var(--text); }
|
||||||
|
.vault-entry-user { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.vault-entry-actions { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.vault-entry:hover .vault-entry-actions { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── Projects / Windows Explorer Detail View ─────────────────────────────── */
|
||||||
|
.explorer-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; padding: 0.5rem; background: var(--surface); border: 1px solid var(--border); border-radius: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.explorer-path { display: flex; align-items: center; gap: 0.25rem; flex: 1; min-width: 0; font-size: 0.875rem; }
|
||||||
|
.explorer-path-part { color: var(--text-light); }
|
||||||
|
.explorer-path-part.current { color: var(--text); font-weight: 600; }
|
||||||
|
.explorer-path-sep { color: var(--text-light); font-size: 0.75rem; margin: 0 2px; }
|
||||||
|
|
||||||
|
.explorer-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.explorer-table thead tr { background: rgba(var(--primary-rgb),0.05); }
|
||||||
|
.explorer-table th { padding: 0.55rem 0.75rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); font-weight: 600; cursor: pointer; user-select: none; border-bottom: 2px solid var(--border); white-space: nowrap; }
|
||||||
|
.explorer-table th:hover { color: var(--primary); }
|
||||||
|
.explorer-table td { padding: 0.55rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.875rem; vertical-align: middle; }
|
||||||
|
.explorer-row { transition: background 0.12s; }
|
||||||
|
.explorer-row:hover { background: rgba(var(--primary-rgb),0.04); }
|
||||||
|
.explorer-row.selected { background: rgba(var(--primary-rgb),0.1); }
|
||||||
|
.explorer-icon { font-size: 1rem; width: 22px; display: inline-block; text-align: center; }
|
||||||
|
.explorer-name { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.explorer-name a { font-weight: 500; color: var(--text); }
|
||||||
|
.explorer-name a:hover { color: var(--primary); text-decoration: none; }
|
||||||
|
.explorer-actions { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.15s; }
|
||||||
|
.explorer-row:hover .explorer-actions { opacity: 1; }
|
||||||
|
.explorer-status-bar { display: flex; gap: 1rem; align-items: center; padding: 0.4rem 0.75rem; background: var(--bg); border: 1px solid var(--border); border-radius: 0.5rem; font-size: 0.78rem; color: var(--text-light); margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
/* ── Admin Panel ─────────────────────────────────────────────────────────── */
|
||||||
|
.admin-tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 1.5rem; overflow-x: auto; }
|
||||||
|
.admin-tab { padding: 0.65rem 1.25rem; border: none; background: none; cursor: pointer; font-size: 0.875rem; font-weight: 600; color: var(--text-light); font-family: inherit; transition: color 0.15s; border-bottom: 2px solid transparent; margin-bottom: -2px; white-space: nowrap; }
|
||||||
|
.admin-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||||
|
.admin-tab-pane { display: none; }
|
||||||
|
.admin-tab-pane.active { display: block; }
|
||||||
|
.admin-user-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
|
||||||
|
.admin-user-card { background: var(--bg); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1rem; }
|
||||||
|
.admin-user-card-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||||
|
.admin-user-avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1rem; overflow: hidden; flex-shrink: 0; }
|
||||||
|
.admin-badge-admin { background: rgba(79,70,229,0.1); color: var(--primary); font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; font-weight: 700; letter-spacing: 0.04em; }
|
||||||
|
.admin-badge-user { background: rgba(107,114,128,0.1); color: var(--text-light); font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Misc utils ──────────────────────────────────────────────────────────── */
|
||||||
|
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.badge { background: #EF4444; color: white; padding: 2px 6px; border-radius: 12px; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
/* ── Dashboard stat card (also used in admin) ────────────────────────────── */
|
||||||
|
.db-stat-card {
|
||||||
|
background: var(--surface); border-radius: 1rem; padding: 1.25rem 1.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.07); border: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
text-decoration: none !important; color: var(--text) !important;
|
||||||
|
}
|
||||||
|
.db-stat-card:hover { transform: translateY(-3px); box-shadow: 0 8px 15px -3px rgba(0,0,0,0.1); }
|
||||||
|
|
||||||
|
/* ── Activity Log (shared) ───────────────────────────────────────────────── */
|
||||||
|
.log-item { display: flex; gap: 0.75rem; align-items: flex-start; font-size: 0.8rem; padding: 0.35rem 0; }
|
||||||
|
.log-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.log-body { flex: 1; min-width: 0; }
|
||||||
|
.log-body strong { color: var(--text); }
|
||||||
|
.log-body .log-detail { color: var(--text-light); }
|
||||||
|
.log-time { font-size: 0.7rem; color: var(--text-light); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── Markdown body styles ────────────────────────────────────────────────── */
|
||||||
|
.markdown-body h1,.markdown-body h2,.markdown-body h3 { margin: 0.75rem 0 0.4rem; font-weight: 700; color: var(--text); }
|
||||||
|
.markdown-body h1 { font-size: 1.3rem; }
|
||||||
|
.markdown-body h2 { font-size: 1.1rem; }
|
||||||
|
.markdown-body h3 { font-size: 1rem; }
|
||||||
|
.markdown-body p { margin: 0.4rem 0; line-height: 1.6; }
|
||||||
|
.markdown-body ul,.markdown-body ol { padding-left: 1.5rem; margin: 0.4rem 0; }
|
||||||
|
.markdown-body li { margin: 0.2rem 0; }
|
||||||
|
.markdown-body code { background: rgba(var(--primary-rgb),0.1); padding: 2px 5px; border-radius: 4px; font-size: 0.85em; font-family: monospace; }
|
||||||
|
.markdown-body pre { background: rgba(0,0,0,0.07); padding: 0.75rem; border-radius: 6px; overflow-x: auto; }
|
||||||
|
.markdown-body pre code { background: none; padding: 0; }
|
||||||
|
body.dark-mode .markdown-body pre { background: rgba(255,255,255,0.05); }
|
||||||
|
.markdown-body blockquote { border-left: 3px solid var(--primary); padding-left: 0.75rem; color: var(--text-light); margin: 0.5rem 0; }
|
||||||
|
.markdown-body hr { border: none; border-top: 1px solid var(--border); margin: 0.75rem 0; }
|
||||||
|
.markdown-body a { color: var(--primary); }
|
||||||
|
|||||||
513
views/admin.php
513
views/admin.php
@@ -1,132 +1,412 @@
|
|||||||
<?php
|
<?php
|
||||||
if(empty($_SESSION['is_admin'])) die("Access Denied");
|
if(empty($_SESSION['is_admin'])) die("Access Denied");
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
// ── DB Migrations ─────────────────────────────────────────────────────────────
|
||||||
if(isset($_POST['create_user'])) {
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
$username = $_POST['username'];
|
|
||||||
$pass = password_hash($_POST['password'], PASSWORD_DEFAULT);
|
|
||||||
$is_admin = isset($_POST['is_admin']) ? 1 : 0;
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)");
|
|
||||||
$stmt->execute([$username, $pass, $is_admin]);
|
|
||||||
}
|
|
||||||
if(isset($_POST['create_group'])) {
|
|
||||||
$stmt = $pdo->prepare("INSERT INTO groups (name) VALUES (?)");
|
|
||||||
$stmt->execute([$_POST['group_name']]);
|
|
||||||
}
|
|
||||||
if(isset($_POST['assign_group'])) {
|
|
||||||
$stmt = $pdo->prepare("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)");
|
|
||||||
$stmt->execute([$_POST['user_id'], $_POST['group_id']]);
|
|
||||||
}
|
|
||||||
if(isset($_POST['update_user_channels'])) {
|
|
||||||
$ch = $_POST['channels'] ?? [];
|
|
||||||
$ch_str = is_array($ch) ? implode(',', $ch) : '';
|
|
||||||
$stmt = $pdo->prepare("UPDATE users SET notify_channels=?, telegram_id=?, email=?, whatsapp_number=? WHERE id=?");
|
|
||||||
$stmt->execute([$ch_str, $_POST['telegram_id'], $_POST['email'], $_POST['whatsapp_number'], $_POST['user_id']]);
|
|
||||||
echo "<div class='alert' style='background:#10B981; color:white;'>User preferences updated!</div>";
|
|
||||||
}
|
|
||||||
if(isset($_POST['update_sys_config'])) {
|
|
||||||
$stmt = $pdo->prepare("UPDATE system_config SET v=? WHERE k=?");
|
|
||||||
$stmt->execute([$_POST['completed_days'], 'completed_days']);
|
|
||||||
$stmt->execute([$_POST['archived_days'], 'archived_days']);
|
|
||||||
echo "<div class='alert' style='background:#10B981; color:white;'>System Kanban Configuration updated!</div>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$users = query("SELECT id, username, is_admin, notify_channels, telegram_id, email, whatsapp_number FROM users")->fetchAll();
|
|
||||||
$all_groups = query("SELECT * FROM groups")->fetchAll();
|
|
||||||
|
|
||||||
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')");
|
||||||
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('archived_days', '90')");
|
$pdo->exec("INSERT OR IGNORE INTO system_config (k, v) VALUES ('archived_days', '90')");
|
||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
|
// ── Handle POST ───────────────────────────────────────────────────────────────
|
||||||
|
$success_msg = '';
|
||||||
|
$error_msg = '';
|
||||||
|
|
||||||
|
if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if(isset($_POST['create_user'])) {
|
||||||
|
$username = trim($_POST['username']);
|
||||||
|
$pass = password_hash($_POST['password'], PASSWORD_DEFAULT);
|
||||||
|
$is_admin = isset($_POST['is_admin']) ? 1 : 0;
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)");
|
||||||
|
$stmt->execute([$username, $pass, $is_admin]);
|
||||||
|
$success_msg = "User '{$username}' created successfully!";
|
||||||
|
} catch(Exception $e) {
|
||||||
|
$error_msg = "Error: Username may already exist.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['delete_user'])) {
|
||||||
|
$del_id = (int)$_POST['del_user_id'];
|
||||||
|
if($del_id != $user_id) {
|
||||||
|
$pdo->prepare("DELETE FROM users WHERE id=?")->execute([$del_id]);
|
||||||
|
$success_msg = "User deleted.";
|
||||||
|
} else {
|
||||||
|
$error_msg = "You cannot delete your own account.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['toggle_admin'])) {
|
||||||
|
$tid = (int)$_POST['toggle_user_id'];
|
||||||
|
$newVal = (int)$_POST['new_admin_val'];
|
||||||
|
if($tid != $user_id) {
|
||||||
|
$pdo->prepare("UPDATE users SET is_admin=? WHERE id=?")->execute([$newVal, $tid]);
|
||||||
|
$success_msg = "User admin status updated.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['create_group'])) {
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO groups (name) VALUES (?)");
|
||||||
|
$stmt->execute([$_POST['group_name']]);
|
||||||
|
$success_msg = "Group '{$_POST['group_name']}' created!";
|
||||||
|
} catch(Exception $e) {
|
||||||
|
$error_msg = "Group name may already exist.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['delete_group'])) {
|
||||||
|
$gid = (int)$_POST['del_group_id'];
|
||||||
|
$pdo->prepare("DELETE FROM groups WHERE id=?")->execute([$gid]);
|
||||||
|
$success_msg = "Group deleted.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['assign_group'])) {
|
||||||
|
$stmt = $pdo->prepare("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$_POST['user_id'], $_POST['group_id']]);
|
||||||
|
$success_msg = "User assigned to group.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['remove_from_group'])) {
|
||||||
|
$pdo->prepare("DELETE FROM user_groups WHERE user_id=? AND group_id=?")->execute([$_POST['user_id'], $_POST['group_id']]);
|
||||||
|
$success_msg = "User removed from group.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['update_user_channels'])) {
|
||||||
|
$ch = $_POST['channels'] ?? [];
|
||||||
|
$ch_str = is_array($ch) ? implode(',', $ch) : '';
|
||||||
|
$stmt = $pdo->prepare("UPDATE users SET notify_channels=?, telegram_id=?, email=?, whatsapp_number=? WHERE id=?");
|
||||||
|
$stmt->execute([$ch_str, $_POST['telegram_id'], $_POST['email'], $_POST['whatsapp_number'], $_POST['user_id']]);
|
||||||
|
$success_msg = "User notification preferences updated!";
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['update_sys_config'])) {
|
||||||
|
$stmt = $pdo->prepare("UPDATE system_config SET v=? WHERE k=?");
|
||||||
|
$stmt->execute([$_POST['completed_days'], 'completed_days']);
|
||||||
|
$stmt->execute([$_POST['archived_days'], 'archived_days']);
|
||||||
|
$success_msg = "System configuration saved!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
|
$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;
|
||||||
|
|
||||||
|
// Activity stats for admin
|
||||||
|
$total_users = count($users);
|
||||||
|
$total_groups = count($all_groups);
|
||||||
|
$total_tasks = query("SELECT COUNT(*) FROM tasks")->fetchColumn();
|
||||||
|
$total_notes = 0; try { $total_notes = query("SELECT COUNT(*) FROM notes")->fetchColumn(); } catch(Exception $e) {}
|
||||||
|
$total_passwords = query("SELECT COUNT(*) FROM passwords")->fetchColumn();
|
||||||
|
$total_projects = query("SELECT COUNT(*) FROM projects")->fetchColumn();
|
||||||
|
|
||||||
|
// Activity log (recent, all users)
|
||||||
|
$recent_activity = [];
|
||||||
|
try {
|
||||||
|
$recent_activity = $pdo->query("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 20")->fetchAll();
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
|
$active_tab = $_GET['tab'] ?? 'overview';
|
||||||
?>
|
?>
|
||||||
<div class="header">
|
|
||||||
<h1>Admin Panel</h1>
|
<div class="header" style="margin-bottom:1rem;">
|
||||||
|
<h1 style="font-size:1.5rem; font-weight:700;"><i class="fa fa-shield-halved" style="color:var(--primary);"></i> Admin Panel</h1>
|
||||||
|
<span style="font-size:0.8rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:4px 12px; border-radius:20px; font-weight:700;">ADMIN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:1.5rem; margin-bottom: 2rem;">
|
<?php if($success_msg): ?>
|
||||||
<div class="card">
|
<div class="alert" style="background:rgba(16,185,129,0.12); color:#10B981; border:1px solid rgba(16,185,129,0.3); margin-bottom:1rem;"><?= htmlspecialchars($success_msg) ?></div>
|
||||||
<h3>Create User</h3>
|
<?php endif; ?>
|
||||||
<form method="POST" style="margin-top:1rem;">
|
<?php if($error_msg): ?>
|
||||||
<div class="form-group"><input type="text" name="username" class="form-control" placeholder="Username" required></div>
|
<div class="alert" style="margin-bottom:1rem;"><?= htmlspecialchars($error_msg) ?></div>
|
||||||
<div class="form-group"><input type="password" name="password" class="form-control" placeholder="Password" required></div>
|
<?php endif; ?>
|
||||||
<div class="form-group">
|
|
||||||
<label><input type="checkbox" name="is_admin"> Admin Privileges</label>
|
<!-- ── Tabs ──────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button class="admin-tab <?= $active_tab==='overview'?'active':'' ?>" onclick="switchTab('overview')"><i class="fa fa-chart-line"></i> Overview</button>
|
||||||
|
<button class="admin-tab <?= $active_tab==='users'?'active':'' ?>" onclick="switchTab('users')"><i class="fa fa-users"></i> Users</button>
|
||||||
|
<button class="admin-tab <?= $active_tab==='groups'?'active':'' ?>" onclick="switchTab('groups')"><i class="fa fa-people-group"></i> Groups</button>
|
||||||
|
<button class="admin-tab <?= $active_tab==='channels'?'active':'' ?>" onclick="switchTab('channels')"><i class="fa fa-bell"></i> Notifications</button>
|
||||||
|
<button class="admin-tab <?= $active_tab==='config'?'active':'' ?>" onclick="switchTab('config')"><i class="fa fa-gear"></i> System Config</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Overview Tab ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tab-pane <?= $active_tab==='overview'?'active':'' ?>" id="tab-overview">
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1rem; margin-bottom:1.5rem;">
|
||||||
|
<?php
|
||||||
|
$stats = [
|
||||||
|
['👤','Users',$total_users,'var(--primary)'],
|
||||||
|
['👥','Groups',$total_groups,'#10B981'],
|
||||||
|
['✅','Tasks',$total_tasks,'#F59E0B'],
|
||||||
|
['📝','Notes',$total_notes,'#8B5CF6'],
|
||||||
|
['🔐','Passwords',$total_passwords,'#EF4444'],
|
||||||
|
['📁','Projects',$total_projects,'#06B6D4'],
|
||||||
|
];
|
||||||
|
foreach($stats as [$icon,$label,$val,$col]):
|
||||||
|
?>
|
||||||
|
<div class="db-stat-card" style="border-top:3px solid <?= $col ?>; gap:0.35rem; padding:1rem;">
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-light); font-weight:600; text-transform:uppercase;"><?= $label ?></div>
|
||||||
|
<div style="font-size:1.8rem; font-weight:700; color:<?= $col ?>;"><?= $icon ?> <?= $val ?></div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="create_user" class="btn">Create User</button>
|
<?php endforeach; ?>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Create Group & Assign Role</h3>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;">🕓 Recent Platform Activity</h3>
|
||||||
<form method="POST" style="margin-top:1rem; margin-bottom:2rem;">
|
<?php if(empty($recent_activity)): ?>
|
||||||
<div style="display:flex; gap:0.5rem;">
|
<p style="color:var(--text-light); font-size:0.85rem;">No activity recorded yet.</p>
|
||||||
<input type="text" name="group_name" class="form-control" placeholder="Group Name" required>
|
<?php else: ?>
|
||||||
<button type="submit" name="create_group" class="btn">Create Group</button>
|
<div style="display:flex; flex-direction:column; gap:0.5rem; max-height:320px; overflow-y:auto;">
|
||||||
|
<?php
|
||||||
|
$aic = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6'];
|
||||||
|
foreach($recent_activity as $log):
|
||||||
|
$t = strtotime($log['created_at']);
|
||||||
|
$diff = time()-$t;
|
||||||
|
$ago = $diff<60?'just now':($diff<3600?floor($diff/60).'m ago':($diff<86400?floor($diff/3600).'h ago':floor($diff/86400).'d ago'));
|
||||||
|
$col = $aic[crc32($log['username']??'')%count($aic)];
|
||||||
|
$init = strtoupper(mb_substr($log['username']??'S',0,1));
|
||||||
|
?>
|
||||||
|
<div class="log-item">
|
||||||
|
<div class="log-avatar" style="background:<?= $col ?>; flex-shrink:0;"><?= $init ?></div>
|
||||||
|
<div class="log-body">
|
||||||
|
<strong><?= htmlspecialchars($log['username']??'System') ?></strong>
|
||||||
|
<span class="log-detail"> · <?= htmlspecialchars($log['action']) ?><?= $log['detail']?' — '.htmlspecialchars(mb_substr($log['detail'],0,40)):'' ?></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div class="log-time"><?= $ago ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Users Tab ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tab-pane <?= $active_tab==='users'?'active':'' ?>" id="tab-users">
|
||||||
|
<div style="display:grid; grid-template-columns:340px 1fr; gap:1.5rem; align-items:start;">
|
||||||
|
<!-- Create User Form -->
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-user-plus"></i> Create New User</h3>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div style="display:flex; gap:0.5rem; align-items:center;">
|
<div class="form-group">
|
||||||
<select name="user_id" class="form-control" required style="width:130px;">
|
<label>Username</label>
|
||||||
<option value="">User...</option>
|
<input type="text" name="username" class="form-control" placeholder="Enter username" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Set password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex; align-items:center; gap:0.5rem;">
|
||||||
|
<input type="checkbox" name="is_admin" id="new-user-admin" style="width:auto;">
|
||||||
|
<label for="new-user-admin" style="margin:0; font-weight:500;">Grant Admin Privileges</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="create_user" class="btn" style="width:100%;">
|
||||||
|
<i class="fa fa-plus"></i> Create User
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Cards -->
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-users"></i> All Users (<?= $total_users ?>)</h3>
|
||||||
|
<div class="admin-user-grid">
|
||||||
|
<?php foreach($users as $u):
|
||||||
|
$pic = $u['profile_pic'] ?? null;
|
||||||
|
$groups_of_u = query("SELECT g.name FROM groups g JOIN user_groups ug ON g.id=ug.group_id WHERE ug.user_id=?", [$u['id']])->fetchAll();
|
||||||
|
?>
|
||||||
|
<div class="admin-user-card">
|
||||||
|
<div class="admin-user-card-header">
|
||||||
|
<div class="admin-user-avatar">
|
||||||
|
<?php if($pic && file_exists($pic)): ?>
|
||||||
|
<img src="<?= htmlspecialchars($pic) ?>" style="width:100%;height:100%;object-fit:cover;border-radius:50%;" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= strtoupper(mb_substr($u['username'],0,1)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-weight:700; font-size:0.9rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"><?= htmlspecialchars($u['username']) ?></div>
|
||||||
|
<div style="margin-top:2px;">
|
||||||
|
<span class="<?= $u['is_admin'] ? 'admin-badge-admin' : 'admin-badge-user' ?>">
|
||||||
|
<?= $u['is_admin'] ? 'Admin' : 'User' ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-light); margin-bottom:0.75rem;">
|
||||||
|
<?php if($groups_of_u): ?>
|
||||||
|
<i class="fa fa-users" style="margin-right:4px;"></i>
|
||||||
|
<?= implode(', ', array_map(fn($g)=>htmlspecialchars($g['name']), $groups_of_u)) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="font-style:italic;">No groups</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="display:flex; gap:0.4rem; flex-wrap:wrap;">
|
||||||
|
<?php if($u['id'] != $user_id): ?>
|
||||||
|
<!-- Toggle admin -->
|
||||||
|
<form method="POST" style="display:inline;">
|
||||||
|
<input type="hidden" name="toggle_user_id" value="<?= $u['id'] ?>">
|
||||||
|
<input type="hidden" name="new_admin_val" value="<?= $u['is_admin']?0:1 ?>">
|
||||||
|
<button type="submit" name="toggle_admin" class="btn btn-sm btn-secondary" title="<?= $u['is_admin']?'Revoke Admin':'Grant Admin' ?>">
|
||||||
|
<i class="fa fa-<?= $u['is_admin']?'user-minus':'user-shield' ?>"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<!-- Delete -->
|
||||||
|
<form method="POST" style="display:inline;" onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>?')">
|
||||||
|
<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">
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="font-size:0.75rem; color:var(--primary); font-style:italic; padding:4px 0;">(You)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Groups Tab ────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tab-pane <?= $active_tab==='groups'?'active':'' ?>" id="tab-groups">
|
||||||
|
<div style="display:grid; grid-template-columns:340px 1fr; gap:1.5rem; align-items:start;">
|
||||||
|
<!-- Create Group + Assign -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-folder-plus"></i> Create Group</h3>
|
||||||
|
<form method="POST" style="display:flex; gap:0.5rem;">
|
||||||
|
<input type="text" name="group_name" class="form-control" placeholder="Group name" required>
|
||||||
|
<button type="submit" name="create_group" class="btn btn-sm"><i class="fa fa-plus"></i> Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-user-plus"></i> Assign User to Group</h3>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>User</label>
|
||||||
|
<select name="user_id" class="form-control" required>
|
||||||
|
<option value="">Select user...</option>
|
||||||
<?php foreach($users as $u): ?><option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
|
<?php foreach($users as $u): ?><option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<select name="group_id" class="form-control" required style="width:130px;">
|
</div>
|
||||||
<option value="">Group...</option>
|
<div class="form-group">
|
||||||
|
<label>Group</label>
|
||||||
|
<select name="group_id" class="form-control" required>
|
||||||
|
<option value="">Select group...</option>
|
||||||
<?php foreach($all_groups as $g): ?><option value="<?= $g['id'] ?>"><?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
|
<?php foreach($all_groups as $g): ?><option value="<?= $g['id'] ?>"><?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" name="assign_group" class="btn">Assign</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" name="assign_group" class="btn" style="width:100%;"><i class="fa fa-link"></i> Assign</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="grid-column: span 2;">
|
<!-- Groups Breakdown -->
|
||||||
<h3>System Operations & Configurations</h3>
|
<div>
|
||||||
<p style="margin-top:0.5rem; color:var(--text-light);">Modify the global lifecycle durations for Kanban Auto-Archiving / Auto-Deleting processes.</p>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:1rem;"><i class="fa fa-people-group"></i> All Groups (<?= $total_groups ?>)</h3>
|
||||||
<form method="POST" style="margin-top:1.5rem; display:flex; gap:1.5rem; align-items:flex-end;">
|
<?php if(!$all_groups): ?>
|
||||||
<div class="form-group" style="margin:0;">
|
<p style="color:var(--text-light);">No groups created yet.</p>
|
||||||
<label>Completed tasks retain for (Days)</label>
|
<?php endif; ?>
|
||||||
<input type="number" name="completed_days" value="<?= htmlspecialchars($conf_comp) ?>" class="form-control" style="width:200px;">
|
<div style="display:flex; flex-direction:column; gap:1rem;">
|
||||||
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 7 Days (Before auto-moving to Archive)</div>
|
<?php foreach($all_groups as $g):
|
||||||
|
$stmt = $pdo->prepare("SELECT u.id, u.username FROM users u JOIN user_groups ug ON u.id=ug.user_id WHERE ug.group_id=?");
|
||||||
|
$stmt->execute([$g['id']]);
|
||||||
|
$g_users = $stmt->fetchAll();
|
||||||
|
?>
|
||||||
|
<div class="card" style="margin-bottom:0;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700; font-size:1rem; color:var(--primary);">
|
||||||
|
<i class="fa fa-people-group"></i> <?= htmlspecialchars($g['name']) ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin:0;">
|
<div style="font-size:0.75rem; color:var(--text-light);"><?= count($g_users) ?> member<?= count($g_users)!=1?'s':'' ?></div>
|
||||||
<label>Archived tasks retain for (Days)</label>
|
|
||||||
<input type="number" name="archived_days" value="<?= htmlspecialchars($conf_arch) ?>" class="form-control" style="width:200px;">
|
|
||||||
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 90 Days (Before permanent deletion)</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="update_sys_config" class="btn">Save Database Config</button>
|
<form method="POST" onsubmit="return confirm('Delete group <?= htmlspecialchars(addslashes($g['name'])) ?>?')">
|
||||||
|
<input type="hidden" name="del_group_id" value="<?= $g['id'] ?>">
|
||||||
|
<button type="submit" name="delete_group" class="btn btn-sm btn-danger"><i class="fa fa-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if($g_users): ?>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem;">
|
||||||
|
<?php foreach($g_users as $gu): ?>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.35rem; background:rgba(var(--primary-rgb),0.07); border-radius:20px; padding:4px 10px;">
|
||||||
|
<span style="font-size:0.8rem; font-weight:600;"><?= htmlspecialchars($gu['username']) ?></span>
|
||||||
|
<form method="POST" style="display:inline;">
|
||||||
|
<input type="hidden" name="user_id" value="<?= $gu['id'] ?>">
|
||||||
|
<input type="hidden" name="group_id" value="<?= $g['id'] ?>">
|
||||||
|
<button type="submit" name="remove_from_group" class="btn btn-sm btn-danger" style="padding:1px 5px; font-size:0.7rem; border-radius:10px;" title="Remove from group">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p style="color:var(--text-light); font-size:0.8rem; font-style:italic;">No members yet.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr; gap:1.5rem;">
|
<!-- ── Notifications Tab ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tab-pane <?= $active_tab==='channels'?'active':'' ?>" id="tab-channels">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>User Profile & Settings (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="margin-bottom:1rem; color:var(--text-light);">Assign specific notification channels (Native, Telegram, Email, WhatsApp) to users. User must be created above first.</p>
|
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1.25rem;">Configure notification channels (Native, Telegram, Email, WhatsApp) per user.</p>
|
||||||
<div style="overflow-x:auto;">
|
<div style="overflow-x:auto;">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Username</th><th>Active Channels</th><th>Telegram ID</th><th>Email</th><th>WhatsApp</th><th>Action</th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Channels</th>
|
||||||
|
<th>Telegram ID</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>WhatsApp</th>
|
||||||
|
<th>Save</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach($users as $u): ?>
|
<?php foreach($users as $u):
|
||||||
|
$chs = explode(',', $u['notify_channels'] ?? '');
|
||||||
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
|
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
|
||||||
<td><strong><?= htmlspecialchars($u['username']) ?></strong></td>
|
<td>
|
||||||
<td style="display:flex; flex-direction:column; gap:0.25rem;">
|
<div style="display:flex; align-items:center; gap:0.5rem;">
|
||||||
<?php $chs = explode(',', $u['notify_channels'] ?? ''); ?>
|
<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;">
|
||||||
<label><input type="checkbox" name="channels[]" value="native" <?= in_array('native', $chs)?'checked':'' ?>> Native</label>
|
<?php if(($u['profile_pic']??null) && file_exists($u['profile_pic'])): ?>
|
||||||
<label><input type="checkbox" name="channels[]" value="email" <?= in_array('email', $chs)?'checked':'' ?>> Email</label>
|
<img src="<?= htmlspecialchars($u['profile_pic']) ?>" style="width:100%;height:100%;object-fit:cover;">
|
||||||
<label><input type="checkbox" name="channels[]" value="telegram" <?= in_array('telegram', $chs)?'checked':'' ?>> TG</label>
|
<?php else: ?>
|
||||||
<label><input type="checkbox" name="channels[]" value="whatsapp" <?= in_array('whatsapp', $chs)?'checked':'' ?>> WA</label>
|
<?= strtoupper(mb_substr($u['username'],0,1)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<strong><?= htmlspecialchars($u['username']) ?></strong>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="text" name="telegram_id" value="<?= htmlspecialchars($u['telegram_id']??'') ?>" class="form-control" style="width:100px; padding:0.25rem;"></td>
|
<td><span class="<?= $u['is_admin']?'admin-badge-admin':'admin-badge-user' ?>"><?= $u['is_admin']?'Admin':'User' ?></span></td>
|
||||||
<td><input type="email" name="email" value="<?= htmlspecialchars($u['email']??'') ?>" class="form-control" style="width:150px; padding:0.25rem;"></td>
|
<td>
|
||||||
<td><input type="text" name="whatsapp_number" value="<?= htmlspecialchars($u['whatsapp_number']??'') ?>" class="form-control" style="width:120px; padding:0.25rem;"></td>
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||||
<td><button type="submit" name="update_user_channels" class="btn btn-sm">Save</button></td>
|
<?php foreach(['native','email','telegram','whatsapp'] as $ch): ?>
|
||||||
|
<label style="display:flex; align-items:center; gap:3px; font-size:0.8rem; cursor:pointer; white-space:nowrap;">
|
||||||
|
<input type="checkbox" name="channels[]" value="<?= $ch ?>" <?= in_array($ch,$chs)?'checked':'' ?>>
|
||||||
|
<?= ucfirst($ch) ?>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<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>
|
</form>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -134,25 +414,54 @@ $conf_arch = query("SELECT v FROM system_config WHERE k='archived_days'")->fetch
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── System Config Tab ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="admin-tab-pane <?= $active_tab==='config'?'active':'' ?>" id="tab-config">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Existing Groups Breakdown</h3>
|
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem;"><i class="fa fa-gear"></i> Kanban Auto-Archive Settings</h3>
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:1.5rem; margin-top:1rem;">
|
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1.5rem;">Control how long tasks stay in Completed or Archived states before being moved/deleted.</p>
|
||||||
<?php foreach($all_groups as $g): ?>
|
<form method="POST">
|
||||||
<div style="border:1px solid var(--border); border-radius:0.5rem; padding:1rem; width:250px;">
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1.5rem; max-width:600px;">
|
||||||
<h4 style="color:var(--primary); font-size:1.1rem; border-bottom:1px solid var(--border); padding-bottom:0.5rem; margin-bottom:0.5rem;"><?= htmlspecialchars($g['name']) ?></h4>
|
<div class="form-group" style="margin:0;">
|
||||||
<ul style="padding-left:1.5rem;">
|
<label>Completed tasks retain for (Days)</label>
|
||||||
<?php
|
<input type="number" name="completed_days" value="<?= htmlspecialchars($conf_comp) ?>" class="form-control" min="1" max="365">
|
||||||
$stmt = $pdo->prepare("SELECT u.username FROM users u JOIN user_groups ug ON u.id = ug.user_id WHERE ug.group_id=?");
|
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 7 days (before auto-moving to Archive)</div>
|
||||||
$stmt->execute([$g['id']]);
|
|
||||||
$g_users = $stmt->fetchAll();
|
|
||||||
foreach($g_users as $gu) echo "<li>".htmlspecialchars($gu['username'])."</li>";
|
|
||||||
if(!$g_users) echo "<li style='color:var(--text-light); list-style:none; margin-left:-1.5rem;'>No users in group</li>";
|
|
||||||
?>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<div class="form-group" style="margin:0;">
|
||||||
<?php if(!$all_groups): ?><p style="color:var(--text-light);">No groups registered.</p><?php endif; ?>
|
<label>Archived tasks retain for (Days)</label>
|
||||||
|
<input type="number" name="archived_days" value="<?= htmlspecialchars($conf_arch) ?>" class="form-control" min="1" max="3650">
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-light); margin-top:4px;">Default: 90 days (before permanent deletion)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="update_sys_config" class="btn" style="margin-top:1.25rem;"><i class="fa fa-save"></i> Save Configuration</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="card" style="border:1px solid rgba(239,68,68,0.3);">
|
||||||
|
<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>
|
||||||
|
<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.')">
|
||||||
|
<i class="fa fa-trash"></i> Purge Archived Tasks
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="if(confirm('Really clear all activity logs?')) alert('Not implemented in this demo.')">
|
||||||
|
<i class="fa fa-eraser"></i> Clear Activity Logs
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.admin-tab-pane').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelector(`[onclick="switchTab('${name}')"]`).classList.add('active');
|
||||||
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
|
// Update URL without reload
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('tab', name);
|
||||||
|
history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -57,10 +57,18 @@ $my_mood = $current_user['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 FROM users WHERE mood IS NOT NULL AND id != ? ORDER BY mood_updated_at DESC", [$user_id])->fetchAll();
|
||||||
|
|
||||||
// ── Activity log (user + their groups, last 20) ───────────────────────────────
|
// ── Activity log ──────────────────────────────────────────────────────────────
|
||||||
|
// Admin: sees ALL activity across all users & groups
|
||||||
|
// Standard user: sees own activity + group members activity
|
||||||
|
$is_admin_user = !empty($_SESSION['is_admin']);
|
||||||
$recent_logs = [];
|
$recent_logs = [];
|
||||||
try {
|
try {
|
||||||
// Collect relevant user IDs: current user + members of same groups
|
if ($is_admin_user) {
|
||||||
|
// Admin: fetch all activity
|
||||||
|
$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];
|
$peer_ids = [$user_id];
|
||||||
if ($my_group_ids) {
|
if ($my_group_ids) {
|
||||||
$gin = implode(',', array_fill(0, count($my_group_ids), '?'));
|
$gin = implode(',', array_fill(0, count($my_group_ids), '?'));
|
||||||
@@ -71,13 +79,21 @@ try {
|
|||||||
$uid_in = implode(',', array_fill(0, count($peer_ids), '?'));
|
$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 = $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);
|
$stmt->execute($peer_ids);
|
||||||
|
}
|
||||||
$recent_logs = $stmt->fetchAll();
|
$recent_logs = $stmt->fetchAll();
|
||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
// ── Auto-log task changes for history (read recent task status changes) ───────
|
// ── Task logs ─────────────────────────────────────────────────────────────────
|
||||||
$task_logs = [];
|
$task_logs = [];
|
||||||
try {
|
try {
|
||||||
// Include group-assigned tasks too
|
if ($is_admin_user) {
|
||||||
|
// Admin: all recent task status changes
|
||||||
|
$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
|
||||||
|
WHERE t.status_updated_at IS NOT NULL
|
||||||
|
ORDER BY t.status_updated_at DESC LIMIT 40");
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
$group_in_tl = $my_group_ids ? implode(',', array_fill(0, count($my_group_ids), '?')) : '0';
|
$group_in_tl = $my_group_ids ? implode(',', array_fill(0, count($my_group_ids), '?')) : '0';
|
||||||
$tl_params = array_merge([$user_id, $user_id], $my_group_ids, $my_group_ids);
|
$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
|
$stmt = $pdo->prepare("SELECT t.title, t.status, t.status_updated_at, u.username as assignor
|
||||||
@@ -90,12 +106,13 @@ try {
|
|||||||
)
|
)
|
||||||
ORDER BY t.status_updated_at DESC LIMIT 30");
|
ORDER BY t.status_updated_at DESC LIMIT 30");
|
||||||
$stmt->execute($tl_params);
|
$stmt->execute($tl_params);
|
||||||
|
}
|
||||||
$task_logs = $stmt->fetchAll();
|
$task_logs = $stmt->fetchAll();
|
||||||
} catch(Exception $e) {}
|
} catch(Exception $e) {}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.db-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px,1fr)); gap:1.25rem; margin-bottom:1.5rem; }
|
.db-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(200px,1fr)); gap:1.25rem; margin-bottom:1.5rem; }
|
||||||
.db-stat-card {
|
.db-stat-card {
|
||||||
background:var(--surface); border-radius:1rem; padding:1.25rem 1.5rem;
|
background:var(--surface); border-radius:1rem; padding:1.25rem 1.5rem;
|
||||||
box-shadow:0 4px 6px -1px rgba(0,0,0,0.07); border:1px solid var(--border);
|
box-shadow:0 4px 6px -1px rgba(0,0,0,0.07); border:1px solid var(--border);
|
||||||
@@ -103,10 +120,10 @@ try {
|
|||||||
text-decoration:none !important; color:var(--text) !important;
|
text-decoration:none !important; color:var(--text) !important;
|
||||||
}
|
}
|
||||||
.db-stat-card:hover { transform:translateY(-3px); box-shadow:0 8px 15px -3px rgba(0,0,0,0.1); }
|
.db-stat-card:hover { transform:translateY(-3px); box-shadow:0 8px 15px -3px rgba(0,0,0,0.1); }
|
||||||
.db-stat-label { font-size:0.8rem; color:var(--text-light); font-weight:500; text-transform:uppercase; letter-spacing:0.04em; }
|
.db-stat-label { font-size:0.78rem; color:var(--text-light); font-weight:600; text-transform:uppercase; letter-spacing:0.05em; display:flex; align-items:center; gap:0.4rem; }
|
||||||
.db-stat-value { font-size:2.2rem; font-weight:700; line-height:1; }
|
.db-stat-value { font-size:2.2rem; font-weight:700; line-height:1; }
|
||||||
.db-stat-sub { display:flex; gap:1rem; flex-wrap:wrap; margin-top:0.35rem; }
|
.db-stat-sub { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.35rem; }
|
||||||
.db-stat-sub span { font-size:0.75rem; font-weight:600; padding:2px 8px; border-radius:20px; }
|
.db-stat-sub span { font-size:0.72rem; font-weight:600; padding:2px 8px; border-radius:20px; }
|
||||||
.db-stat-link { font-size:0.78rem; color:var(--primary); margin-top:auto; font-weight:500; }
|
.db-stat-link { font-size:0.78rem; color:var(--primary); margin-top:auto; font-weight:500; }
|
||||||
|
|
||||||
/* Welcome banner */
|
/* Welcome banner */
|
||||||
@@ -118,21 +135,6 @@ try {
|
|||||||
.db-welcome h2 { font-size:1.4rem; font-weight:700; }
|
.db-welcome h2 { font-size:1.4rem; font-weight:700; }
|
||||||
.db-welcome p { font-size:0.875rem; opacity:0.85; margin-top:0.25rem; }
|
.db-welcome p { font-size:0.875rem; opacity:0.85; margin-top:0.25rem; }
|
||||||
|
|
||||||
/* Holiday strip */
|
|
||||||
.db-holiday {
|
|
||||||
background:var(--surface); border:1px solid var(--border); border-radius:0.75rem;
|
|
||||||
padding:0.75rem 1.25rem; display:flex; align-items:center; gap:0.75rem;
|
|
||||||
font-size:0.875rem; margin-bottom:1.5rem; overflow:hidden;
|
|
||||||
}
|
|
||||||
.db-holiday-list {
|
|
||||||
display:flex; gap:1.5rem; overflow-x:auto; flex:1; padding-bottom:2px;
|
|
||||||
scrollbar-width:thin;
|
|
||||||
}
|
|
||||||
.db-holiday-item { flex-shrink:0; display:flex; align-items:center; gap:0.4rem; white-space:nowrap; }
|
|
||||||
.db-holiday-date { font-size:0.7rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:2px 6px; border-radius:6px; font-weight:600; }
|
|
||||||
.db-holiday-name { color:var(--text); font-weight:500; }
|
|
||||||
.db-holiday-country { font-size:0.7rem; color:var(--text-light); }
|
|
||||||
|
|
||||||
/* Mood picker */
|
/* Mood picker */
|
||||||
.mood-row { display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
|
.mood-row { display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
|
||||||
.mood-btn {
|
.mood-btn {
|
||||||
@@ -142,8 +144,8 @@ try {
|
|||||||
transition:transform 0.15s, border-color 0.15s, background 0.15s;
|
transition:transform 0.15s, border-color 0.15s, background 0.15s;
|
||||||
line-height:1;
|
line-height:1;
|
||||||
}
|
}
|
||||||
.mood-btn:hover { transform:scale(1.15); background:rgba(var(--primary-rgb),0.08); }
|
.mood-btn:hover { transform:scale(1.15); background:rgba(255,255,255,0.15); }
|
||||||
.mood-btn.active { border-color:var(--primary); background:rgba(var(--primary-rgb),0.12); transform:scale(1.1); }
|
.mood-btn.active { border-color:rgba(255,255,255,0.9); background:rgba(255,255,255,0.2); transform:scale(1.1); }
|
||||||
|
|
||||||
/* Team moods */
|
/* Team moods */
|
||||||
.team-mood-row { display:flex; flex-wrap:wrap; gap:0.6rem; }
|
.team-mood-row { display:flex; flex-wrap:wrap; gap:0.6rem; }
|
||||||
@@ -155,8 +157,8 @@ try {
|
|||||||
.team-mood-chip .em { font-size:1.2rem; }
|
.team-mood-chip .em { font-size:1.2rem; }
|
||||||
|
|
||||||
/* Activity log */
|
/* Activity log */
|
||||||
.log-list { display:flex; flex-direction:column; gap:0.5rem; overflow-y:auto; flex:1; }
|
.log-list { display:flex; flex-direction:column; gap:0.5rem; overflow-y:auto; flex:1; max-height:360px; }
|
||||||
.log-item { display:flex; gap:0.75rem; align-items:flex-start; font-size:0.8rem; flex-shrink:0; }
|
.log-item { display:flex; gap:0.75rem; align-items:flex-start; font-size:0.8rem; flex-shrink:0; padding:0.35rem 0; }
|
||||||
.log-avatar { width:26px; height:26px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:700; flex-shrink:0; }
|
.log-avatar { width:26px; height:26px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:700; flex-shrink:0; }
|
||||||
.log-body { flex:1; min-width:0; }
|
.log-body { flex:1; min-width:0; }
|
||||||
.log-body strong { color:var(--text); }
|
.log-body strong { color:var(--text); }
|
||||||
@@ -164,16 +166,18 @@ try {
|
|||||||
.log-time { font-size:0.7rem; color:var(--text-light); white-space:nowrap; flex-shrink:0; }
|
.log-time { font-size:0.7rem; color:var(--text-light); white-space:nowrap; flex-shrink:0; }
|
||||||
|
|
||||||
/* Full-height bottom section */
|
/* Full-height bottom section */
|
||||||
.db-wrapper { display:flex; flex-direction:column; min-height:calc(100vh - 4rem); }
|
.db-wrapper { display:flex; flex-direction:column; }
|
||||||
.two-col {
|
.two-col {
|
||||||
display:grid; grid-template-columns:1fr 1fr; gap:1.25rem;
|
display:grid; grid-template-columns:1fr 1fr; gap:1.25rem;
|
||||||
flex:1; min-height:0;
|
flex:1; min-height:0;
|
||||||
}
|
}
|
||||||
.two-col > .card {
|
.two-col > .card {
|
||||||
margin-bottom:0; display:flex; flex-direction:column;
|
margin-bottom:0; display:flex; flex-direction:column;
|
||||||
min-height:0; /* allow shrink inside grid */
|
min-height:0;
|
||||||
}
|
}
|
||||||
@media(max-width:720px){ .two-col { grid-template-columns:1fr; } }
|
@media(max-width:720px){ .two-col { grid-template-columns:1fr; } }
|
||||||
|
|
||||||
|
.admin-activity-badge { display:inline-flex; align-items:center; gap:0.4rem; font-size:0.7rem; padding:2px 8px; border-radius:12px; background:rgba(239,68,68,0.1); color:#EF4444; font-weight:700; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
@@ -225,7 +229,7 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Today's Holiday Badge (injected by JS into welcome banner) ──────────── -->
|
<!-- ── Today's Holiday Badge ──────────────────────────────────────────────── -->
|
||||||
<div id="today-holiday-banner" style="display:none; margin-bottom:1rem; padding:0.6rem 1.25rem; background:linear-gradient(90deg,rgba(245,158,11,0.12),rgba(16,185,129,0.08)); border:1px solid rgba(245,158,11,0.3); border-radius:0.75rem; align-items:center; gap:0.75rem; font-size:0.875rem;">
|
<div id="today-holiday-banner" style="display:none; margin-bottom:1rem; padding:0.6rem 1.25rem; background:linear-gradient(90deg,rgba(245,158,11,0.12),rgba(16,185,129,0.08)); border:1px solid rgba(245,158,11,0.3); border-radius:0.75rem; align-items:center; gap:0.75rem; font-size:0.875rem;">
|
||||||
<span style="font-size:1.3rem;">🎉</span>
|
<span style="font-size:1.3rem;">🎉</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -322,8 +326,15 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
<!-- Activity Log -->
|
<!-- Activity Log -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<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;">
|
||||||
<h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3>
|
<h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3>
|
||||||
<span style="font-size:0.75rem; color:var(--text-light);">You & your groups</span>
|
<?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>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-light);">
|
||||||
|
<?= $is_admin_user ? 'All activity across platform' : 'You & your groups' ?>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
// Merge activity_log + task_logs into one unified feed sorted by time
|
// Merge activity_log + task_logs into one unified feed sorted by time
|
||||||
@@ -346,13 +357,18 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
usort($feed, fn($a,$b) => strtotime($b['time']) - strtotime($a['time']));
|
usort($feed, fn($a,$b) => strtotime($b['time']) - strtotime($a['time']));
|
||||||
$feed = array_slice($feed, 0, 30);
|
$feed = array_slice($feed, 0, $is_admin_user ? 50 : 30);
|
||||||
|
|
||||||
$action_icons = [
|
$action_icons = [
|
||||||
'visit' => '🌐',
|
'visit' => '🌐',
|
||||||
'mood_set' => '😊',
|
'mood_set' => '😊',
|
||||||
'task_status' => '✅',
|
'task_status' => '✅',
|
||||||
|
'note_create' => '📝',
|
||||||
|
'pw_add' => '🔐',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Generate avatar color based on username hash
|
||||||
|
$avatar_colors = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
|
||||||
?>
|
?>
|
||||||
<?php if(empty($feed)): ?>
|
<?php if(empty($feed)): ?>
|
||||||
<p style="color:var(--text-light); font-size:0.85rem;">No activity yet.</p>
|
<p style="color:var(--text-light); font-size:0.85rem;">No activity yet.</p>
|
||||||
@@ -362,9 +378,10 @@ if (empty($_SESSION['dash_logged_today'])) {
|
|||||||
$initial = strtoupper(mb_substr($entry['user'], 0, 1));
|
$initial = strtoupper(mb_substr($entry['user'], 0, 1));
|
||||||
$icon = $action_icons[$entry['action']] ?? '🔔';
|
$icon = $action_icons[$entry['action']] ?? '🔔';
|
||||||
$ago = human_time_diff(strtotime($entry['time']));
|
$ago = human_time_diff(strtotime($entry['time']));
|
||||||
|
$color = $avatar_colors[crc32($entry['user']) % count($avatar_colors)];
|
||||||
?>
|
?>
|
||||||
<div class="log-item">
|
<div class="log-item">
|
||||||
<div class="log-avatar"><?= $initial ?></div>
|
<div class="log-avatar" style="background:<?= $color ?>;"><?= $initial ?></div>
|
||||||
<div class="log-body">
|
<div class="log-body">
|
||||||
<strong><?= htmlspecialchars($entry['user']) ?></strong>
|
<strong><?= htmlspecialchars($entry['user']) ?></strong>
|
||||||
<span class="log-detail"> <?= $icon ?> <?= htmlspecialchars($entry['detail']) ?></span>
|
<span class="log-detail"> <?= $icon ?> <?= htmlspecialchars($entry['detail']) ?></span>
|
||||||
@@ -396,7 +413,6 @@ function selectMood(em) {
|
|||||||
document.getElementById('mood-value').value = em;
|
document.getElementById('mood-value').value = em;
|
||||||
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('active'));
|
||||||
if (em) {
|
if (em) {
|
||||||
// Find the button whose text equals em
|
|
||||||
document.querySelectorAll('.mood-btn').forEach(b => {
|
document.querySelectorAll('.mood-btn').forEach(b => {
|
||||||
if (b.textContent.trim() === em) b.classList.add('active');
|
if (b.textContent.trim() === em) b.classList.add('active');
|
||||||
});
|
});
|
||||||
@@ -404,10 +420,10 @@ function selectMood(em) {
|
|||||||
document.getElementById('mood-form').submit();
|
document.getElementById('mood-form').submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Holiday check: is TODAY a holiday in Indonesia (ID) or Internationally (UN)?
|
// ── Holiday check ─────────────────────────────────────────────────────────────
|
||||||
(function checkTodayHoliday() {
|
(function checkTodayHoliday() {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const urls = [
|
const urls = [
|
||||||
{ url: `https://date.nager.at/api/v3/PublicHolidays/${year}/ID`, flag: '🇮🇩', label: 'Indonesia' },
|
{ url: `https://date.nager.at/api/v3/PublicHolidays/${year}/ID`, flag: '🇮🇩', label: 'Indonesia' },
|
||||||
@@ -434,7 +450,6 @@ function selectMood(em) {
|
|||||||
labelEl.textContent = '— Today is a holiday! 🎊';
|
labelEl.textContent = '— Today is a holiday! 🎊';
|
||||||
banner.style.display = 'flex';
|
banner.style.display = 'flex';
|
||||||
}
|
}
|
||||||
// If no holiday today, banner stays hidden (display:none)
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
// Dark Mode Toggle
|
// ── Dark Mode Toggle ──────────────────────────────────────────────────
|
||||||
const themeBtn = document.getElementById('theme-toggle');
|
const themeBtn = document.getElementById('theme-toggle');
|
||||||
const iconLight = document.getElementById('theme-icon-light');
|
const iconLight = document.getElementById('theme-icon-light');
|
||||||
const iconDark = document.getElementById('theme-icon-dark');
|
const iconDark = document.getElementById('theme-icon-dark');
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
iconDark.style.display = 'block';
|
iconDark.style.display = 'block';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if(themeBtn) {
|
if(themeBtn) {
|
||||||
updateThemeIcon();
|
updateThemeIcon();
|
||||||
themeBtn.addEventListener('click', () => {
|
themeBtn.addEventListener('click', () => {
|
||||||
@@ -27,13 +26,67 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast Notifier
|
// ── Toast ─────────────────────────────────────────────────────────────
|
||||||
function showToast(msg) {
|
function showToast(msg) {
|
||||||
const t = document.getElementById('toast');
|
const t = document.getElementById('toast');
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
t.classList.add('show');
|
t.classList.add('show');
|
||||||
setTimeout(() => t.classList.remove('show'), 3000);
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sidebar Collapse ──────────────────────────────────────────────────
|
||||||
|
function toggleSidebar() {
|
||||||
|
const collapsed = document.body.classList.toggle('sidebar-collapsed');
|
||||||
|
localStorage.setItem('sidebar_collapsed', collapsed ? '1' : '0');
|
||||||
|
updateSidebarIcon();
|
||||||
|
}
|
||||||
|
function updateSidebarIcon() {
|
||||||
|
const icon = document.getElementById('sidebar-toggle-icon');
|
||||||
|
if(!icon) return;
|
||||||
|
if(document.body.classList.contains('sidebar-collapsed')) {
|
||||||
|
icon.className = 'fa fa-chevron-right';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fa fa-chevron-left';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateSidebarIcon();
|
||||||
|
|
||||||
|
// ── User Dropdown ─────────────────────────────────────────────────────
|
||||||
|
function toggleUserDropdown() {
|
||||||
|
const menu = document.getElementById('user-dropdown-menu');
|
||||||
|
menu.classList.toggle('open');
|
||||||
|
}
|
||||||
|
function closeUserDropdown() {
|
||||||
|
const menu = document.getElementById('user-dropdown-menu');
|
||||||
|
if(menu) menu.classList.remove('open');
|
||||||
|
}
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const wrap = document.getElementById('user-dropdown-wrap');
|
||||||
|
if (wrap && !wrap.contains(e.target)) closeUserDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Notifications ─────────────────────────────────────────────────────
|
||||||
|
function openNotifications() {
|
||||||
|
closeUserDropdown();
|
||||||
|
document.getElementById('notifications-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close modals on overlay click ─────────────────────────────────────
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(mo => {
|
||||||
|
mo.addEventListener('click', (e) => {
|
||||||
|
if (e.target === mo) mo.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auto open change-pw modal if error ────────────────────────────────
|
||||||
|
<?php
|
||||||
|
$pw_msg_bottom = $_SESSION['pw_change_msg_bottom'] ?? null;
|
||||||
|
if($pw_msg_bottom):
|
||||||
|
?>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('change-pw-modal').classList.add('active');
|
||||||
|
});
|
||||||
|
<?php endif; ?>
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,39 +9,233 @@
|
|||||||
<link rel="stylesheet" href="public/css/style.css">
|
<link rel="stylesheet" href="public/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>if(localStorage.getItem('theme') === 'dark') document.body.classList.add('dark-mode');</script>
|
<script>
|
||||||
|
if(localStorage.getItem('theme') === 'dark') document.body.classList.add('dark-mode');
|
||||||
|
if(localStorage.getItem('sidebar_collapsed') === '1') document.body.classList.add('sidebar-collapsed');
|
||||||
|
</script>
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<div class="app-container">
|
|
||||||
<aside class="sidebar">
|
<!-- Profile Picture Modal -->
|
||||||
<h2 style="display:flex; align-items:center; gap:8px;">
|
<div class="modal-overlay" id="profile-pic-modal">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
<div class="modal-content" style="max-width:420px;">
|
||||||
TaskManager
|
<h3 style="margin-bottom:1rem; font-size:1.1rem;">Profile Picture</h3>
|
||||||
</h2>
|
<div style="display:flex; flex-direction:column; align-items:center; gap:1rem; margin-bottom:1.5rem;">
|
||||||
<nav style="display:flex; flex-direction:column; gap:0.5rem;">
|
<?php
|
||||||
<a href="?action=dashboard" class="nav-link <?= $action=='dashboard'?'active':'' ?>"> Dashboard</a>
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
<a href="?action=tasks" class="nav-link <?= $action=='tasks'?'active':'' ?>"> Tasks</a>
|
$pic = $current_user['profile_pic'] ?? null;
|
||||||
<a href="?action=notes" class="nav-link <?= $action=='notes'?'active':'' ?>"> Notes</a>
|
?>
|
||||||
<a href="?action=passwords" class="nav-link <?= $action=='passwords'?'active':'' ?>"> Passwords</a>
|
<?php if($pic && file_exists($pic)): ?>
|
||||||
<a href="?action=projects" class="nav-link <?= $action=='projects'?'active':'' ?>"> Projects</a>
|
<img src="<?= htmlspecialchars($pic) ?>" alt="Profile" style="width:100px; height:100px; border-radius:50%; object-fit:cover; border:3px solid var(--primary);">
|
||||||
<a href="?action=notifications" class="nav-link <?= $action=='notifications'?'active':'' ?>">
|
<?php else: ?>
|
||||||
Notifications
|
<div style="width:100px; height:100px; border-radius:50%; background:var(--primary); display:flex; align-items:center; justify-content:center; font-size:2.5rem; color:#fff; font-weight:700;">
|
||||||
<?php if($unread_notifications > 0): ?>
|
<?= strtoupper(mb_substr($current_user['username'],0,1)) ?>
|
||||||
<span class="badge"><?= $unread_notifications ?></span>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<form method="POST" enctype="multipart/form-data" style="margin-bottom:1rem;">
|
||||||
|
<input type="hidden" name="upload_profile_pic" value="1">
|
||||||
|
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.875rem; color:var(--text-light);">Upload New Picture (JPG, PNG, GIF, WebP)</label>
|
||||||
|
<input type="file" name="profile_pic" accept="image/*" class="form-control" style="margin-top:0.5rem;" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="width:100%;">Upload Picture</button>
|
||||||
|
</form>
|
||||||
|
<?php if($pic && file_exists($pic)): ?>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="delete_profile_pic" value="1">
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
<?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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div class="modal-overlay" id="change-pw-modal">
|
||||||
|
<div class="modal-content" style="max-width:400px;">
|
||||||
|
<h3 style="margin-bottom:1rem; font-size:1.1rem;">Change Password</h3>
|
||||||
|
<?php
|
||||||
|
$pw_msg = $_SESSION['pw_change_msg'] ?? null;
|
||||||
|
unset($_SESSION['pw_change_msg']);
|
||||||
|
if ($pw_msg === 'success'): ?>
|
||||||
|
<div class="alert" style="background:rgba(16,185,129,0.12); color:#10B981; border:1px solid #10B981;">Password changed successfully!</div>
|
||||||
|
<?php elseif($pw_msg): ?>
|
||||||
|
<div class="alert"><?= htmlspecialchars($pw_msg) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="change_password" value="1">
|
||||||
|
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Current Password</label>
|
||||||
|
<input type="password" name="current_password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>New Password</label>
|
||||||
|
<input type="password" name="new_password" class="form-control" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm New Password</label>
|
||||||
|
<input type="password" name="confirm_password" class="form-control" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="width:100%;">Change Password</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-secondary" style="width:100%; margin-top:0.5rem;" onclick="document.getElementById('change-pw-modal').classList.remove('active')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications Modal -->
|
||||||
|
<?php
|
||||||
|
// Fetch notifications for popup
|
||||||
|
$notif_list = [];
|
||||||
|
try {
|
||||||
|
$stmt_n = $pdo->prepare("SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 30");
|
||||||
|
$stmt_n->execute([$user_id]);
|
||||||
|
$notif_list = $stmt_n->fetchAll();
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
?>
|
||||||
|
<div class="modal-overlay" id="notifications-modal">
|
||||||
|
<div class="modal-content" style="max-width:460px; max-height:80vh; display:flex; flex-direction:column; padding:0; overflow:hidden;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:1.25rem 1.5rem; border-bottom:1px solid var(--border);">
|
||||||
|
<h3 style="font-size:1.05rem; font-weight:700;"><i class="fa fa-bell" style="color:var(--primary);"></i> Notifications</h3>
|
||||||
|
<div style="display:flex; gap:0.5rem; align-items:center;">
|
||||||
|
<?php if($unread_notifications > 0): ?>
|
||||||
|
<form method="POST" id="mark-all-read-form" style="display:inline;">
|
||||||
|
<input type="hidden" name="mark_all_read" value="1">
|
||||||
|
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary" style="font-size:0.75rem;">Mark all read</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<button onclick="document.getElementById('notifications-modal').classList.remove('active')" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.2rem;padding:4px;">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-y:auto; flex:1; padding:0.75rem 0;">
|
||||||
|
<?php if(empty($notif_list)): ?>
|
||||||
|
<div style="text-align:center; padding:2.5rem 1rem; color:var(--text-light);">
|
||||||
|
<i class="fa fa-bell-slash" style="font-size:2rem; display:block; margin-bottom:0.75rem;"></i>
|
||||||
|
No notifications yet.
|
||||||
|
</div>
|
||||||
|
<?php else: foreach($notif_list as $n):
|
||||||
|
$t = strtotime($n['created_at']);
|
||||||
|
$diff = time()-$t;
|
||||||
|
$ago = $diff<60?'just now':($diff<3600?floor($diff/60).'m ago':($diff<86400?floor($diff/3600).'h ago':floor($diff/86400).'d ago'));
|
||||||
|
?>
|
||||||
|
<div style="display:flex; align-items:flex-start; gap:0.75rem; padding:0.65rem 1.5rem; border-bottom:1px solid var(--border); transition:background 0.15s; <?= !$n['is_read']?'background:rgba(var(--primary-rgb),0.04);':'' ?>">
|
||||||
|
<div style="width:8px; height:8px; border-radius:50%; background:<?= !$n['is_read']?'var(--primary)':'transparent' ?>; margin-top:6px; flex-shrink:0;"></div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="font-size:0.875rem; color:var(--text); <?= !$n['is_read']?'font-weight:500;':'' ?>"><?= htmlspecialchars($n['message']) ?></div>
|
||||||
|
<div style="font-size:0.72rem; color:var(--text-light); margin-top:3px;"><?= $ago ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<!-- Toggle button on the edge -->
|
||||||
|
<button class="sidebar-toggle-btn" id="sidebar-toggle-btn" onclick="toggleSidebar()" title="Collapse / Expand">
|
||||||
|
<i class="fa fa-chevron-left" id="sidebar-toggle-icon"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 class="sidebar-brand">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
|
||||||
|
<span class="sidebar-label sidebar-brand-text">TaskManager</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<nav style="display:flex; flex-direction:column; gap:0.25rem;">
|
||||||
|
<a href="?action=dashboard" class="nav-link <?= $action=='dashboard'?'active':'' ?>" title="Dashboard">
|
||||||
|
<i class="fa fa-gauge-high nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=tasks" class="nav-link <?= $action=='tasks'?'active':'' ?>" title="Tasks">
|
||||||
|
<i class="fa fa-list-check nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Tasks</span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=notes" class="nav-link <?= $action=='notes'?'active':'' ?>" title="Notes">
|
||||||
|
<i class="fa fa-note-sticky nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Notes</span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=passwords" class="nav-link <?= $action=='passwords'?'active':'' ?>" title="Passwords">
|
||||||
|
<i class="fa fa-lock nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Passwords</span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=projects" class="nav-link <?= $action=='projects'?'active':'' ?>" title="Projects">
|
||||||
|
<i class="fa fa-folder-open nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Projects</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':'' ?>"> Admin Panel</a>
|
<a href="?action=admin" class="nav-link <?= $action=='admin'?'active':'' ?>" title="Admin Panel">
|
||||||
|
<i class="fa fa-shield-halved nav-icon"></i>
|
||||||
|
<span class="sidebar-label">Admin Panel</span>
|
||||||
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
<div style="margin-top:auto; padding-top:1rem; border-top:1px solid var(--border);">
|
|
||||||
<div style="margin-bottom:0.5rem; font-weight:600;"><?= htmlspecialchars($current_user['username']) ?></div>
|
|
||||||
<a href="?action=logout" class="nav-link" style="padding:0; color:var(--text-light);">Logout</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<div style="display:flex; justify-content:flex-end; margin-bottom:1rem;">
|
<!-- Top Bar -->
|
||||||
<button id="theme-toggle" class="btn btn-secondary" style="border-radius:50%; padding:0.5rem; width:40px; height:40px; display:flex; align-items:center; justify-content:center;">
|
<div style="display:flex; justify-content:flex-end; align-items:center; gap:0.75rem; margin-bottom:1.5rem;">
|
||||||
<span id="theme-icon-light" style="display:none;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>
|
<!-- Dark Mode Toggle -->
|
||||||
<span id="theme-icon-dark" style="display:none;"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>
|
<button id="theme-toggle" class="btn btn-secondary" title="Toggle dark mode" style="border-radius:50%; padding:0; width:38px; height:38px; display:flex; align-items:center; justify-content:center; flex-shrink:0;">
|
||||||
|
<span id="theme-icon-light" style="display:none;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>
|
||||||
|
<span id="theme-icon-dark" style="display:none;"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- User Avatar Dropdown -->
|
||||||
|
<div class="user-dropdown-wrap" id="user-dropdown-wrap">
|
||||||
|
<!-- Avatar with notification badge -->
|
||||||
|
<div style="position:relative; display:inline-flex;">
|
||||||
|
<button class="user-avatar-btn" id="user-avatar-btn" onclick="toggleUserDropdown()" title="<?= htmlspecialchars($current_user['username']) ?>">
|
||||||
|
<?php
|
||||||
|
try { $pdo->exec("ALTER TABLE users ADD COLUMN profile_pic TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
|
$pic_top = $current_user['profile_pic'] ?? null;
|
||||||
|
?>
|
||||||
|
<?php if($pic_top && file_exists($pic_top)): ?>
|
||||||
|
<img src="<?= htmlspecialchars($pic_top) ?>?v=<?= time() ?>" alt="avatar" style="width:100%; height:100%; object-fit:cover; border-radius:50%;">
|
||||||
|
<?php else: ?>
|
||||||
|
<?= strtoupper(mb_substr($current_user['username'],0,1)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<?php if($unread_notifications > 0): ?>
|
||||||
|
<span class="avatar-notif-badge" onclick="openNotifications()"><?= $unread_notifications > 9 ? '9+' : $unread_notifications ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="user-dropdown-menu" id="user-dropdown-menu">
|
||||||
|
<div class="user-dropdown-header">
|
||||||
|
<div class="user-dropdown-name"><?= htmlspecialchars($current_user['username']) ?></div>
|
||||||
|
<?php if(!empty($_SESSION['is_admin'])): ?>
|
||||||
|
<div class="user-dropdown-role">Administrator</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="user-dropdown-role">Member</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown-divider"></div>
|
||||||
|
<button class="user-dropdown-item" onclick="openNotifications()">
|
||||||
|
<i class="fa fa-bell"></i>
|
||||||
|
Notifications
|
||||||
|
<?php if($unread_notifications > 0): ?>
|
||||||
|
<span class="badge" style="margin-left:auto; font-size:0.65rem;"><?= $unread_notifications ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</button>
|
||||||
|
<div class="user-dropdown-divider"></div>
|
||||||
|
<button class="user-dropdown-item" onclick="document.getElementById('profile-pic-modal').classList.add('active'); closeUserDropdown();">
|
||||||
|
<i class="fa fa-image"></i> Set Profile Picture
|
||||||
|
</button>
|
||||||
|
<button class="user-dropdown-item" onclick="document.getElementById('change-pw-modal').classList.add('active'); closeUserDropdown();">
|
||||||
|
<i class="fa fa-key"></i> Change Password
|
||||||
|
</button>
|
||||||
|
<div class="user-dropdown-divider"></div>
|
||||||
|
<a href="?action=logout" class="user-dropdown-item user-dropdown-logout" onclick="return confirm('Logout?')">
|
||||||
|
<i class="fa fa-right-from-bracket"></i> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (isset($pw_msg) && $pw_msg): ?>
|
||||||
|
<script>document.addEventListener('DOMContentLoaded',()=>{ document.getElementById('change-pw-modal').classList.add('active'); });</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
478
views/notes.php
478
views/notes.php
@@ -1,35 +1,109 @@
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_note'])) {
|
// ── DB migrations ─────────────────────────────────────────────────────────────
|
||||||
$stmt = $pdo->prepare("INSERT INTO notes (title, content, owner_id) VALUES (?, ?, ?)");
|
try { $pdo->exec("ALTER TABLE notes ADD COLUMN color TEXT DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
$stmt->execute([$_POST['title'], $_POST['content'], $user_id]);
|
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("CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
owner_id INTEGER,
|
||||||
|
color TEXT DEFAULT NULL,
|
||||||
|
image_path TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT NULL,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)");
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
try {
|
||||||
|
$pdo->exec("CREATE TABLE IF NOT EXISTS note_shares (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_id INTEGER,
|
||||||
|
share_type TEXT,
|
||||||
|
share_id INTEGER,
|
||||||
|
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||||
|
)");
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_note'])) {
|
// ── Handle POST ───────────────────────────────────────────────────────────────
|
||||||
|
if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
|
||||||
|
if(isset($_POST['create_note'])) {
|
||||||
|
$image_path = null;
|
||||||
|
if(isset($_FILES['note_image']) && $_FILES['note_image']['error'] === 0) {
|
||||||
|
$allowed = ['image/jpeg','image/png','image/gif','image/webp'];
|
||||||
|
if(in_array($_FILES['note_image']['type'], $allowed)) {
|
||||||
|
$ext = pathinfo($_FILES['note_image']['name'], PATHINFO_EXTENSION);
|
||||||
|
$fname = 'note_' . $user_id . '_' . time() . '.' . $ext;
|
||||||
|
$dest = 'uploads/' . $fname;
|
||||||
|
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->execute([$_POST['title'] ?? 'Untitled', $_POST['content'], $user_id, $_POST['color'] ?? null, $image_path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['update_note'])) {
|
||||||
|
$n = query("SELECT owner_id, image_path FROM notes WHERE id=?", [$_POST['note_id']])->fetch();
|
||||||
|
if($n && $n['owner_id'] == $user_id) {
|
||||||
|
$image_path = $n['image_path'];
|
||||||
|
if(isset($_FILES['note_image']) && $_FILES['note_image']['error'] === 0) {
|
||||||
|
$allowed = ['image/jpeg','image/png','image/gif','image/webp'];
|
||||||
|
if(in_array($_FILES['note_image']['type'], $allowed)) {
|
||||||
|
$ext = pathinfo($_FILES['note_image']['name'], PATHINFO_EXTENSION);
|
||||||
|
$fname = 'note_' . $user_id . '_' . time() . '.' . $ext;
|
||||||
|
$dest = 'uploads/' . $fname;
|
||||||
|
if(move_uploaded_file($_FILES['note_image']['tmp_name'], $dest)) {
|
||||||
|
if($image_path && file_exists($image_path)) @unlink($image_path);
|
||||||
|
$image_path = $dest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(isset($_POST['remove_image']) && $_POST['remove_image']) {
|
||||||
|
if($image_path && file_exists($image_path)) @unlink($image_path);
|
||||||
|
$image_path = null;
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare("UPDATE notes SET title=?, content=?, color=?, image_path=?, 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['delete_note'])) {
|
||||||
|
$n = query("SELECT owner_id, image_path FROM notes WHERE id=?", [$_POST['note_id']])->fetch();
|
||||||
|
if($n && $n['owner_id'] == $user_id) {
|
||||||
|
if($n['image_path'] && file_exists($n['image_path'])) @unlink($n['image_path']);
|
||||||
|
query("DELETE FROM notes WHERE id=? AND owner_id=?", [$_POST['note_id'], $user_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['share_note'])) {
|
||||||
$n_id = $_POST['note_id'];
|
$n_id = $_POST['note_id'];
|
||||||
$share = $_POST['share_target']; // 'user_1'
|
$share = $_POST['share_target'];
|
||||||
$parts = explode('_', $share);
|
$parts = explode('_', $share, 2);
|
||||||
|
if(count($parts)===2) {
|
||||||
$stmt = $pdo->prepare("INSERT INTO note_shares (note_id, share_type, share_id) VALUES (?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO note_shares (note_id, share_type, share_id) VALUES (?, ?, ?)");
|
||||||
$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);
|
if($parts[0] === 'user') notify_user($parts[1], $msg);
|
||||||
else notify_group($parts[1], $msg);
|
else notify_group($parts[1], $msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Notes
|
// ── Fetch Notes ───────────────────────────────────────────────────────────────
|
||||||
$user_groups = get_user_groups($user_id);
|
$user_groups = get_user_groups($user_id);
|
||||||
$my_group_ids = array_column($user_groups, 'id');
|
$my_group_ids = array_column($user_groups, 'id');
|
||||||
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
||||||
|
|
||||||
$sql = "SELECT n.*, (n.owner_id = ?) as is_mine FROM notes n
|
$sql = "SELECT n.*, (n.owner_id = ?) as is_mine, u.username as owner_name FROM notes n
|
||||||
LEFT JOIN note_shares ns ON n.id = ns.note_id
|
LEFT JOIN note_shares ns ON n.id = ns.note_id
|
||||||
|
LEFT JOIN users u ON n.owner_id = u.id
|
||||||
WHERE n.owner_id = ?
|
WHERE n.owner_id = ?
|
||||||
OR (ns.share_type='user' AND ns.share_id = ?)
|
OR (ns.share_type='user' AND ns.share_id = ?)
|
||||||
OR (ns.share_type='group' AND ns.share_id IN ($group_in))
|
OR (ns.share_type='group' AND ns.share_id IN ($group_in))
|
||||||
GROUP BY n.id ORDER BY n.created_at DESC";
|
GROUP BY n.id ORDER BY n.updated_at DESC, n.created_at DESC";
|
||||||
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
|
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
@@ -37,47 +111,361 @@ $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();
|
||||||
|
|
||||||
|
// Define note colors — Google Keep inspired
|
||||||
|
$note_colors = [
|
||||||
|
'' => ['bg'=>'var(--surface)', 'label'=>'Default', 'hex'=>'#ffffff'],
|
||||||
|
'#fff9c4' => ['bg'=>'#fff9c4', 'label'=>'Yellow', 'hex'=>'#fff9c4'],
|
||||||
|
'#ccff90' => ['bg'=>'#ccff90', 'label'=>'Lime', 'hex'=>'#ccff90'],
|
||||||
|
'#d4edda' => ['bg'=>'#d4edda', 'label'=>'Sage', 'hex'=>'#d4edda'],
|
||||||
|
'#80cbc4' => ['bg'=>'#80cbc4', 'label'=>'Teal', 'hex'=>'#80cbc4'],
|
||||||
|
'#b3e5fc' => ['bg'=>'#b3e5fc', 'label'=>'Sky', 'hex'=>'#b3e5fc'],
|
||||||
|
'#cce5ff' => ['bg'=>'#cce5ff', 'label'=>'Blue', 'hex'=>'#cce5ff'],
|
||||||
|
'#e2d9f3' => ['bg'=>'#e2d9f3', 'label'=>'Lavender', 'hex'=>'#e2d9f3'],
|
||||||
|
'#f8d7da' => ['bg'=>'#f8d7da', 'label'=>'Rose', 'hex'=>'#f8d7da'],
|
||||||
|
'#ef9a9a' => ['bg'=>'#ef9a9a', 'label'=>'Red', 'hex'=>'#ef9a9a'],
|
||||||
|
'#ffcc80' => ['bg'=>'#ffcc80', 'label'=>'Peach', 'hex'=>'#ffcc80'],
|
||||||
|
'#fde8d8' => ['bg'=>'#fde8d8', 'label'=>'Orange', 'hex'=>'#fde8d8'],
|
||||||
|
'#d7ccc8' => ['bg'=>'#d7ccc8', 'label'=>'Sand', 'hex'=>'#d7ccc8'],
|
||||||
|
];
|
||||||
?>
|
?>
|
||||||
<div class="header">
|
|
||||||
<h1>Markdown Notes</h1>
|
<div style="display:flex; justify-content:flex-end; align-items:center; margin-bottom:1rem;">
|
||||||
|
<button class="btn" onclick="document.getElementById('new-note-modal').classList.add('active')">
|
||||||
|
<i class="fa fa-plus"></i> New Note
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<form method="POST" enctype="multipart/form-data" id="new-note-form">
|
||||||
|
<input type="hidden" name="create_note" value="1">
|
||||||
|
<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>
|
||||||
|
<!-- WYSIWYG Toolbar -->
|
||||||
|
<div class="note-editor-toolbar" id="new-note-toolbar">
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="formatNote('new-note-content','bold')" title="Bold"><b>B</b></button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="formatNote('new-note-content','italic')" title="Italic"><i>I</i></button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','# ')" title="H1">H1</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','## ')" title="H2">H2</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','### ')" title="H3">H3</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','- ')" title="Bullet">• List</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','1. ')" title="Numbered">1. List</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','- [ ] ')" title="Todo">☑ Todo</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="wrapMarkdown('new-note-content','`','`')" title="Code"><code>Code</code></button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="wrapMarkdown('new-note-content','```\n','\n```')" title="Code Block">{ } Block</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','> ')" title="Quote">❝ Quote</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('new-note-content','---\n')" title="Divider">─ Line</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" id="new-preview-btn" onclick="togglePreview('new-note-content','new-note-preview','new-preview-btn')">👁 Preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea name="content" id="new-note-content" class="note-content-area" placeholder="Write your note... (Markdown supported)" rows="8"></textarea>
|
||||||
|
<div id="new-note-preview" class="markdown-body" style="display:none; min-height:160px; border:1px solid var(--border); border-radius:0.5rem; padding:0.75rem; background:var(--bg); font-size:0.9rem;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Color picker -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light);">Note Color</label>
|
||||||
|
<div class="note-color-palette" id="new-note-colors">
|
||||||
|
<?php foreach($note_colors as $hex => $info): ?>
|
||||||
|
<div class="color-swatch <?= !$hex ? 'selected' : '' ?>"
|
||||||
|
style="background:<?= $hex ?: '#fff' ?>; border:2px solid var(--border);"
|
||||||
|
onclick="selectColor(this,'new-note-color-input','<?= $hex ?>')"
|
||||||
|
title="<?= $info['label'] ?>"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="color" id="new-note-color-input" value="">
|
||||||
|
</div>
|
||||||
|
<!-- Image upload -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light);">Attach Image (optional)</label>
|
||||||
|
<input type="file" name="note_image" accept="image/*" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('new-note-modal').classList.remove('active')">Cancel</button>
|
||||||
|
<button type="submit" class="btn"><i class="fa fa-save"></i> Save Note</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 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;">
|
||||||
|
<h3 style="margin-bottom:1rem;">Edit Note</h3>
|
||||||
|
<form method="POST" enctype="multipart/form-data" id="edit-note-form">
|
||||||
|
<input type="hidden" name="update_note" value="1">
|
||||||
|
<input type="hidden" name="note_id" id="edit-note-id">
|
||||||
|
<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="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','italic')"><i>I</i></button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','# ')">H1</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','## ')">H2</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','### ')">H3</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','- ')">• List</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','1. ')">1. List</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','- [ ] ')">☑ Todo</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="wrapMarkdown('edit-note-content','`','`')"><code>Code</code></button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="wrapMarkdown('edit-note-content','```\n','\n```')">{ } Block</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" onclick="insertMarkdown('edit-note-content','> ')">❝ Quote</button>
|
||||||
|
<button type="button" class="note-toolbar-btn" id="edit-preview-btn" onclick="togglePreview('edit-note-content','edit-note-preview','edit-preview-btn')">👁 Preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea name="content" id="edit-note-content" class="note-content-area" rows="8"></textarea>
|
||||||
|
<div id="edit-note-preview" class="markdown-body" style="display:none; min-height:160px; border:1px solid var(--border); border-radius:0.5rem; padding:0.75rem; background:var(--bg); font-size:0.9rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light);">Note Color</label>
|
||||||
|
<div class="note-color-palette" id="edit-note-colors">
|
||||||
|
<?php foreach($note_colors as $hex => $info): ?>
|
||||||
|
<div class="color-swatch"
|
||||||
|
style="background:<?= $hex ?: '#fff' ?>; border:2px solid var(--border);"
|
||||||
|
onclick="selectColor(this,'edit-note-color-input','<?= $hex ?>')"
|
||||||
|
title="<?= $info['label'] ?>"></div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="color" id="edit-note-color-input" value="">
|
||||||
|
</div>
|
||||||
|
<div id="edit-current-image" style="display:none; margin-bottom:0.75rem;">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light);">Current Image:</label>
|
||||||
|
<div style="display:flex; align-items:center; gap:0.5rem; margin-top:0.25rem;">
|
||||||
|
<img id="edit-current-img-preview" src="" alt="Current" style="height:60px; border-radius:0.5rem; object-fit:cover;">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.25rem; font-size:0.8rem; cursor:pointer;">
|
||||||
|
<input type="checkbox" name="remove_image" value="1"> Remove image
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light);">Replace Image (optional)</label>
|
||||||
|
<input type="file" name="note_image" accept="image/*" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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;">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.4rem; display:block;">Share this note</label>
|
||||||
|
<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;">
|
||||||
|
<?php foreach($all_users as $u): ?>
|
||||||
|
<option value="user_<?= $u['id'] ?>">👤 <?= htmlspecialchars($u['username']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach($all_groups as $g): ?>
|
||||||
|
<option value="group_<?= $g['id'] ?>">👥 <?= htmlspecialchars($g['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" name="share_note" value="1" class="btn btn-sm" onclick="this.form.action=''; document.querySelector('[name=update_note]').disabled=true; this.form.onsubmit=null;" formaction="">
|
||||||
|
<i class="fa fa-share-nodes"></i> Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:0.5rem; justify-content:space-between; margin-top:1rem; flex-wrap:wrap;">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="deleteNote(document.getElementById('edit-note-id').value)"><i class="fa fa-trash"></i> Delete</button>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-note-modal').classList.remove('active')">Cancel</button>
|
||||||
|
<button type="submit" class="btn"><i class="fa fa-save"></i> Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Note Form -->
|
||||||
|
<form method="POST" id="delete-note-form" style="display:none;">
|
||||||
|
<input type="hidden" name="delete_note" value="1">
|
||||||
|
<input type="hidden" name="note_id" id="delete-note-id">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Share Note Form (separate POST for share_note) -->
|
||||||
|
<form method="POST" id="share-note-standalone-form" style="display:none;">
|
||||||
|
<input type="hidden" name="share_note" value="1">
|
||||||
|
<input type="hidden" name="note_id" id="share-note-standalone-id">
|
||||||
|
<input type="hidden" name="share_target" id="share-note-standalone-target">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr 2fr; gap:1.5rem;">
|
<!-- ── Notes Grid ─────────────────────────────────────────────────────────── -->
|
||||||
<div class="card">
|
<?php if(empty($notes)): ?>
|
||||||
<h3>Create Note</h3>
|
<div style="text-align:center; padding:4rem 2rem; color:var(--text-light);">
|
||||||
<form method="POST" style="margin-top:1rem;">
|
<div style="font-size:3rem; margin-bottom:1rem;">📝</div>
|
||||||
<div class="form-group"><input type="text" name="title" class="form-control" placeholder="Note Title" required></div>
|
<h3 style="margin-bottom:0.5rem;">No notes yet</h3>
|
||||||
<div class="form-group">
|
<p>Click <strong>New Note</strong> to start writing something.</p>
|
||||||
<textarea name="content" class="form-control" placeholder="Markdown supported..." style="min-height:200px;" required></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" name="create_note" class="btn">Save Note</button>
|
<?php else: ?>
|
||||||
</form>
|
<div class="notes-grid">
|
||||||
</div>
|
<?php foreach($notes as $n):
|
||||||
|
$bg = $n['color'] ?: 'var(--surface)';
|
||||||
<div class="card">
|
$preview = strip_tags(isset($n['content']) ? (function_exists('marked') ? $n['content'] : $n['content']) : '');
|
||||||
<h3>My Notes</h3>
|
$preview = mb_substr($preview, 0, 120) . (mb_strlen($preview) > 120 ? '...' : '');
|
||||||
<?php foreach($notes as $n): ?>
|
?>
|
||||||
<div style="border:1px solid var(--border); border-radius:1rem; padding:1.5rem; margin-bottom:1.5rem;">
|
<div class="note-card"
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:1rem;">
|
style="background:<?= htmlspecialchars($bg) ?>;"
|
||||||
<h4 style="font-size:1.2rem; color:var(--primary);"><?= htmlspecialchars($n['title']) ?> <?= !$n['is_mine'] ? '<span class="badge" style="background:#10B981; font-size:10px;">Shared</span>' : '' ?></h4>
|
data-id="<?= $n['id'] ?>"
|
||||||
<?php if($n['is_mine']): ?>
|
data-title="<?= htmlspecialchars(addslashes((string)$n['title'])) ?>"
|
||||||
<form method="POST" style="display:inline; display:flex; gap:0.5rem;">
|
data-content="<?= htmlspecialchars(addslashes((string)$n['content'])) ?>"
|
||||||
<input type="hidden" name="note_id" value="<?= $n['id'] ?>">
|
data-color="<?= htmlspecialchars((string)$n['color']) ?>"
|
||||||
<select name="share_target" class="form-control" style="width:auto; padding:0.25rem;">
|
data-image="<?= htmlspecialchars((string)($n['image_path'] ?? '')) ?>"
|
||||||
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
|
data-mine="<?= $n['is_mine'] ?>"
|
||||||
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
|
onclick="openNote(this)">
|
||||||
</select>
|
<?php if($n['image_path'] && file_exists($n['image_path'])): ?>
|
||||||
<button type="submit" name="share_note" class="btn btn-sm">Share</button>
|
<img src="<?= htmlspecialchars($n['image_path']) ?>" alt="Note image" class="note-card-img">
|
||||||
</form>
|
<?php endif; ?>
|
||||||
|
<?php if($n['title']): ?>
|
||||||
|
<div class="note-card-title"><?= htmlspecialchars($n['title']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="note-card-preview"><?= htmlspecialchars($preview) ?></div>
|
||||||
|
<div class="note-card-footer">
|
||||||
|
<div style="display:flex; align-items:center; gap:0.4rem;">
|
||||||
|
<?php if($n['color']): ?><div class="note-color-dot" style="background:<?= htmlspecialchars($n['color']) ?>;"></div><?php endif; ?>
|
||||||
|
<span style="font-size:0.7rem; color:var(--text-light);"><?= date('M j', strtotime($n['updated_at'] ?: $n['created_at'])) ?></span>
|
||||||
|
<?php if(!$n['is_mine']): ?>
|
||||||
|
<span class="note-card-shared-badge">Shared</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="note-card-actions" onclick="event.stopPropagation()">
|
||||||
|
<?php if($n['is_mine']): ?>
|
||||||
|
<button class="btn btn-sm btn-danger" style="padding:3px 7px;" onclick="deleteNote(<?= $n['id'] ?>)" title="Delete"><i class="fa fa-trash"></i></button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="markdown-body" style="background:var(--bg); padding:1rem; border-radius:0.5rem; font-size:0.95rem;">
|
|
||||||
<script>document.write(marked.parse(`<?= str_replace(array('\\', '`', '$'), array('\\\\', '\\`', '\\$'), (string)$n['content']) ?>`));</script>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if(!$notes): ?><p>No notes written yet.</p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── WYSIWYG Markdown Tools ────────────────────────────────────────────────────
|
||||||
|
function formatNote(textareaId, cmd) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
const start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
const sel = ta.value.substring(start, end);
|
||||||
|
let wrap = cmd === 'bold' ? '**' : '_';
|
||||||
|
const replacement = `${wrap}${sel || 'text'}${wrap}`;
|
||||||
|
ta.value = ta.value.substring(0, start) + replacement + ta.value.substring(end);
|
||||||
|
ta.focus();
|
||||||
|
ta.selectionStart = start + wrap.length;
|
||||||
|
ta.selectionEnd = start + wrap.length + (sel || 'text').length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMarkdown(textareaId, prefix) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
// Go to start of line
|
||||||
|
const before = ta.value.substring(0, start);
|
||||||
|
const lineStart = before.lastIndexOf('\n') + 1;
|
||||||
|
ta.value = ta.value.substring(0, lineStart) + prefix + ta.value.substring(lineStart);
|
||||||
|
ta.focus();
|
||||||
|
const newPos = lineStart + prefix.length + (start - lineStart);
|
||||||
|
ta.selectionStart = ta.selectionEnd = newPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapMarkdown(textareaId, before, after) {
|
||||||
|
const ta = document.getElementById(textareaId);
|
||||||
|
const start = ta.selectionStart, end = ta.selectionEnd;
|
||||||
|
const sel = ta.value.substring(start, end);
|
||||||
|
ta.value = ta.value.substring(0, start) + before + (sel || 'code') + after + ta.value.substring(end);
|
||||||
|
ta.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview(contentId, previewId, btnId) {
|
||||||
|
const content = document.getElementById(contentId);
|
||||||
|
const preview = document.getElementById(previewId);
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if(preview.style.display === 'none') {
|
||||||
|
preview.innerHTML = marked.parse(content.value || '');
|
||||||
|
preview.style.display = 'block';
|
||||||
|
content.style.display = 'none';
|
||||||
|
btn.textContent = '✏️ Edit';
|
||||||
|
} else {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
content.style.display = 'block';
|
||||||
|
btn.textContent = '👁 Preview';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectColor(el, inputId, color) {
|
||||||
|
el.closest('.note-color-palette').querySelectorAll('.color-swatch').forEach(s => s.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
document.getElementById(inputId).value = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(card) {
|
||||||
|
const id = card.dataset.id;
|
||||||
|
const title = card.dataset.title;
|
||||||
|
const content = card.dataset.content;
|
||||||
|
const color = card.dataset.color;
|
||||||
|
const image = card.dataset.image;
|
||||||
|
const isMine = card.dataset.mine === '1';
|
||||||
|
|
||||||
|
document.getElementById('edit-note-id').value = id;
|
||||||
|
document.getElementById('edit-note-title').value = title;
|
||||||
|
document.getElementById('edit-note-content').value = content;
|
||||||
|
document.getElementById('edit-note-color-input').value = color;
|
||||||
|
document.getElementById('edit-note-preview').style.display = 'none';
|
||||||
|
document.getElementById('edit-note-content').style.display = 'block';
|
||||||
|
document.getElementById('edit-preview-btn').textContent = '👁 Preview';
|
||||||
|
|
||||||
|
// Color swatches
|
||||||
|
document.querySelectorAll('#edit-note-colors .color-swatch').forEach(s => {
|
||||||
|
s.classList.remove('selected');
|
||||||
|
const swatchColor = s.getAttribute('onclick').match(/'([^']*)'/g);
|
||||||
|
if(swatchColor && swatchColor[1] && swatchColor[1].replace(/'/g,'') === color) s.classList.add('selected');
|
||||||
|
if(!color && s.title === 'Default') s.classList.add('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image preview
|
||||||
|
const imgBlock = document.getElementById('edit-current-image');
|
||||||
|
if(image) {
|
||||||
|
imgBlock.style.display = 'block';
|
||||||
|
document.getElementById('edit-current-img-preview').src = image;
|
||||||
|
} else {
|
||||||
|
imgBlock.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share section
|
||||||
|
document.getElementById('edit-share-section').style.display = isMine ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Wire up share button
|
||||||
|
const shareBtn = document.querySelector('#edit-note-form button[name="share_note"]');
|
||||||
|
if(shareBtn) {
|
||||||
|
shareBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = document.getElementById('edit-share-target').value;
|
||||||
|
document.getElementById('share-note-standalone-id').value = id;
|
||||||
|
document.getElementById('share-note-standalone-target').value = target;
|
||||||
|
document.getElementById('share-note-standalone-form').submit();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('edit-note-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNote(id) {
|
||||||
|
if(!confirm('Delete this note permanently?')) return;
|
||||||
|
document.getElementById('delete-note-id').value = id;
|
||||||
|
document.getElementById('delete-note-form').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix note card colors in dark mode
|
||||||
|
function applyDarkNoteColors() {
|
||||||
|
const isDark = document.body.classList.contains('dark-mode');
|
||||||
|
document.querySelectorAll('.note-card').forEach(card => {
|
||||||
|
const origColor = card.dataset.color;
|
||||||
|
if(!origColor && isDark) {
|
||||||
|
card.style.background = 'var(--surface)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const observer = new MutationObserver(applyDarkNoteColors);
|
||||||
|
observer.observe(document.body, {attributes: true, attributeFilter: ['class']});
|
||||||
|
applyDarkNoteColors();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,26 +1,68 @@
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_password'])) {
|
// ── DB migrations ─────────────────────────────────────────────────────────────
|
||||||
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id) VALUES (?, ?, ?, ?, ?, ?)");
|
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN folder_id INTEGER DEFAULT NULL"); } catch(Exception $e) {}
|
||||||
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id]);
|
try {
|
||||||
}
|
$pdo->exec("CREATE TABLE IF NOT EXISTS password_folders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_id INTEGER,
|
||||||
|
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)");
|
||||||
|
} catch(Exception $e) {}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bulk_action'])) {
|
// ── Handle POST ───────────────────────────────────────────────────────────────
|
||||||
if(isset($_POST['selected_passwords'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
|
||||||
|
if(isset($_POST['create_folder'])) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO password_folders (name, owner_id) VALUES (?, ?)");
|
||||||
|
$stmt->execute([$_POST['folder_name'], $user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['delete_folder'])) {
|
||||||
|
// Move items to no folder
|
||||||
|
$pdo->prepare("UPDATE passwords SET folder_id=NULL WHERE folder_id=? AND owner_id=?")->execute([$_POST['folder_id'], $user_id]);
|
||||||
|
$pdo->prepare("DELETE FROM password_folders WHERE id=? AND owner_id=?")->execute([$_POST['folder_id'], $user_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['create_password'])) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id, folder_id) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id, $_POST['folder_id'] ?: null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['update_password'])) {
|
||||||
|
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
||||||
|
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->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $_POST['folder_id'] ?: null, $_POST['password_id'], $user_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['delete_password'])) {
|
||||||
|
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
|
||||||
|
if($p && $p['owner_id'] == $user_id) {
|
||||||
|
query("DELETE FROM passwords WHERE id=? AND owner_id=?", [$_POST['password_id'], $user_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unshare a specific share record
|
||||||
|
if(isset($_POST['remove_password_share'])) {
|
||||||
|
$share_id = (int)$_POST['share_id'];
|
||||||
|
$chk = query("SELECT ps.id FROM password_shares ps JOIN passwords p ON ps.password_id=p.id WHERE ps.id=? AND p.owner_id=?", [$share_id, $user_id])->fetch();
|
||||||
|
if($chk) query("DELETE FROM password_shares WHERE id=?", [$share_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isset($_POST['bulk_action']) && isset($_POST['selected_passwords'])) {
|
||||||
$selected = $_POST['selected_passwords'];
|
$selected = $_POST['selected_passwords'];
|
||||||
$action = $_POST['bulk_action'];
|
$action = $_POST['bulk_action'];
|
||||||
|
|
||||||
if($action === 'delete') {
|
if($action === 'delete') {
|
||||||
foreach($selected as $p_id) {
|
foreach($selected as $p_id) {
|
||||||
// Check if owner
|
|
||||||
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$p_id])->fetch();
|
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$p_id])->fetch();
|
||||||
if($p && $p['owner_id']==$user_id) {
|
if($p && $p['owner_id']==$user_id) query("DELETE FROM passwords WHERE id=?", [$p_id]);
|
||||||
query("DELETE FROM passwords WHERE id=?", [$p_id]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} elseif($action === 'share') {
|
} elseif($action === 'share') {
|
||||||
$share_target = $_POST['share_target']; // 'user_1'
|
$share_target = $_POST['share_target'];
|
||||||
$parts = explode('_', $share_target);
|
$parts = explode('_', $share_target, 2);
|
||||||
|
if(count($parts)===2) {
|
||||||
foreach($selected as $p_id) {
|
foreach($selected as $p_id) {
|
||||||
$p = query("SELECT owner_id, title 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) {
|
if($p && $p['owner_id']==$user_id) {
|
||||||
@@ -32,134 +74,500 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bulk_action'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} elseif($action === 'move_folder' && isset($_POST['move_folder_target'])) {
|
||||||
|
$fid = $_POST['move_folder_target'] ?: null;
|
||||||
|
foreach($selected as $p_id) {
|
||||||
|
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$p_id])->fetch();
|
||||||
|
if($p && $p['owner_id']==$user_id) {
|
||||||
|
$pdo->prepare("UPDATE passwords SET folder_id=? WHERE id=?")->execute([$fid, $p_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch passwords (owned + shared)
|
// ── Fetch ─────────────────────────────────────────────────────────────────────
|
||||||
$user_groups = get_user_groups($user_id);
|
$user_groups = get_user_groups($user_id);
|
||||||
$my_group_ids = array_column($user_groups, 'id');
|
$my_group_ids = array_column($user_groups, 'id');
|
||||||
$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, $user_id], $my_group_ids);
|
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
|
||||||
|
|
||||||
$sql = "SELECT p.*, (p.owner_id = ?) as is_mine FROM passwords p
|
$sql = "SELECT p.*, (p.owner_id = ?) as is_mine, pf.name as folder_name FROM passwords p
|
||||||
LEFT JOIN password_shares ps ON p.id = ps.password_id
|
LEFT JOIN password_shares ps ON p.id = ps.password_id
|
||||||
|
LEFT JOIN password_folders pf ON p.folder_id = pf.id
|
||||||
WHERE p.owner_id = ?
|
WHERE p.owner_id = ?
|
||||||
OR (ps.share_type='user' AND ps.share_id = ?)
|
OR (ps.share_type='user' AND ps.share_id = ?)
|
||||||
OR (ps.share_type='group' AND ps.share_id IN ($group_in))
|
OR (ps.share_type='group' AND ps.share_id IN ($group_in))
|
||||||
GROUP BY p.id ORDER BY p.title";
|
GROUP BY p.id ORDER BY pf.name ASC, p.title ASC";
|
||||||
$params = array_merge([$user_id, $user_id, $user_id], $my_group_ids);
|
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
$passwords = $stmt->fetchAll();
|
$passwords = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Folder list (own folders only)
|
||||||
|
$folders = query("SELECT * FROM password_folders WHERE owner_id=? ORDER BY name", [$user_id])->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();
|
||||||
|
|
||||||
|
// Group passwords by folder
|
||||||
|
$grouped = ['__none__' => []];
|
||||||
|
foreach($folders as $f) $grouped[$f['id']] = [];
|
||||||
|
foreach($passwords as $p) {
|
||||||
|
$fk = $p['folder_id'] ?? '__none__';
|
||||||
|
if(!isset($grouped[$fk])) $grouped[$fk] = [];
|
||||||
|
$grouped[$fk][] = $p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active folder filter
|
||||||
|
$active_folder = $_GET['vault_folder'] ?? 'all';
|
||||||
?>
|
?>
|
||||||
<div class="header">
|
|
||||||
<h1>Passwords Vault</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-overlay" id="password-modal">
|
<!-- ── Password View Modal ─────────────────────────────────────────────────── -->
|
||||||
<div class="modal-content">
|
<div class="modal-overlay" id="password-view-modal">
|
||||||
<h3 style="margin-bottom:1rem;">Password View</h3>
|
<div class="modal-content" style="max-width:380px;">
|
||||||
<input type="text" id="modal-password" class="form-control" readonly style="margin-bottom:1rem; font-family:monospace; font-size:1.1rem;">
|
<h3 style="margin-bottom:1rem; font-size:1.05rem;">Password Entry</h3>
|
||||||
<div style="display:flex; gap:0.5rem; justify-content:flex-end;">
|
<div id="pw-view-title" style="font-weight:700; font-size:1rem; margin-bottom:0.25rem;"></div>
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('password-modal').classList.remove('active')">Close</button>
|
<div id="pw-view-url" style="font-size:0.8rem; color:var(--primary); margin-bottom:1rem;"></div>
|
||||||
</div>
|
<div class="form-group" style="margin-bottom:0.5rem;">
|
||||||
</div>
|
<label style="font-size:0.78rem; color:var(--text-light);">Username</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:grid; grid-template-columns: 1fr 2fr; gap:1.5rem;">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Add Password</h3>
|
|
||||||
<form method="POST" style="margin-top:1rem;">
|
|
||||||
<div class="form-group"><input type="text" name="title" class="form-control" placeholder="Title/Service" required></div>
|
|
||||||
<div class="form-group"><input type="text" name="username" class="form-control" placeholder="Username"></div>
|
|
||||||
<div class="form-group" style="display:flex; gap:0.5rem;">
|
|
||||||
<input type="password" name="password" id="new_pw" class="form-control" placeholder="Password" required>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="genPassword()">🎲</button>
|
|
||||||
</div>
|
|
||||||
<div class="form-group"><input type="text" name="url" class="form-control" placeholder="Website URL"></div>
|
|
||||||
<div class="form-group"><textarea name="notes" class="form-control" placeholder="Notes"></textarea></div>
|
|
||||||
<button type="submit" name="create_password" class="btn">Save Password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>My Vault</h3>
|
|
||||||
<form method="POST" id="bulk-form">
|
|
||||||
<div style="display:flex; justify-content:space-between; margin-bottom:1rem;">
|
|
||||||
<div style="display:flex; gap:0.5rem;">
|
<div style="display:flex; gap:0.5rem;">
|
||||||
<button type="submit" name="bulk_action" value="delete" class="btn btn-sm btn-danger" onclick="return confirm('Delete selected?')">Delete Selected</button>
|
<input type="text" id="pw-view-user" class="form-control" readonly>
|
||||||
<div style="display:flex; align-items:center;">
|
<button class="btn btn-secondary btn-sm" onclick="copyFieldValue('pw-view-user')"><i class="fa fa-copy"></i></button>
|
||||||
<select name="share_target" class="form-control" style="width:150px; padding:0.25rem;">
|
|
||||||
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
|
|
||||||
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button type="submit" name="bulk_action" value="share" class="btn btn-sm" style="margin-left:0.5rem;">Share Selected</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="color:var(--text-light);font-size:0.875rem;">Shortcuts:<br>Ctrl+B (Copy User), Ctrl+C (Copy Password)<br>Hover row to activate</div>
|
<div class="form-group">
|
||||||
|
<label style="font-size:0.78rem; color:var(--text-light);">Password</label>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<input type="password" id="pw-view-pw" class="form-control" readonly style="font-family:monospace;">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="togglePwView()" id="pw-view-toggle"><i class="fa fa-eye"></i></button>
|
||||||
|
<button class="btn btn-sm" onclick="copyFieldValue('pw-view-pw')"><i class="fa fa-copy"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
</div>
|
||||||
<thead><tr><th><input type="checkbox" onclick="toggleAll(this)"></th><th>Title</th><th>Username</th><th>Actions</th></tr></thead>
|
<div id="pw-view-notes" style="font-size:0.8rem; color:var(--text-light); margin-top:0.5rem; white-space:pre-wrap;"></div>
|
||||||
<tbody>
|
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem;">
|
||||||
<?php foreach($passwords as $p): ?>
|
<button class="btn btn-secondary" onclick="document.getElementById('password-view-modal').classList.remove('active')">Close</button>
|
||||||
<tr class="pw-row" data-pw="<?= htmlspecialchars($p['password']) ?>" data-usr="<?= htmlspecialchars((string)$p['username']) ?>">
|
</div>
|
||||||
<td><?php if($p['is_mine']): ?><input type="checkbox" name="selected_passwords[]" value="<?= $p['id'] ?>" class="pw-checkbox"><?php endif; ?></td>
|
</div>
|
||||||
<td><?= htmlspecialchars($p['title']) ?> <?= !$p['is_mine'] ? '<span class="badge" style="background:var(--secondary);">Shared</span>' : '' ?></td>
|
</div>
|
||||||
<td><?= htmlspecialchars((string)$p['username']) ?></td>
|
|
||||||
<td>
|
<!-- ── Add/Edit Password Modal ────────────────────────────────────────────── -->
|
||||||
<button type="button" onclick="showGw('<?= htmlspecialchars(addslashes($p['password'])) ?>')" class="btn btn-sm btn-secondary">👀 View</button>
|
<div class="modal-overlay" id="password-edit-modal">
|
||||||
<button type="button" onclick="copyText('<?= htmlspecialchars(addslashes($p['password'])) ?>')" class="btn btn-sm">📋 Copy Pw</button>
|
<div class="modal-content" style="max-width:480px;">
|
||||||
</td>
|
<h3 style="margin-bottom:1rem; font-size:1.05rem;" id="pw-edit-modal-title">Add Password</h3>
|
||||||
</tr>
|
<form method="POST" id="pw-edit-form">
|
||||||
|
<input type="hidden" name="password_id" id="pw-edit-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Title / Service</label>
|
||||||
|
<input type="text" name="title" id="pw-edit-title" class="form-control" placeholder="e.g. Google Account" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username / Email</label>
|
||||||
|
<input type="text" name="username" id="pw-edit-uname" class="form-control" placeholder="Username or email">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<input type="password" name="password" id="pw-edit-pw" class="form-control" placeholder="Password" required>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleEditPwView()" title="Show/hide"><i class="fa fa-eye" id="pw-edit-eye"></i></button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="genPassword()" title="Generate"><i class="fa fa-shuffle"></i></button>
|
||||||
|
</div>
|
||||||
|
<!-- Password strength bar -->
|
||||||
|
<div id="pw-strength-bar" style="height:4px; background:var(--border); border-radius:4px; margin-top:4px; overflow:hidden;">
|
||||||
|
<div id="pw-strength-fill" style="height:100%; width:0%; transition:width 0.3s, background 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pw-strength-label" style="font-size:0.72rem; color:var(--text-light); margin-top:2px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Website URL</label>
|
||||||
|
<input type="text" name="url" id="pw-edit-url" class="form-control" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Folder</label>
|
||||||
|
<select name="folder_id" id="pw-edit-folder" class="form-control">
|
||||||
|
<option value="">— No Folder —</option>
|
||||||
|
<?php foreach($folders as $f): ?>
|
||||||
|
<option value="<?= $f['id'] ?>"><?= htmlspecialchars($f['name']) ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</select>
|
||||||
</table>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea name="notes" id="pw-edit-notes" class="form-control" rows="2" placeholder="Optional notes..."></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- 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;">
|
||||||
|
<label style="font-size:0.8rem; color:var(--text-light); display:block; margin-bottom:0.4rem;">Shared with</label>
|
||||||
|
<div id="pw-shares-list" style="display:flex; flex-wrap:wrap; gap:0.35rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; justify-content:space-between; gap:0.5rem; flex-wrap:wrap; margin-top:0.75rem;">
|
||||||
|
<div id="pw-edit-delete-wrap" style="display:none;">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="deletePassword()"><i class="fa fa-trash"></i> Delete</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.5rem; margin-left:auto;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('password-edit-modal').classList.remove('active')">Cancel</button>
|
||||||
|
<button type="submit" class="btn" id="pw-save-btn"><i class="fa fa-save"></i> Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Password form -->
|
||||||
|
<form method="POST" id="delete-pw-form" style="display:none;">
|
||||||
|
<input type="hidden" name="delete_password" value="1">
|
||||||
|
<input type="hidden" name="password_id" id="delete-pw-id">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Add folder form (hidden) -->
|
||||||
|
<form method="POST" id="add-folder-form" style="display:none;">
|
||||||
|
<input type="hidden" name="create_folder" value="1">
|
||||||
|
<input type="hidden" name="folder_name" id="add-folder-name">
|
||||||
|
</form>
|
||||||
|
<form method="POST" id="del-folder-form" style="display:none;">
|
||||||
|
<input type="hidden" name="delete_folder" value="1">
|
||||||
|
<input type="hidden" name="folder_id" id="del-folder-id">
|
||||||
|
</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 class="vault-layout" style="min-height:calc(100vh - 200px);">
|
||||||
|
<!-- Vault Sidebar (Folders) -->
|
||||||
|
<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>
|
||||||
|
<a href="?action=passwords&vault_folder=all" class="vault-folder-item <?= $active_folder==='all'?'active':'' ?>">
|
||||||
|
<i class="fa fa-vault"></i> All Entries
|
||||||
|
<span style="margin-left:auto; font-size:0.75rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 6px; border-radius:10px;"><?= count($passwords) ?></span>
|
||||||
|
</a>
|
||||||
|
<a href="?action=passwords&vault_folder=__none__" class="vault-folder-item <?= $active_folder==='__none__'?'active':'' ?>">
|
||||||
|
<i class="fa fa-inbox"></i> No Folder
|
||||||
|
<span style="margin-left:auto; font-size:0.75rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 6px; border-radius:10px;"><?= count($grouped['__none__'] ?? []) ?></span>
|
||||||
|
</a>
|
||||||
|
<?php foreach($folders as $f): ?>
|
||||||
|
<div style="display:flex; align-items:center; gap:0;">
|
||||||
|
<a href="?action=passwords&vault_folder=<?= $f['id'] ?>" class="vault-folder-item <?= $active_folder==$f['id']?'active':'' ?>" style="flex:1;">
|
||||||
|
<i class="fa fa-folder"></i> <?= htmlspecialchars($f['name']) ?>
|
||||||
|
<span style="margin-left:auto; font-size:0.75rem; background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 6px; border-radius:10px;"><?= count($grouped[$f['id']] ?? []) ?></span>
|
||||||
|
</a>
|
||||||
|
<button class="vault-folder-item" style="width:28px; padding:0.3rem; flex-shrink:0; color:var(--text-light);" onclick="deleteFolder(<?= $f['id'] ?>, '<?= htmlspecialchars(addslashes($f['name'])) ?>')" title="Delete folder"><i class="fa fa-xmark" style="font-size:0.75rem;"></i></button>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div style="margin-top:auto; padding-top:0.75rem; border-top:1px solid var(--border);">
|
||||||
|
<div style="font-size:0.7rem; color:var(--text-light); text-align:center;">
|
||||||
|
<i class="fa fa-lock"></i> <?= count($passwords) ?> entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vault Main -->
|
||||||
|
<div class="vault-main">
|
||||||
|
<!-- Search + Bulk Actions -->
|
||||||
|
<div class="vault-search">
|
||||||
|
<div style="position:relative; flex:1;">
|
||||||
|
<i class="fa fa-search" style="position:absolute; left:0.75rem; top:50%; transform:translateY(-50%); color:var(--text-light); font-size:0.875rem;"></i>
|
||||||
|
<input type="text" id="vault-search-input" class="form-control" placeholder="Search entries..." style="padding-left:2.2rem;" oninput="filterVault(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Actions -->
|
||||||
|
<form method="POST" id="bulk-form" style="margin-bottom:1rem;">
|
||||||
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; font-size:0.8rem; color:var(--text-light);">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.35rem; cursor:pointer; font-weight:600;">
|
||||||
|
<input type="checkbox" id="select-all-chk" onclick="toggleAll(this)"> Select All
|
||||||
|
</label>
|
||||||
|
<select name="share_target" class="form-control" style="width:160px; padding:0.35rem; font-size:0.8rem;">
|
||||||
|
<?php foreach($all_users as $u): ?>
|
||||||
|
<option value="user_<?= $u['id'] ?>">👤 <?= htmlspecialchars($u['username']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach($all_groups as $g): ?>
|
||||||
|
<option value="group_<?= $g['id'] ?>">👥 <?= htmlspecialchars($g['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" name="bulk_action" value="share" class="btn btn-sm btn-secondary"><i class="fa fa-share-nodes"></i> Share</button>
|
||||||
|
<select name="move_folder_target" class="form-control" style="width:130px; padding:0.35rem; font-size:0.8rem;">
|
||||||
|
<option value="">— No Folder —</option>
|
||||||
|
<?php foreach($folders as $f): ?>
|
||||||
|
<option value="<?= $f['id'] ?>"><?= htmlspecialchars($f['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</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="delete" class="btn btn-sm btn-danger" onclick="return confirm('Delete selected entries?')"><i class="fa fa-trash"></i> Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entries List -->
|
||||||
|
<div style="margin-top:0.75rem;">
|
||||||
|
<?php
|
||||||
|
// Determine which passwords to show
|
||||||
|
$show_passwords = [];
|
||||||
|
if($active_folder === 'all') {
|
||||||
|
$show_passwords = $passwords;
|
||||||
|
} elseif(isset($grouped[$active_folder])) {
|
||||||
|
$show_passwords = $grouped[$active_folder];
|
||||||
|
}
|
||||||
|
|
||||||
|
if(empty($show_passwords)):
|
||||||
|
?>
|
||||||
|
<div style="text-align:center; padding:3rem 1rem; color:var(--text-light);">
|
||||||
|
<div style="font-size:2.5rem; margin-bottom:0.75rem;">🔐</div>
|
||||||
|
<p>No entries here yet.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php
|
||||||
|
// Group by folder when showing "all"
|
||||||
|
if($active_folder === 'all') {
|
||||||
|
$display_groups = ['__none__' => ['label'=>'No Folder','entries'=>[]]];
|
||||||
|
foreach($folders as $f) $display_groups[$f['id']] = ['label'=>$f['name'],'entries'=>[]];
|
||||||
|
foreach($show_passwords as $p) {
|
||||||
|
$key = $p['folder_id'] ?? '__none__';
|
||||||
|
if(!isset($display_groups[$key])) $display_groups[$key] = ['label'=>'No Folder','entries'=>[]];
|
||||||
|
$display_groups[$key]['entries'][] = $p;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$display_groups = ['current' => ['label'=> ($active_folder==='__none__' ? 'No Folder' : (array_column($folders,'name','id')[$active_folder] ?? 'Folder')), 'entries'=>$show_passwords]];
|
||||||
|
}
|
||||||
|
foreach($display_groups as $gkey => $gdata):
|
||||||
|
if(empty($gdata['entries'])) continue;
|
||||||
|
?>
|
||||||
|
<?php if($active_folder === 'all'): ?>
|
||||||
|
<div style="font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-light); font-weight:700; padding:0.75rem 0 0.35rem; display:flex; align-items:center; gap:0.5rem;">
|
||||||
|
<i class="fa <?= $gkey==='__none__' ? 'fa-inbox' : 'fa-folder' ?>"></i>
|
||||||
|
<?= htmlspecialchars($gdata['label']) ?>
|
||||||
|
<span style="background:rgba(var(--primary-rgb),0.1); color:var(--primary); padding:1px 6px; border-radius:10px;"><?= count($gdata['entries']) ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach($gdata['entries'] as $p):
|
||||||
|
$domain = '';
|
||||||
|
if($p['url']) {
|
||||||
|
$parsed = parse_url($p['url']);
|
||||||
|
$domain = $parsed['host'] ?? '';
|
||||||
|
$domain = preg_replace('/^www\./', '', $domain);
|
||||||
|
}
|
||||||
|
$favicon = $domain ? "https://www.google.com/s2/favicons?domain={$domain}&sz=32" : '';
|
||||||
|
?>
|
||||||
|
<div class="vault-entry pw-row"
|
||||||
|
data-pw="<?= htmlspecialchars(addslashes($p['password'])) ?>"
|
||||||
|
data-usr="<?= htmlspecialchars((string)$p['username']) ?>"
|
||||||
|
data-id="<?= $p['id'] ?>"
|
||||||
|
onclick="openViewEntry(this)">
|
||||||
|
<!-- Checkbox (only for owned items) -->
|
||||||
|
<?php if($p['is_mine']): ?>
|
||||||
|
<div onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" name="selected_passwords[]" value="<?= $p['id'] ?>" class="pw-checkbox">
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="width:16px;"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="vault-entry-icon">
|
||||||
|
<?php if($favicon): ?>
|
||||||
|
<img src="<?= htmlspecialchars($favicon) ?>" alt="" onerror="this.style.display='none'; this.nextSibling.style.display='flex';">
|
||||||
|
<span style="display:none;"><i class="fa fa-globe"></i></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="fa fa-key"></i>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="vault-entry-info">
|
||||||
|
<div class="vault-entry-title">
|
||||||
|
<?= htmlspecialchars($p['title']) ?>
|
||||||
|
<?php if(!$p['is_mine']): ?><span class="badge" style="font-size:0.6rem; padding:1px 5px; vertical-align:middle; margin-left:4px;">Shared</span><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="vault-entry-user"><?= htmlspecialchars((string)$p['username']) ?: ($domain ?: '—') ?></div>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="vault-entry-actions" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="copyPw(this.closest('.vault-entry').dataset.pw)" title="Copy Password"><i class="fa fa-copy"></i></button>
|
||||||
|
<?php if($p['is_mine']): ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditEntry(this.closest('.vault-entry'))" title="Edit"><i class="fa fa-pen"></i></button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Status bar -->
|
||||||
|
<div class="explorer-status-bar">
|
||||||
|
<span><i class="fa fa-database" style="margin-right:4px;"></i><?= count($passwords) ?> total entries</span>
|
||||||
|
<span title="Keyboard Shortcuts">Hover + Ctrl+C = Copy PW | Ctrl+B = Copy Username</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ── Password strength ─────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('pw-edit-pw').addEventListener('input', function() {
|
||||||
|
const v = this.value;
|
||||||
|
let score = 0;
|
||||||
|
if(v.length >= 8) score++;
|
||||||
|
if(v.length >= 12) score++;
|
||||||
|
if(/[A-Z]/.test(v)) score++;
|
||||||
|
if(/[0-9]/.test(v)) score++;
|
||||||
|
if(/[^A-Za-z0-9]/.test(v)) score++;
|
||||||
|
const fills = ['0%','25%','50%','75%','100%'];
|
||||||
|
const colors = ['#EF4444','#F59E0B','#EAB308','#10B981','#10B981'];
|
||||||
|
const labels = ['','Weak','Fair','Good','Strong','Very Strong'];
|
||||||
|
document.getElementById('pw-strength-fill').style.width = fills[score];
|
||||||
|
document.getElementById('pw-strength-fill').style.background = colors[Math.max(0,score-1)];
|
||||||
|
document.getElementById('pw-strength-label').textContent = v.length > 0 ? labels[score] : '';
|
||||||
|
});
|
||||||
|
|
||||||
function genPassword() {
|
function genPassword() {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
|
||||||
let p = "";
|
let p = "";
|
||||||
for(let i=0; i<16; i++) p += chars[Math.floor(Math.random()*chars.length)];
|
for(let i=0; i<18; i++) p += chars[Math.floor(Math.random()*chars.length)];
|
||||||
document.getElementById('new_pw').value = p;
|
const inp = document.getElementById('pw-edit-pw');
|
||||||
document.getElementById('new_pw').type = 'text'; // show it temporarily
|
inp.value = p;
|
||||||
|
inp.type = 'text';
|
||||||
|
inp.dispatchEvent(new Event('input'));
|
||||||
|
setTimeout(() => inp.type = 'password', 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGw(pw) {
|
function toggleEditPwView() {
|
||||||
document.getElementById('modal-password').value = pw;
|
const inp = document.getElementById('pw-edit-pw');
|
||||||
document.getElementById('password-modal').classList.add('active');
|
const eye = document.getElementById('pw-edit-eye');
|
||||||
|
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||||
|
eye.className = inp.type === 'password' ? 'fa fa-eye' : 'fa fa-eye-slash';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePwView() {
|
||||||
|
const inp = document.getElementById('pw-view-pw');
|
||||||
|
const btn = document.getElementById('pw-view-toggle');
|
||||||
|
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||||
|
btn.innerHTML = inp.type === 'password' ? '<i class="fa fa-eye"></i>' : '<i class="fa fa-eye-slash"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPw(pw) {
|
||||||
|
navigator.clipboard.writeText(pw);
|
||||||
|
showToast('Password copied!');
|
||||||
|
}
|
||||||
|
function copyFieldValue(id) {
|
||||||
|
const val = document.getElementById(id).value;
|
||||||
|
navigator.clipboard.writeText(val);
|
||||||
|
showToast('Copied!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open view modal ───────────────────────────────────────────────────────────
|
||||||
|
function openViewEntry(row) {
|
||||||
|
document.getElementById('pw-view-title').textContent = row.querySelector('.vault-entry-title').textContent.trim();
|
||||||
|
document.getElementById('pw-view-user').value = row.dataset.usr;
|
||||||
|
document.getElementById('pw-view-pw').value = row.dataset.pw;
|
||||||
|
document.getElementById('pw-view-pw').type = 'password';
|
||||||
|
document.getElementById('pw-view-toggle').innerHTML = '<i class="fa fa-eye"></i>';
|
||||||
|
const urlEl = document.getElementById('pw-view-url');
|
||||||
|
urlEl.textContent = '';
|
||||||
|
// try to show url from data attribute if we add it - just show username for now
|
||||||
|
document.getElementById('pw-view-notes').textContent = '';
|
||||||
|
document.getElementById('password-view-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open add/edit modal ───────────────────────────────────────────────────────
|
||||||
|
function openAddPassword() {
|
||||||
|
document.getElementById('pw-edit-modal-title').textContent = 'Add Entry';
|
||||||
|
document.getElementById('pw-edit-form').onsubmit = null;
|
||||||
|
document.getElementById('pw-edit-form').querySelector('[name="password_id"]').name = '_unused';
|
||||||
|
// Reset name for create
|
||||||
|
const form = document.getElementById('pw-edit-form');
|
||||||
|
// Remove any update_password and add create_password
|
||||||
|
let existing = form.querySelector('[name="update_password"]');
|
||||||
|
if(existing) existing.remove();
|
||||||
|
let cp = form.querySelector('[name="create_password"]');
|
||||||
|
if(!cp) {
|
||||||
|
cp = document.createElement('input');
|
||||||
|
cp.type = 'hidden'; cp.name = 'create_password'; cp.value = '1';
|
||||||
|
form.appendChild(cp);
|
||||||
|
}
|
||||||
|
document.getElementById('pw-edit-id').value = '';
|
||||||
|
document.getElementById('pw-edit-title').value = '';
|
||||||
|
document.getElementById('pw-edit-uname').value = '';
|
||||||
|
document.getElementById('pw-edit-pw').value = '';
|
||||||
|
document.getElementById('pw-edit-pw').type = 'password';
|
||||||
|
document.getElementById('pw-edit-url').value = '';
|
||||||
|
document.getElementById('pw-edit-notes').value = '';
|
||||||
|
document.getElementById('pw-edit-folder').value = '';
|
||||||
|
document.getElementById('pw-edit-delete-wrap').style.display = 'none';
|
||||||
|
document.getElementById('pw-edit-pw').dispatchEvent(new Event('input'));
|
||||||
|
document.getElementById('password-edit-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditEntry(row) {
|
||||||
|
// We need full data – fetch from server or use data attributes
|
||||||
|
// For now we do a quick fetch via a hidden AJAX or just open with available data
|
||||||
|
const id = row.dataset.id;
|
||||||
|
document.getElementById('pw-edit-modal-title').textContent = 'Edit Entry';
|
||||||
|
const form = document.getElementById('pw-edit-form');
|
||||||
|
let cp = form.querySelector('[name="create_password"]');
|
||||||
|
if(cp) cp.remove();
|
||||||
|
let up = form.querySelector('[name="update_password"]');
|
||||||
|
if(!up) {
|
||||||
|
up = document.createElement('input');
|
||||||
|
up.type = 'hidden'; up.name = 'update_password'; up.value = '1';
|
||||||
|
form.appendChild(up);
|
||||||
|
}
|
||||||
|
form.querySelector('[name="password_id"]').name = 'password_id';
|
||||||
|
document.getElementById('pw-edit-id').value = id;
|
||||||
|
document.getElementById('pw-edit-title').value = row.querySelector('.vault-entry-title').textContent.trim();
|
||||||
|
document.getElementById('pw-edit-uname').value = row.dataset.usr;
|
||||||
|
document.getElementById('pw-edit-pw').value = row.dataset.pw;
|
||||||
|
document.getElementById('pw-edit-pw').type = 'password';
|
||||||
|
document.getElementById('pw-edit-url').value = '';
|
||||||
|
document.getElementById('pw-edit-notes').value = '';
|
||||||
|
document.getElementById('pw-edit-folder').value = '';
|
||||||
|
document.getElementById('pw-edit-delete-wrap').style.display = 'block';
|
||||||
|
document.getElementById('password-edit-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePassword() {
|
||||||
|
if(!confirm('Delete this entry permanently?')) return;
|
||||||
|
document.getElementById('delete-pw-id').value = document.getElementById('pw-edit-id').value;
|
||||||
|
document.getElementById('delete-pw-form').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggles ───────────────────────────────────────────────────────────────────
|
||||||
function toggleAll(chk) {
|
function toggleAll(chk) {
|
||||||
document.querySelectorAll('.pw-checkbox').forEach(c => c.checked = chk.checked);
|
document.querySelectorAll('.pw-checkbox').forEach(c => c.checked = chk.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyText(txt) {
|
function filterVault(q) {
|
||||||
navigator.clipboard.writeText(txt);
|
q = q.toLowerCase();
|
||||||
showToast('Copied to clipboard!');
|
document.querySelectorAll('.vault-entry').forEach(row => {
|
||||||
|
const text = row.textContent.toLowerCase();
|
||||||
|
row.style.display = text.includes(q) ? '' : 'none';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hotkeys Ctrl+C and Ctrl+B on hovered row
|
// ── Folder actions ────────────────────────────────────────────────────────────
|
||||||
|
function showAddFolderPrompt() {
|
||||||
|
const name = prompt('New folder name:');
|
||||||
|
if(name && name.trim()) {
|
||||||
|
document.getElementById('add-folder-name').value = name.trim();
|
||||||
|
document.getElementById('add-folder-form').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFolder(id, name) {
|
||||||
|
if(!confirm(`Delete folder "${name}"? Entries will not be deleted.`)) return;
|
||||||
|
document.getElementById('del-folder-id').value = id;
|
||||||
|
document.getElementById('del-folder-form').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hotkeys on hovered row ────────────────────────────────────────────────────
|
||||||
let hoveredRow = null;
|
let hoveredRow = null;
|
||||||
document.querySelectorAll('.pw-row').forEach(row => {
|
document.querySelectorAll('.pw-row').forEach(row => {
|
||||||
row.addEventListener('mouseenter', () => hoveredRow = row);
|
row.addEventListener('mouseenter', () => hoveredRow = row);
|
||||||
row.addEventListener('mouseleave', () => hoveredRow = null);
|
row.addEventListener('mouseleave', () => hoveredRow = null);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if(hoveredRow && e.ctrlKey) {
|
if(hoveredRow && e.ctrlKey) {
|
||||||
if(e.key.toLowerCase() === 'c') {
|
if(e.key.toLowerCase() === 'c') { copyPw(hoveredRow.dataset.pw); e.preventDefault(); }
|
||||||
copyText(hoveredRow.getAttribute('data-pw'));
|
else if(e.key.toLowerCase() === 'b') { navigator.clipboard.writeText(hoveredRow.dataset.usr); showToast('Username copied!'); e.preventDefault(); }
|
||||||
e.preventDefault();
|
|
||||||
} else if(e.key.toLowerCase() === 'b') {
|
|
||||||
copyText(hoveredRow.getAttribute('data-usr'));
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
<?php
|
<?php
|
||||||
$parent_id = $_GET['folder'] ?? null;
|
$parent_id = $_GET['folder'] ?? null;
|
||||||
|
|
||||||
@@ -20,29 +19,29 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_item'])) {
|
|||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isset($_FILES['file'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload_file']) && isset($_FILES['file'])) {
|
||||||
$name = $_FILES['file']['name'];
|
$name = $_FILES['file']['name'];
|
||||||
$tmp_name = $_FILES['file']['tmp_name'];
|
$tmp_name = $_FILES['file']['tmp_name'];
|
||||||
|
$size = $_FILES['file']['size'];
|
||||||
$path = 'uploads/' . time() . '_' . $name;
|
$path = 'uploads/' . time() . '_' . $name;
|
||||||
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]);
|
||||||
echo "OK"; exit; // For AJAX
|
echo "OK"; exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_project'])) {
|
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['share_project'])) {
|
||||||
$pr_id = $_POST['project_id'];
|
$pr_id = $_POST['project_id'];
|
||||||
$share = $_POST['share_target']; // 'user_1'
|
$share = $_POST['share_target'];
|
||||||
$parts = explode('_', $share);
|
$parts = explode('_', $share, 2);
|
||||||
|
if(count($parts)===2) {
|
||||||
$stmt = $pdo->prepare("INSERT INTO project_shares (project_id, share_type, share_id) VALUES (?, ?, ?)");
|
$stmt = $pdo->prepare("INSERT INTO project_shares (project_id, share_type, share_id) VALUES (?, ?, ?)");
|
||||||
$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);
|
if($parts[0] === 'user') notify_user($parts[1], $msg);
|
||||||
else notify_group($parts[1], $msg);
|
else notify_group($parts[1], $msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch items in current folder
|
// ── Fetch current folder content ──────────────────────────────────────────────
|
||||||
$user_groups = get_user_groups($user_id);
|
$user_groups = get_user_groups($user_id);
|
||||||
$my_group_ids = array_column($user_groups, 'id');
|
$my_group_ids = array_column($user_groups, 'id');
|
||||||
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
$group_in = implode(',', array_fill(0, count($my_group_ids), '?')) ?: '0';
|
||||||
@@ -67,80 +66,210 @@ $items = $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();
|
||||||
|
|
||||||
|
// ── Build breadcrumb path ─────────────────────────────────────────────────────
|
||||||
|
$breadcrumbs = [];
|
||||||
|
$cur_id = $parent_id;
|
||||||
|
while($cur_id) {
|
||||||
|
$node = query("SELECT id, name, parent_id FROM projects WHERE id=?", [$cur_id])->fetch();
|
||||||
|
if(!$node) break;
|
||||||
|
array_unshift($breadcrumbs, $node);
|
||||||
|
$cur_id = $node['parent_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: file size ─────────────────────────────────────────────────────────
|
||||||
|
function fmt_size($bytes) {
|
||||||
|
if(!$bytes) return '—';
|
||||||
|
if($bytes < 1024) return $bytes . ' B';
|
||||||
|
if($bytes < 1048576) return round($bytes/1024,1) . ' KB';
|
||||||
|
return round($bytes/1048576,1) . ' MB';
|
||||||
|
}
|
||||||
|
function get_file_icon($name, $is_folder) {
|
||||||
|
if($is_folder) return '📁';
|
||||||
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||||
|
$map = [
|
||||||
|
'pdf'=>'📄','doc'=>'📝','docx'=>'📝','xls'=>'📊','xlsx'=>'📊','ppt'=>'📑','pptx'=>'📑',
|
||||||
|
'jpg'=>'🖼','jpeg'=>'🖼','png'=>'🖼','gif'=>'🖼','webp'=>'🖼','svg'=>'🖼',
|
||||||
|
'mp4'=>'🎬','avi'=>'🎬','mov'=>'🎬','mp3'=>'🎵','wav'=>'🎵',
|
||||||
|
'zip'=>'🗜','rar'=>'🗜','7z'=>'🗜','tar'=>'🗜',
|
||||||
|
'php'=>'⚙','js'=>'⚙','css'=>'🎨','html'=>'🌐','txt'=>'📃','md'=>'📃',
|
||||||
|
'sh'=>'💻','py'=>'🐍',
|
||||||
|
];
|
||||||
|
return $map[$ext] ?? '📄';
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<div class="header">
|
|
||||||
<h1>Project Files Explorer</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-between">
|
<!-- Rename form -->
|
||||||
<?php if($parent_id):
|
|
||||||
$parent = query("SELECT parent_id FROM projects WHERE id=?", [$parent_id])->fetch();
|
|
||||||
?>
|
|
||||||
<a href="?action=projects&folder=<?= $parent['parent_id'] ?>" class="btn btn-secondary">← Back</a>
|
|
||||||
<?php else: ?>
|
|
||||||
<div>Root Directory</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div style="display:flex; gap:1rem;">
|
|
||||||
<form method="POST" style="display:flex; gap:0.5rem;">
|
|
||||||
<input type="text" name="name" class="form-control" placeholder="New Folder Name" required>
|
|
||||||
<button type="submit" name="create_folder" class="btn">New Folder</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-top:1.5rem;">
|
|
||||||
<!-- Drag Drop Zone -->
|
|
||||||
<div id="dropzone" class="dropzone" ondragover="this.classList.add('dragover'); return false;" ondragleave="this.classList.remove('dragover');" ondrop="handleDrop(event)">
|
|
||||||
SVG Drop files here to upload instantly
|
|
||||||
<br><br><span style="color:var(--text-light); font-size:0.875rem;">(HTML5 Drag & Drop)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:2rem;">
|
|
||||||
<?php foreach($items as $item): ?>
|
|
||||||
<div class="project-item">
|
|
||||||
<div style="display:flex; align-items:center; gap:0.5rem; flex:1;">
|
|
||||||
<span style="font-size:1.5rem;"><?= $item['is_folder'] ? '📁' : '📄' ?></span>
|
|
||||||
<?php if($item['is_folder']): ?>
|
|
||||||
<a href="?action=projects&folder=<?= $item['id'] ?>" style="font-weight:600;"><?= htmlspecialchars($item['name']) ?></a>
|
|
||||||
<?php else: ?>
|
|
||||||
<a href="<?= htmlspecialchars($item['file_path']) ?>" target="_blank"><?= htmlspecialchars($item['name']) ?></a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<div style="width:200px; color:var(--text-light); font-size:0.875rem;">
|
|
||||||
Owner: <?= htmlspecialchars($item['owner_name']) ?>
|
|
||||||
<?= !$item['is_mine'] ? '<span class="badge" style="background:#10B981; font-size:10px;">Shared</span>' : '' ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex; gap:0.5rem;">
|
|
||||||
<?php if($item['is_mine']): ?>
|
|
||||||
<form method="POST" style="display:inline; display:flex; gap:0.5rem;">
|
|
||||||
<input type="hidden" name="project_id" value="<?= $item['id'] ?>">
|
|
||||||
<select name="share_target" class="form-control" style="width:auto; padding:0.25rem;">
|
|
||||||
<?php foreach($all_users as $u): ?><option value="user_<?= $u['id'] ?>">User: <?= htmlspecialchars($u['username']) ?></option><?php endforeach; ?>
|
|
||||||
<?php foreach($all_groups as $g): ?><option value="group_<?= $g['id'] ?>">Group: <?= htmlspecialchars($g['name']) ?></option><?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button type="submit" name="share_project" class="btn btn-sm" title="Share">🔗</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" style="display:inline; display:flex; gap:0.5rem;" onsubmit="return confirm('Delete this item?')">
|
|
||||||
<input type="hidden" name="item_id" value="<?= $item['id'] ?>">
|
|
||||||
<button type="button" class="btn btn-sm btn-secondary" title="Rename" onclick="renameItem(<?= $item['id'] ?>, '<?= htmlspecialchars(addslashes($item['name'])) ?>')">✏️</button>
|
|
||||||
<button type="submit" name="delete_item" class="btn btn-sm btn-danger" title="Delete">🗑️</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<?php if(!$items): ?><p style="margin-top:1rem;">Empty folder.</p><?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rename form hidden -->
|
|
||||||
<form method="POST" id="rename-form" style="display:none;">
|
<form method="POST" id="rename-form" style="display:none;">
|
||||||
<input type="hidden" name="edit_item" value="1">
|
<input type="hidden" name="edit_item" value="1">
|
||||||
<input type="hidden" name="item_id" id="rn-item_id">
|
<input type="hidden" name="item_id" id="rn-item_id">
|
||||||
<input type="hidden" name="new_name" id="rn-new_name">
|
<input type="hidden" name="new_name" id="rn-new_name">
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Share Modal -->
|
||||||
|
<div class="modal-overlay" id="share-modal">
|
||||||
|
<div class="modal-content" style="max-width:400px;">
|
||||||
|
<h3 style="margin-bottom:1rem; font-size:1.05rem;"><i class="fa fa-share-nodes"></i> Share Item</h3>
|
||||||
|
<form method="POST" id="share-form">
|
||||||
|
<input type="hidden" name="share_project" value="1">
|
||||||
|
<input type="hidden" name="project_id" id="share-project-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Share with</label>
|
||||||
|
<select name="share_target" class="form-control">
|
||||||
|
<?php foreach($all_users as $u): ?>
|
||||||
|
<option value="user_<?= $u['id'] ?>">👤 <?= htmlspecialchars($u['username']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php foreach($all_groups as $g): ?>
|
||||||
|
<option value="group_<?= $g['id'] ?>">👥 <?= htmlspecialchars($g['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('share-modal').classList.remove('active')">Cancel</button>
|
||||||
|
<button type="submit" class="btn"><i class="fa fa-share-nodes"></i> Share</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete form -->
|
||||||
|
<form method="POST" id="delete-item-form" style="display:none;">
|
||||||
|
<input type="hidden" name="delete_item" value="1">
|
||||||
|
<input type="hidden" name="item_id" id="delete-item-id">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="header" style="margin-bottom:0.75rem;">
|
||||||
|
<h1 style="font-size:1.5rem; font-weight:700;">📁 Project Explorer</h1>
|
||||||
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||||
|
<label class="btn btn-secondary" 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" onclick="showNewFolderDialog()"><i class="fa fa-folder-plus"></i> New Folder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden folder create form -->
|
||||||
|
<form method="POST" id="new-folder-form" style="display:none;">
|
||||||
|
<input type="hidden" name="create_folder" value="1">
|
||||||
|
<input type="hidden" name="name" id="new-folder-name">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Explorer Toolbar / Breadcrumb -->
|
||||||
|
<div class="explorer-toolbar">
|
||||||
|
<a href="?action=projects" class="btn btn-secondary btn-sm" title="Home">
|
||||||
|
<i class="fa fa-house"></i>
|
||||||
|
</a>
|
||||||
|
<a href="javascript:history.back()" class="btn btn-secondary btn-sm" title="Back">
|
||||||
|
<i class="fa fa-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<div class="explorer-path">
|
||||||
|
<a class="explorer-path-part <?= !$parent_id?'current':'' ?>" href="?action=projects">Root</a>
|
||||||
|
<?php foreach($breadcrumbs as $bc): ?>
|
||||||
|
<span class="explorer-path-sep">›</span>
|
||||||
|
<a class="explorer-path-part <?= $bc['id']==$parent_id?'current':'' ?>" href="?action=projects&folder=<?= $bc['id'] ?>">
|
||||||
|
<?= htmlspecialchars($bc['name']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</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)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drop Zone -->
|
||||||
|
<div id="dropzone" class="dropzone" style="padding:1.5rem; margin-bottom:0.75rem;"
|
||||||
|
ondragover="this.classList.add('dragover'); return false;"
|
||||||
|
ondragleave="this.classList.remove('dragover');"
|
||||||
|
ondrop="handleDrop(event)">
|
||||||
|
<i class="fa fa-cloud-arrow-up" style="font-size:1.5rem; margin-bottom:0.5rem; display:block;"></i>
|
||||||
|
Drag & drop files here to upload
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail Table -->
|
||||||
|
<div class="card" style="padding:0; overflow:hidden;">
|
||||||
|
<table class="explorer-table" id="explorer-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:30px;"><input type="checkbox" id="explorer-select-all" onclick="explorerSelectAll(this)"></th>
|
||||||
|
<th onclick="sortExplorer('name')" style="min-width:200px;">
|
||||||
|
<i class="fa fa-sort" style="font-size:0.7rem; margin-right:4px;"></i>Name
|
||||||
|
</th>
|
||||||
|
<th onclick="sortExplorer('type')" style="width:80px;">Type</th>
|
||||||
|
<th onclick="sortExplorer('owner')" style="width:120px;">Owner</th>
|
||||||
|
<th onclick="sortExplorer('size')" style="width:80px;">Size</th>
|
||||||
|
<th style="width:140px;">Shared</th>
|
||||||
|
<th style="width:160px; text-align:right;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="explorer-tbody">
|
||||||
|
<?php if(empty($items)): ?>
|
||||||
|
<tr><td colspan="7" style="text-align:center; padding:3rem; color:var(--text-light);">
|
||||||
|
<i class="fa fa-folder-open" style="font-size:2rem; display:block; margin-bottom:0.5rem;"></i>
|
||||||
|
This folder is empty.
|
||||||
|
</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach($items as $item):
|
||||||
|
$ext = $item['is_folder'] ? 'folder' : strtoupper(pathinfo($item['name'], PATHINFO_EXTENSION) ?: 'File');
|
||||||
|
$size = (!$item['is_folder'] && $item['file_path'] && file_exists($item['file_path'])) ? filesize($item['file_path']) : null;
|
||||||
|
$icon = get_file_icon($item['name'], $item['is_folder']);
|
||||||
|
?>
|
||||||
|
<tr class="explorer-row" data-name="<?= htmlspecialchars($item['name']) ?>" data-type="<?= $item['is_folder']?'folder':'file' ?>">
|
||||||
|
<td><input type="checkbox" class="explorer-check" value="<?= $item['id'] ?>"></td>
|
||||||
|
<td>
|
||||||
|
<div class="explorer-name">
|
||||||
|
<span class="explorer-icon"><?= $icon ?></span>
|
||||||
|
<?php if($item['is_folder']): ?>
|
||||||
|
<a href="?action=projects&folder=<?= $item['id'] ?>"><?= htmlspecialchars($item['name']) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="<?= htmlspecialchars($item['file_path']) ?>" target="_blank"><?= htmlspecialchars($item['name']) ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-light); font-size:0.8rem;"><?= htmlspecialchars($ext) ?></td>
|
||||||
|
<td style="font-size:0.8rem;">
|
||||||
|
<?= htmlspecialchars($item['owner_name']) ?>
|
||||||
|
<?php if(!$item['is_mine']): ?><span class="badge" style="font-size:0.6rem; padding:1px 5px; margin-left:2px;">Shared</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--text-light); font-size:0.8rem;"><?= $size !== null ? fmt_size($size) : '—' ?></td>
|
||||||
|
<td style="font-size:0.8rem; color:var(--text-light);">
|
||||||
|
<?php
|
||||||
|
// 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();
|
||||||
|
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;">';
|
||||||
|
echo $sh['share_type']==='user' ? ('👤 '.htmlspecialchars($sh['username']??'')) : ('👥 '.htmlspecialchars($sh['gname']??''));
|
||||||
|
echo '</span>';
|
||||||
|
}
|
||||||
|
if(!$shares) echo '<span style="color:var(--text-light); font-size:0.8rem;">—</span>';
|
||||||
|
?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="explorer-actions" style="justify-content:flex-end;">
|
||||||
|
<?php if($item['is_mine']): ?>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="openShare(<?= $item['id'] ?>)" title="Share"><i class="fa fa-share-nodes"></i></button>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="renameItem(<?= $item['id'] ?>, '<?= htmlspecialchars(addslashes($item['name'])) ?>')" title="Rename"><i class="fa fa-pen"></i></button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteItem(<?= $item['id'] ?>, '<?= htmlspecialchars(addslashes($item['name'])) ?>')" title="Delete"><i class="fa fa-trash"></i></button>
|
||||||
|
<?php else: ?>
|
||||||
|
<span style="font-size:0.75rem; color:var(--text-light); padding:0 0.5rem;">View only</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="explorer-status-bar">
|
||||||
|
<span id="explorer-count"><?= count($items) ?> item<?= count($items)!=1?'s':'' ?></span>
|
||||||
|
<span id="explorer-selected-count" style="display:none; color:var(--primary); font-weight:600;"></span>
|
||||||
|
<span style="margin-left:auto;">
|
||||||
|
<?= $parent_id ? 'Folder: '.htmlspecialchars(end($breadcrumbs)['name'] ?? '') : 'Root directory' ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function renameItem(id, currName) {
|
function renameItem(id, currName) {
|
||||||
let nn = prompt("Enter new name:", currName);
|
let nn = prompt("Enter new name:", currName);
|
||||||
@@ -151,24 +280,91 @@ function renameItem(id, currName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteItem(id, name) {
|
||||||
|
if(!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||||||
|
document.getElementById('delete-item-id').value = id;
|
||||||
|
document.getElementById('delete-item-form').submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openShare(id) {
|
||||||
|
document.getElementById('share-project-id').value = id;
|
||||||
|
document.getElementById('share-modal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewFolderDialog() {
|
||||||
|
const name = prompt('New folder name:');
|
||||||
|
if(name && name.trim()) {
|
||||||
|
document.getElementById('new-folder-name').value = name.trim();
|
||||||
|
document.getElementById('new-folder-form').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(input) {
|
||||||
|
if(input.files.length > 0) uploadFile(input.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDrop(e) {
|
function handleDrop(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.classList.remove('dragover');
|
e.currentTarget.classList.remove('dragover');
|
||||||
|
if(e.dataTransfer.files.length > 0) uploadFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
let dt = e.dataTransfer;
|
function uploadFile(file) {
|
||||||
let files = dt.files;
|
showToast('Uploading ' + file.name + '...');
|
||||||
|
|
||||||
if(files.length > 0) {
|
|
||||||
let formData = new FormData();
|
let formData = new FormData();
|
||||||
formData.append('upload_file', '1');
|
formData.append('upload_file', '1');
|
||||||
formData.append('file', files[0]);
|
formData.append('file', file);
|
||||||
|
fetch('?action=projects&folder=<?= $parent_id ?>', { method: 'POST', body: formData })
|
||||||
|
.then(() => window.location.reload());
|
||||||
|
}
|
||||||
|
|
||||||
fetch('?action=projects&folder=<?= $parent_id ?>', {
|
// ── Select all ────────────────────────────────────────────────────────────────
|
||||||
method: 'POST',
|
function explorerSelectAll(chk) {
|
||||||
body: formData
|
document.querySelectorAll('.explorer-check').forEach(c => c.checked = chk.checked);
|
||||||
}).then(res => {
|
updateSelectedCount();
|
||||||
window.location.reload();
|
}
|
||||||
});
|
document.querySelectorAll('.explorer-check').forEach(c => c.addEventListener('change', updateSelectedCount));
|
||||||
|
function updateSelectedCount() {
|
||||||
|
const n = document.querySelectorAll('.explorer-check:checked').length;
|
||||||
|
const el = document.getElementById('explorer-selected-count');
|
||||||
|
if(n > 0) {
|
||||||
|
el.textContent = n + ' selected';
|
||||||
|
el.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search filter ─────────────────────────────────────────────────────────────
|
||||||
|
function filterExplorer(q) {
|
||||||
|
q = q.toLowerCase();
|
||||||
|
let visible = 0;
|
||||||
|
document.querySelectorAll('#explorer-tbody tr.explorer-row').forEach(row => {
|
||||||
|
const name = row.dataset.name || '';
|
||||||
|
if(name.toLowerCase().includes(q)) { row.style.display = ''; visible++; }
|
||||||
|
else row.style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('explorer-count').textContent = visible + ' item' + (visible!==1?'s':'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sort ──────────────────────────────────────────────────────────────────────
|
||||||
|
let sortDir = {};
|
||||||
|
function sortExplorer(col) {
|
||||||
|
sortDir[col] = !sortDir[col];
|
||||||
|
const tbody = document.getElementById('explorer-tbody');
|
||||||
|
const rows = Array.from(tbody.querySelectorAll('tr.explorer-row'));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let av = '', bv = '';
|
||||||
|
if(col==='name') { av=a.dataset.name||''; bv=b.dataset.name||''; }
|
||||||
|
if(col==='type') { av=a.dataset.type||''; bv=b.dataset.type||''; }
|
||||||
|
// folders first for type sort
|
||||||
|
if(col==='type' && av===bv) return 0;
|
||||||
|
const cmp = av.localeCompare(bv);
|
||||||
|
return sortDir[col] ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
// Folders always first
|
||||||
|
const folders = rows.filter(r => r.dataset.type==='folder');
|
||||||
|
const files = rows.filter(r => r.dataset.type!=='folder');
|
||||||
|
[...folders,...files].forEach(r => tbody.appendChild(r));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user