Changeset 837fc9c in Klonkt


Ignore:
Timestamp:
06/27/2026 07:51:44 AM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
92f6976
Parents:
aa45208
Message:

feat(posts): NSFW / sensitive content

Mark a post NSFW (checkbox in the editor, available to all). On-site the cover blurs in
feed/grid and the whole post blurs behind a 'Gevoelige inhoud' banner until clicked. In the
fediverse the Note gets sensitive:true + a content-warning summary (Mastodon CW). i18n NL/EN/DE.

Location:
src
Files:
12 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    raa45208 r837fc9c  
    45394539.fedi-remote-form .fedi-remote-cancel { background: none; border: none; color: var(--ink-soft, #888); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .25rem .35rem; }
    45404540.fedi-remote-form .fedi-remote-cancel:hover { color: var(--ink, #000); }
     4541
     4542/* ── NSFW / sensitive content — blur + click to reveal ───────────────── */
     4543.nsfw-media { position: relative; }
     4544.nsfw-veil {
     4545  position: absolute; inset: 0; z-index: 4; cursor: pointer; border-radius: inherit;
     4546  display: flex; flex-direction: column; align-items: center; justify-content: center;
     4547  gap: .5rem; padding: .75rem; text-align: center;
     4548  background: color-mix(in srgb, var(--paper, #14141a) 50%, transparent);
     4549  backdrop-filter: blur(22px) saturate(.6); -webkit-backdrop-filter: blur(22px) saturate(.6);
     4550}
     4551.nsfw-media.is-shown > .nsfw-veil { display: none; }
     4552.nsfw-veil-label { font-weight: 700; font-size: .82rem; color: var(--ink, #f3f1ea); text-shadow: 0 1px 2px rgba(0,0,0,.5); }
     4553.nsfw-veil-btn {
     4554  font-size: .72rem; font-weight: 600; padding: .3rem .8rem; border-radius: 999px;
     4555  border: 1px solid color-mix(in srgb, var(--ink, #fff) 55%, transparent);
     4556  background: color-mix(in srgb, var(--paper, #000) 35%, transparent); color: var(--ink, #f3f1ea);
     4557}
     4558/* Post page: blur cover + body behind a content-warning banner until revealed */
     4559.nsfw-gate .post-cover,
     4560.nsfw-gate .post-content { filter: blur(26px); pointer-events: none; user-select: none; transition: filter .2s ease; }
     4561.nsfw-gate.is-shown .post-cover,
     4562.nsfw-gate.is-shown .post-content { filter: none; pointer-events: auto; user-select: auto; }
     4563.nsfw-banner {
     4564  display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: 0 0 1.5rem;
     4565  padding: .8rem 1rem; border-radius: 12px;
     4566  border: 1px solid color-mix(in srgb, var(--accent, #e8b04b) 45%, transparent);
     4567  background: color-mix(in srgb, var(--accent, #e8b04b) 9%, transparent);
     4568}
     4569.nsfw-gate.is-shown .nsfw-banner { display: none; }
     4570.nsfw-banner-icon { font-size: 1.2rem; }
     4571.nsfw-banner-text { font-weight: 600; font-size: .92rem; color: var(--ink); }
     4572.nsfw-banner-btn {
     4573  margin-left: auto; cursor: pointer; font: inherit; font-size: .82rem; font-weight: 600;
     4574  padding: .4rem 1rem; border-radius: 999px; border: 0;
     4575  background: var(--accent, #e8b04b); color: #1a1205;
     4576}
  • src/config/database.js

    raa45208 r837fc9c  
    8484  ensureColumn('posts', 'publish_at', 'DATETIME');         // release planning (premium #3): scheduled go-live
    8585  ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
     86  ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
    8687  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
    8788
  • src/routes/activitypub.js

    raa45208 r837fc9c  
    6767  if (!site) return res.status(404).end();
    6868  const posts = db.prepare(
    69     `SELECT id, slug, title, content, cover_image_url, published_at, created_at
     69    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
    7070     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    7171     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
     
    9090  // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
    9191  const posts = db.prepare(
    92     `SELECT id, slug, title, content, cover_image_url, published_at, created_at
     92    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
    9393     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    9494       AND pinned IS NOT NULL AND pinned > 0
  • src/routes/posts.js

    raa45208 r837fc9c  
    176176  const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
    177177  const fanOnly = req.body.fan_only ? 1 : 0;
     178  const nsfw = req.body.nsfw ? 1 : 0;
    178179
    179180  // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
     
    213214    INSERT INTO posts (
    214215      id, site_id, slug, author_id, title, content, excerpt,
    215       status, cover_image_url, pinned, tags, type, noindex, fan_only, publish_at,
     216      status, cover_image_url, pinned, tags, type, noindex, fan_only, nsfw, publish_at,
    216217      created_at, updated_at, published_at
    217     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     218    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    218219  `).run(
    219220    postId, site.id, finalSlug, req.session.user.id,
     
    221222    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
    222223    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    223     finalType, noindex ? 1 : 0, fanOnly, publishAt,
     224    finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
    224225    now, now, publishedAt
    225226  );
     
    238239        id: postId, slug: finalSlug, title: title || finalSlug,
    239240        content: cleanContent, cover_image_url: cover_image_url || null,
    240         published_at: publishedAt, created_at: now, fan_only: fanOnly,
     241        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw,
    241242      }).catch(() => { /* best-effort */ });
    242243    }
     
    296297  const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
    297298  const fanOnly = req.body.fan_only ? 1 : 0;
     299  const nsfw = req.body.nsfw ? 1 : 0;
    298300  const newSlug = req.body.slug;
    299301  const action = req.body.action || 'save';
     
    334336      title = ?, content = ?, excerpt = ?, status = ?,
    335337      cover_image_url = ?, pinned = ?, tags = ?,
    336       type = ?, noindex = ?, fan_only = ?, publish_at = ?,
     338      type = ?, noindex = ?, fan_only = ?, nsfw = ?, publish_at = ?,
    337339      slug = ?, published_at = ?, updated_at = ?
    338340    WHERE id = ?
     
    341343    cover_image_url || null, parsePinnedRank(pinned),
    342344    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    343     finalType, noindex ? 1 : 0, fanOnly, publishAt,
     345    finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
    344346    finalSlug, publishedAt, now, post.id
    345347  );
     
    362364      id: post.id, slug: finalSlug, title: title || finalSlug,
    363365      content: cleanContent, cover_image_url: cover_image_url || null,
    364       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly,
     366      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw,
    365367    };
    366368    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
  • src/services/ActivityPubService.js

    raa45208 r837fc9c  
    246246    tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [],
    247247    replies: `${id}/replies`,
     248    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
     249    // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
     250    sensitive: !!post.nsfw,
    248251  };
     252  if (post.nsfw) note.summary = 'Gevoelige inhoud';
    249253  if (attachment.length) note.attachment = attachment;
    250254  // Playable-audio posts suppress the cover attachment (player card). Still expose
     
    771775  if (!site) return;
    772776  const recent = db.prepare(
    773     `SELECT id, slug, title, content, cover_image_url, published_at, created_at
     777    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
    774778     FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
    775779     ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
  • src/services/Scheduler.js

    raa45208 r837fc9c  
    1515  try {
    1616    const due = db.prepare(`
    17       SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, p.nsfw,
    1818             p.published_at, p.publish_at, p.created_at, u.username
    1919      FROM posts p JOIN users u ON u.id = p.author_id
     
    3939            id: p.id, slug: p.slug, title: p.title || p.slug,
    4040            content: p.content, cover_image_url: p.cover_image_url || null,
    41             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only,
     41            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw,
    4242          }).catch(() => { /* best-effort */ });
    4343        }
  • src/services/i18n.js

    raa45208 r837fc9c  
    771771    'pedit.pin_top': 'bovenaan',
    772772    'pedit.pin_nth_suffix': 'e van boven',
    773     'pedit.noindex_label': 'noindex (verberg voor zoekmachines)',
     773    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen',
    774774    'pedit.fan_only_label': 'Alleen voor ingelogde fans',
    775775    'pedit.schedule_label': 'Publicatie inplannen',
     
    17821782    'pedit.pin_top': 'at the top',
    17831783    'pedit.pin_nth_suffix': 'th from top',
    1784     'pedit.noindex_label': 'noindex (hide from search engines)',
     1784    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show',
    17851785    'pedit.fan_only_label': 'Logged-in fans only',
    17861786    'pedit.schedule_label': 'Schedule publication',
     
    27932793    'pedit.pin_top': 'ganz oben',
    27942794    'pedit.pin_nth_suffix': '. von oben',
    2795     'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)',
     2795    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen',
    27962796    'pedit.fan_only_label': 'Nur für angemeldete Fans',
    27972797    'pedit.schedule_label': 'Veröffentlichung planen',
  • src/views/pages/post-edit.ejs

    raa45208 r837fc9c  
    237237          <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
    238238          <span>🚫 <%= t('pedit.noindex_label') %></span>
     239        </label>
     240        <label class="pe-checkbox">
     241          <input type="checkbox" name="nsfw" value="1" <%= post.nsfw ? 'checked' : '' %>>
     242          <span>🔞 <%= t('pedit.nsfw_label') %></span>
    239243        </label>
    240244        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
  • src/views/pages/post.ejs

    raa45208 r837fc9c  
    55const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
    66%>
    7 <article class="container post-page">
     7<article class="container post-page<%= post.nsfw ? ' nsfw-gate' : '' %>">
    88
    99  <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
    1010  <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
     11
     12  <% if (post.nsfw) { %>
     13    <div class="nsfw-banner">
     14      <span class="nsfw-banner-icon">🔞</span>
     15      <span class="nsfw-banner-text"><%= t('post.nsfw_warning') %></span>
     16      <button type="button" class="nsfw-banner-btn nsfw-reveal"><%= t('post.nsfw_show') %></button>
     17    </div>
     18  <% } %>
    1119
    1220  <% if (post.cover_image_url) { %>
  • src/views/partials/post-card.ejs

    raa45208 r837fc9c  
    4646
    4747  <% if (_hasCover) { %>
    48     <a class="post-list-cover" href="<%= _href %>"
     48    <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
    4949       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
    5050       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
    5151      <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
     52      <% if (post.nsfw) { %><span class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></span><% } %>
    5253    </a>
    5354  <% } %>
  • src/views/partials/post-tile.ejs

    raa45208 r837fc9c  
    1414const _ext = !!_external;
    1515%>
    16 <a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %>"
     16<a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %><%= post.nsfw ? ' nsfw-media' : '' %>"
    1717   href="<%= _href %>"
    1818   <% if (_hasCover) { %>style="background-image: url('<%= post.cover_image_url %>'); --tile-hue: <%= _tileHue %>;"<% } else { %>style="--tile-hue: <%= _tileHue %>;"<% } %>
     
    3737    <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
    3838  <% } %>
     39  <% if (post.nsfw) { %><div class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></div><% } %>
    3940</a>
  • src/views/shell.ejs

    raa45208 r837fc9c  
    198198
    199199<!-- v9 stylesheet (full palette system) -->
    200 <link rel="stylesheet" href="/assets/css/style.css?v=55">
     200<link rel="stylesheet" href="/assets/css/style.css?v=56">
    201201<script>
    202202/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
     
    725725</script>
    726726
     727<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
     728     click reveals instead of following the card link or firing htmx navigation. -->
     729<script>
     730(function () {
     731  if (window.__nsfwWired) return; window.__nsfwWired = true;
     732  document.addEventListener('click', function (e) {
     733    var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
     734    if (!hit) return;
     735    e.preventDefault(); e.stopPropagation();
     736    var box = hit.closest('.nsfw-media, .nsfw-gate');
     737    if (box) box.classList.add('is-shown');
     738  }, true);
     739})();
     740</script>
     741
    727742<!-- Per-site custom footer HTML -->
    728743<% if (safeSite.custom_foot_html) { %>
Note: See TracChangeset for help on using the changeset viewer.