feat: integrate Tagify for multi-tag support and add a new view-note modal across the application

This commit is contained in:
root
2026-03-29 16:25:45 +07:00
parent f4f842d658
commit 8dedb57692
8 changed files with 408 additions and 106 deletions

View File

@@ -256,7 +256,7 @@ $active_tab = $_GET['tab'] ?? 'overview';
</button> </button>
</form> </form>
<!-- Delete --> <!-- Delete -->
<form method="POST" style="display:inline;" onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>?')"> <form method="POST" style="display:inline;" onsubmit="event.preventDefault(); appConfirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>?').then(y => { if(y) { this.onsubmit=null; this.submit(); } })">
<input type="hidden" name="del_user_id" value="<?= $u['id'] ?>"> <input type="hidden" name="del_user_id" value="<?= $u['id'] ?>">
<button type="submit" name="delete_user" class="btn btn-sm btn-danger" title="Delete user"> <button type="submit" name="delete_user" class="btn btn-sm btn-danger" title="Delete user">
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
@@ -327,7 +327,7 @@ $active_tab = $_GET['tab'] ?? 'overview';
</div> </div>
<div style="font-size:0.75rem; color:var(--text-light);"><?= count($g_users) ?> member<?= count($g_users)!=1?'s':'' ?></div> <div style="font-size:0.75rem; color:var(--text-light);"><?= count($g_users) ?> member<?= count($g_users)!=1?'s':'' ?></div>
</div> </div>
<form method="POST" onsubmit="return confirm('Delete group <?= htmlspecialchars(addslashes($g['name'])) ?>?')"> <form method="POST" onsubmit="event.preventDefault(); appConfirm('Delete group <?= htmlspecialchars(addslashes($g['name'])) ?>?').then(y => { if(y) { this.onsubmit=null; this.submit(); } })">
<input type="hidden" name="del_group_id" value="<?= $g['id'] ?>"> <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> <button type="submit" name="delete_group" class="btn btn-sm btn-danger"><i class="fa fa-trash"></i></button>
</form> </form>
@@ -443,10 +443,10 @@ $active_tab = $_GET['tab'] ?? 'overview';
<h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem; color:#EF4444;"><i class="fa fa-triangle-exclamation"></i> Danger Zone</h3> <h3 style="font-size:1rem; font-weight:700; margin-bottom:0.5rem; color:#EF4444;"><i class="fa fa-triangle-exclamation"></i> Danger Zone</h3>
<p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1rem;">These actions are irreversible. Proceed with extreme caution.</p> <p style="font-size:0.85rem; color:var(--text-light); margin-bottom:1rem;">These actions are irreversible. Proceed with extreme caution.</p>
<div style="display:flex; gap:0.75rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.75rem; flex-wrap:wrap;">
<button class="btn btn-danger" onclick="if(confirm('Really purge all archive tasks?')) alert('Not implemented in this demo.')"> <button class="btn btn-danger" onclick="appConfirm('Really purge all archive tasks?').then(y => { if(y) appAlert('Not implemented in this demo.'); })">
<i class="fa fa-trash"></i> Purge Archived Tasks <i class="fa fa-trash"></i> Purge Archived Tasks
</button> </button>
<button class="btn btn-danger" onclick="if(confirm('Really clear all activity logs?')) alert('Not implemented in this demo.')"> <button class="btn btn-danger" onclick="appConfirm('Really clear all activity logs?').then(y => { if(y) appAlert('Not implemented in this demo.'); })">
<i class="fa fa-eraser"></i> Clear Activity Logs <i class="fa fa-eraser"></i> Clear Activity Logs
</button> </button>
</div> </div>

View File

@@ -52,23 +52,17 @@ try {
} catch(Exception $e) {} } catch(Exception $e) {}
// ── Current user mood ───────────────────────────────────────────────────────── // ── Current user mood ─────────────────────────────────────────────────────────
$my_mood = $current_user['mood'] ?? null; $me = query("SELECT username, profile_pic, mood, mood_updated_at FROM users WHERE id=?", [$user_id])->fetch();
$my_mood = $me['mood'] ?? null;
// ── Team moods (all users + their mood) ────────────────────────────────────── // ── Team moods (all users + their mood) ──────────────────────────────────────
$team_moods = query("SELECT id, username, mood, mood_updated_at FROM users WHERE mood IS NOT NULL AND id != ? ORDER BY mood_updated_at DESC", [$user_id])->fetchAll(); $team_moods = query("SELECT id, username, mood, mood_updated_at, profile_pic FROM users WHERE mood IS NOT NULL AND id != ? ORDER BY mood_updated_at DESC", [$user_id])->fetchAll();
// ── Activity log ────────────────────────────────────────────────────────────── // ── Activity log ──────────────────────────────────────────────────────────────
// Admin: sees ALL activity across all users & groups // Admin: No longer sees all activity by default.
// Standard user: sees own activity + group members activity // Standard user/Admin: sees own activity + group members activity, BUT we hide admin activity from non-self.
$is_admin_user = !empty($_SESSION['is_admin']); $is_admin_user = !empty($_SESSION['is_admin']);
$recent_logs = [];
try {
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), '?'));
@@ -76,26 +70,40 @@ try {
$peers->execute($my_group_ids); $peers->execute($my_group_ids);
$peer_ids = array_unique(array_merge($peer_ids, array_column($peers->fetchAll(), 'user_id'))); $peer_ids = array_unique(array_merge($peer_ids, array_column($peers->fetchAll(), 'user_id')));
} }
// Ensure at least the user themselves is present
if(empty($peer_ids)) { $peer_ids = [$user_id]; }
// Get all admins except the current user
$other_admins = [];
try {
$other_admins = $pdo->prepare("SELECT id FROM users WHERE is_admin = 1 AND id != ?");
$other_admins->execute([$user_id]);
$other_admins = $other_admins->fetchAll(PDO::FETCH_COLUMN);
} catch(Exception $e) {}
// Filter out other admins from peer ids
$peer_ids = array_filter($peer_ids, function($id) use ($other_admins) {
return !in_array($id, $other_admins);
});
$uid_in = implode(',', array_fill(0, count($peer_ids), '?')); $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"); $recent_logs = [];
$stmt->execute($peer_ids); try {
} $stmt = $pdo->prepare("SELECT al.*, u.username FROM activity_log al LEFT JOIN users u ON al.user_id=u.id WHERE al.user_id IN ($uid_in) ORDER BY al.created_at DESC LIMIT 30");
$stmt->execute(array_values($peer_ids));
$recent_logs = $stmt->fetchAll(); $recent_logs = $stmt->fetchAll();
} catch(Exception $e) {} } catch(Exception $e) {}
// ── Task logs ───────────────────────────────────────────────────────────────── // ── Task logs ─────────────────────────────────────────────────────────────────
$task_logs = []; $task_logs = [];
try { try {
if ($is_admin_user) {
// 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);
$admin_excl = $other_admins ? "AND t.assignor_id NOT IN (" . implode(',', array_fill(0, count($other_admins), '?')) . ")" : "";
if($other_admins) { $tl_params = array_merge($tl_params, $other_admins); }
$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
FROM tasks t LEFT JOIN users u ON t.assignor_id=u.id FROM tasks t LEFT JOIN users u ON t.assignor_id=u.id
WHERE t.status_updated_at IS NOT NULL WHERE t.status_updated_at IS NOT NULL
@@ -104,9 +112,9 @@ try {
OR (t.assignee_type='group' AND t.assignee_id IN ($group_in_tl)) OR (t.assignee_type='group' AND t.assignee_id IN ($group_in_tl))
OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in_tl)) OR (t.assignor_type='group' AND t.assignor_group_id IN ($group_in_tl))
) )
$admin_excl
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) {}
?> ?>
@@ -293,6 +301,19 @@ if (empty($_SESSION['dash_logged_today'])) {
</div> </div>
<div class="db-stat-link">Open Notes →</div> <div class="db-stat-link">Open Notes →</div>
</a> </a>
<!-- Tools -->
<a href="?action=tools" class="db-stat-card" style="border-top:3px solid #EC4899;">
<div class="db-stat-label">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 9.36l-7.1 7.1a1 1 0 0 1-1.4 0l-2.8-2.8a1 1 0 0 1 0-1.4l7.1-7.1a6 6 0 0 1 9.36-7.94z"></path></svg>
Tools
</div>
<div class="db-stat-value" style="color:#EC4899;">3</div>
<div class="db-stat-sub">
<span style="background:rgba(236,72,153,0.12); color:#EC4899;">🛠 Utilities</span>
</div>
<div class="db-stat-link">Open Tools →</div>
</a>
</div> </div>
<!-- ── Team Moods ───────────────────────────────────────────── --> <!-- ── Team Moods ───────────────────────────────────────────── -->
@@ -301,19 +322,46 @@ if (empty($_SESSION['dash_logged_today'])) {
<h3 style="font-size:1rem; font-weight:700;">👥 Team Mood</h3> <h3 style="font-size:1rem; font-weight:700;">👥 Team Mood</h3>
<span style="font-size:0.75rem; color:var(--text-light); font-style:italic;">Live from colleagues</span> <span style="font-size:0.75rem; color:var(--text-light); font-style:italic;">Live from colleagues</span>
</div> </div>
<?php if(empty($team_moods)): ?> <?php if(empty($team_moods) && empty($my_mood)): ?>
<p style="color:var(--text-light); font-size:0.85rem;">No one else has set a mood yet.</p> <p style="color:var(--text-light); font-size:0.85rem;">No team mood activity yet.</p>
<?php else: ?> <?php else: ?>
<div class="team-mood-row" style="display:flex; flex-wrap:nowrap; overflow-x:auto; gap:1rem; padding-bottom:0.5rem;"> <div class="team-mood-row" style="display:flex; flex-wrap:nowrap; overflow-x:auto; gap:1rem; padding-bottom:0.5rem;">
<?php <?php
$avatar_colors_tm = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16']; $avatar_colors_tm = ['#4F46E5','#10B981','#F59E0B','#EF4444','#8B5CF6','#06B6D4','#EC4899','#84CC16'];
if(!empty($my_mood)):
$m_initial = strtoupper(mb_substr($me['username'], 0, 1));
$m_color = $avatar_colors_tm[crc32($me['username']) % count($avatar_colors_tm)];
?>
<div style="position:relative; flex-shrink:0; cursor:pointer;" title="You (Updated: <?= date('M j, H:i', strtotime($me['mood_updated_at'])) ?>)">
<div style="width:40px; height:40px; border-radius:50%; background:<?= $m_color ?>; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; overflow:hidden;">
<?php if(!empty($me['profile_pic']) && file_exists($me['profile_pic'])): ?>
<img src="<?= htmlspecialchars($me['profile_pic']) ?>?v=<?= time() ?>" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<?= $m_initial ?>
<?php endif; ?>
</div>
<div style="position:absolute; top:-4px; right:-4px; background:var(--surface); border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<?= $my_mood ?>
</div>
<div style="font-size:0.65rem; color:var(--text-light); text-align:center; margin-top:4px; max-width:40px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-style:italic;">
You
</div>
</div>
<?php endif; ?>
<?php
foreach($team_moods as $tm): foreach($team_moods as $tm):
$initial = strtoupper(mb_substr($tm['username'], 0, 1)); $initial = strtoupper(mb_substr($tm['username'], 0, 1));
$color = $avatar_colors_tm[crc32($tm['username']) % count($avatar_colors_tm)]; $color = $avatar_colors_tm[crc32($tm['username']) % count($avatar_colors_tm)];
?> ?>
<div style="position:relative; flex-shrink:0; cursor:pointer;" title="<?= htmlspecialchars($tm['username']) ?> (Updated: <?= date('M j, H:i', strtotime($tm['mood_updated_at'])) ?>)"> <div style="position:relative; flex-shrink:0; cursor:pointer;" title="<?= htmlspecialchars($tm['username']) ?> (Updated: <?= date('M j, H:i', strtotime($tm['mood_updated_at'])) ?>)">
<div style="width:40px; height:40px; border-radius:50%; background:<?= $color ?>; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem;"> <div style="width:40px; height:40px; border-radius:50%; background:<?= $color ?>; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1rem; overflow:hidden;">
<?php if(!empty($tm['profile_pic']) && file_exists($tm['profile_pic'])): ?>
<img src="<?= htmlspecialchars($tm['profile_pic']) ?>?v=<?= time() ?>" style="width:100%; height:100%; object-fit:cover;">
<?php else: ?>
<?= $initial ?> <?= $initial ?>
<?php endif; ?>
</div> </div>
<div style="position:absolute; top:-4px; right:-4px; background:var(--surface); border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.1);"> <div style="position:absolute; top:-4px; right:-4px; background:var(--surface); border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; font-size:0.8rem; box-shadow:0 2px 4px rgba(0,0,0,0.1);">
<?= $tm['mood'] ?> <?= $tm['mood'] ?>
@@ -325,11 +373,6 @@ if (empty($_SESSION['dash_logged_today'])) {
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if($my_mood): ?>
<div style="margin-top:0.5rem; font-size:0.8rem; color:var(--text-light);">
Your mood is visible to your team: <strong style="color:var(--text);"><?= $my_mood ?></strong>
</div>
<?php endif; ?>
</div> </div>
<!-- ── Activity Log ───────────────────────────────────────────── --> <!-- ── Activity Log ───────────────────────────────────────────── -->
@@ -337,12 +380,9 @@ if (empty($_SESSION['dash_logged_today'])) {
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<div style="display:flex; align-items:center; gap:0.65rem;"> <div style="display:flex; align-items:center; gap:0.65rem;">
<h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3> <h3 style="font-size:1rem; font-weight:700;">🕓 Recent Activity</h3>
<?php if($is_admin_user): ?>
<span class="admin-activity-badge"><i class="fa fa-shield-halved" style="font-size:0.65rem;"></i> All Users</span>
<?php endif; ?>
</div> </div>
<span style="font-size:0.75rem; color:var(--text-light);"> <span style="font-size:0.75rem; color:var(--text-light);">
<?= $is_admin_user ? 'All activity across platform' : 'You & your groups' ?> You & your groups
</span> </span>
</div> </div>
<?php <?php

View File

@@ -34,6 +34,61 @@
setTimeout(() => t.classList.remove('show'), 3000); setTimeout(() => t.classList.remove('show'), 3000);
} }
// ── Custom Alert / Confirm ─────────────────────────────────────────────
function appAlert(msg) {
return new Promise(resolve => {
let modal = document.getElementById('app-alert-modal');
if(!modal) {
modal = document.createElement('div');
modal.id = 'app-alert-modal';
modal.className = 'modal-overlay';
modal.style.zIndex = '10005';
modal.innerHTML = `
<div class="modal-content" style="max-width:320px; text-align:center;">
<h3 style="margin-bottom:1rem; font-size:1.1rem;">Notice</h3>
<p style="margin-bottom:1.5rem; font-size:0.9rem; color:var(--text);" id="app-alert-msg"></p>
<button class="btn" style="width:100%;" id="app-alert-ok">OK</button>
</div>`;
document.body.appendChild(modal);
}
document.getElementById('app-alert-msg').textContent = msg;
modal.classList.add('active');
document.getElementById('app-alert-ok').onclick = () => { modal.classList.remove('active'); resolve(true); };
});
}
window.alert = function(msg) { appAlert(msg); };
function appConfirm(msg) {
return new Promise(resolve => {
let modal = document.getElementById('app-confirm-modal');
if(!modal) {
modal = document.createElement('div');
modal.id = 'app-confirm-modal';
modal.className = 'modal-overlay';
modal.style.zIndex = '10005';
modal.innerHTML = `
<div class="modal-content" style="max-width:350px;">
<h3 style="margin-bottom:0.75rem; font-size:1.1rem; text-align:center;">Confirmation</h3>
<p style="margin-bottom:1.5rem; font-size:0.95rem; color:var(--text); text-align:center; line-height:1.4;" id="app-confirm-msg"></p>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary" style="flex:1;" id="app-confirm-no">Cancel</button>
<button class="btn btn-danger" style="flex:1;" id="app-confirm-yes">Confirm</button>
</div>
</div>`;
document.body.appendChild(modal);
}
document.getElementById('app-confirm-msg').textContent = msg;
modal.classList.add('active');
document.getElementById('app-confirm-no').onclick = () => { modal.classList.remove('active'); resolve(false); };
document.getElementById('app-confirm-yes').onclick = () => { modal.classList.remove('active'); resolve(true); };
});
}
function appConfirmUrl(event, url, msg) {
event.preventDefault();
appConfirm(msg).then(yes => { if(yes) window.location.href = url; });
}
// ── Sidebar Collapse ────────────────────────────────────────────────── // ── Sidebar Collapse ──────────────────────────────────────────────────
function toggleSidebar() { function toggleSidebar() {
const collapsed = document.body.classList.toggle('sidebar-collapsed'); const collapsed = document.body.classList.toggle('sidebar-collapsed');
@@ -87,6 +142,19 @@
document.getElementById('change-pw-modal').classList.add('active'); document.getElementById('change-pw-modal').classList.add('active');
}); });
<?php endif; ?> <?php endif; ?>
// ── Tagify globally ───────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if(typeof Tagify !== 'undefined' && window.APP_GLOBAL_TAGS) {
document.querySelectorAll('.tagify-input').forEach(el => {
new Tagify(el, {
whitelist: window.APP_GLOBAL_TAGS,
dropdown: { maxItems: 20, classname: "tags-look", enabled: 0, closeOnSelect: false },
originalInputValueFormat: valuesArr => valuesArr.map(item => item.value).join(',')
});
});
}
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -7,6 +7,26 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="public/css/style.css"> <link rel="stylesheet" href="public/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify"></script>
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet" type="text/css" />
<style>
.tags-look .tagify__dropdown__item{ display: inline-block; border-radius: 3px; padding: .3em .5em; border: 1px solid var(--border); background: var(--surface); margin: .2em; font-size: .85em; transition: 0s; color: var(--text); }
.tags-look .tagify__dropdown__item--active{ color: black; }
.tags-look .tagify__dropdown__item:hover{ background: var(--primary); color: white; border-color: var(--primary); }
</style>
<?php
$all_app_tags = [];
try {
$rows = $pdo->query("SELECT label_name as tag FROM notes WHERE label_name IS NOT NULL AND label_name != ''")->fetchAll();
foreach($rows as $r) { foreach(explode(',', $r['tag']) as $p) { $all_app_tags[trim($p)] = 1; } }
$rows = $pdo->query("SELECT tags as tag FROM tasks WHERE tags IS NOT NULL AND tags != ''")->fetchAll();
foreach($rows as $r) { foreach(explode(',', $r['tag']) as $p) { $all_app_tags[trim($p)] = 1; } }
} catch(Exception $e) {}
$global_tags = array_keys($all_app_tags);
?>
<script>
window.APP_GLOBAL_TAGS = <?= json_encode(array_filter($global_tags)) ?>;
</script>
</head> </head>
<body> <body>
<script> <script>
@@ -45,7 +65,7 @@
<form method="POST"> <form method="POST">
<input type="hidden" name="delete_profile_pic" value="1"> <input type="hidden" name="delete_profile_pic" value="1">
<input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>"> <input type="hidden" name="redirect_to" value="<?= htmlspecialchars($action) ?>">
<button type="submit" class="btn btn-danger" style="width:100%;" onclick="return confirm('Delete profile picture?')">Delete Picture</button> <button type="button" class="btn btn-danger" style="width:100%;" onclick="appConfirm('Delete profile picture?').then(y => { if(y) this.form.submit(); })">Delete Picture</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
<button class="btn btn-secondary" style="width:100%; margin-top:0.5rem;" onclick="document.getElementById('profile-pic-modal').classList.remove('active')">Close</button> <button class="btn btn-secondary" style="width:100%; margin-top:0.5rem;" onclick="document.getElementById('profile-pic-modal').classList.remove('active')">Close</button>
@@ -234,7 +254,7 @@
<i class="fa fa-key"></i> Change Password <i class="fa fa-key"></i> Change Password
</button> </button>
<div class="user-dropdown-divider"></div> <div class="user-dropdown-divider"></div>
<a href="?action=logout" class="user-dropdown-item user-dropdown-logout" onclick="return confirm('Logout?')"> <a href="?action=logout" class="user-dropdown-item user-dropdown-logout" onclick="appConfirmUrl(event, this.href, 'Logout?')">
<i class="fa fa-right-from-bracket"></i> Logout <i class="fa fa-right-from-bracket"></i> Logout
</a> </a>
</div> </div>

View File

@@ -122,9 +122,12 @@ $all_users = query("SELECT id, username FROM users WHERE id != ?", [$user_id])->
$all_groups = query("SELECT * FROM groups")->fetchAll(); $all_groups = query("SELECT * FROM groups")->fetchAll();
$label_names = []; $label_names = [];
foreach($notes as $n) { foreach($notes as $n) {
$lbl = trim((string)($n['label_name'] ?? '')); $lbls = explode(',', (string)($n['label_name'] ?? ''));
foreach($lbls as $lbl) {
$lbl = trim($lbl);
if($lbl !== '') $label_names[strtolower($lbl)] = $lbl; if($lbl !== '') $label_names[strtolower($lbl)] = $lbl;
} }
}
natcasesort($label_names); natcasesort($label_names);
// Define note colors — Google Keep inspired // Define note colors — Google Keep inspired
@@ -182,7 +185,8 @@ $note_colors = [
<input type="text" name="title" class="form-control" placeholder="Title..." id="new-note-title" style="font-weight:600; font-size:1rem;"> <input type="text" name="title" class="form-control" placeholder="Title..." id="new-note-title" style="font-weight:600; font-size:1rem;">
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" name="label_name" class="form-control" id="new-note-label" placeholder="Tag/Label name"> <label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.25rem; display:block;">Tags</label>
<input type="text" name="label_name" class="form-control tagify-input" id="new-note-label" placeholder="Add tags...">
</div> </div>
</div> </div>
<!-- WYSIWYG Toolbar --> <!-- WYSIWYG Toolbar -->
@@ -231,6 +235,36 @@ $note_colors = [
</div> </div>
</div> </div>
<!-- ── View Note Modal ────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="view-note-modal">
<div class="modal-content note-modal-compact" style="max-width:760px; max-height:92vh; overflow-y:auto; padding:0; background:var(--bg);">
<div id="view-note-header" style="padding:1.5rem; border-bottom:1px solid var(--border); background:var(--surface); position:sticky; top:0; z-index:10;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:1rem;">
<div style="flex:1; min-width:0;">
<h2 id="view-note-title" style="margin:0 0 0.5rem 0; font-size:1.4rem; font-weight:800; line-height:1.3; color:var(--text); word-wrap:break-word;"></h2>
<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap;">
<span id="view-note-label-container" style="display:none; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.75rem; font-weight:600;">
<i class="fa fa-tag" style="font-size:0.7rem;"></i> <span id="view-note-label"></span>
</span>
<span id="view-note-date" style="font-size:0.8rem; color:var(--text-light);"></span>
<span id="view-note-shared-badge" class="note-card-shared-badge" style="display:none;">Shared</span>
</div>
</div>
<div style="display:flex; align-items:center; gap:0.5rem; flex-shrink:0;">
<button class="btn btn-sm btn-secondary" id="view-note-edit-btn" onclick="transToEditNote()"><i class="fa fa-pencil"></i> Edit</button>
<button type="button" style="background:none;border:none;color:var(--text-light);cursor:pointer;font-size:1.6rem; line-height:1; padding:0 4px;" onclick="document.getElementById('view-note-modal').classList.remove('active')">×</button>
</div>
</div>
<div id="view-note-image-container" style="display:none; margin-top:1.25rem; border-radius:0.75rem; overflow:hidden; border:1px solid var(--border);">
<img id="view-note-image" src="" style="width:100%; max-height:450px; object-fit:cover; display:block;">
</div>
</div>
<div style="padding:1.5rem;">
<div id="view-note-content" class="markdown-body" style="font-size:1rem; line-height:1.7; color:var(--text);"></div>
</div>
</div>
</div>
<!-- ── Edit Note Modal ────────────────────────────────────────────────────── --> <!-- ── Edit Note Modal ────────────────────────────────────────────────────── -->
<div class="modal-overlay" id="edit-note-modal"> <div class="modal-overlay" id="edit-note-modal">
<div class="modal-content note-modal-compact" style="max-width:760px; max-height:90vh; overflow-y:auto;"> <div class="modal-content note-modal-compact" style="max-width:760px; max-height:90vh; overflow-y:auto;">
@@ -246,7 +280,8 @@ $note_colors = [
<input type="text" name="title" id="edit-note-title" class="form-control" placeholder="Title..." style="font-weight:600; font-size:1rem;"> <input type="text" name="title" id="edit-note-title" class="form-control" placeholder="Title..." style="font-weight:600; font-size:1rem;">
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" name="label_name" id="edit-note-label" class="form-control" placeholder="Tag/Label name"> <label style="font-size:0.8rem; color:var(--text-light); margin-bottom:0.25rem; display:block;">Tags</label>
<input type="text" name="label_name" id="edit-note-label" class="form-control tagify-input" placeholder="Add tags...">
</div> </div>
</div> </div>
<div class="note-editor-toolbar"> <div class="note-editor-toolbar">
@@ -374,7 +409,8 @@ $note_colors = [
data-image="<?= htmlspecialchars((string)($n['image_path'] ?? '')) ?>" data-image="<?= htmlspecialchars((string)($n['image_path'] ?? '')) ?>"
data-shares="<?= $shares_json ?>" data-shares="<?= $shares_json ?>"
data-mine="<?= $n['is_mine'] ?>" data-mine="<?= $n['is_mine'] ?>"
onclick="openNote(this)"> data-updated="<?= date('M j, Y, H:i', strtotime($n['updated_at'] ?: $n['created_at'])) ?>"
onclick="viewNote(this)">
<?php if($n['image_path'] && file_exists($n['image_path'])): ?> <?php if($n['image_path'] && file_exists($n['image_path'])): ?>
<img src="<?= htmlspecialchars($n['image_path']) ?>" alt="Note image" class="note-card-img"> <img src="<?= htmlspecialchars($n['image_path']) ?>" alt="Note image" class="note-card-img">
<?php endif; ?> <?php endif; ?>
@@ -382,8 +418,12 @@ $note_colors = [
<div class="note-card-title"><?= htmlspecialchars($n['title']) ?></div> <div class="note-card-title"><?= htmlspecialchars($n['title']) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if(!empty($n['label_name'])): ?> <?php if(!empty($n['label_name'])): ?>
<div style="display:inline-flex; align-items:center; gap:0.35rem; width:max-content; max-width:100%; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.7rem; font-weight:600;"> <div style="display:flex; flex-wrap:wrap; gap:0.35rem; margin-bottom:0.5rem;">
<i class="fa fa-tag" style="font-size:0.65rem;"></i> <?= htmlspecialchars($n['label_name']) ?> <?php foreach(explode(',', $n['label_name']) as $lbl): $lbl=trim($lbl); if(!$lbl) continue; ?>
<div style="display:inline-flex; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.7rem; font-weight:600;">
<i class="fa fa-tag" style="font-size:0.65rem;"></i> <?= htmlspecialchars($lbl) ?>
</div>
<?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="note-card-preview"><?= htmlspecialchars($preview) ?></div> <div class="note-card-preview"><?= htmlspecialchars($preview) ?></div>
@@ -478,7 +518,7 @@ function openNote(card) {
document.getElementById('edit-note-color-input').value = color; document.getElementById('edit-note-color-input').value = color;
document.getElementById('edit-note-preview').style.display = 'none'; document.getElementById('edit-note-preview').style.display = 'none';
document.getElementById('edit-note-content').style.display = 'block'; document.getElementById('edit-note-content').style.display = 'block';
document.getElementById('edit-preview-btn').textContent = '👁 Preview'; document.getElementById('edit-preview-btn').innerHTML = '👁 Preview';
// Color swatches // Color swatches
document.querySelectorAll('#edit-note-colors .color-swatch').forEach(s => { document.querySelectorAll('#edit-note-colors .color-swatch').forEach(s => {
@@ -518,6 +558,60 @@ function openNote(card) {
document.getElementById('edit-note-modal').classList.add('active'); document.getElementById('edit-note-modal').classList.add('active');
} }
let currentViewedCard = null;
function viewNote(card) {
currentViewedCard = card;
const title = card.dataset.title || 'Untitled Note';
const content = card.dataset.content || '';
const label = card.dataset.labelRaw || '';
const image = card.dataset.image;
const updatedAt = card.dataset.updated;
const isMine = card.dataset.mine === '1';
document.getElementById('view-note-title').textContent = title;
// Clear old tags
document.getElementById('view-note-label-container').innerHTML = '';
if(label) {
document.getElementById('view-note-label-container').style.display = 'flex';
const lbls = label.split(',');
lbls.forEach(l => {
const span = document.createElement('span');
span.style.cssText = 'display:inline-flex; align-items:center; gap:0.35rem; padding:3px 8px; border-radius:999px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-size:0.75rem; font-weight:600;';
span.innerHTML = `<i class="fa fa-tag" style="font-size:0.7rem;"></i> ${l.trim()}`;
document.getElementById('view-note-label-container').appendChild(span);
});
} else {
document.getElementById('view-note-label-container').style.display = 'none';
}
document.getElementById('view-note-date').textContent = 'Updated ' + updatedAt;
document.getElementById('view-note-shared-badge').style.display = !isMine ? 'inline-block' : 'none';
if(image) {
document.getElementById('view-note-image-container').style.display = 'block';
document.getElementById('view-note-image').src = image;
} else {
document.getElementById('view-note-image-container').style.display = 'none';
}
document.getElementById('view-note-content').innerHTML = marked.parse(content);
document.getElementById('view-note-edit-btn').style.display = isMine ? 'inline-flex' : 'none';
document.getElementById('view-note-modal').classList.add('active');
}
function transToEditNote() {
if(currentViewedCard) {
document.getElementById('view-note-modal').classList.remove('active');
openNote(currentViewedCard);
}
}
function renderNoteShares(jsonShares) { function renderNoteShares(jsonShares) {
const list = document.getElementById('note-shares-list'); const list = document.getElementById('note-shares-list');
list.innerHTML = ''; list.innerHTML = '';
@@ -542,10 +636,13 @@ function renderNoteShares(jsonShares) {
function removeNoteShare(shareId) { function removeNoteShare(shareId) {
if(!shareId) return; if(!shareId) return;
if(!confirm('Unshare this note access?')) return; appConfirm('Unshare this note access?').then(y => {
if(y) {
document.getElementById('remove-note-share-id').value = String(shareId); document.getElementById('remove-note-share-id').value = String(shareId);
document.getElementById('remove-note-share-form').submit(); document.getElementById('remove-note-share-form').submit();
} }
});
}
function filterNotes() { function filterNotes() {
const q = (document.getElementById('notes-search-input')?.value || '').toLowerCase().trim(); const q = (document.getElementById('notes-search-input')?.value || '').toLowerCase().trim();
@@ -553,18 +650,22 @@ function filterNotes() {
document.querySelectorAll('.note-filter-item').forEach(card => { document.querySelectorAll('.note-filter-item').forEach(card => {
const t = (card.dataset.title || '').toLowerCase(); const t = (card.dataset.title || '').toLowerCase();
const c = (card.dataset.content || '').toLowerCase(); const c = (card.dataset.content || '').toLowerCase();
const cl = (card.dataset.label || '').toLowerCase(); // data-label is now comma separated
const okText = !q || t.includes(q) || c.includes(q) || cl.includes(q); const cardLabels = (card.dataset.label || '').split(',').map(s=>s.trim());
const okLabel = !lbl || cl === lbl; const okText = !q || t.includes(q) || c.includes(q) || cardLabels.join(' ').includes(q);
const okLabel = !lbl || cardLabels.includes(lbl);
card.style.display = (okText && okLabel) ? '' : 'none'; card.style.display = (okText && okLabel) ? '' : 'none';
}); });
} }
function deleteNote(id) { function deleteNote(id) {
if(!confirm('Delete this note permanently?')) return; appConfirm('Delete this note permanently?').then(y => {
if(y) {
document.getElementById('delete-note-id').value = id; document.getElementById('delete-note-id').value = id;
document.getElementById('delete-note-form').submit(); document.getElementById('delete-note-form').submit();
} }
});
}
// Fix note card colors in dark mode // Fix note card colors in dark mode
function applyDarkNoteColors() { function applyDarkNoteColors() {

View File

@@ -1,6 +1,7 @@
<?php <?php
// ── DB migrations ───────────────────────────────────────────────────────────── // ── DB migrations ─────────────────────────────────────────────────────────────
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN folder_id INTEGER DEFAULT NULL"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE passwords ADD COLUMN folder_id INTEGER DEFAULT NULL"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE passwords ADD COLUMN tags TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { try {
$pdo->exec("CREATE TABLE IF NOT EXISTS password_folders ( $pdo->exec("CREATE TABLE IF NOT EXISTS password_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -25,15 +26,15 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
if(isset($_POST['create_password'])) { if(isset($_POST['create_password'])) {
$stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id, folder_id) VALUES (?, ?, ?, ?, ?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO passwords (title, username, password, url, notes, owner_id, folder_id, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id, $_POST['folder_id'] ?: null]); $stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $user_id, $_POST['folder_id'] ?: null, trim($_POST['tags'] ?? '')]);
} }
if(isset($_POST['update_password'])) { if(isset($_POST['update_password'])) {
$p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch(); $p = query("SELECT owner_id FROM passwords WHERE id=?", [$_POST['password_id']])->fetch();
if($p && $p['owner_id'] == $user_id) { if($p && $p['owner_id'] == $user_id) {
$stmt = $pdo->prepare("UPDATE passwords SET title=?, username=?, password=?, url=?, notes=?, folder_id=? WHERE id=? AND owner_id=?"); $stmt = $pdo->prepare("UPDATE passwords SET title=?, username=?, password=?, url=?, notes=?, folder_id=?, tags=? WHERE id=? AND owner_id=?");
$stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $_POST['folder_id'] ?: null, $_POST['password_id'], $user_id]); $stmt->execute([$_POST['title'], $_POST['username'], $_POST['password'], $_POST['url'], $_POST['notes'], $_POST['folder_id'] ?: null, trim($_POST['tags'] ?? ''), $_POST['password_id'], $user_id]);
} }
} }
@@ -144,6 +145,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
</div> </div>
</div> </div>
<div id="pw-view-notes" style="font-size:0.8rem; color:var(--text-light); margin-top:0.5rem; white-space:pre-wrap;"></div> <div id="pw-view-notes" style="font-size:0.8rem; color:var(--text-light); margin-top:0.5rem; white-space:pre-wrap;"></div>
<div id="pw-view-tags-container" style="display:none; flex-wrap:wrap; gap:0.25rem; margin-top:1rem; padding-top:0.5rem; border-top:1px dashed var(--border);"></div>
<div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem;"> <div style="display:flex; justify-content:flex-end; gap:0.5rem; margin-top:1rem;">
<button class="btn btn-secondary" onclick="document.getElementById('password-view-modal').classList.remove('active')">Close</button> <button class="btn btn-secondary" onclick="document.getElementById('password-view-modal').classList.remove('active')">Close</button>
</div> </div>
@@ -194,6 +196,10 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
<label>Notes</label> <label>Notes</label>
<textarea name="notes" id="pw-edit-notes" class="form-control" rows="2" placeholder="Optional notes..."></textarea> <textarea name="notes" id="pw-edit-notes" class="form-control" rows="2" placeholder="Optional notes..."></textarea>
</div> </div>
<div class="form-group">
<label>Tags</label>
<input type="text" name="tags" id="pw-edit-tags" class="form-control tagify-input" placeholder="Add tags...">
</div>
<!-- Shares management section (populated by JS) --> <!-- Shares management section (populated by JS) -->
<div id="pw-shares-section" style="display:none; border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;"> <div id="pw-shares-section" style="display:none; border-top:1px solid var(--border); padding-top:0.75rem; margin-top:0.5rem;">
<label style="font-size:0.8rem; color:var(--text-light); display:block; margin-bottom:0.4rem;">Shared with</label> <label style="font-size:0.8rem; color:var(--text-light); display:block; margin-bottom:0.4rem;">Shared with</label>
@@ -298,7 +304,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<button type="submit" name="bulk_action" value="move_folder" class="btn btn-sm btn-secondary"><i class="fa fa-folder-arrow-down"></i> Move</button> <button type="submit" name="bulk_action" value="move_folder" class="btn btn-sm btn-secondary"><i class="fa fa-folder-arrow-down"></i> Move</button>
<button type="submit" name="bulk_action" value="delete" class="btn btn-sm btn-danger" onclick="return confirm('Delete selected entries?')"><i class="fa fa-trash"></i> Delete</button> <button type="button" class="btn btn-sm btn-danger" onclick="appConfirm('Delete selected entries?').then(y => { if(y) { const el = document.createElement('input'); el.type='hidden'; el.name='bulk_action'; el.value='delete'; this.form.appendChild(el); this.form.submit(); } })"><i class="fa fa-trash"></i> Delete</button>
</div> </div>
<!-- Entries List --> <!-- Entries List -->
@@ -364,6 +370,7 @@ $active_folder = $_GET['vault_folder'] ?? 'all';
data-notes="<?= htmlspecialchars(addslashes((string)($p['notes'] ?? ''))) ?>" data-notes="<?= htmlspecialchars(addslashes((string)($p['notes'] ?? ''))) ?>"
data-folder-id="<?= htmlspecialchars((string)($p['folder_id'] ?? '')) ?>" data-folder-id="<?= htmlspecialchars((string)($p['folder_id'] ?? '')) ?>"
data-shares="<?= $shares_json ?>" data-shares="<?= $shares_json ?>"
data-tags="<?= htmlspecialchars((string)($p['tags'] ?? '')) ?>"
onclick="openViewEntry(this)"> onclick="openViewEntry(this)">
<!-- Checkbox (only for owned items) --> <!-- Checkbox (only for owned items) -->
<?php if($p['is_mine']): ?> <?php if($p['is_mine']): ?>
@@ -473,10 +480,33 @@ function openViewEntry(row) {
document.getElementById('pw-view-pw').value = row.dataset.pw; document.getElementById('pw-view-pw').value = row.dataset.pw;
document.getElementById('pw-view-pw').type = 'password'; document.getElementById('pw-view-pw').type = 'password';
document.getElementById('pw-view-toggle').innerHTML = '<i class="fa fa-eye"></i>'; document.getElementById('pw-view-toggle').innerHTML = '<i class="fa fa-eye"></i>';
const urlEl = document.getElementById('pw-view-url'); const urlEl = document.createElement('div');
urlEl.textContent = ''; document.getElementById('pw-view-url').innerHTML = ''; // Clear existing
urlEl.textContent = row.dataset.url || ''; if(row.dataset.url) {
urlEl.innerHTML = `<a href="${row.dataset.url}" target="_blank" style="color:var(--primary);">${row.dataset.url}</a>`;
document.getElementById('pw-view-url').appendChild(urlEl);
}
document.getElementById('pw-view-notes').textContent = row.dataset.notes || ''; document.getElementById('pw-view-notes').textContent = row.dataset.notes || '';
// Tag rendering
const tagsContainer = document.getElementById('pw-view-tags-container');
tagsContainer.innerHTML = '';
const tagsStr = row.dataset.tags || '';
if(tagsStr) {
tagsContainer.style.display = 'flex';
tagsStr.split(',').forEach(t => {
t = t.trim();
if(!t) return;
const span = document.createElement('span');
span.style.cssText = 'font-size:0.65rem; padding:2px 8px; border-radius:12px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-weight:600; display:inline-flex; align-items:center; gap:0.2rem;';
span.innerHTML = `<i class="fa fa-tag" style="font-size:0.6rem;"></i> ${t}`;
tagsContainer.appendChild(span);
});
} else {
tagsContainer.style.display = 'none';
}
document.getElementById('password-view-modal').classList.add('active'); document.getElementById('password-view-modal').classList.add('active');
} }
@@ -504,6 +534,7 @@ function openAddPassword() {
document.getElementById('pw-edit-url').value = ''; document.getElementById('pw-edit-url').value = '';
document.getElementById('pw-edit-notes').value = ''; document.getElementById('pw-edit-notes').value = '';
document.getElementById('pw-edit-folder').value = ''; document.getElementById('pw-edit-folder').value = '';
document.getElementById('pw-edit-tags').value = '';
document.getElementById('pw-shares-section').style.display = 'none'; document.getElementById('pw-shares-section').style.display = 'none';
document.getElementById('pw-shares-list').innerHTML = ''; document.getElementById('pw-shares-list').innerHTML = '';
document.getElementById('pw-edit-delete-wrap').style.display = 'none'; document.getElementById('pw-edit-delete-wrap').style.display = 'none';
@@ -534,6 +565,7 @@ function openEditEntry(row) {
document.getElementById('pw-edit-url').value = row.dataset.url || ''; document.getElementById('pw-edit-url').value = row.dataset.url || '';
document.getElementById('pw-edit-notes').value = row.dataset.notes || ''; document.getElementById('pw-edit-notes').value = row.dataset.notes || '';
document.getElementById('pw-edit-folder').value = row.dataset.folderId || ''; document.getElementById('pw-edit-folder').value = row.dataset.folderId || '';
document.getElementById('pw-edit-tags').value = row.dataset.tags || '';
document.getElementById('pw-edit-delete-wrap').style.display = 'block'; document.getElementById('pw-edit-delete-wrap').style.display = 'block';
renderPasswordShares(row.dataset.shares || '[]'); renderPasswordShares(row.dataset.shares || '[]');
document.getElementById('password-edit-modal').classList.add('active'); document.getElementById('password-edit-modal').classList.add('active');
@@ -568,16 +600,22 @@ function renderPasswordShares(jsonShares) {
function removePasswordShare(shareId) { function removePasswordShare(shareId) {
if(!shareId) return; if(!shareId) return;
if(!confirm('Unshare this password access?')) return; appConfirm('Unshare this password access?').then(y => {
if(y) {
document.getElementById('remove-share-id').value = String(shareId); document.getElementById('remove-share-id').value = String(shareId);
document.getElementById('remove-share-form').submit(); document.getElementById('remove-share-form').submit();
} }
});
}
function deletePassword() { function deletePassword() {
if(!confirm('Delete this entry permanently?')) return; appConfirm('Delete this entry permanently?').then(y => {
if(y) {
document.getElementById('delete-pw-id').value = document.getElementById('pw-edit-id').value; document.getElementById('delete-pw-id').value = document.getElementById('pw-edit-id').value;
document.getElementById('delete-pw-form').submit(); document.getElementById('delete-pw-form').submit();
} }
});
}
// ── Toggles ─────────────────────────────────────────────────────────────────── // ── Toggles ───────────────────────────────────────────────────────────────────
function toggleAll(chk) { function toggleAll(chk) {
@@ -588,7 +626,8 @@ function filterVault(q) {
q = q.toLowerCase(); q = q.toLowerCase();
document.querySelectorAll('.vault-entry').forEach(row => { document.querySelectorAll('.vault-entry').forEach(row => {
const text = row.textContent.toLowerCase(); const text = row.textContent.toLowerCase();
row.style.display = text.includes(q) ? '' : 'none'; const tags = (row.dataset.tags || '').toLowerCase();
row.style.display = (text.includes(q) || tags.includes(q)) ? '' : 'none';
}); });
} }
@@ -602,10 +641,13 @@ function showAddFolderPrompt() {
} }
function deleteFolder(id, name) { function deleteFolder(id, name) {
if(!confirm(`Delete folder "${name}"? Entries will not be deleted.`)) return; appConfirm(`Delete folder "${name}"? Entries will not be deleted.`).then(y => {
if(y) {
document.getElementById('del-folder-id').value = id; document.getElementById('del-folder-id').value = id;
document.getElementById('del-folder-form').submit(); document.getElementById('del-folder-form').submit();
} }
});
}
// ── Hotkeys on hovered row ──────────────────────────────────────────────────── // ── Hotkeys on hovered row ────────────────────────────────────────────────────
let hoveredRow = null; let hoveredRow = null;

View File

@@ -289,10 +289,13 @@ function renameItem(id, currName) {
} }
function deleteItem(id, name) { function deleteItem(id, name) {
if(!confirm(`Delete "${name}"? This cannot be undone.`)) return; appConfirm(`Delete "${name}"? This cannot be undone.`).then(y => {
if(y) {
document.getElementById('delete-item-id').value = id; document.getElementById('delete-item-id').value = id;
document.getElementById('delete-item-form').submit(); document.getElementById('delete-item-form').submit();
} }
});
}
function openShare(id) { function openShare(id) {
document.getElementById('share-project-id').value = id; document.getElementById('share-project-id').value = id;
@@ -300,10 +303,13 @@ function openShare(id) {
} }
function removeProjectShare(shareId) { function removeProjectShare(shareId) {
if(!confirm('Unshare this item?')) return; appConfirm('Unshare this item?').then(y => {
if(y) {
document.getElementById('remove-share-id').value = shareId; document.getElementById('remove-share-id').value = shareId;
document.getElementById('remove-share-form').submit(); document.getElementById('remove-share-form').submit();
} }
});
}
function showNewFolderDialog() { function showNewFolderDialog() {
const name = prompt('New folder name:'); const name = prompt('New folder name:');

View File

@@ -4,6 +4,7 @@ try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_type TEXT DEFAULT 'user'
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN assignor_group_id INTEGER"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN status_updated_at DATETIME"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {} try { $pdo->exec("ALTER TABLE tasks ADD COLUMN due_date DATE"); } catch(Exception $e) {}
try { $pdo->exec("ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {} try { $pdo->exec("CREATE TABLE IF NOT EXISTS task_attachments (id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER, file_name TEXT, file_path TEXT, owner_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)"); } catch(Exception $e) {}
try { try {
$pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)"); $pdo->exec("CREATE TABLE IF NOT EXISTS system_config (k TEXT PRIMARY KEY, v TEXT)");
@@ -117,8 +118,9 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_task'])) {
} }
} }
$stmt = $pdo->prepare("INSERT INTO tasks (title, description, assignor_id, assignor_type, assignor_group_id, assignee_type, assignee_id, custom_assignee_name, status_updated_at, due_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)"); $tags = trim($_POST['tags'] ?? '');
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date])) { $stmt = $pdo->prepare("INSERT INTO tasks (title, description, assignor_id, assignor_type, assignor_group_id, assignee_type, assignee_id, custom_assignee_name, status_updated_at, due_date, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)");
if(!$stmt->execute([$title, $desc, $user_id, $assignor_type, $assignor_group_id, $a_type, $a_id, $custom_name, $due_date, $tags])) {
if(isset($_POST['ajax_submit'])) { echo json_encode(['success'=>false, 'error'=>'Task insert failed']); exit; } if(isset($_POST['ajax_submit'])) { echo json_encode(['success'=>false, 'error'=>'Task insert failed']); exit; }
} }
$new_task_id = $pdo->lastInsertId(); $new_task_id = $pdo->lastInsertId();
@@ -435,6 +437,16 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
<input type="file" multiple style="display:none;" onchange="handleTaskFilesSelect(this)"> <input type="file" multiple style="display:none;" onchange="handleTaskFilesSelect(this)">
</label> </label>
<div style="position:relative;" title="Tags">
<div id="tag-icon-btn" style="cursor:pointer; display:flex; align-items:center; justify-content:center; width:30px; height:30px; border-radius:50%; background:var(--surface); border:1px solid var(--border); color:var(--text-light); transition:background 0.2s;" onclick="document.getElementById('tags-dropdown-container').style.display='block';">
<i class="fas fa-tag" style="font-size:0.85rem;"></i>
</div>
<div id="tags-dropdown-container" style="display:none; position:absolute; top:35px; right:-20px; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:0.75rem; z-index:100; box-shadow:0 10px 15px -3px rgba(0,0,0,0.1); width:280px; align-items:stretch;">
<label style="font-size:0.75rem; font-weight:600; color:var(--text); margin-bottom:0.4rem; display:block; text-align:left;">Tags</label>
<input type="text" name="tags" class="form-control tagify-input" style="font-size:0.8rem; border:1px solid var(--border); border-radius:4px; padding:4px; max-width:100%;" placeholder="Add tags...">
</div>
</div>
<button type="button" class="btn" style="padding:0.3rem 1rem; border-radius:20px; font-weight:600; font-size:0.8rem;" onclick="submitCreateTaskForm()">Create</button> <button type="button" class="btn" style="padding:0.3rem 1rem; border-radius:20px; font-weight:600; font-size:0.8rem;" onclick="submitCreateTaskForm()">Create</button>
<button type="submit" id="hidden-create-btn" name="create_task" style="display:none;"></button> <button type="submit" id="hidden-create-btn" name="create_task" style="display:none;"></button>
</div> </div>
@@ -494,7 +506,15 @@ body.dark-mode .kanban-task { border: 1px solid rgba(255,255,255,0.05); border-l
</div> </div>
</div> </div>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem;"> <?php if(!empty($t['tags'])): ?>
<div style="display:flex; flex-wrap:wrap; gap:0.25rem; margin-top:0.5rem;">
<?php foreach(explode(',', $t['tags']) as $tag): $tag=trim($tag); if(!$tag) continue; ?>
<span style="font-size:0.65rem; padding:2px 8px; border-radius:12px; background:rgba(var(--primary-rgb),0.12); color:var(--primary); font-weight:600;"><i class="fa fa-tag" style="font-size:0.6rem;"></i> <?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:0.75rem; margin-top:0.5rem;">
<?php <?php
echo '<span class="badge-role role-to" style="padding:2px 8px;">'; echo '<span class="badge-role role-to" style="padding:2px 8px;">';
if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']); if($t['assignee_type'] == 'custom') echo '👤 '.htmlspecialchars((string)$t['custom_assignee_name']);
@@ -773,9 +793,14 @@ document.addEventListener("DOMContentLoaded", () => {
document.addEventListener('mousedown', function(e) { document.addEventListener('mousedown', function(e) {
let container = document.getElementById('to-dropdown-container'); let container = document.getElementById('to-dropdown-container');
if(container && !container.contains(e.target) && e.target.id !== 'to-input' && !e.target.closest('label')) { if(container && !container.contains(e.target) && e.target.id !== 'to-input' && !e.target.closest('#to-icon-btn')) {
container.style.display = 'none'; container.style.display = 'none';
} }
let tagContainer = document.getElementById('tags-dropdown-container');
if(tagContainer && !tagContainer.contains(e.target) && !e.target.closest('#tag-icon-btn') && !e.target.closest('.tagify')) {
tagContainer.style.display = 'none';
}
}); });
let pendingTaskFiles = []; let pendingTaskFiles = [];