Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/assets/css/style.css	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -4539,2 +4539,38 @@
 .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; }
 .fedi-remote-form .fedi-remote-cancel:hover { color: var(--ink, #000); }
+
+/* ── NSFW / sensitive content — blur + click to reveal ───────────────── */
+.nsfw-media { position: relative; }
+.nsfw-veil {
+  position: absolute; inset: 0; z-index: 4; cursor: pointer; border-radius: inherit;
+  display: flex; flex-direction: column; align-items: center; justify-content: center;
+  gap: .5rem; padding: .75rem; text-align: center;
+  background: color-mix(in srgb, var(--paper, #14141a) 50%, transparent);
+  backdrop-filter: blur(22px) saturate(.6); -webkit-backdrop-filter: blur(22px) saturate(.6);
+}
+.nsfw-media.is-shown > .nsfw-veil { display: none; }
+.nsfw-veil-label { font-weight: 700; font-size: .82rem; color: var(--ink, #f3f1ea); text-shadow: 0 1px 2px rgba(0,0,0,.5); }
+.nsfw-veil-btn {
+  font-size: .72rem; font-weight: 600; padding: .3rem .8rem; border-radius: 999px;
+  border: 1px solid color-mix(in srgb, var(--ink, #fff) 55%, transparent);
+  background: color-mix(in srgb, var(--paper, #000) 35%, transparent); color: var(--ink, #f3f1ea);
+}
+/* Post page: blur cover + body behind a content-warning banner until revealed */
+.nsfw-gate .post-cover,
+.nsfw-gate .post-content { filter: blur(26px); pointer-events: none; user-select: none; transition: filter .2s ease; }
+.nsfw-gate.is-shown .post-cover,
+.nsfw-gate.is-shown .post-content { filter: none; pointer-events: auto; user-select: auto; }
+.nsfw-banner {
+  display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: 0 0 1.5rem;
+  padding: .8rem 1rem; border-radius: 12px;
+  border: 1px solid color-mix(in srgb, var(--accent, #e8b04b) 45%, transparent);
+  background: color-mix(in srgb, var(--accent, #e8b04b) 9%, transparent);
+}
+.nsfw-gate.is-shown .nsfw-banner { display: none; }
+.nsfw-banner-icon { font-size: 1.2rem; }
+.nsfw-banner-text { font-weight: 600; font-size: .92rem; color: var(--ink); }
+.nsfw-banner-btn {
+  margin-left: auto; cursor: pointer; font: inherit; font-size: .82rem; font-weight: 600;
+  padding: .4rem 1rem; border-radius: 999px; border: 0;
+  background: var(--accent, #e8b04b); color: #1a1205;
+}
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/config/database.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -84,4 +84,5 @@
   ensureColumn('posts', 'publish_at', 'DATETIME');         // release planning (premium #3): scheduled go-live
   ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
+  ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
   ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
 
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/routes/activitypub.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -67,5 +67,5 @@
   if (!site) return res.status(404).end();
   const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
@@ -90,5 +90,5 @@
   // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
   const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
        AND pinned IS NOT NULL AND pinned > 0
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/routes/posts.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -176,4 +176,5 @@
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
   const fanOnly = req.body.fan_only ? 1 : 0;
+  const nsfw = req.body.nsfw ? 1 : 0;
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -213,7 +214,7 @@
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, pinned, tags, type, noindex, fan_only, publish_at,
+      status, cover_image_url, pinned, tags, type, noindex, fan_only, nsfw, publish_at,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
@@ -221,5 +222,5 @@
     finalStatus, cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, publishAt,
+    finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
     now, now, publishedAt
   );
@@ -238,5 +239,5 @@
         id: postId, slug: finalSlug, title: title || finalSlug,
         content: cleanContent, cover_image_url: cover_image_url || null,
-        published_at: publishedAt, created_at: now, fan_only: fanOnly,
+        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw,
       }).catch(() => { /* best-effort */ });
     }
@@ -296,4 +297,5 @@
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
   const fanOnly = req.body.fan_only ? 1 : 0;
+  const nsfw = req.body.nsfw ? 1 : 0;
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
@@ -334,5 +336,5 @@
       title = ?, content = ?, excerpt = ?, status = ?,
       cover_image_url = ?, pinned = ?, tags = ?,
-      type = ?, noindex = ?, fan_only = ?, publish_at = ?,
+      type = ?, noindex = ?, fan_only = ?, nsfw = ?, publish_at = ?,
       slug = ?, published_at = ?, updated_at = ?
     WHERE id = ?
@@ -341,5 +343,5 @@
     cover_image_url || null, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
-    finalType, noindex ? 1 : 0, fanOnly, publishAt,
+    finalType, noindex ? 1 : 0, fanOnly, nsfw, publishAt,
     finalSlug, publishedAt, now, post.id
   );
@@ -362,5 +364,5 @@
       id: post.id, slug: finalSlug, title: title || finalSlug,
       content: cleanContent, cover_image_url: cover_image_url || null,
-      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly,
+      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw,
     };
     if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/services/ActivityPubService.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -246,5 +246,9 @@
     tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [],
     replies: `${id}/replies`,
+    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
+    // (hides the whole post behind a "Gevoelige inhoud" button until the reader opens it).
+    sensitive: !!post.nsfw,
   };
+  if (post.nsfw) note.summary = 'Gevoelige inhoud';
   if (attachment.length) note.attachment = attachment;
   // Playable-audio posts suppress the cover attachment (player card). Still expose
@@ -771,5 +775,5 @@
   if (!site) return;
   const recent = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, nsfw, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/services/Scheduler.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -15,5 +15,5 @@
   try {
     const due = db.prepare(`
-      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only,
+      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, p.nsfw,
              p.published_at, p.publish_at, p.created_at, u.username
       FROM posts p JOIN users u ON u.id = p.author_id
@@ -39,5 +39,5 @@
             id: p.id, slug: p.slug, title: p.title || p.slug,
             content: p.content, cover_image_url: p.cover_image_url || null,
-            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only,
+            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw,
           }).catch(() => { /* best-effort */ });
         }
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/services/i18n.js	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -771,5 +771,5 @@
     'pedit.pin_top': 'bovenaan',
     'pedit.pin_nth_suffix': 'e van boven',
-    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)',
+    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen',
     'pedit.fan_only_label': 'Alleen voor ingelogde fans',
     'pedit.schedule_label': 'Publicatie inplannen',
@@ -1782,5 +1782,5 @@
     'pedit.pin_top': 'at the top',
     'pedit.pin_nth_suffix': 'th from top',
-    'pedit.noindex_label': 'noindex (hide from search engines)',
+    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show',
     'pedit.fan_only_label': 'Logged-in fans only',
     'pedit.schedule_label': 'Schedule publication',
@@ -2793,5 +2793,5 @@
     'pedit.pin_top': 'ganz oben',
     'pedit.pin_nth_suffix': '. von oben',
-    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)',
+    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen',
     'pedit.fan_only_label': 'Nur für angemeldete Fans',
     'pedit.schedule_label': 'Veröffentlichung planen',
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/views/pages/post-edit.ejs	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -237,4 +237,8 @@
           <input type="checkbox" name="noindex" value="1" <%= post.noindex ? 'checked' : '' %>>
           <span>🚫 <%= t('pedit.noindex_label') %></span>
+        </label>
+        <label class="pe-checkbox">
+          <input type="checkbox" name="nsfw" value="1" <%= post.nsfw ? 'checked' : '' %>>
+          <span>🔞 <%= t('pedit.nsfw_label') %></span>
         </label>
         <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/views/pages/post.ejs	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -5,8 +5,16 @@
 const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
 %>
-<article class="container post-page">
+<article class="container post-page<%= post.nsfw ? ' nsfw-gate' : '' %>">
 
   <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
   <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
+
+  <% if (post.nsfw) { %>
+    <div class="nsfw-banner">
+      <span class="nsfw-banner-icon">🔞</span>
+      <span class="nsfw-banner-text"><%= t('post.nsfw_warning') %></span>
+      <button type="button" class="nsfw-banner-btn nsfw-reveal"><%= t('post.nsfw_show') %></button>
+    </div>
+  <% } %>
 
   <% if (post.cover_image_url) { %>
Index: src/views/partials/post-card.ejs
===================================================================
--- src/views/partials/post-card.ejs	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/views/partials/post-card.ejs	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -46,8 +46,9 @@
 
   <% if (_hasCover) { %>
-    <a class="post-list-cover" href="<%= _href %>"
+    <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
        <% 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"<% } %>
        aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
       <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
+      <% 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><% } %>
     </a>
   <% } %>
Index: src/views/partials/post-tile.ejs
===================================================================
--- src/views/partials/post-tile.ejs	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/views/partials/post-tile.ejs	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -14,5 +14,5 @@
 const _ext = !!_external;
 %>
-<a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %>"
+<a class="grid-tile<%= _hasCover ? '' : ' grid-tile-gradient' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %><%= post.nsfw ? ' nsfw-media' : '' %>"
    href="<%= _href %>"
    <% if (_hasCover) { %>style="background-image: url('<%= post.cover_image_url %>'); --tile-hue: <%= _tileHue %>;"<% } else { %>style="--tile-hue: <%= _tileHue %>;"<% } %>
@@ -37,3 +37,4 @@
     <% if (_src) { %><span class="grid-tile-gradient-src">van <%= _src %></span><% } %>
   <% } %>
+  <% 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><% } %>
 </a>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision aa45208c239be20a3667852f4cc17c43e78c4394)
+++ src/views/shell.ejs	(revision 837fc9c1dc50db6b6a042e84da63ed9989edf2fa)
@@ -198,5 +198,5 @@
 
 <!-- v9 stylesheet (full palette system) -->
-<link rel="stylesheet" href="/assets/css/style.css?v=55">
+<link rel="stylesheet" href="/assets/css/style.css?v=56">
 <script>
 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
@@ -725,4 +725,19 @@
 </script>
 
+<!-- NSFW / sensitive content: click a veil/reveal to un-blur. Capture-phase so the
+     click reveals instead of following the card link or firing htmx navigation. -->
+<script>
+(function () {
+  if (window.__nsfwWired) return; window.__nsfwWired = true;
+  document.addEventListener('click', function (e) {
+    var hit = e.target.closest && e.target.closest('.nsfw-veil, .nsfw-reveal');
+    if (!hit) return;
+    e.preventDefault(); e.stopPropagation();
+    var box = hit.closest('.nsfw-media, .nsfw-gate');
+    if (box) box.classList.add('is-shown');
+  }, true);
+})();
+</script>
+
 <!-- Per-site custom footer HTML -->
 <% if (safeSite.custom_foot_html) { %>
