Changeset c9c6a2d in Klonkt


Ignore:
Timestamp:
06/21/2026 06:38:24 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
9a34d31
Parents:
d41f4be
Message:

feat(meldingen): notifications — reply on comment, comment on post, like on post

For every logged-in user (Google visitors/fans and admin). Bell icon in the
header with unread counter + notifications page /notifications (via user menu
desktop + profile sheet mobile; badge also on the mobile Profile tab). Opening =
read. Triggers in comments (reply→comment author, top-level→post author) and
like (→post author); notify() skips notifying yourself. Table notifications
+ NotificationService. NL/EN/DE.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
3 added
9 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rd41f4be rc9c6a2d  
    252252  `);
    253253
     254  // Meldingen: iemand reageert op je reactie / post, of liket je post. Snapshots
     255  // van naam/titel zodat de lijst goedkoop te tonen is zonder joins.
     256  db.exec(`
     257    CREATE TABLE IF NOT EXISTS notifications (
     258      id TEXT PRIMARY KEY,
     259      user_id TEXT NOT NULL,
     260      type TEXT NOT NULL,
     261      actor_id TEXT,
     262      actor_name TEXT,
     263      post_slug TEXT,
     264      post_title TEXT,
     265      url TEXT,
     266      read INTEGER DEFAULT 0,
     267      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     268    );
     269    CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read, created_at);
     270  `);
     271
    254272  // Link-in-bio klikstatistiek (premium #6). Per (site, url) een teller; de
    255273  // link-in-bio-pagina linkt via /links/go/:i dat de klik telt en doorstuurt.
  • src/middleware/render.js

    rd41f4be rc9c6a2d  
    1919import { getSetting } from '../services/SettingsService.js';
    2020import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
     21import { unreadCount as notifUnreadCount } from '../services/NotificationService.js';
    2122import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
    2223import { t as i18nT, resolveLang, SUPPORTED as LANGS, LANG_NAMES } from '../services/i18n.js';
     
    9495    langs: LANGS.map((c) => ({ code: c, name: LANG_NAMES[c], active: c === _lang })),
    9596    timezone: getSetting('timezone') || '',
     97    notifUnread: _u ? notifUnreadCount(_u.id) : 0,
    9698    userOwnsSite,
    9799    canSeeBeheer,
  • src/routes/comments.js

    rd41f4be rc9c6a2d  
    1919import { requireAuth } from '../middleware/auth.js';
    2020import PermissionsService from '../services/PermissionsService.js';
     21import { notify } from '../services/NotificationService.js';
    2122
    2223const router = express.Router();
     
    3940
    4041  const post = db.prepare(
    41     'SELECT id, slug FROM posts WHERE site_id = ? AND slug = ? AND status = ?'
     42    'SELECT id, slug, title, author_id FROM posts WHERE site_id = ? AND slug = ? AND status = ?'
    4243  ).get(site.id, postSlug, 'published');
    4344  if (!post) return res.status(404).send('Post not found');
     
    5051  // up to the top-level parent so we never go deeper than 1)
    5152  let resolvedParent = null;
     53  let parentAuthorId = null;
    5254  if (parentId) {
    5355    const parent = db.prepare(
    54       'SELECT id, parent_comment_id FROM comments WHERE id = ? AND post_id = ?'
     56      'SELECT id, parent_comment_id, author_id FROM comments WHERE id = ? AND post_id = ?'
    5557    ).get(parentId, post.id);
    5658    if (!parent) return res.status(400).send('Invalid parent comment');
    5759    resolvedParent = parent.parent_comment_id || parent.id;
     60    parentAuthorId = parent.author_id; // ontvanger van de "antwoord"-melding
    5861  }
    5962
     
    7376    VALUES (?, ?, ?, ?, ?, ?)
    7477  `).run(commentId, post.id, req.session.user.id, resolvedParent, rawContent, status);
     78
     79  // Melding (alleen bij een zichtbare reactie): antwoord → de auteur van de reactie
     80  // waarop gereageerd is; top-level reactie → de auteur van de post. notify() slaat
     81  // jezelf-notificeren over.
     82  if (status === 'approved') {
     83    const url = `${res.locals.siteUrlBase || ''}/${post.slug}#comment-${commentId}`;
     84    const actorId = req.session.user.id;
     85    const actorName = req.session.user.username;
     86    if (parentId) {
     87      notify({ userId: parentAuthorId, actorId, actorName, type: 'reply', postSlug: post.slug, postTitle: post.title, url });
     88    } else {
     89      notify({ userId: post.author_id, actorId, actorName, type: 'comment', postSlug: post.slug, postTitle: post.title, url });
     90    }
     91  }
    7592
    7693  // Where to land after submit:
  • src/routes/posts.js

    rd41f4be rc9c6a2d  
    1010import { renderPage } from '../middleware/render.js';
    1111import { recordPageview, recordPostView } from '../services/StatsService.js';
     12import { notify } from '../services/NotificationService.js';
    1213import PermissionsService from '../services/PermissionsService.js';
    1314import MarkdownService from '../services/MarkdownService.js';
     
    405406router.post('/posts/:id/like', requireAuth, (req, res) => {
    406407  const userId = req.session.user.id;
    407   const post = db.prepare('SELECT id, status FROM posts WHERE id = ?').get(req.params.id);
     408  const post = db.prepare('SELECT id, status, slug, title, author_id FROM posts WHERE id = ?').get(req.params.id);
    408409  if (!post) return res.status(404).send('Post niet gevonden');
    409410  if (post.status !== 'published') return res.status(403).send('Niet beschikbaar');
     
    414415  } else {
    415416    db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId);
     417    // Melding voor de post-auteur (notify slaat jezelf-liken over).
     418    notify({
     419      userId: post.author_id, actorId: userId, actorName: req.session.user.username, type: 'like',
     420      postSlug: post.slug, postTitle: post.title, url: (res.locals.siteUrlBase || '') + '/' + post.slug,
     421    });
    416422  }
    417423  const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
  • src/server.js

    rd41f4be rc9c6a2d  
    2727import authRoutes from './routes/auth.js';
    2828import accountRoutes from './routes/account.js';
     29import notificationsRoutes from './routes/notifications.js';
    2930import adminRoutes from './routes/admin.js';
    3031import adminAudioRoutes from './routes/admin-audio.js';
     
    257258app.use('/auth', authRoutes);
    258259app.use('/account', accountRoutes);
     260app.use('/notifications', notificationsRoutes);
    259261app.use('/admin/audio', adminAudioRoutes);
    260262app.use('/admin/playlists', adminPlaylistsRoutes);
  • src/services/i18n.js

    rd41f4be rc9c6a2d  
    2525    'nav.new_post': 'Nieuwe post',
    2626    'nav.language': 'Taal',
     27    'nav.notifications': 'Meldingen',
     28    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand',
     29    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
    2730    'switch.agenda': 'Agenda',
    2831    'switch.solo': 'Solo',
     
    9991002    'nav.new_post': 'New post',
    10001003    'nav.language': 'Language',
     1004    'nav.notifications': 'Notifications',
     1005    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone',
     1006    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
    10011007    'switch.agenda': 'Agenda',
    10021008    'switch.solo': 'Solo',
     
    19641970    'nav.new_post': 'Neuer Beitrag',
    19651971    'nav.language': 'Sprache',
     1972    'nav.notifications': 'Benachrichtigungen',
     1973    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand',
     1974    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefällt dein Beitrag',
    19661975    'switch.agenda': 'Termine',
    19671976    'switch.solo': 'Solo',
  • src/views/partials/bottom-tab.ejs

    rd41f4be rc9c6a2d  
    107107      <% } %>
    108108      <span class="bottom-tab-label"><%= t('tab.profile') %></span>
     109      <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %>
     110        <span class="bottom-tab-badge" aria-label="<%= notifUnread %> ongelezen meldingen"><%= notifUnread > 99 ? '99+' : notifUnread %></span>
     111      <% } %>
    109112    </button>
    110113  <% } else { %>
  • src/views/partials/profile-sheet.ejs

    rd41f4be rc9c6a2d  
    5353          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
    5454          <span class="psi-label">Account</span>
     55          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
     56        </a>
     57      </li>
     58      <li role="none">
     59        <a href="/notifications" class="profile-sheet-item" role="menuitem" data-close-sheet>
     60          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
     61          <span class="psi-label"><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
    5562          <svg class="psi-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
    5663        </a>
  • src/views/partials/topnav.ejs

    rd41f4be rc9c6a2d  
    103103        <a class="nav-btn" href="<%= _siteUrlBase %>/prutter" aria-label="Prutter (DMs)" title="Prutter">
    104104          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
     105        </a>
     106      <% } %>
     107
     108      <% if (user) { %>
     109        <a class="nav-btn nav-notif" href="/notifications" aria-label="<%= t('nav.notifications') %>" title="<%= t('nav.notifications') %>"
     110           hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/notifications" hx-indicator="#pcms-loading">
     111          <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
     112          <% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %><span class="notif-badge"><%= notifUnread > 9 ? '9+' : notifUnread %></span><% } %>
    105113        </a>
    106114      <% } %>
     
    137145                <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
    138146                <span><%= t('nav.account') %></span>
     147              </a>
     148              <a href="/notifications" role="menuitem" class="udi"
     149                 hx-get="/notifications?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
     150                 hx-push-url="/notifications" hx-indicator="#pcms-loading">
     151                <svg class="udi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
     152                <span><%= t('nav.notifications') %><% if (typeof notifUnread !== 'undefined' && notifUnread > 0) { %> (<%= notifUnread %>)<% } %></span>
    139153              </a>
    140154              <a href="/favorieten" role="menuitem" class="udi"
     
    558572.ss-all { display: block; margin-top: .35rem; padding: .55rem .6rem; border-top: 1px solid var(--rule); color: var(--accent); font-weight: 600; text-decoration: none; font-size: .9rem; }
    559573.ss-all:hover { text-decoration: underline; }
     574/* Meldingen-bel + ongelezen-badge */
     575.nav-notif { position: relative; }
     576.notif-badge {
     577  position: absolute; top: 1px; right: 1px;
     578  min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
     579  display: inline-flex; align-items: center; justify-content: center;
     580  background: #e0245e; color: #fff; font-size: 10px; font-weight: 700;
     581  border-radius: 999px; line-height: 1; pointer-events: none;
     582}
    560583</style>
Note: See TracChangeset for help on using the changeset viewer.