Changeset 0403187 in Klonkt
- Timestamp:
- 07/01/2026 12:53:32 PM (2 months ago)
- Branches:
- main
- Children:
- bb3f67c
- Parents:
- 731e431
- Files:
-
- 1 added
- 10 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
src/config/database.js (modified) (2 diffs)
-
src/routes/posts.js (modified) (11 diffs)
-
src/services/ActivityPubService.js (modified) (5 diffs)
-
src/services/Scheduler.js (modified) (3 diffs)
-
src/services/i18n.js (modified) (6 diffs)
-
src/views/pages/post-edit.ejs (modified) (1 diff)
-
src/views/pages/post.ejs (modified) (2 diffs)
-
test/polls.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
r731e431 r0403187 13 13 im News-Feed mit Optionen und aktuellen Ergebnissen, und du kannst abstimmen — das föderiert zurück 14 14 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. 15 19 16 20 ## [1.2.0] — 2026-07-01 -
CHANGELOG.md
r731e431 r0403187 11 11 - **Vote on fediverse polls.** A poll from an account you follow now shows in the News feed with its 12 12 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. 13 16 14 17 ## [1.2.0] — 2026-07-01 -
CHANGELOG.nl.md
r731e431 r0403187 13 13 met opties en de huidige resultaten, en je kunt je stem uitbrengen — die federeert terug zoals een 14 14 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. 15 18 16 19 ## [1.2.0] — 2026-07-01 -
src/config/database.js
r731e431 r0403187 87 87 ensureColumn('posts', 'content_warning', 'TEXT'); // custom CW label (empty = default "Gevoelige inhoud") 88 88 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} 89 90 90 91 // Statistics (premium module) — bare counters, cookie-free. … … 331 332 ); 332 333 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); 333 343 `); 334 344 // "Feature" a followed account: its posts show in the local Cirkel. -
src/routes/posts.js
r731e431 r0403187 116 116 if (!Number.isFinite(n) || n < 0) return 0; 117 117 return n; 118 } 119 120 // Poll durations offered in the editor (seconds) — the Mastodon set (5m … 7d). 121 const 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. 126 function 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 }); 118 143 } 119 144 … … 241 266 const validTypes = new Set(['post', 'foto', 'video', 'audio']); 242 267 const finalType = validTypes.has(type) ? type : 'post'; 268 const pollJson = parsePollForm(req.body); // AS2 Question definition, or null 243 269 const postId = uuid(); 244 270 const now = new Date().toISOString(); … … 258 284 INSERT INTO posts ( 259 285 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, p ublish_at,286 status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at, 261 287 created_at, updated_at, published_at 262 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )288 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 263 289 `).run( 264 290 postId, site.id, finalSlug, req.session.user.id, … … 266 292 finalStatus, cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned), 267 293 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)), 268 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, p ublishAt,294 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt, 269 295 now, now, publishedAt 270 296 ); … … 287 313 id: postId, slug: finalSlug, title: title || finalSlug, 288 314 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, 290 316 }).catch(() => { /* best-effort */ }); 291 317 } … … 321 347 } 322 348 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 323 353 renderPage(req, res, 'pages/post-edit', { 324 354 post, 325 355 isNew: false, 356 pollLocked, 326 357 fediOpenAudio: postAudioFediOpen(site.id, post.content), 327 358 pageTitle: 'Edit: ' + (post.title || 'Untitled'), … … 353 384 const finalType = validTypes.has(type) ? type : (post.type || 'post'); 354 385 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 355 392 // Sanitize before storage — same pipeline as create. 356 393 const cleanContent = HtmlSanitizerService.sanitize(content || ''); … … 386 423 title = ?, content = ?, excerpt = ?, status = ?, 387 424 cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?, 388 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, p ublish_at = ?,425 type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?, 389 426 slug = ?, published_at = ?, updated_at = ? 390 427 WHERE id = ? … … 393 430 cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned), 394 431 JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)), 395 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, p ublishAt,432 finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt, 396 433 finalSlug, publishedAt, now, post.id 397 434 ); … … 418 455 id: post.id, slug: finalSlug, title: title || finalSlug, 419 456 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, 421 458 }; 422 459 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ }); … … 1079 1116 renderPage(req, res, 'pages/post', { 1080 1117 post, 1118 poll: ActivityPubService.ownPollView(post), 1081 1119 newerPost, 1082 1120 olderPost, -
src/services/ActivityPubService.js
r731e431 r0403187 42 42 value: 'schema:value', 43 43 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', 44 48 }, 45 49 ]; … … 381 385 // make it JSON-LD-clean with a context term, otherwise it degrades to the player card. 382 386 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); 383 391 return note; 384 392 } … … 786 794 } 787 795 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. 800 export 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. 812 export 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. 823 export 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. 839 function 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". 861 function 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. 882 const _pollUpdTimers = new Map(); 883 function 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). 891 export 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 788 903 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 789 904 export async function handleInbox(req, slugParam) { … … 863 978 if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) { 864 979 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 } 865 990 const tgt = findThreadTarget(o.inReplyTo, base); 866 991 if (tgt && actorUri && !isLocalActor) { … … 1969 2094 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, 1970 2095 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, sendInteraction, voteOnPoll, 2096 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, 1971 2097 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 1972 2098 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, -
src/services/Scheduler.js
r731e431 r0403187 15 15 try { 16 16 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, 18 18 p.published_at, p.publish_at, p.created_at, u.username 19 19 FROM posts p JOIN users u ON u.id = p.author_id … … 39 39 id: p.id, slug: p.slug, title: p.title || p.slug, 40 40 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, 42 42 }).catch(() => { /* best-effort */ }); 43 43 } … … 48 48 } 49 49 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. 53 export 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 50 75 let _timer = null; 76 function tick() { flipScheduledPosts(); closeExpiredPolls(); } 51 77 export function startScheduler() { 52 flipScheduledPosts();// run immediately on boot78 tick(); // run immediately on boot 53 79 if (_timer) return; 54 _timer = setInterval( flipScheduledPosts, 60 * 1000); // every minute80 _timer = setInterval(tick, 60 * 1000); // every minute 55 81 if (_timer.unref) _timer.unref(); 56 82 } -
src/services/i18n.js
r731e431 r0403187 113 113 'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse', 114 114 '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.', 116 116 'comments.to_start': 'om de conversatie te starten.', 117 117 'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren', … … 697 697 'pedit.pin_top': 'bovenaan', 698 698 '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', 700 700 'pedit.fan_only_label': 'Alleen voor ingelogde fans', 701 701 'pedit.schedule_label': 'Publicatie inplannen', … … 1033 1033 'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse', 1034 1034 '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.', 1036 1036 'comments.to_start': 'to start the conversation.', 1037 1037 'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel', … … 1615 1615 'pedit.pin_top': 'at the top', 1616 1616 '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', 1618 1618 'pedit.fan_only_label': 'Logged-in fans only', 1619 1619 'pedit.schedule_label': 'Schedule publication', … … 1951 1951 'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse', 1952 1952 '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.', 1954 1954 'comments.to_start': 'um das Gespräch zu starten.', 1955 1955 'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen', … … 2533 2533 'pedit.pin_top': 'ganz oben', 2534 2534 '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', 2536 2536 'pedit.fan_only_label': 'Nur für angemeldete Fans', 2537 2537 'pedit.schedule_label': 'Veröffentlichung planen', -
src/views/pages/post-edit.ejs
r731e431 r0403187 257 257 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; 258 258 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 }); 259 304 } 260 305 })(); -
src/views/pages/post.ejs
r731e431 r0403187 44 44 <%- post.content_html %> 45 45 </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 <% } %> 46 69 47 70 <% if (post.tags && post.tags.length > 0) { %> … … 210 233 .post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); } 211 234 .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; } 212 245 .like-btn { 213 246 display: inline-flex; align-items: center; gap: 0.45rem;
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)