Changeset b9dc94c in Klonkt


Ignore:
Timestamp:
06/19/2026 04:28:02 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
8d32dcf
Parents:
6be57b4
Message:

Release scheduling + fan-only previews (premium feature #3)

  • DB: posts.publish_at (scheduled go-live) + posts.fan_only.
  • Release scheduling: published + future publish_at -> status 'scheduled' (excluded from public status='published' queries). Scheduler.js publishes them when the time is reached (setInterval 60s + on boot) + adds them to posts_fts. No public queries changed -> low risk.
  • Fan-only: posts.fan_only; the single-post view shows anonymous visitors a clean login gate (pages/fan-gate.ejs, link /auth/login?next=) instead of the content; logged-in fans see everything. Listings show the teaser.
  • create/save: read publish_at + fan_only; FTS excludes 'scheduled'.
  • editor (post-edit): premium-gated fields "Fans only" + "Publish on" (datetime-local) + "scheduled for" notice.
  • server.js: startScheduler() after initializeDatabase.

node --check passed.

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

Location:
src
Files:
2 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r6be57b4 rb9dc94c  
    8282  // Per-post noindex + type
    8383  ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
     84  ensureColumn('posts', 'publish_at', 'DATETIME');         // release-planning (premium #3): geplande go-live
     85  ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0');  // fan-only preview (premium #3)
    8486  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
    8587
  • src/routes/posts.js

    r6be57b4 rb9dc94c  
    154154
    155155  const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
     156  const fanOnly = req.body.fan_only ? 1 : 0;
    156157
    157158  // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
     
    177178  const postId = uuid();
    178179  const now = new Date().toISOString();
    179   const finalStatus = status || 'draft';
    180   const publishedAt = finalStatus === 'published' ? now : null;
     180  let finalStatus = status || 'draft';
     181  let publishedAt = finalStatus === 'published' ? now : null;
     182  // Release-planning: gepubliceerd + een toekomstige publish_at -> 'scheduled'
     183  // (de Scheduler zet 'm live op het moment zelf). Verleden/leeg -> meteen live.
     184  let publishAt = null;
     185  const pa = Date.parse(req.body.publish_at || '');
     186  if (finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
     187    finalStatus = 'scheduled';
     188    publishAt = new Date(pa).toISOString();
     189    publishedAt = null;
     190  }
    181191
    182192  db.prepare(`
    183193    INSERT INTO posts (
    184194      id, site_id, slug, author_id, title, content, excerpt,
    185       status, cover_image_url, pinned, tags, type, noindex,
     195      status, cover_image_url, pinned, tags, type, noindex, fan_only, publish_at,
    186196      created_at, updated_at, published_at
    187     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     197    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    188198  `).run(
    189199    postId, site.id, finalSlug, req.session.user.id,
     
    191201    finalStatus, cover_image_url || null, parsePinnedRank(pinned),
    192202    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    193     finalType, noindex ? 1 : 0,
     203    finalType, noindex ? 1 : 0, fanOnly, publishAt,
    194204    now, now, publishedAt
    195205  );
     
    255265
    256266  const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
     267  const fanOnly = req.body.fan_only ? 1 : 0;
    257268  const newSlug = req.body.slug;
    258269  const action = req.body.action || 'save';
     
    281292  }
    282293
     294  // Release-planning: gepubliceerd + toekomstige publish_at -> 'scheduled'.
     295  let publishAt = null;
     296  const pa = Date.parse(req.body.publish_at || '');
     297  if (finalStatus === 'published' && Number.isFinite(pa) && pa > Date.now()) {
     298    finalStatus = 'scheduled';
     299    publishAt = new Date(pa).toISOString();
     300    publishedAt = null;
     301  }
     302
    283303  db.prepare(`
    284304    UPDATE posts SET
    285305      title = ?, content = ?, excerpt = ?, status = ?,
    286306      cover_image_url = ?, pinned = ?, tags = ?,
    287       type = ?, noindex = ?,
     307      type = ?, noindex = ?, fan_only = ?, publish_at = ?,
    288308      slug = ?, published_at = ?, updated_at = ?
    289309    WHERE id = ?
     
    292312    cover_image_url || null, parsePinnedRank(pinned),
    293313    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    294     finalType, noindex ? 1 : 0,
     314    finalType, noindex ? 1 : 0, fanOnly, publishAt,
    295315    finalSlug, publishedAt, now, post.id
    296316  );
     
    393413    const canEdit = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
    394414    if (!canEdit) return res.status(403).send('Not published');
     415  }
     416
     417  // Fan-only preview (premium #3): volledige inhoud alleen voor ingelogde fans.
     418  // Anonieme bezoekers krijgen een nette login-gate i.p.v. de inhoud (de titel/
     419  // teaser mag elders wel als lokkertje verschijnen).
     420  if (post.fan_only && !(req.session && req.session.user)) {
     421    return renderPage(req, res, 'pages/fan-gate', {
     422      pageTitle: post.title || 'Alleen voor fans',
     423      bodyClass: 'on-special',
     424      fgTitle: post.title || '',
     425      fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
     426    });
    395427  }
    396428
  • src/server.js

    r6be57b4 rb9dc94c  
    1616import http from 'http';
    1717import db, { initializeDatabase } from './config/database.js';
     18import { startScheduler } from './services/Scheduler.js';
    1819import { SqliteSessionStore } from './services/SqliteSessionStore.js';
    1920import { ensurePrimarySite } from './services/ensurePrimarySite.js';
     
    140141// → crash-loop op de allereerste boot).
    141142initializeDatabase();
     143startScheduler(); // release-planning: zet geplande posts live zodra publish_at bereikt is
    142144
    143145// Vangnet: garandeer dat er altijd een primaire site is (solo/hub/circle).
  • src/views/pages/post-edit.ejs

    r6be57b4 rb9dc94c  
    193193          <span>🚫 noindex (verberg voor zoekmachines)</span>
    194194        </label>
     195        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
     196          <label class="pe-checkbox">
     197            <input type="checkbox" name="fan_only" value="1" <%= post.fan_only ? 'checked' : '' %>>
     198            <span>🔒 Alleen voor ingelogde fans</span>
     199          </label>
     200          <div style="margin-top:8px">
     201            <label style="display:block;font-size:12.5px;opacity:.8;margin-bottom:4px">📅 Publiceren op (laat leeg = direct)</label>
     202            <input type="datetime-local" name="publish_at" value="<%= post.publish_at ? String(post.publish_at).replace(' ','T').slice(0,16) : '' %>" style="padding:8px 10px;border-radius:8px;border:1px solid var(--rule,rgba(128,128,128,.4));background:transparent;color:inherit">
     203            <% if (post.status === 'scheduled' && post.publish_at) { %><div style="font-size:12px;opacity:.7;margin-top:4px">⏳ Ingepland voor <%= post.publish_at %></div><% } %>
     204          </div>
     205        <% } %>
    195206      </div>
    196207    </section>
Note: See TracChangeset for help on using the changeset viewer.