feat: implement user notifications system
- Add UI components for notification navigation (btn + badge) and notification list with unread indicators - Introduce notifications service with CRUD methods for managing user notifications - Trigger notifications when users like/favorite songs or playlists via social actions - Add /account/notifications route to view and mark all notifications as read - Display unread count in header and account page navigation
This commit is contained in:
parent
d50117f334
commit
021d0694df
|
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
actor_id INTEGER,
|
||||||
|
actor_name TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id INTEGER NOT NULL,
|
||||||
|
entity_title TEXT NOT NULL,
|
||||||
|
entity_slug TEXT,
|
||||||
|
read_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX notifications_user_unread ON notifications (user_id, read_at);
|
||||||
|
|
@ -1203,6 +1203,94 @@ a.detail-creator-card:hover {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Notifications ---------- */
|
||||||
|
.notif-nav-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: rgba(255, 179, 71, 0.08);
|
||||||
|
border: 1px solid rgba(255, 179, 71, 0.4);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--neon-amber);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.notif-nav-btn:hover {
|
||||||
|
background: rgba(255, 179, 71, 0.15);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 179, 71, 0.25);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.notif-nav-icon {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.notif-nav-label {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--neon-amber);
|
||||||
|
color: #05060d;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: 0 0.3em;
|
||||||
|
margin-left: 0.3em;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 179, 71, 0.7);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.notif-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
.notif-item {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.notif-item.unread {
|
||||||
|
border-left: 3px solid var(--neon-amber);
|
||||||
|
box-shadow: 0 0 8px rgba(255, 179, 71, 0.15);
|
||||||
|
}
|
||||||
|
.notif-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notif-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.notif-text a {
|
||||||
|
color: var(--neon-cyan);
|
||||||
|
}
|
||||||
|
.notif-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.notif-time {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobile ---------- */
|
/* ---------- Mobile ---------- */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.player-bar { flex-wrap: wrap; }
|
.player-bar { flex-wrap: wrap; }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const pinoHttp = require('pino-http');
|
||||||
|
|
||||||
const { db } = require('../db');
|
const { db } = require('../db');
|
||||||
const SqliteStore = SqliteStoreFactory(session);
|
const SqliteStore = SqliteStoreFactory(session);
|
||||||
|
const notifications = require('./services/notifications');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
|
@ -62,6 +63,7 @@ app.use((req, res, next) => {
|
||||||
delete req.session.flash;
|
delete req.session.flash;
|
||||||
res.locals.csrfToken = generateToken(req);
|
res.locals.csrfToken = generateToken(req);
|
||||||
res.locals.currentPath = req.path;
|
res.locals.currentPath = req.path;
|
||||||
|
res.locals.unreadNotifCount = req.session.user ? notifications.countUnread(req.session.user.id) : 0;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const { z } = require('zod');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const users = require('../services/users');
|
const users = require('../services/users');
|
||||||
|
const notifications = require('../services/notifications');
|
||||||
const { requireUser } = require('../middleware/auth');
|
const { requireUser } = require('../middleware/auth');
|
||||||
|
|
||||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||||
|
|
@ -38,6 +39,12 @@ module.exports = function accountRoutes(csrfProtection) {
|
||||||
res.render('account/index', { title: 'Account', errors: {}, values: {} });
|
res.render('account/index', { title: 'Account', errors: {}, values: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/notifications', (req, res) => {
|
||||||
|
const items = notifications.list(req.session.user.id);
|
||||||
|
notifications.markAllRead(req.session.user.id);
|
||||||
|
res.render('account/notifications', { title: 'Notifications', items });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/', csrfProtection, (req, res, next) => {
|
router.post('/', csrfProtection, (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const parsed = profileSchema.safeParse(req.body);
|
const parsed = profileSchema.safeParse(req.body);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
const { db } = require('../../db');
|
||||||
|
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO notifications (user_id, actor_id, actor_name, action, entity_type, entity_id, entity_title, entity_slug)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const countUnreadStmt = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS c FROM notifications WHERE user_id = ? AND read_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
const listStmt = db.prepare(`
|
||||||
|
SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const markAllReadStmt = db.prepare(`
|
||||||
|
UPDATE notifications SET read_at = datetime('now') WHERE user_id = ? AND read_at IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
function create({ userId, actorId, actorName, action, entityType, entityId, entityTitle, entitySlug }) {
|
||||||
|
// No self-notifications
|
||||||
|
if (userId === actorId) return;
|
||||||
|
// No notification if owner is unknown
|
||||||
|
if (!userId) return;
|
||||||
|
insertStmt.run(userId, actorId || null, actorName || null, action, entityType, entityId, entityTitle, entitySlug || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countUnread(userId) {
|
||||||
|
return countUnreadStmt.get(userId).c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(userId, limit = 50) {
|
||||||
|
return listStmt.all(userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllRead(userId) {
|
||||||
|
markAllReadStmt.run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, countUnread, list, markAllRead };
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
const { db } = require('../../db');
|
const { db } = require('../../db');
|
||||||
|
const notifications = require('./notifications');
|
||||||
|
|
||||||
|
// ── Lookup statements for notification context ───────────────────────────────
|
||||||
|
const songOwnerStmt = db.prepare('SELECT uploaded_by, title, slug FROM songs WHERE id = ?');
|
||||||
|
const playlistOwnerStmt = db.prepare('SELECT created_by, title, slug FROM playlists WHERE id = ?');
|
||||||
|
const userNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
|
||||||
|
|
||||||
|
function getActorName(actorId) {
|
||||||
|
return actorId ? (userNameStmt.get(actorId)?.display_name || null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Song statements ──────────────────────────────────────────────────────────
|
// ── Song statements ──────────────────────────────────────────────────────────
|
||||||
const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?');
|
const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?');
|
||||||
|
|
@ -23,29 +33,49 @@ const deletePlFav = db.prepare('DELETE FROM playlist_favorites WHERE use
|
||||||
// ── Toggle functions ─────────────────────────────────────────────────────────
|
// ── Toggle functions ─────────────────────────────────────────────────────────
|
||||||
function toggleSongLike(userId, songId) {
|
function toggleSongLike(userId, songId) {
|
||||||
const exists = songUserLikedStmt.get(userId, songId);
|
const exists = songUserLikedStmt.get(userId, songId);
|
||||||
if (exists) deleteSongLike.run(userId, songId);
|
if (exists) {
|
||||||
else insertSongLike.run(userId, songId);
|
deleteSongLike.run(userId, songId);
|
||||||
|
} else {
|
||||||
|
insertSongLike.run(userId, songId);
|
||||||
|
const song = songOwnerStmt.get(songId);
|
||||||
|
if (song) notifications.create({ userId: song.uploaded_by, actorId: userId, actorName: getActorName(userId), action: 'like', entityType: 'song', entityId: songId, entityTitle: song.title, entitySlug: song.slug });
|
||||||
|
}
|
||||||
return { liked: !exists, count: songLikeCountStmt.get(songId).c };
|
return { liked: !exists, count: songLikeCountStmt.get(songId).c };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSongFavorite(userId, songId) {
|
function toggleSongFavorite(userId, songId) {
|
||||||
const exists = songUserFavoritedStmt.get(userId, songId);
|
const exists = songUserFavoritedStmt.get(userId, songId);
|
||||||
if (exists) deleteSongFav.run(userId, songId);
|
if (exists) {
|
||||||
else insertSongFav.run(userId, songId);
|
deleteSongFav.run(userId, songId);
|
||||||
|
} else {
|
||||||
|
insertSongFav.run(userId, songId);
|
||||||
|
const song = songOwnerStmt.get(songId);
|
||||||
|
if (song) notifications.create({ userId: song.uploaded_by, actorId: userId, actorName: getActorName(userId), action: 'favorite', entityType: 'song', entityId: songId, entityTitle: song.title, entitySlug: song.slug });
|
||||||
|
}
|
||||||
return { favorited: !exists, count: songFavCountStmt.get(songId).c };
|
return { favorited: !exists, count: songFavCountStmt.get(songId).c };
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlaylistLike(userId, playlistId) {
|
function togglePlaylistLike(userId, playlistId) {
|
||||||
const exists = plUserLikedStmt.get(userId, playlistId);
|
const exists = plUserLikedStmt.get(userId, playlistId);
|
||||||
if (exists) deletePlLike.run(userId, playlistId);
|
if (exists) {
|
||||||
else insertPlLike.run(userId, playlistId);
|
deletePlLike.run(userId, playlistId);
|
||||||
|
} else {
|
||||||
|
insertPlLike.run(userId, playlistId);
|
||||||
|
const pl = playlistOwnerStmt.get(playlistId);
|
||||||
|
if (pl) notifications.create({ userId: pl.created_by, actorId: userId, actorName: getActorName(userId), action: 'like', entityType: 'playlist', entityId: playlistId, entityTitle: pl.title, entitySlug: pl.slug });
|
||||||
|
}
|
||||||
return { liked: !exists, count: plLikeCountStmt.get(playlistId).c };
|
return { liked: !exists, count: plLikeCountStmt.get(playlistId).c };
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlaylistFavorite(userId, playlistId) {
|
function togglePlaylistFavorite(userId, playlistId) {
|
||||||
const exists = plUserFavoritedStmt.get(userId, playlistId);
|
const exists = plUserFavoritedStmt.get(userId, playlistId);
|
||||||
if (exists) deletePlFav.run(userId, playlistId);
|
if (exists) {
|
||||||
else insertPlFav.run(userId, playlistId);
|
deletePlFav.run(userId, playlistId);
|
||||||
|
} else {
|
||||||
|
insertPlFav.run(userId, playlistId);
|
||||||
|
const pl = playlistOwnerStmt.get(playlistId);
|
||||||
|
if (pl) notifications.create({ userId: pl.created_by, actorId: userId, actorName: getActorName(userId), action: 'favorite', entityType: 'playlist', entityId: playlistId, entityTitle: pl.title, entitySlug: pl.slug });
|
||||||
|
}
|
||||||
return { favorited: !exists, count: plFavCountStmt.get(playlistId).c };
|
return { favorited: !exists, count: plFavCountStmt.get(playlistId).c };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ const listVerifiedStmt = db.prepare(`
|
||||||
(SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count
|
(SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.email_verified = 1 AND u.disabled = 0
|
WHERE u.email_verified = 1 AND u.disabled = 0
|
||||||
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM songs WHERE uploaded_by = u.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM playlists WHERE created_by = u.id)
|
||||||
|
)
|
||||||
ORDER BY u.display_name ASC
|
ORDER BY u.display_name ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<h1>Account</h1>
|
<h1>Account</h1>
|
||||||
<p class="muted"><%= user.email %> · <%= user.role %></p>
|
<p class="muted"><%= user.email %> · <%= user.role %></p>
|
||||||
|
<a href="/account/notifications" class="notif-nav-btn">
|
||||||
|
<span class="notif-nav-icon">🔔</span>
|
||||||
|
<span class="notif-nav-label">Notifications</span>
|
||||||
|
<% if (unreadNotifCount > 0) { %><span class="notif-badge"><%= unreadNotifCount %></span><% } %>
|
||||||
|
</a>
|
||||||
|
|
||||||
<section class="form-card">
|
<section class="form-card">
|
||||||
<h2>Profile picture</h2>
|
<h2>Profile picture</h2>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
|
||||||
|
<% if (!items || items.length === 0) { %>
|
||||||
|
<p class="muted">No notifications yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="notif-list">
|
||||||
|
<% items.forEach(function(item) {
|
||||||
|
const isUnread = !item.read_at;
|
||||||
|
const actorLabel = item.actor_name || 'Someone';
|
||||||
|
const actionLabel = item.action === 'like' ? 'liked' : 'favorited';
|
||||||
|
const entityLabel = item.entity_type === 'song' ? 'song' : 'playlist';
|
||||||
|
const entityPath = item.entity_type === 'song' ? '/songs/' : '/playlists/';
|
||||||
|
const icon = item.action === 'like' ? '👍' : '⭐';
|
||||||
|
|
||||||
|
// Relative time
|
||||||
|
const now = new Date();
|
||||||
|
const created = new Date(item.created_at + 'Z');
|
||||||
|
const diffMs = now - created;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
let timeAgo;
|
||||||
|
if (diffMin < 1) timeAgo = 'just now';
|
||||||
|
else if (diffMin < 60) timeAgo = diffMin + ' minute' + (diffMin === 1 ? '' : 's') + ' ago';
|
||||||
|
else if (diffHr < 24) timeAgo = diffHr + ' hour' + (diffHr === 1 ? '' : 's') + ' ago';
|
||||||
|
else timeAgo = diffDay + ' day' + (diffDay === 1 ? '' : 's') + ' ago';
|
||||||
|
%>
|
||||||
|
<li class="notif-item<%= isUnread ? ' unread' : '' %>">
|
||||||
|
<span class="notif-icon"><%= icon %></span>
|
||||||
|
<div>
|
||||||
|
<div class="notif-text">
|
||||||
|
<strong><%= actorLabel %></strong> <%= actionLabel %> your <%= entityLabel %>
|
||||||
|
<% if (item.entity_slug) { %>
|
||||||
|
"<a href="<%= entityPath %><%= item.entity_slug %>"><%= item.entity_title %></a>"
|
||||||
|
<% } else { %>
|
||||||
|
"<%= item.entity_title %>"
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="notif-time"><%= timeAgo %></div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<% if (user.role === 'admin') { %>
|
<% if (user.role === 'admin') { %>
|
||||||
<a href="/mymusic/users" class="<%= currentPath && currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">Users</a>
|
<a href="/mymusic/users" class="<%= currentPath && currentPath.startsWith('/mymusic/users') ? 'active' : '' %>">Users</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
<a href="/account">Account</a>
|
<a href="/account" class="<%= currentPath && currentPath.startsWith('/account') ? 'active' : '' %>">Account<% if (unreadNotifCount > 0) { %><span class="notif-badge"><%= unreadNotifCount %></span><% } %></a>
|
||||||
<form method="post" action="/logout" class="inline-form">
|
<form method="post" action="/logout" class="inline-form">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
<button type="submit" class="linklike">Log out</button>
|
<button type="submit" class="linklike">Log out</button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue