Changeset 535f955 in Klonkt


Ignore:
Timestamp:
06/19/2026 09:49:35 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
247988e
Parents:
8cdb377
Message:

feat: post likes + favourites page for logged-in users

  • New post_likes table (unique per post+user).
  • ♄ like button on the post (shared partial, htmx toggle via POST /posts/:id/like); shows the like count. Anonymous → button links to login.
  • /favorieten page (requireAuth) shows your liked posts (solo: own site, hub: across all sites with correct links). Link in account menu (sheet + topnav).

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

Files:
2 added
6 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.md

    r8cdb377 r535f955  
    1313
    1414## [Unreleased]
     15
     16### Toegevoegd
     17- Posts liken / favorieten: ingelogde gebruikers kunnen een post liken met een ♄-knop
     18  (toont het aantal likes). Je gelikete posts staan op de nieuwe **Favorieten**-pagina
     19  (`/favorieten`, link in het accountmenu). Niet-ingelogd → de knop leidt naar de login.
    1520
    1621### Gewijzigd
  • src/config/database.js

    r8cdb377 r535f955  
    256256    );
    257257  `);
     258
     259  // Likes / favorieten: een ingelogde gebruiker kan een post liken. De set van
     260  // posts die een gebruiker likte = z'n favorieten (/favorieten-pagina). EĆ©n rij
     261  // per (post, user); uniek zodat liken idempotent is.
     262  db.exec(`
     263    CREATE TABLE IF NOT EXISTS post_likes (
     264      post_id TEXT NOT NULL,
     265      user_id TEXT NOT NULL,
     266      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     267      PRIMARY KEY (post_id, user_id)
     268    );
     269    CREATE INDEX IF NOT EXISTS idx_post_likes_user ON post_likes(user_id, created_at);
     270    CREATE INDEX IF NOT EXISTS idx_post_likes_post ON post_likes(post_id);
     271  `);
    258272}
    259273
  • src/routes/posts.js

    r8cdb377 r535f955  
    55import { fileURLToPath } from 'url';
    66import multer from 'multer';
     7import ejs from 'ejs';
    78import db from '../config/database.js';
    89import { requireAuth } from '../middleware/auth.js';
     
    6364  'archive', 'search', 'account', 'sites', 'comments',
    6465  'posts', 'media', 'audio', 'prutter', 'forum',
    65   'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
     66  'tag', 'type', 'user', 'users', 'artiesten', 'leden', 'favorieten', 'feed.xml', 'atom.xml', 'sitemap.xml',
    6667  'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
    6768]);
     
    392393    bodyClass: 'on-archive',
    393394  });
     395});
     396
     397// Pad naar de like-knop-partial (voor de htmx-toggle re-render).
     398const LIKE_PARTIAL = path.join(__dirname, '..', 'views', 'partials', 'like-button.ejs');
     399
     400// ==================== LIKE / FAVORIET ====================
     401// Een ingelogde gebruiker (geen kijker — de globale guard blokkeert non-GET voor
     402// kijkers) togglet een like op een gepubliceerde post. Geeft de her-gerenderde
     403// knop terug (htmx outerHTML-swap).
     404router.post('/posts/:id/like', requireAuth, (req, res) => {
     405  const userId = req.session.user.id;
     406  const post = db.prepare('SELECT id, status FROM posts WHERE id = ?').get(req.params.id);
     407  if (!post) return res.status(404).send('Post niet gevonden');
     408  if (post.status !== 'published') return res.status(403).send('Niet beschikbaar');
     409
     410  const exists = db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, userId);
     411  if (exists) {
     412    db.prepare('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?').run(post.id, userId);
     413  } else {
     414    db.prepare('INSERT OR IGNORE INTO post_likes (post_id, user_id) VALUES (?, ?)').run(post.id, userId);
     415  }
     416  const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
     417
     418  const html = ejs.render(fs.readFileSync(LIKE_PARTIAL, 'utf8'), {
     419    post: { id: post.id }, likedByMe: !exists, likeCount, loggedIn: true, loginNext: '/',
     420  });
     421  res.send(html);
     422});
     423
     424// Favorieten = de posts die de ingelogde gebruiker likete. Solo: binnen de
     425// huidige site. Hub: over alle sites (met juiste /user/<slug>-links).
     426router.get('/favorieten', requireAuth, (req, res) => {
     427  const userId = req.session.user.id;
     428  const isHub = res.locals.tenancy === 'hub';
     429  const site = res.locals.site;
     430  const rows = isHub
     431    ? db.prepare(`
     432        SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.published_at,
     433               p.tags, p.type, p.pinned, p.status, s.slug AS site_slug
     434        FROM post_likes pl JOIN posts p ON p.id = pl.post_id JOIN sites s ON s.id = p.site_id
     435        WHERE pl.user_id = ? AND p.status = 'published'
     436        ORDER BY pl.created_at DESC
     437      `).all(userId)
     438    : db.prepare(`
     439        SELECT p.id, p.slug, p.title, p.excerpt, p.cover_image_url, p.published_at,
     440               p.tags, p.type, p.pinned, p.status
     441        FROM post_likes pl JOIN posts p ON p.id = pl.post_id
     442        WHERE pl.user_id = ? AND p.site_id = ? AND p.status = 'published'
     443        ORDER BY pl.created_at DESC
     444      `).all(userId, site ? site.id : '');
     445  const posts = rows.map((p) => ({ ...p, _urlBase: (isHub && p.site_slug) ? `/user/${p.site_slug}` : '' }));
     446  renderPage(req, res, 'pages/favorites', { posts, pageTitle: 'Favorieten', bodyClass: 'on-favorites' });
    394447});
    395448
     
    631684  relatedPosts = relatedPosts.map(({ _overlap, tags, ...rest }) => ({ ...rest, _urlBase: urlBaseFor(rest) }));
    632685
     686  // Likes / favorieten: aantal + of de ingelogde gebruiker deze post likete.
     687  const likeCount = db.prepare('SELECT COUNT(*) AS c FROM post_likes WHERE post_id = ?').get(post.id).c;
     688  const likedByMe = !!(req.session?.user &&
     689    db.prepare('SELECT 1 FROM post_likes WHERE post_id = ? AND user_id = ?').get(post.id, req.session.user.id));
     690
    633691  renderPage(req, res, 'pages/post', {
    634692    post,
     
    638696    comments: topLevel,
    639697    totalComments,
     698    likeCount,
     699    likedByMe,
    640700    pageTitle: post.title + ' - ' + site.title,
    641701    socialDescr: post.excerpt || '',
  • src/views/pages/post.ejs

    r8cdb377 r535f955  
    3131      <% } %>
    3232    </div>
     33    <% if (post.status === 'published') { %>
     34      <div class="post-like-row">
     35        <%- include('../partials/like-button', { post: post, likedByMe: (typeof likedByMe !== 'undefined' && likedByMe), likeCount: (typeof likeCount !== 'undefined' ? likeCount : 0), loggedIn: !!user, loginNext: _base + '/' + post.slug }) %>
     36      </div>
     37    <% } %>
    3338  </header>
    3439
     
    250255.post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
    251256.post-content p { margin: 1.2em 0; }
     257.post-like-row { margin-top: 1rem; }
     258.like-btn {
     259  display: inline-flex; align-items: center; gap: 0.45rem;
     260  padding: 0.4rem 0.85rem; border: 1px solid var(--rule); border-radius: 999px;
     261  background: var(--paper-2); color: var(--ink); font: inherit; font-size: 0.95rem;
     262  cursor: pointer; text-decoration: none; transition: border-color 120ms, color 120ms, background 120ms;
     263}
     264.like-btn:hover { border-color: var(--accent); }
     265.like-btn .like-heart { font-size: 1.1rem; line-height: 1; }
     266.like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
     267.like-btn.is-liked .like-heart { color: var(--accent); }
     268.like-btn .like-count { font-variant-numeric: tabular-nums; }
    252269.post-content h2 { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 2rem 0 1rem; }
    253270.post-content h3 { font-family: var(--font-display, serif); font-size: 1.3rem; margin: 1.5rem 0 0.75rem; }
  • src/views/partials/profile-sheet.ejs

    r8cdb377 r535f955  
    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="/favorieten" class="profile-sheet-item" role="menuitem" data-close-sheet
     60           hx-get="/favorieten?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
     61           hx-push-url="/favorieten" hx-indicator="#pcms-loading">
     62          <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.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
     63          <span class="psi-label">Favorieten</span>
    5564          <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>
    5665        </a>
  • src/views/partials/topnav.ejs

    r8cdb377 r535f955  
    119119                <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>
    120120                <span>Account</span>
     121              </a>
     122              <a href="/favorieten" role="menuitem" class="udi"
     123                 hx-get="/favorieten?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
     124                 hx-push-url="/favorieten" hx-indicator="#pcms-loading">
     125                <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.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
     126                <span>Favorieten</span>
    121127              </a>
    122128              <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
Note: See TracChangeset for help on using the changeset viewer.