Changeset 0403187 in Klonkt


Ignore:
Timestamp:
07/01/2026 12:53:32 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
bb3f67c
Parents:
731e431
Message:

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

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

Files:
1 added
10 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r731e431 r0403187  
    1313  im News-Feed mit Optionen und aktuellen Ergebnissen, und du kannst abstimmen — das föderiert zurück
    1414  wie eine normale Mastodon-Stimme.
     15- **Erstelle eigene Umfragen.** Ein Beitrag kann jetzt eine Umfrage enthalten (Einfach- oder
     16  Mehrfachauswahl, mit einer Laufzeit). Sie föderiert als echte Fediverse-Umfrage, sodass deine
     17  Mastodon-Follower aus ihrer eigenen App abstimmen können; die Live-Ergebnisse stehen am Beitrag und
     18  die Umfrage schließt sich selbst, sobald die Zeit abgelaufen ist.
    1519
    1620## [1.2.0] — 2026-07-01
  • CHANGELOG.md

    r731e431 r0403187  
    1111- **Vote on fediverse polls.** A poll from an account you follow now shows in the News feed with its
    1212  options and current results, and you can cast your vote — it federates back like any Mastodon vote.
     13- **Create your own polls.** A post can now carry a poll (single or multiple choice, with a set
     14  duration). It federates as a real fediverse poll, so your Mastodon followers can vote from their own
     15  app; the live results show on the post and the poll closes itself when the time is up.
    1316
    1417## [1.2.0] — 2026-07-01
  • CHANGELOG.nl.md

    r731e431 r0403187  
    1313  met opties en de huidige resultaten, en je kunt je stem uitbrengen — die federeert terug zoals een
    1414  gewone Mastodon-stem.
     15- **Maak je eigen polls.** Een post kan nu een poll bevatten (enkel- of meerkeuze, met een looptijd).
     16  Die federeert als een echte fediverse-poll, dus je Mastodon-volgers kunnen stemmen vanuit hun eigen
     17  app; de live-resultaten staan op de post en de poll sluit zichzelf zodra de tijd om is.
    1518
    1619## [1.2.0] — 2026-07-01
  • src/config/database.js

    r731e431 r0403187  
    8787  ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
    8888  ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
     89  ensureColumn('posts', 'poll_json', 'TEXT');              // a poll WE host → federates as AS2 Question: {multiple,options[{name}],endTime,closed}
    8990
    9091  // Statistics (premium module) — bare counters, cookie-free.
     
    331332    );
    332333    CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at);
     334    CREATE TABLE IF NOT EXISTS poll_votes (
     335      id INTEGER PRIMARY KEY AUTOINCREMENT,
     336      post_id INTEGER NOT NULL,     -- our local poll post (posts.id)
     337      actor_uri TEXT NOT NULL,      -- the remote voter's AP actor URI
     338      choice TEXT NOT NULL,         -- the chosen option's name (matches poll_json options[].name)
     339      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     340      UNIQUE(post_id, actor_uri, choice)
     341    );
     342    CREATE INDEX IF NOT EXISTS idx_poll_votes_post ON poll_votes(post_id);
    333343  `);
    334344  // "Feature" a followed account: its posts show in the local Cirkel.
  • src/routes/posts.js

    r731e431 r0403187  
    116116  if (!Number.isFinite(n) || n < 0) return 0;
    117117  return n;
     118}
     119
     120// Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d).
     121const POLL_DURATIONS = new Set([300, 1800, 3600, 21600, 43200, 86400, 259200, 604800]);
     122// Parse the editor's poll fields into the poll_json we store on the post (which
     123// buildNote federates as an AS2 Question). Returns null when no valid poll (< 2
     124// options or the poll checkbox is off). endTime is set from the chosen duration
     125// (default 1 day) so the Scheduler can close it.
     126function parsePollForm(body) {
     127  if (!body || !body.poll_enabled) return null;
     128  const raw = body.poll_option == null ? [] : (Array.isArray(body.poll_option) ? body.poll_option : [body.poll_option]);
     129  const options = [];
     130  const seen = new Set();
     131  for (const o of raw) {
     132    const name = String(o == null ? '' : o).trim().slice(0, 100);
     133    if (!name) continue;
     134    const key = name.toLowerCase();
     135    if (seen.has(key)) continue; seen.add(key);
     136    options.push({ name });
     137    if (options.length >= 8) break;
     138  }
     139  if (options.length < 2) return null;
     140  const dur = parseInt(body.poll_duration, 10);
     141  const secs = POLL_DURATIONS.has(dur) ? dur : 86400;
     142  return JSON.stringify({ multiple: !!body.poll_multiple, options, endTime: new Date(Date.now() + secs * 1000).toISOString(), closed: false });
    118143}
    119144
     
    241266  const validTypes = new Set(['post', 'foto', 'video', 'audio']);
    242267  const finalType = validTypes.has(type) ? type : 'post';
     268  const pollJson = parsePollForm(req.body);   // AS2 Question definition, or null
    243269  const postId = uuid();
    244270  const now = new Date().toISOString();
     
    258284    INSERT INTO posts (
    259285      id, site_id, slug, author_id, title, content, excerpt,
    260       status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, publish_at,
     286      status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
    261287      created_at, updated_at, published_at
    262     ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     288    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    263289  `).run(
    264290    postId, site.id, finalSlug, req.session.user.id,
     
    266292    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
    267293    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    268     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
     294    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
    269295    now, now, publishedAt
    270296  );
     
    287313        id: postId, slug: finalSlug, title: title || finalSlug,
    288314        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
    289         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw,
     315        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    290316      }).catch(() => { /* best-effort */ });
    291317    }
     
    321347  }
    322348
     349  // A poll with votes is frozen (options can't change) — flag it so the editor disables the poll fields.
     350  let pollLocked = false;
     351  try { pollLocked = !!(post.poll_json && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id)); } catch { /* ignore */ }
     352
    323353  renderPage(req, res, 'pages/post-edit', {
    324354    post,
    325355    isNew: false,
     356    pollLocked,
    326357    fediOpenAudio: postAudioFediOpen(site.id, post.content),
    327358    pageTitle: 'Edit: ' + (post.title || 'Untitled'),
     
    353384  const finalType = validTypes.has(type) ? type : (post.type || 'post');
    354385
     386  // A poll that has already received votes is frozen (you can still edit the surrounding
     387  // post, but not the options) — changing options after votes would scramble the tally and
     388  // is disallowed on the fediverse too. Otherwise re-parse the poll form (add/remove/disable).
     389  const hasVotes = !!(post.poll_json && (() => { try { return db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? LIMIT 1').get(post.id); } catch { return false; } })());
     390  const pollJson = hasVotes ? post.poll_json : parsePollForm(req.body);
     391
    355392  // Sanitize before storage — same pipeline as create.
    356393  const cleanContent = HtmlSanitizerService.sanitize(content || '');
     
    386423      title = ?, content = ?, excerpt = ?, status = ?,
    387424      cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?,
    388       type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, publish_at = ?,
     425      type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
    389426      slug = ?, published_at = ?, updated_at = ?
    390427    WHERE id = ?
     
    393430    cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
    394431    JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
    395     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, publishAt,
     432    finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
    396433    finalSlug, publishedAt, now, post.id
    397434  );
     
    418455      id: post.id, slug: finalSlug, title: title || finalSlug,
    419456      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
    420       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw,
     457      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
    421458    };
    422459    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
     
    10791116  renderPage(req, res, 'pages/post', {
    10801117    post,
     1118    poll: ActivityPubService.ownPollView(post),
    10811119    newerPost,
    10821120    olderPost,
  • src/services/ActivityPubService.js

    r731e431 r0403187  
    4242    value: 'schema:value',
    4343    embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
     44    // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
     45    // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
     46    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
     47    votersCount: 'toot:votersCount',
    4448  },
    4549];
     
    381385  // make it JSON-LD-clean with a context term, otherwise it degrades to the player card.
    382386  if (playable) note.embedUrl = `${base}/embed?post=${encodeURIComponent(post.slug)}`;
     387  // A hosted poll → federate as an AS2 Question (options + live tally). Do this last so it
     388  // reuses the note's content/addressing/tags, then swaps the type and strips media.
     389  const ownPoll = parseOwnPoll(post.poll_json);
     390  if (ownPoll) applyPollToNote(note, post.id, ownPoll);
    383391  return note;
    384392}
     
    786794}
    787795
     796// ── Polls WE host (a local post with a poll) ──────────────────────
     797// Parse the poll definition stored on our own post (posts.poll_json). Counts are
     798// NOT stored here — they're derived from the poll_votes ballots so a re-render always
     799// reflects the authoritative tally.
     800export function parseOwnPoll(pollJson) {
     801  if (!pollJson) return null;
     802  let d; try { d = typeof pollJson === 'string' ? JSON.parse(pollJson) : pollJson; } catch { return null; }
     803  if (!d || !Array.isArray(d.options)) return null;
     804  const options = d.options.map((o) => ({ name: String((o && o.name != null ? o.name : o) || '').slice(0, 300) })).filter((o) => o.name);
     805  if (options.length < 2) return null;
     806  const endTime = d.endTime || null;
     807  const closed = !!d.closed || (endTime ? Date.parse(endTime) <= Date.now() : false);
     808  return { multiple: !!d.multiple, options, endTime, closed };
     809}
     810
     811// Live tally of a hosted poll from its ballots: per-option counts + unique voters.
     812export function pollTally(postId) {
     813  const counts = {}; let voters = 0;
     814  try {
     815    for (const r of db.prepare('SELECT choice, COUNT(*) AS n FROM poll_votes WHERE post_id = ? GROUP BY choice').all(postId)) counts[r.choice] = r.n;
     816    voters = db.prepare('SELECT COUNT(DISTINCT actor_uri) AS n FROM poll_votes WHERE post_id = ?').get(postId).n || 0;
     817  } catch { /* table may not exist yet */ }
     818  return { counts, voters };
     819}
     820
     821// Render-ready view of a hosted poll (options with counts + percentages, totals, state).
     822// Voting is fediverse-only, so this is display-only on the site.
     823export function ownPollView(post) {
     824  const poll = parseOwnPoll(post && post.poll_json);
     825  if (!poll) return null;
     826  const { counts, voters } = pollTally(post.id);
     827  const total = Object.values(counts).reduce((a, b) => a + b, 0);
     828  const denom = poll.multiple ? voters : total; // multiple-choice %: share of voters (can sum >100%)
     829  const options = poll.options.map((o) => {
     830    const count = counts[o.name] || 0;
     831    return { name: o.name, count, pct: denom ? Math.round((count / denom) * 100) : 0 };
     832  });
     833  return { multiple: poll.multiple, options, total, voters, endTime: poll.endTime, closed: poll.closed };
     834}
     835
     836// Attach the AS2 Question shape to a note built for a hosted poll. Mastodon renders a
     837// status with either media OR a poll (never both), so a poll federates as content +
     838// options with no media attachment. oneOf = single choice, anyOf = multiple.
     839function applyPollToNote(note, postId, poll) {
     840  const { counts, voters } = pollTally(postId);
     841  const opts = poll.options.map((o) => ({
     842    type: 'Note',
     843    name: o.name,
     844    replies: { type: 'Collection', totalItems: counts[o.name] || 0 },
     845  }));
     846  note.type = 'Question';
     847  note[poll.multiple ? 'anyOf' : 'oneOf'] = opts;
     848  if (poll.endTime) note.endTime = new Date(poll.endTime).toISOString();
     849  // Once closed, Mastodon expects a `closed` timestamp (the effective end).
     850  if (poll.closed) note.closed = poll.endTime ? new Date(poll.endTime).toISOString() : new Date().toISOString();
     851  note.votersCount = voters;
     852  delete note.attachment;   // media + poll are mutually exclusive on Mastodon
     853  delete note.image;
     854  return note;
     855}
     856
     857// Record an inbound ballot on one of OUR polls. A vote arrives as a Create(Note) whose
     858// `name` is the chosen option and `inReplyTo` is our poll note — the Mastodon-standard
     859// vote form. Returns { handled } — handled=true means it was addressed to a poll (so the
     860// caller must NOT also store it as a reply), false means "not a poll, fall through".
     861function recordPollBallot(postId, actorUri, rawChoice) {
     862  const choice = String(rawChoice == null ? '' : rawChoice).slice(0, 300);
     863  if (!choice) return { handled: false };
     864  let post; try { post = db.prepare('SELECT poll_json FROM posts WHERE id = ?').get(postId); } catch { return { handled: false }; }
     865  const poll = post && parseOwnPoll(post.poll_json);
     866  if (!poll) return { handled: false };               // not a poll → let the reply logic handle it
     867  if (poll.closed) return { handled: true };          // voting closed → drop
     868  if (!poll.options.some((o) => o.name === choice)) return { handled: true }; // unknown option → drop
     869  try {
     870    // Single choice = one ballot per actor: ignore a later/different vote. Multiple choice
     871    // allows one ballot per distinct option (the UNIQUE(post,actor,choice) dedupes repeats).
     872    if (!poll.multiple && db.prepare('SELECT 1 FROM poll_votes WHERE post_id = ? AND actor_uri = ? LIMIT 1').get(postId, actorUri)) return { handled: true };
     873    db.prepare('INSERT OR IGNORE INTO poll_votes (post_id, actor_uri, choice) VALUES (?, ?, ?)').run(postId, actorUri, choice);
     874  } catch { return { handled: true }; }
     875  schedulePollUpdate(postId);
     876  return { handled: true };
     877}
     878
     879// Coalesce a burst of votes into ONE Update(Question) per poll: the first vote schedules a
     880// refresh ~15s out; further votes in that window ride the same pending update (which carries
     881// the accumulated tally). Non-follower voters re-fetch the Question (live tally) themselves.
     882const _pollUpdTimers = new Map();
     883function schedulePollUpdate(postId) {
     884  if (_pollUpdTimers.has(postId)) return;
     885  const t = setTimeout(() => { _pollUpdTimers.delete(postId); deliverPollUpdate(postId).catch(() => { /* best-effort */ }); }, 15000);
     886  if (t.unref) t.unref();
     887  _pollUpdTimers.set(postId, t);
     888}
     889
     890// Push the fresh poll tally (or closed state) to followers as Update(Question).
     891export async function deliverPollUpdate(postId) {
     892  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     893  if (!base || !postId) return;
     894  let post, site;
     895  try {
     896    post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
     897    if (!post || !post.poll_json) return;
     898    site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
     899  } catch { return; }
     900  if (site) await deliverUpdate(site, post);
     901}
     902
    788903// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    789904export async function handleInbox(req, slugParam) {
     
    863978  if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
    864979    const o = act.object;
     980    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
     981    // posts. Record it (deduped per actor) BEFORE the reply logic so a vote is never stored
     982    // as a comment. recordPollBallot returns handled=false only if the target isn't a poll.
     983    if (o.name && o.inReplyTo && actorUri && !isLocalActor) {
     984      const seg = postIdFromNoteUrl(o.inReplyTo, base);
     985      if (seg && localPostExists(seg)) {
     986        const rec = recordPollBallot(seg, actorUri, o.name);
     987        if (rec.handled) { console.log('[AP] poll vote', actorUri, '→', seg); return 202; }
     988      }
     989    }
    865990    const tgt = findThreadTarget(o.inReplyTo, base);
    866991    if (tgt && actorUri && !isLocalActor) {
     
    19692094  listOutbox, deliverOutboxDelete, deliverOutboxUpdate,
    19702095  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll,
     2096  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate,
    19712097  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    19722098  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
  • src/services/Scheduler.js

    r731e431 r0403187  
    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.cover_video_url, p.fan_only, p.nsfw, p.content_warning,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
    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, cover_video_url: p.cover_video_url || null,
    41             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning,
     41            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
    4242          }).catch(() => { /* best-effort */ });
    4343        }
     
    4848}
    4949
     50// Close hosted polls whose endTime has passed: mark them closed (once) and push the final
     51// tally + closed state to followers as Update(Question). The `closed` flag in poll_json
     52// guards against re-sending — a poll is only processed on the tick that crosses its endTime.
     53export function closeExpiredPolls() {
     54  try {
     55    const due = db.prepare(`
     56      SELECT id, poll_json FROM posts
     57      WHERE poll_json IS NOT NULL
     58        AND status = 'published'
     59        AND json_extract(poll_json, '$.endTime') IS NOT NULL
     60        AND IFNULL(json_extract(poll_json, '$.closed'), 0) = 0
     61        AND datetime(json_extract(poll_json, '$.endTime')) <= datetime('now')
     62    `).all();
     63    if (!due.length) return 0;
     64    const upd = db.prepare('UPDATE posts SET poll_json = ? WHERE id = ?');
     65    for (const p of due) {
     66      let d; try { d = JSON.parse(p.poll_json); } catch { continue; }
     67      d.closed = true;
     68      upd.run(JSON.stringify(d), p.id);
     69      ActivityPubService.deliverPollUpdate(p.id).catch(() => { /* best-effort */ });
     70    }
     71    return due.length;
     72  } catch { return 0; }
     73}
     74
    5075let _timer = null;
     76function tick() { flipScheduledPosts(); closeExpiredPolls(); }
    5177export function startScheduler() {
    52   flipScheduledPosts();                 // run immediately on boot
     78  tick();                               // run immediately on boot
    5379  if (_timer) return;
    54   _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
     80  _timer = setInterval(tick, 60 * 1000); // every minute
    5581  if (_timer.unref) _timer.unref();
    5682}
  • src/services/i18n.js

    r731e431 r0403187  
    113113    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
    114114    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij',
    115     'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten',
     115    'fedi.remote_title': 'Reageer via de fediverse', 'fedi.follow_heading': 'Volgen via de fediverse', 'fedi.profile_follow': 'Volg via de fediverse', 'profile.since': 'Op Klonkt sinds', 'profile.free': 'Gratis', 'fedi.follow_intro': 'Je staat op het punt te volgen:', 'fedi.follow_btn': 'Volgen', 'fedi.cancel': 'Annuleren', 'fedi.followed_title': 'Volgverzoek verstuurd ✅', 'fedi.followed_done': 'Je volgverzoek is onderweg. Zodra de andere kant het accepteert, verschijnen hun berichten in je tijdlijn.', 'fedi.view_profile': 'Bekijk profiel →', 'fedi.remote_reply': 'Reageer via de fediverse', 'fedi.remote_prompt': 'Je fediverse-adres:', 'fedi.remote_notfound': 'Kon die post niet ophalen. Plak de volledige post-URL:', 'fedi.remote_load': 'Ophalen', 'fedi.remote_replying_to': 'Je reageert op', 'fedi.remote_as': 'Wordt verzonden als {site}.', 'fedi.remote_view_original': 'Bekijk de hele post + reacties op de bron →', 'fedi.remote_reply_short': 'via de fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Like intrekken', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'jouw server', 'fedi.remote_sent_title': 'Verzonden ✅', 'fedi.remote_sent': 'Je reactie is verstuurd. Hij verschijnt zo bij de originele post op de fediverse, niet op deze pagina. Bekijk hem daar:', 'fedi.reply_where': 'Je reactie verschijnt bij de originele post op de fediverse, niet op deze pagina. Via de link hierboven zie je hem daar.', 'fedi.remote_back': '← Terug naar je site', 'fedi.like_btn': 'Like deze post', 'fedi.or_reply': 'of reageer:', 'fedi.liked_title': 'Geliket', 'fedi.liked_done': 'Je like is onderweg naar de fediverse.', 'fedi.boost_btn': 'Boost deze post', 'fedi.boosted_title': 'Geboost', 'fedi.boosted_done': 'Je boost is onderweg naar de fediverse.', 'fedi.remote_interact': 'Interageer via de fediverse', 'fedi.delete_confirm': 'Deze reactie verwijderen?', 'fedi.manage_title': 'Mijn fediverse-reacties', 'fedi.manage_empty': 'Je hebt nog geen reacties verstuurd.', 'fedi.goto_post': 'Naar de post', 'fedi.edit': 'Bewerken', 'fedi.save_edit': 'Opslaan', 'fedi.bm_label': 'Interageer via mijn site', 'fedi.bm_help': 'Sleep deze knop naar je bladwijzerbalk. Klik ’m daarna op elke fediverse-post (Mastodon, een andere Klonkt…) om er via jouw site op te reageren, te liken of te boosten.', 'tl.title': 'Krant', 'tl.lead': 'Volg accounts in de fediverse en zie hun berichten hier.', 'tl.follow_btn': 'Volgen', 'tl.following': 'Je volgt', 'tl.unfollow': 'Ontvolgen', 'tl.autoboost': 'Uitgelicht', 'tl.autoboost_follow': 'uitlichten in cirkel', 'tl.autoboost_hint': 'Hun nieuwe posts verschijnen doorlopend in jouw Cirkel (lokaal, geen fediverse-boost).', 'tl.pending': 'in afwachting', 'tl.unboost': 'Boost intrekken', 'tl.feed': 'Berichten', 'tl.tab_feed': 'Krant', 'tl.tab_following': 'Volgend', 'tl.tab_replies': 'Reacties', 'tl.empty_following': 'Je volgt nog niemand.', 'tl.empty': 'Nog niks — volg iemand om hun berichten hier te zien.', 'tl.view_original': 'Bekijk origineel →', 'tl.open_player': 'Open de speler', 'tl.paste_ph': 'Plak een fediverse-post-URL', 'tl.paste_go': 'Openen', 'tl.boosted': 'boostte dit', 'tl.read_more': 'Meer lezen', 'tl.show_less': 'Minder', 'poll.vote': 'Stem', 'poll.votes': 'stemmen', 'poll.closed': 'gesloten', 'poll.aria': 'Peiling', 'poll.voter_one': 'stemmer', 'poll.voter_many': 'stemmers', 'poll.closes': 'sluit op', 'poll.multiple': 'meerkeuze', 'poll.fedi_only': 'Stemmen kan vanuit de fediverse — volg deze site en stem in je eigen app.',
    116116    'comments.to_start': 'om de conversatie te starten.',
    117117    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    697697    'pedit.pin_top': 'bovenaan',
    698698    'pedit.pin_nth_suffix': 'e van boven',
    699     'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'pedit.fedi_audio_label': 'Audio openbaar delen op de fediverse (speelt inline in apps; bestand downloadbaar)', 'pedit.nsfw_cw_ph': 'Waarschuwingstekst (optioneel, standaard: Gevoelige inhoud)', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen', 'post.share': 'Deel', 'post.share_copied': 'Link gekopieerd ✓',
     699    'pedit.noindex_label': 'noindex (verberg voor zoekmachines)', 'pedit.nsfw_label': 'NSFW / gevoelige inhoud', 'pedit.fedi_audio_label': 'Audio openbaar delen op de fediverse (speelt inline in apps; bestand downloadbaar)', 'pedit.nsfw_cw_ph': 'Waarschuwingstekst (optioneel, standaard: Gevoelige inhoud)', 'post.nsfw_warning': 'Gevoelige inhoud', 'post.nsfw_show': 'Tonen', 'post.share': 'Deel', 'post.share_copied': 'Link gekopieerd ✓', 'pedit.poll_label': 'Peiling toevoegen', 'pedit.poll_locked': 'Er is al gestemd — de opties kunnen niet meer wijzigen.', 'pedit.poll_option_ph': 'Optie', 'pedit.poll_add': 'Optie toevoegen', 'pedit.poll_multiple': 'Meerdere keuzes toestaan', 'pedit.poll_duration': 'Looptijd', 'pedit.poll_dur_5m': '5 minuten', 'pedit.poll_dur_30m': '30 minuten', 'pedit.poll_dur_1h': '1 uur', 'pedit.poll_dur_6h': '6 uur', 'pedit.poll_dur_12h': '12 uur', 'pedit.poll_dur_1d': '1 dag', 'pedit.poll_dur_3d': '3 dagen', 'pedit.poll_dur_7d': '7 dagen',
    700700    'pedit.fan_only_label': 'Alleen voor ingelogde fans',
    701701    'pedit.schedule_label': 'Publicatie inplannen',
     
    10331033    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
    10341034    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You',
    1035     'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed',
     1035    'fedi.remote_title': 'Reply via the fediverse', 'fedi.follow_heading': 'Follow via the fediverse', 'fedi.profile_follow': 'Follow via the fediverse', 'profile.since': 'On Klonkt since', 'profile.free': 'Free', 'fedi.follow_intro': 'You are about to follow:', 'fedi.follow_btn': 'Follow', 'fedi.cancel': 'Cancel', 'fedi.followed_title': 'Follow request sent ✅', 'fedi.followed_done': 'Your follow request is on its way. Once accepted, their posts show up in your timeline.', 'fedi.view_profile': 'View profile →', 'fedi.remote_reply': 'Reply via the fediverse', 'fedi.remote_prompt': 'Your fediverse address:', 'fedi.remote_notfound': 'Could not fetch that post. Paste the full post URL:', 'fedi.remote_load': 'Fetch', 'fedi.remote_replying_to': 'Replying to', 'fedi.remote_as': 'Sent as {site}.', 'fedi.remote_view_original': 'View the full post + comments on the source →', 'fedi.remote_reply_short': 'via the fediverse', 'fedi.like_short': 'Like', 'fedi.unlike_short': 'Unlike', 'fedi.boost_short': 'Boost', 'fedi.remote_ph': 'your server', 'fedi.remote_sent_title': 'Sent ✅', 'fedi.remote_sent': 'Your reply has been sent. It will show up on the original post on the fediverse, not on this page. See it there:', 'fedi.reply_where': 'Your reply appears on the original post on the fediverse, not on this page. Use the link above to see it there.', 'fedi.remote_back': '← Back to your site', 'fedi.like_btn': 'Like this post', 'fedi.or_reply': 'or reply:', 'fedi.liked_title': 'Liked', 'fedi.liked_done': 'Your like is on its way to the fediverse.', 'fedi.boost_btn': 'Boost this post', 'fedi.boosted_title': 'Boosted', 'fedi.boosted_done': 'Your boost is on its way to the fediverse.', 'fedi.remote_interact': 'Interact via the fediverse', 'fedi.delete_confirm': 'Delete this reply?', 'fedi.manage_title': 'My fediverse replies', 'fedi.manage_empty': 'You have not sent any replies yet.', 'fedi.goto_post': 'Go to post', 'fedi.edit': 'Edit', 'fedi.save_edit': 'Save', 'fedi.bm_label': 'Interact via my site', 'fedi.bm_help': 'Drag this button to your bookmarks bar. Then click it on any fediverse post (Mastodon, another Klonkt…) to reply, like or boost it via your own site.', 'tl.title': 'News', 'tl.lead': 'Follow accounts on the fediverse and see their posts here.', 'tl.follow_btn': 'Follow', 'tl.following': 'Following', 'tl.unfollow': 'Unfollow', 'tl.autoboost': 'Featured', 'tl.autoboost_follow': 'feature in circle', 'tl.autoboost_hint': 'Their new posts keep showing in your Circle (local, no fediverse boost).', 'tl.pending': 'pending', 'tl.unboost': 'Unboost', 'tl.feed': 'Posts', 'tl.tab_feed': 'News', 'tl.tab_following': 'Following', 'tl.tab_replies': 'Replies', 'tl.empty_following': 'You do not follow anyone yet.', 'tl.empty': 'Nothing yet — follow someone to see their posts here.', 'tl.view_original': 'View original →', 'tl.open_player': 'Open the player', 'tl.paste_ph': 'Paste a fediverse post URL', 'tl.paste_go': 'Open', 'tl.boosted': 'boosted', 'tl.read_more': 'Read more', 'tl.show_less': 'Show less', 'poll.vote': 'Vote', 'poll.votes': 'votes', 'poll.closed': 'closed', 'poll.aria': 'Poll', 'poll.voter_one': 'voter', 'poll.voter_many': 'voters', 'poll.closes': 'closes', 'poll.multiple': 'multiple choice', 'poll.fedi_only': 'Voting happens on the fediverse — follow this site and vote from your own app.',
    10361036    'comments.to_start': 'to start the conversation.',
    10371037    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    16151615    'pedit.pin_top': 'at the top',
    16161616    'pedit.pin_nth_suffix': 'th from top',
    1617     'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'pedit.fedi_audio_label': 'Share audio openly on the fediverse (plays inline in apps; file downloadable)', 'pedit.nsfw_cw_ph': 'Warning text (optional, default: Sensitive content)', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show', 'post.share': 'Share', 'post.share_copied': 'Link copied ✓',
     1617    'pedit.noindex_label': 'noindex (hide from search engines)', 'pedit.nsfw_label': 'NSFW / sensitive content', 'pedit.fedi_audio_label': 'Share audio openly on the fediverse (plays inline in apps; file downloadable)', 'pedit.nsfw_cw_ph': 'Warning text (optional, default: Sensitive content)', 'post.nsfw_warning': 'Sensitive content', 'post.nsfw_show': 'Show', 'post.share': 'Share', 'post.share_copied': 'Link copied ✓', 'pedit.poll_label': 'Add a poll', 'pedit.poll_locked': 'Votes are in — the options can no longer change.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Add option', 'pedit.poll_multiple': 'Allow multiple choices', 'pedit.poll_duration': 'Duration', 'pedit.poll_dur_5m': '5 minutes', 'pedit.poll_dur_30m': '30 minutes', 'pedit.poll_dur_1h': '1 hour', 'pedit.poll_dur_6h': '6 hours', 'pedit.poll_dur_12h': '12 hours', 'pedit.poll_dur_1d': '1 day', 'pedit.poll_dur_3d': '3 days', 'pedit.poll_dur_7d': '7 days',
    16181618    'pedit.fan_only_label': 'Logged-in fans only',
    16191619    'pedit.schedule_label': 'Schedule publication',
     
    19511951    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
    19521952    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du',
    1953     'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen',
     1953    'fedi.remote_title': 'Über das Fediverse antworten', 'fedi.follow_heading': 'Über das Fediverse folgen', 'fedi.profile_follow': 'Über das Fediverse folgen', 'profile.since': 'Auf Klonkt seit', 'profile.free': 'Kostenlos', 'fedi.follow_intro': 'Du folgst gleich:', 'fedi.follow_btn': 'Folgen', 'fedi.cancel': 'Abbrechen', 'fedi.followed_title': 'Folge-Anfrage gesendet ✅', 'fedi.followed_done': 'Deine Folge-Anfrage ist unterwegs. Sobald sie akzeptiert wird, erscheinen ihre Beiträge in deiner Timeline.', 'fedi.view_profile': 'Profil ansehen →', 'fedi.remote_reply': 'Über das Fediverse antworten', 'fedi.remote_prompt': 'Deine Fediverse-Adresse:', 'fedi.remote_notfound': 'Beitrag konnte nicht geladen werden. Füge die vollständige Beitrags-URL ein:', 'fedi.remote_load': 'Laden', 'fedi.remote_replying_to': 'Antwort an', 'fedi.remote_as': 'Wird als {site} gesendet.', 'fedi.remote_view_original': 'Ganzen Beitrag + Kommentare an der Quelle ansehen →', 'fedi.remote_reply_short': 'übers Fediverse', 'fedi.like_short': 'Liken', 'fedi.unlike_short': 'Like zurücknehmen', 'fedi.boost_short': 'Boosten', 'fedi.remote_ph': 'dein Server', 'fedi.remote_sent_title': 'Gesendet ✅', 'fedi.remote_sent': 'Deine Antwort wurde gesendet. Sie erscheint gleich beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Sieh sie dir dort an:', 'fedi.reply_where': 'Deine Antwort erscheint beim Originalbeitrag im Fediverse, nicht auf dieser Seite. Über den Link oben siehst du sie dort.', 'fedi.remote_back': '← Zurück zu deiner Seite', 'fedi.like_btn': 'Diesen Beitrag liken', 'fedi.or_reply': 'oder antworten:', 'fedi.liked_title': 'Geliked', 'fedi.liked_done': 'Dein Like ist unterwegs ins Fediverse.', 'fedi.boost_btn': 'Diesen Beitrag boosten', 'fedi.boosted_title': 'Geboostet', 'fedi.boosted_done': 'Dein Boost ist unterwegs ins Fediverse.', 'fedi.remote_interact': 'Übers Fediverse interagieren', 'fedi.delete_confirm': 'Diese Antwort löschen?', 'fedi.manage_title': 'Meine Fediverse-Antworten', 'fedi.manage_empty': 'Du hast noch keine Antworten gesendet.', 'fedi.goto_post': 'Zur Post', 'fedi.edit': 'Bearbeiten', 'fedi.save_edit': 'Speichern', 'fedi.bm_label': 'Über meine Seite interagieren', 'fedi.bm_help': 'Zieh diesen Button in deine Lesezeichenleiste. Klick ihn dann auf einem beliebigen Fediverse-Beitrag (Mastodon, ein anderes Klonkt…), um über deine eigene Seite zu antworten, zu liken oder zu boosten.', 'tl.title': 'Zeitung', 'tl.lead': 'Folge Konten im Fediverse und sieh ihre Beiträge hier.', 'tl.follow_btn': 'Folgen', 'tl.following': 'Du folgst', 'tl.unfollow': 'Entfolgen', 'tl.autoboost': 'Hervorgehoben', 'tl.autoboost_follow': 'im Zirkel hervorheben', 'tl.autoboost_hint': 'Ihre neuen Beiträge erscheinen laufend in deinem Zirkel (lokal, kein Fediverse-Boost).', 'tl.pending': 'ausstehend', 'tl.unboost': 'Boost zurücknehmen', 'tl.feed': 'Beiträge', 'tl.tab_feed': 'Zeitung', 'tl.tab_following': 'Folge ich', 'tl.tab_replies': 'Antworten', 'tl.empty_following': 'Du folgst noch niemandem.', 'tl.empty': 'Noch nichts — folge jemandem, um Beiträge hier zu sehen.', 'tl.view_original': 'Original ansehen →', 'tl.open_player': 'Player öffnen', 'tl.paste_ph': 'URL eines Fediverse-Beitrags einfügen', 'tl.paste_go': 'Öffnen', 'tl.boosted': 'hat geteilt', 'tl.read_more': 'Mehr lesen', 'tl.show_less': 'Weniger', 'poll.vote': 'Abstimmen', 'poll.votes': 'Stimmen', 'poll.closed': 'geschlossen', 'poll.aria': 'Umfrage', 'poll.voter_one': 'Teilnehmer', 'poll.voter_many': 'Teilnehmer', 'poll.closes': 'endet am', 'poll.multiple': 'Mehrfachauswahl', 'poll.fedi_only': 'Abstimmen geht über das Fediverse — folge dieser Seite und stimme in deiner eigenen App ab.',
    19541954    'comments.to_start': 'um das Gespräch zu starten.',
    19551955    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
     
    25332533    'pedit.pin_top': 'ganz oben',
    25342534    'pedit.pin_nth_suffix': '. von oben',
    2535     'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'pedit.fedi_audio_label': 'Audio offen im Fediverse teilen (spielt inline in Apps; Datei herunterladbar)', 'pedit.nsfw_cw_ph': 'Warntext (optional, Standard: Sensibler Inhalt)', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen', 'post.share': 'Teilen', 'post.share_copied': 'Link kopiert ✓',
     2535    'pedit.noindex_label': 'noindex (vor Suchmaschinen verbergen)', 'pedit.nsfw_label': 'NSFW / sensibler Inhalt', 'pedit.fedi_audio_label': 'Audio offen im Fediverse teilen (spielt inline in Apps; Datei herunterladbar)', 'pedit.nsfw_cw_ph': 'Warntext (optional, Standard: Sensibler Inhalt)', 'post.nsfw_warning': 'Sensibler Inhalt', 'post.nsfw_show': 'Anzeigen', 'post.share': 'Teilen', 'post.share_copied': 'Link kopiert ✓', 'pedit.poll_label': 'Umfrage hinzufügen', 'pedit.poll_locked': 'Es wurde bereits abgestimmt — die Optionen lassen sich nicht mehr ändern.', 'pedit.poll_option_ph': 'Option', 'pedit.poll_add': 'Option hinzufügen', 'pedit.poll_multiple': 'Mehrfachauswahl erlauben', 'pedit.poll_duration': 'Laufzeit', 'pedit.poll_dur_5m': '5 Minuten', 'pedit.poll_dur_30m': '30 Minuten', 'pedit.poll_dur_1h': '1 Stunde', 'pedit.poll_dur_6h': '6 Stunden', 'pedit.poll_dur_12h': '12 Stunden', 'pedit.poll_dur_1d': '1 Tag', 'pedit.poll_dur_3d': '3 Tage', 'pedit.poll_dur_7d': '7 Tage',
    25362536    'pedit.fan_only_label': 'Nur für angemeldete Fans',
    25372537    'pedit.schedule_label': 'Veröffentlichung planen',
  • src/views/pages/post-edit.ejs

    r731e431 r0403187  
    257257          if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
    258258            cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
     259          }
     260        })();
     261        </script>
     262        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
     263           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     264           var _pollLocked = (typeof pollLocked !== 'undefined' && pollLocked);
     265           var _pollOpts = (_poll && Array.isArray(_poll.options) && _poll.options.length) ? _poll.options : [{ name: '' }, { name: '' }]; %>
     266        <label class="pe-checkbox" style="margin-top:8px">
     267          <input type="checkbox" name="poll_enabled" value="1" id="pe-poll-toggle" <%= _poll ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
     268          <span>📊 <%= t('pedit.poll_label') %></span>
     269        </label>
     270        <div id="pe-poll-fields" style="margin-top:6px<%= _poll ? '' : ';display:none' %>">
     271          <% if (_pollLocked) { %><p style="font-size:12px;opacity:.7;margin:0 0 6px"><%= t('pedit.poll_locked') %></p><% } %>
     272          <div id="pe-poll-opts">
     273            <% _pollOpts.forEach(function (o) { %>
     274              <input type="text" name="poll_option" class="pe-poll-opt" maxlength="100" value="<%= (o && o.name) || '' %>" placeholder="<%= t('pedit.poll_option_ph') %>" <%= _pollLocked ? 'disabled' : '' %>
     275                     style="display:block;width:100%;box-sizing:border-box;margin-bottom:5px;font-size:13px;padding:7px 9px;border-radius:7px;border:1px solid var(--rule,rgba(128,128,128,.35));background:transparent;color:inherit">
     276            <% }); %>
     277          </div>
     278          <button type="button" id="pe-poll-add" class="btn" data-ph="<%= t('pedit.poll_option_ph') %>" <%= _pollLocked ? 'disabled' : '' %> style="font-size:12.5px;padding:5px 10px">+ <%= t('pedit.poll_add') %></button>
     279          <label class="pe-checkbox" style="margin-top:8px">
     280            <input type="checkbox" name="poll_multiple" value="1" <%= (_poll && _poll.multiple) ? 'checked' : '' %> <%= _pollLocked ? 'disabled' : '' %>>
     281            <span><%= t('pedit.poll_multiple') %></span>
     282          </label>
     283          <label style="display:block;font-size:12.5px;opacity:.8;margin:6px 0 4px"><%= t('pedit.poll_duration') %></label>
     284          <select name="poll_duration" <%= _pollLocked ? 'disabled' : '' %> style="padding:7px 9px;border-radius:7px;border:1px solid var(--rule,rgba(128,128,128,.35));background:transparent;color:inherit;font-size:13px">
     285            <% [['300','5m'],['1800','30m'],['3600','1h'],['21600','6h'],['43200','12h'],['86400','1d'],['259200','3d'],['604800','7d']].forEach(function (d) { %>
     286              <option value="<%= d[0] %>" <%= d[0] === '86400' ? 'selected' : '' %>><%= t('pedit.poll_dur_' + d[1]) %></option>
     287            <% }); %>
     288          </select>
     289        </div>
     290        <script>
     291        (function () {
     292          var tog = document.getElementById('pe-poll-toggle'), box = document.getElementById('pe-poll-fields');
     293          if (tog && box && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
     294          var add = document.getElementById('pe-poll-add'), opts = document.getElementById('pe-poll-opts');
     295          if (add && opts && !add.__wired) { add.__wired = true;
     296            add.addEventListener('click', function () {
     297              if (opts.querySelectorAll('.pe-poll-opt').length >= 8) return;
     298              var i = document.createElement('input');
     299              i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100;
     300              i.placeholder = add.getAttribute('data-ph') || '';
     301              i.setAttribute('style', 'display:block;width:100%;box-sizing:border-box;margin-bottom:5px;font-size:13px;padding:7px 9px;border-radius:7px;border:1px solid var(--rule,rgba(128,128,128,.35));background:transparent;color:inherit');
     302              opts.appendChild(i);
     303            });
    259304          }
    260305        })();
  • src/views/pages/post.ejs

    r731e431 r0403187  
    4444    <%- post.content_html %>
    4545  </div>
     46
     47  <% if (typeof poll !== 'undefined' && poll) { %>
     48    <section class="poll" aria-label="<%= t('poll.aria') %>">
     49      <% poll.options.forEach(function(o){ var _lead = poll.total > 0 && o.count === Math.max.apply(null, poll.options.map(function(x){return x.count;})); %>
     50        <div class="poll-opt<%= _lead ? ' is-lead' : '' %>">
     51          <div class="poll-bar" style="width:<%= o.pct %>%"></div>
     52          <span class="poll-name"><%= o.name %></span>
     53          <span class="poll-pct"><%= o.pct %>%</span>
     54        </div>
     55      <% }); %>
     56      <div class="poll-meta">
     57        <span><%= poll.voters %> <%= poll.voters === 1 ? t('poll.voter_one') : t('poll.voter_many') %></span>
     58        <span aria-hidden="true">·</span>
     59        <% if (poll.closed) { %>
     60          <span><%= t('poll.closed') %></span>
     61        <% } else if (poll.endTime) { %>
     62          <span><%= t('poll.closes') %> <%= new Date(poll.endTime).toLocaleString() %></span>
     63        <% } %>
     64        <% if (poll.multiple) { %><span aria-hidden="true">·</span><span><%= t('poll.multiple') %></span><% } %>
     65      </div>
     66      <p class="poll-note"><%= t('poll.fedi_only') %></p>
     67    </section>
     68  <% } %>
    4669
    4770  <% if (post.tags && post.tags.length > 0) { %>
     
    210233.post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
    211234.post-content p { margin: 1.2em 0; }
     235/* Poll (a hosted AS2 Question) — display-only; voting happens from the fediverse. */
     236.poll { margin: 1.75rem 0; display: flex; flex-direction: column; gap: .5rem; }
     237.poll-opt { position: relative; display: flex; align-items: center; gap: .5rem; padding: .55rem .75rem; border: 1px solid var(--line, rgba(128,128,128,.25)); border-radius: 8px; overflow: hidden; font-size: .98rem; }
     238.poll-bar { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--accent) 18%, transparent); z-index: 0; transition: width .3s ease; }
     239.poll-opt.is-lead .poll-bar { background: color-mix(in srgb, var(--accent) 30%, transparent); }
     240.poll-name { position: relative; z-index: 1; flex: 1; color: var(--ink); }
     241.poll-opt.is-lead .poll-name { font-weight: 600; }
     242.poll-pct { position: relative; z-index: 1; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
     243.poll-meta { display: flex; flex-wrap: wrap; gap: .4rem; font-size: .85rem; color: var(--ink-muted); }
     244.poll-note { font-size: .8rem; color: var(--ink-muted); margin: .1rem 0 0; }
    212245.like-btn {
    213246  display: inline-flex; align-items: center; gap: 0.45rem;
Note: See TracChangeset for help on using the changeset viewer.