- Timestamp:
- 07/28/2026 06:17:36 PM (6 weeks ago)
- Branches:
- main
- Children:
- a7bcf66
- Parents:
- 2708282
- Location:
- src
- Files:
-
- 1 added
- 12 edited
-
assets/css/guardian.css (modified) (1 diff)
-
assets/js/guardian.js (modified) (1 diff)
-
config/database.js (modified) (1 diff)
-
middleware/render.js (modified) (1 diff)
-
routes/guardian.js (modified) (2 diffs)
-
routes/posts.js (modified) (3 diffs)
-
services/ActivityPubService.js (modified) (11 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/news.ejs (modified) (2 diffs)
-
views/partials/msg-item.ejs (modified) (2 diffs)
-
views/partials/note-body.ejs (added)
-
views/partials/shared-styles.ejs (modified) (1 diff)
-
views/partials/tl-item.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/guardian.css
r2708282 rd9ad6c5 88 88 .g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; } 89 89 .g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; } 90 91 /* ── The body of a post ──────────────────────────────────────────────────── 92 The 🛟 card shows a real post, so it is rendered by the same partial de 93 Krant and Berichten use (partials/note-body). These are that partial's 94 classes, in the PWA's own colours: the dashboard is standalone and does not 95 load the site's stylesheet. */ 96 .g-note .tl-content { line-height: 1.5; overflow-wrap: anywhere; } 97 .g-note .tl-content p { margin: .35rem 0; } 98 .g-note .tl-content p:first-child { margin-top: 0; } 99 .g-note .tl-content p:last-child { margin-bottom: 0; } 100 .g-note .tl-content a { color: var(--accent); } 101 .g-note img.emoji { height: 1.25em; width: auto; margin: 0 .05em; vertical-align: -.2em; 102 display: inline-block; border-radius: 0; background: none; } 103 104 /* Quote card and link preview: one card, two origins. */ 105 .g-note .tl-quote { margin: 8px 0 0; padding: 8px 10px; border-radius: 10px; 106 border: 1px solid var(--line); background: var(--card-2, rgba(128,128,128,.07)); } 107 .g-note .tl-quote-head { display: flex; align-items: center; gap: 6px; margin: 0 0 4px; min-width: 0; } 108 .g-note .tl-quote-avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; } 109 .g-note .tl-quote-name { font-weight: 600; font-size: .82rem; white-space: nowrap; } 110 .g-note .tl-quote-handle { color: var(--sub); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } 111 .g-note .tl-quote-body { color: var(--sub); font-size: .88rem; line-height: 1.45; max-height: 14em; overflow: hidden; } 112 .g-note .tl-quote-media { display: block; margin: 6px 0 0; } 113 114 /* The capture a 🛟 usually carries. */ 115 .g-note .tl-media { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; } 116 .g-note .tl-media-img { display: block; border-radius: 10px; overflow: hidden; } 117 .g-note .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; } 118 .g-note .tl-media-video, .g-note .tl-media-audio { width: 100%; margin: 8px 0 0; border-radius: 10px; display: block; } 119 .g-note .tl-media-video { max-height: 320px; background: #000; } -
src/assets/js/guardian.js
r2708282 rd9ad6c5 43 43 var card = el('div', 'g-card help'); 44 44 var row = el('div', 'row'); 45 row.appendChild(el('span', 'who grow', h.actor_name || handleOf(h.actor_uri, h.actor_handle))); 45 var who = el('span', 'who grow'); 46 // name_html carries the custom emojis (FEP-9098) of the display name, the 47 // same way de Krant renders a byline. Falls back to the plain name. 48 if (h.name_html) who.innerHTML = h.name_html; 49 else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle); 50 row.appendChild(who); 46 51 row.appendChild(el('span', 'when', when(h.published || h.created_at))); 47 52 card.appendChild(row); 48 var body = el('div', 'body'); 49 body.innerHTML = h.content || ''; // sanitized server-side on ingest 53 var body = el('div', 'body g-note'); 54 // body_html is the shared note-body partial, rendered server-side: the 55 // content with its emojis, the quote / link-preview card and the media. 56 // Falls back to the bare content for rows stored before that existed. 57 body.innerHTML = h.body_html || h.content || ''; // sanitized server-side on ingest 50 58 card.appendChild(body); 51 59 if (h.note_url) { -
src/config/database.js
r2708282 rd9ad6c5 622 622 ensureColumn('ap_timeline', 'has_guardians', 'INTEGER'); 623 623 ensureColumn('ap_mentions', 'has_guardians', 'INTEGER'); 624 // Berichten and de Krant render a post the same way, so a mention or a reply 625 // needs the same trimmings a timeline row already has: custom emojis, the 626 // media the note carried, and the quote / link-preview card. 627 ensureColumn('ap_mentions', 'emoji_json', 'TEXT'); // FEP-9098, in the content 628 ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT'); // FEP-9098, in the display name 629 ensureColumn('ap_mentions', 'media_json', 'TEXT'); 630 ensureColumn('ap_mentions', 'quote_json', 'TEXT'); // FEP-044f quoted post 631 ensureColumn('ap_mentions', 'embed_json', 'TEXT'); // external link preview 632 ensureColumn('ap_interactions', 'media_json', 'TEXT'); 633 ensureColumn('ap_interactions', 'quote_json', 'TEXT'); 634 ensureColumn('ap_interactions', 'embed_json', 'TEXT'); 624 635 ensureColumn('ap_followers', 'name', 'TEXT'); // cached display name (shaer-aa3) 625 636 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host -
src/middleware/render.js
r2708282 rd9ad6c5 37 37 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 38 38 const VIEWS_DIR = path.join(__dirname, '..', 'views'); 39 40 /** 41 * Render a post body to an HTML string with the SAME partial de Krant uses. 42 * 43 * For surfaces that are not an EJS page: the Guardian PWA builds its cards in 44 * the browser, so it gets the finished HTML in its state blob instead of the 45 * raw columns. One renderer, so a post cannot drift into looking different 46 * depending on where you run into it. 47 */ 48 export function renderNoteBody(nb, lang) { 49 if (!nb || !nb.content) return ''; 50 const _l = lang || 'nl'; 51 // ejs.renderFile hands back a Promise even with async:false, and the callers 52 // here are plain synchronous route code. Compile the file ourselves instead; 53 // `filename` is what lets the partial's own relative includes resolve. 54 const file = path.join(VIEWS_DIR, 'partials', 'note-body.ejs'); 55 try { 56 return ejs.render(fs.readFileSync(file, 'utf8'), { 57 nb, 58 t: (key, vars) => i18nT(_l, key, vars), 59 emojiHtml, 60 emojiName, 61 noteQuote: parseQuote, 62 thumb: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 480) : url), 63 avatar: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 128) : url), 64 }, { filename: file, async: false }); 65 } catch (e) { 66 console.warn('[render] note body failed:', e.message); 67 return ''; 68 } 69 } 39 70 40 71 // App version (from package.json) + short commit hash (from .klonkt-version, written by -
src/routes/guardian.js
r2708282 rd9ad6c5 19 19 import * as Guardianship from '../services/guardianship/index.js'; 20 20 import { t as i18nT, resolveLang } from '../services/i18n.js'; 21 import { injectCspNonce } from '../middleware/render.js'; 21 import { injectCspNonce, renderNoteBody } from '../middleware/render.js'; 22 import { emojiName } from '../services/NoteRender.js'; 22 23 23 24 const router = express.Router(); … … 51 52 const me = AP.actorId(base, site.slug); 52 53 const help = db.prepare( 53 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at 54 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at, 55 emoji_json, actor_emoji_json, media_json, quote_json, embed_json 54 56 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50` 55 ).all(site.slug); 57 ).all(site.slug).map((h) => ({ 58 ...h, 59 // The dashboard is built in the browser, so it gets the body finished: the 60 // same partial de Krant and Berichten use. A 🛟 often carries a screenshot 61 // and a link to the post it is about; both belong in the card. 62 body_html: renderNoteBody(h, L), 63 name_html: emojiName(h.actor_name || '', h.actor_emoji_json), 64 })); 56 65 return { 57 66 site: site.slug, -
src/routes/posts.js
r2708282 rd9ad6c5 920 920 const append = req.query.append === '1'; 921 921 const offset = Math.max(0, parseInt(req.query.offset, 10) || 0); 922 const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];922 const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []); 923 923 const hasMore = page.length > FEED_PAGE; 924 924 const items = page.slice(0, FEED_PAGE); … … 1084 1084 } 1085 1085 1086 /** 1087 * FEP-633c §5.3-style gated feature: may this account see previews of links 1088 * that point OUTSIDE the fediverse? For a ward that is the guardians' call. 1089 * 1090 * Applied at SERVE time on every surface, the way the app's inbox read already 1091 * does it (routes/activitypub.js): a card the client merely hides has still 1092 * been delivered. 1093 */ 1094 function gateEmbeds(site, rows) { 1095 if (!site || !rows.length) return rows; 1096 let isWard = false; 1097 try { isWard = Guardianship.listGuardians(site.slug).length > 0; } catch { /* no relations yet */ } 1098 if (Guardianship.externalEmbedsAllowed(site.external_embeds, isWard)) return rows; 1099 return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r)); 1100 } 1101 1086 1102 router.get('/news', requireSiteManager, (req, res) => { 1087 1103 const site = res.locals.site; … … 1090 1106 const cspOrigins = new Set(); 1091 1107 // Fetch one extra to know whether a "Load more" button belongs on this page. 1092 const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];1108 const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []); 1093 1109 const hasMore = rows.length > FEED_PAGE; 1094 1110 const timeline = rows.slice(0, FEED_PAGE).map((p) => { -
src/services/ActivityPubService.js
r2708282 rd9ad6c5 832 832 } 833 833 834 /** 835 * Does this note belong in the home timeline (de Krant)? 836 * 837 * Only if it is a POST. A direct note is addressed to named people, so it is a 838 * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a 839 * guardian's wave. Those are stored as mentions instead and surface in 840 * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed. 841 */ 842 export function belongsInTimeline(o) { 843 if (!o || !o.id || o.inReplyTo) return false; 844 return noteVisibility(o) !== 'direct'; 845 } 846 834 847 function iStmts() { 835 848 if (!_insI) { … … 1524 1537 iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis)); 1525 1538 console.log('[AP] reply', actorUri, '→', tgt.post_id); 1539 // A reply is a post too: Berichten renders it the way de Krant renders a 1540 // timeline row, so it needs the same media and the same quote/preview card. 1541 { 1542 const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?'; 1543 const key = ['reply', tgt.post_id, actorUri, o.id || '']; 1544 const mj = mediaFromNote(o); 1545 if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } } 1546 resolveCard(o).then((c) => { 1547 if (!c) return; 1548 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire 1549 try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ } 1550 }).catch(() => { /* best-effort */ }); 1551 } 1526 1552 { 1527 1553 // Private (followers/direct) replies push as a DM ping WITHOUT content … … 1541 1567 } 1542 1568 // Home timeline (client): a top-level post from an account we follow. 1543 if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {1569 if (actorUri && !isLocalActor && belongsInTimeline(o)) { 1544 1570 let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ } 1545 1571 if (subs.length) { … … 1610 1636 for (const slug of slugs) { 1611 1637 try { 1612 const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)') 1613 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0); 1638 const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at) 1639 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) 1640 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0, 1641 extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o)); 1614 1642 if (r.changes) { 1643 // The quote / link-preview card resolves out of band (a remote 1644 // fetch), exactly as it does for a timeline post, so the inbox 1645 // answer is never blocked on it. 1646 resolveCard(o).then((c) => { 1647 if (!c) return; 1648 const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json'; // never a value from the wire 1649 try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ } 1650 }).catch(() => { /* best-effort */ }); 1615 1651 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : ''); 1616 1652 const vis = noteVisibility(o); … … 2849 2885 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone 2850 2886 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync. 2851 const SELFHEAL_VERSION = 2 0; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve2887 const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts 2852 2888 async function fetchNoteAP(url) { 2853 2889 try { … … 2991 3027 return JSON.stringify(snapshot); 2992 3028 } 3029 3030 /** 3031 * The card under a post: a fediverse quote (FEP-044f) when the note has one, 3032 * otherwise an external link preview. Both render as the SAME card, so only one 3033 * of the two is ever stored. Returns {column, json} or null. 3034 * 3035 * Both halves reach out over the network, which is why every caller runs this 3036 * out of band: an inbox answer must never wait on a third party. 3037 */ 3038 async function resolveCard(o) { 3039 if (quoteHrefOf(o)) { 3040 const qj = await resolveQuote(o); 3041 return qj ? { column: 'quote_json', json: qj } : null; 3042 } 3043 const ej = await resolveExternalEmbed(o && o.content); 3044 return ej ? { column: 'embed_json', json: ej } : null; 3045 } 3046 2993 3047 // A generic SSRF-safe AP GET (collections / pages). 2994 3048 async function apGetJson(url) { … … 3158 3212 try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; } 3159 3213 if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots 3214 // v21: direct notes used to land in the timeline as if they were posts, so a 3215 // ward's 🛟 help request showed up in the guardian's Krant. The insert now 3216 // refuses them; drop the ones already cached. Scoped to the two kinds we can 3217 // still recognise afterwards (help request, wave) — a plain public mention 3218 // from someone you follow IS a timeline post and must stay. 3219 try { 3220 const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS ( 3221 SELECT 1 FROM ap_mentions m 3222 WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug 3223 AND (m.help_request = 1 OR m.wave = 1))`).run(); 3224 if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`); 3225 } catch { /* table may predate the columns */ } 3160 3226 let rows = []; 3161 3227 try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ } … … 3456 3522 const rows = db.prepare(` 3457 3523 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility, 3458 i.emoji_json, i.actor_emoji_json, 3524 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json, 3459 3525 p.slug AS post_slug, p.title AS post_title 3460 3526 FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id … … 3466 3532 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at, 3467 3533 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (messages render) 3534 media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json, // rendered like a Krant post 3468 3535 // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages 3469 3536 visibility: r.visibility || 'public', … … 3488 3555 } catch { /* ignore */ } 3489 3556 try { 3490 for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) { 3491 out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at }); 3557 for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at, 3558 emoji_json, actor_emoji_json, media_json, quote_json, embed_json 3559 FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) { 3560 out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at, 3561 // Same trimmings a Krant row has, so Berichten renders the post identically. 3562 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json }); 3492 3563 } 3493 3564 } catch { /* ignore */ } … … 3716 3787 getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio, 3717 3788 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections, 3718 noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,3789 noteVisibility, belongsInTimeline, isRejectedObject, rejectInteraction, interactionReportTarget, 3719 3790 getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched, 3720 3791 }; -
src/services/i18n.js
r2708282 rd9ad6c5 67 67 'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer', 68 68 'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter', 69 'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg. wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',69 'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.', 70 70 'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd', 71 71 'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.', … … 1008 1008 'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin', 1009 1009 'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed', 1010 'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg. wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',1010 'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.', 1011 1011 'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged', 1012 1012 'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.', … … 1943 1943 'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung', 1944 1944 'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen', 1945 'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg. wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',1945 'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.', 1946 1946 'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert', 1947 1947 'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.', -
src/views/pages/news.ejs
r2708282 rd9ad6c5 124 124 .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; } 125 125 126 .tl-content { line-height: 1.55; overflow-wrap: anywhere; } 127 .tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; } 128 .tl-content a { color: var(--accent, #06c); } 129 .tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; } 126 /* .tl-content, .tl-quote* and .tl-media* now live in partials/shared-styles, 127 next to partials/note-body: the markup is shared with Berichten and the 128 Guardian PWA, so the styling has to be too. What stays here is Krant-only. */ 130 129 /* Long posts collapse to a max height with a fade + "read more" (added by JS only when it overflows). */ 131 130 .tl-content.tl-clamp { max-height: 20em; overflow: hidden; … … 137 136 .tl-readmore:hover { text-decoration: underline; } 138 137 139 /* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). */140 .tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;141 border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);142 background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }143 .tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }144 .tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }145 .tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }146 .tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }147 .tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);148 overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }149 .tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }150 .tl-quote-body a { color: var(--accent, #06c); }151 .tl-quote-media { display: block; margin: .45rem 0 0; }152 .tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }153 154 /* Always show the FULL image at its natural ratio — never cropped, no letterbox. */155 .tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }156 .tl-media-img { display: block; border-radius: 12px; overflow: hidden;157 background-image: linear-gradient(135deg,158 color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,159 color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }160 .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }161 .tl-media-img img.is-loading { opacity: 0; }162 /* Reserve placeholder space while the (natural-aspect) feed image loads. */163 .tl-media-img:has(img.is-loading) { min-height: 220px; }164 165 .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }166 .tl-media-video { max-height: 480px; background: #000; }167 138 .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; } 168 139 .tl-poll-form { display: flex; flex-direction: column; gap: 8px; } -
src/views/partials/msg-item.ejs
r2708282 rd9ad6c5 55 55 <% } else if (_t === 'boost') { %><%= t('notif.boosted') %> 56 56 <% } else if (_t === 'report') { %><%= t('notif.reported') %> 57 <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %> 57 58 <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %> 58 59 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> … … 67 68 68 69 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> 69 <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content "><%- emojiHtml(n.content, n.emoji_json) %></div>70 <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div> 70 71 <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %> 71 72 <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px"> -
src/views/partials/shared-styles.ejs
r2708282 rd9ad6c5 202 202 .tl-author img.emoji, .tl-boost-by img.emoji, .tl-quote-name img.emoji, .msg-who img.emoji, .comment-author img.emoji { 203 203 max-width: none; height: 1.3em; border-radius: 0; background: none; box-shadow: none; vertical-align: -0.2em; } 204 205 /* ── The body of a post ──────────────────────────────────────────────────── 206 Shared with partials/note-body.ejs, so a post looks the same in de Krant, in 207 Berichten and in the Guardian PWA. These used to live in the Krant's own 208 <style> block, which is why a post outside de Krant came out unstyled. 209 Krant-only behaviour (the clamp + "read more", the poll, the action bar) 210 stays in pages/news.ejs. */ 211 .tl-content { line-height: 1.55; overflow-wrap: anywhere; } 212 .tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; } 213 .tl-content a { color: var(--accent, #06c); } 214 .tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; } 215 216 /* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). Also carries 217 an external link preview: same card, different origin. */ 218 .tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px; 219 border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent); 220 background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); } 221 .tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; } 222 .tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; } 223 .tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; } 224 .tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } 225 .tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555); 226 overflow-wrap: anywhere; max-height: 16em; overflow: hidden; } 227 .tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; } 228 .tl-quote-body a { color: var(--accent, #06c); } 229 .tl-quote-media { display: block; margin: .45rem 0 0; } 230 .tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; } 231 232 /* Always show the FULL image at its natural ratio — never cropped, no letterbox. */ 233 .tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; } 234 .tl-media-img { display: block; border-radius: 12px; overflow: hidden; 235 background-image: linear-gradient(135deg, 236 color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%, 237 color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); } 238 .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; } 239 .tl-media-img img.is-loading { opacity: 0; } 240 /* Reserve placeholder space while the (natural-aspect) feed image loads. */ 241 .tl-media-img:has(img.is-loading) { min-height: 220px; } 242 .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; } 243 .tl-media-video { max-height: 480px; background: #000; } 244 245 /* In Berichten a post sits inside a notification row, so it starts tighter and 246 its media stays modest: the row is a pointer to the post, not the post page. */ 247 .msg-note .tl-content { line-height: 1.5; } 248 .msg-note .tl-quote, .msg-note .tl-media, .msg-note .tl-media-video, .msg-note .tl-media-audio { margin-top: .5rem; } 249 .msg-note .tl-media-img:has(img.is-loading) { min-height: 120px; } 250 .msg-note .tl-media-video { max-height: 320px; } 204 251 </style> -
src/views/partials/tl-item.ejs
r2708282 rd9ad6c5 10 10 </div> 11 11 12 <% 13 var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; } 14 var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); }); 15 var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); }); 16 var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); }); 17 var _nsfwVisual = !!p.nsfw && (imgs.length || vids.length || auds.length); 18 var _nsfwText = !!p.nsfw && !(imgs.length || vids.length || auds.length); 19 %> 20 <% if (_nsfwText) { %> 21 <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- emojiHtml(p.content, p.emoji_json) %></div> 22 <% } else { %> 23 <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div> 24 <% } %> 25 <% 26 // One card for both: a fediverse quote and an external embed look 27 // identical; only where they came from differs. An embed carries a 28 // title and a thumbnail, never an iframe. 29 var _quote = noteQuote(p.quote_json); 30 if (!_quote) { 31 var _emb = noteQuote(p.embed_json); 32 // The title comes from a third-party oEmbed provider, so it is 33 // escaped here: the quote card renders `content` as HTML (fine for 34 // AP content, which we sanitise on the way in, but not for this). 35 if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null), 36 content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] }; 37 } 38 %> 39 <% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %> 40 <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %> 41 <% if (imgs.length) { %> 42 <div class="tl-media"> 43 <% imgs.forEach(function(m){ %> 44 <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a> 45 <% }); %> 46 </div> 47 <% } %> 48 <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %> 49 <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already 50 covers these tracks, so don't ALSO render the raw Audio attachments as bare 51 players here. Posts without a Klonkt embed (e.g. a plain remote audio) keep them. */ %> 52 <% if (!p.embedHtml && !p.embedUrl) { auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %> 53 <% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></div></div><% } %> 12 <% /* Content, quote/preview card and media: shared with Berichten and the 13 Guardian PWA so a post looks the same wherever it turns up. 14 suppressAudio: a Klonkt audio post carries an embed player below 15 that already covers these tracks, so don't ALSO render the raw 16 Audio attachments as bare players. */ %> 17 <%- include('../partials/note-body', { nb: Object.assign({}, p, { suppressAudio: !!(p.embedHtml || p.embedUrl) }) }) %> 54 18 55 19 <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
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)