Changeset 9a00f28 in Klonkt


Ignore:
Timestamp:
07/22/2026 08:18:39 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
6b5d7da
Parents:
96c714f
git-author:
Robin <roboburr@…> (07/22/2026 08:18:19 AM)
git-committer:
Robin <roboburr@…> (07/22/2026 08:18:39 AM)
Message:

Fix: test feedback round, cirkels tagline + report details + full i18n

Three findings from Bart's test pass, tied together by the new i18n keys:

  1. The admin tagline said "Solo mode" even with Cirkels (federation) on. Solo-tenancy now distinguishes: apEnabled -> "Cirkels-modus: jouw site, verbonden met de fediverse", else the solo line. New key in nl/en/de.
  1. Reports only showed who reported; now they show the reason and WHICH post. getNotifications resolves the stored objects URIs of a report: our own /ap/notes/<id> become "Over de post: <title>" links under the description; an empty reason renders "Geen reden opgegeven". Other URIs (the actor itself) are skipped, the row already names the account.
  1. Notificaties and Betaalde posts were hardcoded Dutch on an English-configured Klonkt. Everything now goes through t(): the two admin pages (including their client-side status strings, passed via the JSON data block), the admin menu buttons, and the visitor-facing paid pages (gate, passkey, result). Server-side push payloads translate too, using the SITE's content language (fallback KLONKT_DEFAULT_LANG), and the test ping uses the request language. Full nl/en/de dictionaries.

Changed files:
src/services/i18n.js

  • admin.tagline_cirkels, admin.b_paid/b_push/back, notif.report_about/ report_noreason, and the push.*, apaid.*, pgate.*, ppk.*, pres.* blocks in nl/en/de

src/routes/admin.js, src/views/pages/admin.ejs

  • circlesOn (apEnabled) -> cirkels tagline; menu buttons via t()

src/services/ActivityPubService.js

  • report objects resolved to post links; push payloads via i18nT with pushLang(slug) (site language)

src/views/pages/fedi-notifications.ejs

  • report reason fallback + "Over de post" links (+ styles)

src/routes/admin-push.js, src/views/pages/admin-push.ejs

  • pageTitleKey push.t; all copy + JS status strings via t()

src/routes/admin-paid.js, src/views/pages/admin-paid.ejs

  • pageTitleKey apaid.t; all copy via t(), copy-button label via data-attr

src/routes/paid.js

  • pageTitleKey pres.t / ppk.t

src/views/pages/paid-gate.ejs, paid-passkey.ejs, paid-result.ejs

  • visitor copy + JS strings via t()

src/routes/push.js

  • test notification via resolveLang(req)

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Location:
src
Files:
14 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-paid.js

    r96c714f r9a00f28  
    3636  if (!gate(req, res)) return;
    3737  renderPage(req, res, 'pages/admin-paid', {
    38     pageTitle: 'Betaalde posts',
     38    pageTitleKey: 'apaid.t',
    3939    bodyClass: 'on-admin',
    4040    status: PaidPatreon.ownerStatus(res.locals.site.id),
  • src/routes/admin-push.js

    r96c714f r9a00f28  
    1212router.get('/', requireSiteManager, async (req, res) => {
    1313  renderPage(req, res, 'pages/admin-push', {
    14     pageTitle: 'Notificaties',
     14    pageTitleKey: 'push.t',
    1515    bodyClass: 'on-admin',
    1616    vapidKey: await Push.publicKey(),          // null → feature unavailable
  • src/routes/admin.js

    r96c714f r9a00f28  
    99import { renderPage } from '../middleware/render.js';
    1010import { requireAuth } from '../middleware/auth.js';
    11 import { getTenancy } from '../services/SettingsService.js';
     11import { getTenancy, apEnabled } from '../services/SettingsService.js';
    1212import { getPrimarySite } from '../middleware/site.js';
    1313
     
    9696    bodyClass: 'on-admin',
    9797    tenancy,
     98    circlesOn: apEnabled(),   // solo vs cirkels tagline (federatie aan/uit)
    9899    primarySite,
    99100    stats,
  • src/routes/paid.js

    r96c714f r9a00f28  
    5454  if (!payload || payload.purpose !== 'patron' || payload.siteId !== r.site.id) {
    5555    return renderPage(req, res, 'pages/paid-result', {
    56       pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'expired',
     56      pageTitleKey: 'pres.t', bodyClass: 'on-special', ok: false, reason: 'expired',
    5757    });
    5858  }
     
    6060  const code = String(req.query.code || '');
    6161  if (req.query.error || !code) {
    62     return renderPage(req, res, 'pages/paid-result', { pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
     62    return renderPage(req, res, 'pages/paid-result', { pageTitleKey: 'pres.t', bodyClass: 'on-special', ok: false, reason: 'declined', postSlug: payload.post, patronUrl });
    6363  }
    6464  const membership = await PaidPatreon.verifyPatron(r.site.id, code, baseUrl(req) + '/paid/callback').catch(() => null);
     
    6767  if (!active || cents < payload.cents) {
    6868    return renderPage(req, res, 'pages/paid-result', {
    69       pageTitle: 'Ontgrendelen', bodyClass: 'on-special', ok: false,
     69      pageTitleKey: 'pres.t', bodyClass: 'on-special', ok: false,
    7070      reason: active ? 'tier' : 'notpatron', neededCents: payload.cents, haveCents: cents, postSlug: payload.post, patronUrl,
    7171    });
     
    7676  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
    7777  renderPage(req, res, 'pages/paid-passkey', {
    78     pageTitle: 'Maak je passkey', bodyClass: 'on-special',
     78    pageTitleKey: 'ppk.t', bodyClass: 'on-special',
    7979    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
    8080  });
  • src/routes/push.js

    r96c714f r9a00f28  
    99import { requireAuth } from '../middleware/auth.js';
    1010import Push from '../services/PushService.js';
     11import { t as i18nT, resolveLang } from '../services/i18n.js';
    1112
    1213const router = express.Router();
     
    5556// A test ping to all of the caller's own devices (bypasses alert prefs).
    5657router.post('/test', requireAuth, async (req, res) => {
     58  const L = resolveLang(req);
    5759  const sent = await Push.notifyUser(req.session.user.id, {
    58     type: 'test', title: 'Klonkt-testnotificatie',
    59     body: 'Werkt. Zo komen meldingen binnen op dit apparaat.', url: '/admin/push',
     60    type: 'test', title: i18nT(L, 'push.n_test_t'),
     61    body: i18nT(L, 'push.n_test_b'), url: '/admin/push',
    6062  });
    6163  res.json({ ok: true, sent });
  • src/services/ActivityPubService.js

    r96c714f r9a00f28  
    2424import Push from './PushService.js';
    2525import { getTenancy } from './SettingsService.js';
     26import { t as i18nT } from './i18n.js';
    2627
    2728const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     
    12731274  try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; }
    12741275}
     1276// Notification language: the site's content language (fallback: instance default).
     1277function pushLang(slug) {
     1278  try { const r = db.prepare('SELECT language FROM sites WHERE slug = ?').get(slug); return (r && r.language) || process.env.KLONKT_DEFAULT_LANG || 'nl'; } catch { return 'nl'; }
     1279}
    12751280// Site slug, target URL and title for a post-scoped notification.
    12761281function pushPostCtx(postId) {
     
    13431348    fStmts().ins.run(slug, who, remote.inbox, sharedInbox, fi.name, fi.handle, fi.icon);
    13441349    try { _updFDisp.run(fi.name, fi.handle, fi.icon, slug, who); } catch { /* best effort */ }
    1345     pushEvent(slug, { type: 'follow', title: 'Nieuwe volger', body: `${fi.name || fi.handle || 'Iemand'} volgt je nu`, url: `${pushPrefix(slug)}/connect` });
     1350    { const L = pushLang(slug); pushEvent(slug, { type: 'follow', title: i18nT(L, 'push.n_follow_t'), body: i18nT(L, 'push.n_follow_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(slug)}/connect` }); }
    13461351    const me = actorId(base, slug);
    13471352    const keys = getOrCreateKeys(slug);
     
    14131418        const vis = noteVisibility(o);
    14141419        const priv = vis === 'direct' || vis === 'followers';
    1415         const who = ai.name || ai.handle || 'Iemand';
    14161420        if (ctx) {
    1417           if (priv) pushEvent(ctx.site, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(ctx.site)}/messages` });
    1418           else pushEvent(ctx.site, { type: 'reply', title: `Reactie op "${ctx.title}"`, body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
     1421          const L = pushLang(ctx.site);
     1422          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
     1423          if (priv) pushEvent(ctx.site, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(ctx.site)}/messages` });
     1424          else pushEvent(ctx.site, { type: 'reply', title: i18nT(L, 'push.n_reply_t', { title: ctx.title }), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: ctx.url });
    14191425        }
    14201426      }
     
    14651471              const vis = noteVisibility(o);
    14661472              const priv = vis === 'direct' || vis === 'followers';
    1467               const who = ai.name || ai.handle || 'Iemand';
     1473              const L = pushLang(slug);
     1474              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
    14681475              // Same privacy rule as replies: private mentions push without content.
    1469               if (priv) pushEvent(slug, { type: 'dm', title: 'Privébericht', body: `Nieuw bericht van ${who}`, url: `${pushPrefix(slug)}/messages` });
    1470               else pushEvent(slug, { type: 'reply', title: 'Vermelding', body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
     1476              if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
     1477              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
    14711478            }
    14721479          } catch { /* ignore */ }
     
    15261533      {
    15271534        const ctx = pushPostCtx(pid);
    1528         const who = ai.name || ai.handle || 'Iemand';
    15291535        if (ctx) {
    1530           if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: 'Nieuwe waardering', body: `${who} waardeerde "${ctx.title}"`, url: ctx.url });
    1531           else pushEvent(ctx.site, { type: 'boost', title: 'Geboost', body: `${who} boostte "${ctx.title}"`, url: ctx.url });
     1536          const L = pushLang(ctx.site);
     1537          const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
     1538          if (type === 'Like') pushEvent(ctx.site, { type: 'like', title: i18nT(L, 'push.n_like_t'), body: i18nT(L, 'push.n_like_b', { who, title: ctx.title }), url: ctx.url });
     1539          else pushEvent(ctx.site, { type: 'boost', title: i18nT(L, 'push.n_boost_t'), body: i18nT(L, 'push.n_boost_b', { who, title: ctx.title }), url: ctx.url });
    15321540        }
    15331541      }
     
    29692977  } catch { /* ignore */ }
    29702978  try {
    2971     for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
    2972       out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, created_at: r.created_at });
     2979    for (const r of db.prepare('SELECT actor_uri, actor_name, actor_handle, actor_icon, content, objects, created_at FROM ap_reports WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
     2980      // The reported objects: our own notes resolve to post links so the owner
     2981      // sees WHICH post the report is about; other URIs (e.g. the actor itself)
     2982      // are skipped — the report row already names the account.
     2983      const about = [];
     2984      try {
     2985        for (const u of JSON.parse(r.objects || '[]')) {
     2986          const m = String(u).match(/\/ap\/notes\/([^/?#]+)/);
     2987          if (!m) continue;
     2988          const p = db.prepare('SELECT slug, title FROM posts WHERE id = ?').get(decodeURIComponent(m[1]));
     2989          if (p) about.push({ slug: p.slug, title: p.title || p.slug });
     2990        }
     2991      } catch { /* malformed objects json → no links */ }
     2992      out.push({ type: 'report', name: r.actor_name, handle: r.actor_handle, url: r.actor_uri, icon: r.actor_icon, content: r.content, objects: about, created_at: r.created_at });
    29732993    }
    29742994  } catch { /* ignore */ }
  • src/services/i18n.js

    r96c714f r9a00f28  
    2828    'nav.language': 'Taal',
    2929    'nav.notifications': 'Meldingen',
    30     'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'notif.mentioned': 'noemde je in een post', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
     30    'notif.title': 'Meldingen', 'notif.empty': 'Nog geen meldingen.', 'notif.someone': 'Iemand', 'notif.followed': 'volgt je nu', 'notif.liked': 'likete je post', 'notif.boosted': 'boostte je post', 'notif.replied': 'reageerde op', 'notif.reported': 'rapporteerde je bij hun server', 'notif.report_about': 'Over de post', 'notif.report_noreason': 'Geen reden opgegeven.', 'notif.mentioned': 'noemde je in een post', 'blk.title': 'Blokkeren', 'blk.lead': 'Blokkeer een account of een heel domein — hun reacties, likes en posts verdwijnen en nieuwe worden geweigerd.', 'blk.block_btn': 'Blokkeren', 'blk.empty': 'Niks geblokkeerd.', 'blk.unblock': 'Deblokkeren', 'tl.block': 'Blokkeer',
    3131    'notif.reply': '{actor} reageerde op je reactie', 'notif.comment': '{actor} reageerde op je post', 'notif.like': '{actor} vindt je post leuk',
    3232    'switch.agenda': 'Agenda',
     
    6464    'adash.prem_layeroff': 'Premium-laag uit',
    6565    'admin.tagline_solo': 'Solo-modus — jouw site.',
     66    'admin.tagline_cirkels': 'Cirkels-modus: jouw site, verbonden met de fediverse.',
     67    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
     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}"', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     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    '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.',
     72    'ppk.t': 'Maak je passkey', 'ppk.h': 'Je bent supporter, mooi.', 'ppk.sub': 'Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder account en zonder cookie. We bewaren geen naam of e-mailadres.', 'ppk.make': 'Maak passkey', 'ppk.unsupported': 'Passkeys worden niet ondersteund in deze browser.', 'ppk.follow': 'Volg de vraag van je apparaat…', 'ppk.done': 'Gelukt. Je passkey is aangemaakt.', 'ppk.failed': 'Aanmaken mislukt ({err}). Probeer opnieuw.', 'ppk.cancelled': 'Geannuleerd.',
     73    'pres.t': 'Ontgrendelen', 'pres.notpatron_h': 'Nog geen supporter', 'pres.notpatron_p': 'Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het daarna opnieuw vanaf de post.', 'pres.tier_h': 'Een niveau hoger nodig', 'pres.tier_p': 'Deze post vraagt vanaf €{need}. Jouw steun is nu €{have}. Verhoog je steun en probeer opnieuw.', 'pres.expired_h': 'Aanvraag verlopen', 'pres.expired_p': 'Deze ontgrendel-link is verlopen of al gebruikt. Ga terug naar de post en probeer het opnieuw.', 'pres.declined_h': 'Ontgrendelen afgebroken', 'pres.declined_p': 'Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.', 'pres.join': 'Word supporter op Patreon', 'pres.back_post': 'Terug naar de post', 'pres.back_site': 'Terug naar de site',
    6674    'admin.tagline_hub': 'Hub-modus — bedrijfssite met gebruikers, elk hun eigen Klonkt Hub.',
    6775    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Gebruikers', 'admin.b_audio': '🎵 Audio',
     
    964972    'nav.language': 'Language',
    965973    'nav.notifications': 'Notifications',
    966     'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'notif.mentioned': 'mentioned you in a post', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
     974    'notif.title': 'Notifications', 'notif.empty': 'No notifications yet.', 'notif.someone': 'Someone', 'notif.followed': 'followed you', 'notif.liked': 'liked your post', 'notif.boosted': 'boosted your post', 'notif.replied': 'replied to', 'notif.reported': 'reported you to their server', 'notif.report_about': 'About the post', 'notif.report_noreason': 'No reason given.', 'notif.mentioned': 'mentioned you in a post', 'blk.title': 'Blocking', 'blk.lead': 'Block an account or a whole domain — their replies, likes and posts disappear and new ones are refused.', 'blk.block_btn': 'Block', 'blk.empty': 'Nothing blocked.', 'blk.unblock': 'Unblock', 'tl.block': 'Block',
    967975    'notif.reply': '{actor} replied to your comment', 'notif.comment': '{actor} commented on your post', 'notif.like': '{actor} liked your post',
    968976    'switch.agenda': 'Agenda',
     
    9971005    'adash.prem_layeroff': 'Premium layer off',
    9981006    'admin.tagline_solo': 'Solo mode — your site.',
     1007    'admin.tagline_cirkels': 'Circles mode: your site, connected to the fediverse.',
     1008    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
     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}"', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     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    '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.',
     1013    'ppk.t': 'Create your passkey', 'ppk.h': 'You are a supporter, nice.', 'ppk.sub': 'Now create a passkey. It becomes your key for paid posts, without an account and without a cookie. We store no name or email address.', 'ppk.make': 'Create passkey', 'ppk.unsupported': 'Passkeys are not supported in this browser.', 'ppk.follow': 'Follow the prompt on your device…', 'ppk.done': 'Done. Your passkey has been created.', 'ppk.failed': 'Creating failed ({err}). Try again.', 'ppk.cancelled': 'Cancelled.',
     1014    'pres.t': 'Unlock', 'pres.notpatron_h': 'Not a supporter yet', 'pres.notpatron_p': 'You are not (yet) an active supporter of this site on Patreon. Become a supporter and then try again from the post.', 'pres.tier_h': 'A higher tier is needed', 'pres.tier_p': 'This post asks from €{need}. Your support is currently €{have}. Raise your support and try again.', 'pres.expired_h': 'Request expired', 'pres.expired_p': 'This unlock link has expired or was already used. Go back to the post and try again.', 'pres.declined_h': 'Unlocking cancelled', 'pres.declined_p': 'Nothing was connected. You can try again from the post.', 'pres.join': 'Become a supporter on Patreon', 'pres.back_post': 'Back to the post', 'pres.back_site': 'Back to the site',
    9991015    'admin.tagline_hub': 'Hub mode — a company site with users, each their own Klonkt Hub.',
    10001016    'admin.b_sites': '🌐 Sites', 'admin.b_users': '👥 Users', 'admin.b_audio': '🎵 Audio',
     
    18911907    'nav.language': 'Sprache',
    18921908    'nav.notifications': 'Benachrichtigungen',
    1893     'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'notif.mentioned': 'hat dich in einem Beitrag erwähnt', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
     1909    'notif.title': 'Benachrichtigungen', 'notif.empty': 'Noch keine Benachrichtigungen.', 'notif.someone': 'Jemand', 'notif.followed': 'folgt dir jetzt', 'notif.liked': 'gefällt dein Beitrag', 'notif.boosted': 'teilte deinen Beitrag', 'notif.replied': 'antwortete auf', 'notif.reported': 'hat dich bei ihrem Server gemeldet', 'notif.report_about': 'Zum Beitrag', 'notif.report_noreason': 'Kein Grund angegeben.', 'notif.mentioned': 'hat dich in einem Beitrag erwähnt', 'blk.title': 'Blockieren', 'blk.lead': 'Blockiere ein Konto oder eine ganze Domain — ihre Antworten, Likes und Beiträge verschwinden und neue werden abgelehnt.', 'blk.block_btn': 'Blockieren', 'blk.empty': 'Nichts blockiert.', 'blk.unblock': 'Entsperren', 'tl.block': 'Blockieren',
    18941910    'notif.reply': '{actor} hat auf deinen Kommentar geantwortet', 'notif.comment': '{actor} hat deinen Beitrag kommentiert', 'notif.like': '{actor} gefällt dein Beitrag',
    18951911    'switch.agenda': 'Termine',
     
    19241940    'adash.prem_layeroff': 'Premium-Ebene aus',
    19251941    'admin.tagline_solo': 'Solo-Modus — deine Seite.',
     1942    'admin.tagline_cirkels': 'Cirkel-Modus: deine Seite, verbunden mit dem Fediverse.',
     1943    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
     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', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     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    '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.',
     1948    'ppk.t': 'Erstelle deinen Passkey', 'ppk.h': 'Du bist Unterstützer, schön.', 'ppk.sub': 'Erstelle jetzt einen Passkey. Er wird dein Schlüssel für bezahlte Beiträge, ohne Konto und ohne Cookie. Wir speichern keinen Namen und keine E-Mail-Adresse.', 'ppk.make': 'Passkey erstellen', 'ppk.unsupported': 'Passkeys werden in diesem Browser nicht unterstützt.', 'ppk.follow': 'Folge der Abfrage deines Geräts…', 'ppk.done': 'Geschafft. Dein Passkey wurde erstellt.', 'ppk.failed': 'Erstellen fehlgeschlagen ({err}). Versuch es erneut.', 'ppk.cancelled': 'Abgebrochen.',
     1949    'pres.t': 'Entsperren', 'pres.notpatron_h': 'Noch kein Unterstützer', 'pres.notpatron_p': 'Du bist (noch) kein aktiver Unterstützer dieser Seite auf Patreon. Werde Unterstützer und versuch es danach erneut vom Beitrag aus.', 'pres.tier_h': 'Eine Stufe höher nötig', 'pres.tier_p': 'Dieser Beitrag verlangt ab €{need}. Deine Unterstützung ist derzeit €{have}. Erhöhe deine Unterstützung und versuch es erneut.', 'pres.expired_h': 'Anfrage abgelaufen', 'pres.expired_p': 'Dieser Entsperr-Link ist abgelaufen oder wurde schon benutzt. Geh zurück zum Beitrag und versuch es erneut.', 'pres.declined_h': 'Entsperren abgebrochen', 'pres.declined_p': 'Es wurde nichts verbunden. Du kannst es vom Beitrag aus erneut versuchen.', 'pres.join': 'Unterstützer werden auf Patreon', 'pres.back_post': 'Zurück zum Beitrag', 'pres.back_site': 'Zurück zur Seite',
    19261950    'admin.tagline_hub': 'Hub-Modus — eine Firmenseite mit Nutzern, je ein eigener Klonkt Hub.',
    19271951    'admin.b_sites': '🌐 Seiten', 'admin.b_users': '👥 Nutzer', 'admin.b_audio': '🎵 Audio',
  • src/views/pages/admin-paid.ejs

    r96c714f r9a00f28  
    11<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
    2   <h1 style="margin:0 0 .3rem">Betaalde posts</h1>
     2  <h1 style="margin:0 0 .3rem"><%= t('apaid.t') %></h1>
    33  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
    4     Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey,
    5     zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters,
    6     alleen het versleutelde token van jouw campagne.
     4    <%= t('apaid.intro') %>
    75  </p>
    86
    9   <% if (saved) { %><p style="color:var(--accent);font-weight:600">Opgeslagen.</p><% } %>
     7  <% if (saved) { %><p style="color:var(--accent);font-weight:600"><%= t('apaid.saved') %></p><% } %>
    108  <% if (error) { %><p style="color:#c0392b"><%= error %></p><% } %>
    119  <% if (!secretReady) { %>
    12     <p style="color:#c0392b">Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.</p>
     10    <p style="color:#c0392b"><%= t('apaid.nokey') %></p>
    1311  <% } %>
    1412
    1513  <p style="margin:.2rem 0 1.2rem">
    16     Status:
     14    <%= t('apaid.status') %>
    1715    <% if (status.connected) { %>
    18       <strong style="color:var(--accent)">verbonden</strong> (campagne <%= status.campaignId %>)
     16      <strong style="color:var(--accent)"><%= t('apaid.connected') %></strong> (<%= t('apaid.campaign') %> <%= status.campaignId %>)
    1917    <% } else if (status.configured) { %>
    20       <strong>ingesteld, nog niet verbonden</strong> (vul een token in)
     18      <strong><%= t('apaid.configured') %></strong>
    2119    <% } else { %>
    22       <strong>nog niet ingesteld</strong>
     20      <strong><%= t('apaid.notyet') %></strong>
    2321    <% } %>
    2422  </p>
    2523
    2624  <div style="border:1px solid var(--border,#333);border-radius:10px;padding:.9rem 1rem;margin:0 0 1.3rem;background:color-mix(in srgb,var(--accent,#6b8f71) 8%,transparent)">
    27     <p style="margin:0 0 .4rem;font-weight:600">Zet deze redirect-URI in je Patreon-client</p>
     25    <p style="margin:0 0 .4rem;font-weight:600"><%= t('apaid.redirect_h') %></p>
    2826    <p style="margin:0 0 .6rem;color:var(--ink-soft,#888);font-size:.9rem">
    29       Bij je Patreon API-client, onder <em>Redirect URIs</em>, moet exact deze regel staan.
    30       Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.
     27      <%= t('apaid.redirect_p') %>
    3128    </p>
    3229    <div style="display:flex;gap:.5rem;align-items:center">
    3330      <input id="pd-redirect" type="text" readonly value="<%= redirectUri %>" style="flex:1;min-width:0;font-family:monospace;font-size:.9rem">
    34       <button type="button" id="pd-copy" class="btn">Kopieer</button>
     31      <button type="button" id="pd-copy" class="btn" data-copied="<%= t('apaid.copied') %>"><%= t('apaid.copy') %></button>
    3532    </div>
    3633  </div>
    3734
    3835  <form method="post" action="/admin/paid" style="display:flex;flex-direction:column;gap:.9rem">
    39     <label>Patreon client id
     36    <label><%= t('apaid.client_id') %>
    4037      <input type="text" name="client_id" value="<%= status.clientId || '' %>" autocomplete="off" style="width:100%">
    4138    </label>
    42     <label>Patreon client secret
    43       <input type="password" name="client_secret" placeholder="<%= status.hasSecret ? 'blijft ongewijzigd' : '' %>" autocomplete="off" style="width:100%">
    44       <small style="color:var(--ink-soft,#888)">Leeg laten = huidige waarde behouden.</small>
     39    <label><%= t('apaid.client_secret') %>
     40      <input type="password" name="client_secret" placeholder="<%= status.hasSecret ? t('apaid.unchanged') : '' %>" autocomplete="off" style="width:100%">
     41      <small style="color:var(--ink-soft,#888)"><%= t('apaid.keep') %></small>
    4542    </label>
    46     <label>Campagne-id
     43    <label><%= t('apaid.campaign_id') %>
    4744      <input type="text" name="campaign_id" value="<%= status.campaignId || '' %>" autocomplete="off" style="width:100%">
    4845    </label>
    49     <label>Openbare Patreon-pagina
     46    <label><%= t('apaid.public_page') %>
    5047      <input type="url" name="patreon_url" value="<%= status.patreonUrl || '' %>" placeholder="https://www.patreon.com/jouwnaam" autocomplete="off" style="width:100%">
    51       <small style="color:var(--ink-soft,#888)">De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.</small>
     48      <small style="color:var(--ink-soft,#888)"><%= t('apaid.public_help') %></small>
    5249    </label>
    53     <label>Creator access token
    54       <input type="password" name="access_token" placeholder="blijft ongewijzigd" autocomplete="off" style="width:100%">
     50    <label><%= t('apaid.access') %>
     51      <input type="password" name="access_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
    5552    </label>
    56     <label>Creator refresh token
    57       <input type="password" name="refresh_token" placeholder="blijft ongewijzigd" autocomplete="off" style="width:100%">
    58       <small style="color:var(--ink-soft,#888)">De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.</small>
     53    <label><%= t('apaid.refresh') %>
     54      <input type="password" name="refresh_token" placeholder="<%= t('apaid.unchanged') %>" autocomplete="off" style="width:100%">
     55      <small style="color:var(--ink-soft,#888)"><%= t('apaid.token_help') %></small>
    5956    </label>
    60     <label>Standaard-steunbedrag voor een betaalde post (euro)
     57    <label><%= t('apaid.min_eur') %>
    6158      <input type="text" name="default_min_eur" value="<%= status.defaultMinCents ? (status.defaultMinCents/100).toFixed(2) : '' %>" inputmode="decimal" style="width:120px">
    6259    </label>
    6360    <div style="display:flex;gap:.6rem;align-items:center">
    64       <button type="submit" class="btn btn-primary">Opslaan</button>
     61      <button type="submit" class="btn btn-primary"><%= t('apaid.save') %></button>
    6562    </div>
    6663  </form>
    6764
    6865  <% if (status.configured || status.connected) { %>
    69     <form method="post" action="/admin/paid/disconnect" data-confirm="Patreon-koppeling verwijderen?" style="margin-top:1rem">
    70       <button type="submit" class="btn btn-danger">Koppeling verwijderen</button>
     66    <form method="post" action="/admin/paid/disconnect" data-confirm="<%= t('apaid.disconnect_confirm') %>" style="margin-top:1rem">
     67      <button type="submit" class="btn btn-danger"><%= t('apaid.disconnect') %></button>
    7168    </form>
    7269  <% } %>
    7370
    74   <p style="margin-top:1.5rem"><a href="/admin">&larr; Terug naar Beheer</a></p>
     71  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
    7572</div>
    7673
     
    8380  btn.addEventListener('click', function () {
    8481    field.select();
    85     var done = function () { var t = btn.textContent; btn.textContent = 'Gekopieerd'; setTimeout(function () { btn.textContent = t; }, 1400); };
     82    var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
    8683    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
    8784    else { try { document.execCommand('copy'); done(); } catch (e) {} }
  • src/views/pages/admin-push.ejs

    r96c714f r9a00f28  
    11<div class="container" style="max-width:640px;margin:1.5rem auto 3rem;padding:0 1rem">
    2   <h1 style="margin:0 0 .3rem">Notificaties</h1>
     2  <h1 style="margin:0 0 .3rem"><%= t('push.t') %></h1>
    33  <p style="color:var(--ink-soft,#888);margin:0 0 1.2rem">
    4     Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten,
    5     ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo
    6     min mogelijk inhoud mee.
     4    <%= t('push.intro') %>
    75  </p>
    86
    97  <% if (!vapidKey) { %>
    10     <p style="color:#c0392b">Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).</p>
     8    <p style="color:#c0392b"><%= t('push.unavailable') %></p>
    119  <% } else { %>
    1210
    1311  <div id="np-unsupported" hidden style="color:#c0392b;margin:0 0 1rem">
    14     Deze browser ondersteunt geen push-notificaties.
     12    <%= t('push.unsupported') %>
    1513  </div>
    1614  <div id="np-ios-hint" hidden style="border:1px solid var(--border,#333);border-radius:10px;padding:.8rem 1rem;margin:0 0 1rem;font-size:.92rem;color:var(--ink-soft,#888)">
    17     Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat:
    18     deel-knop → <em>Zet op beginscherm</em>, en open 'm daarna vanaf daar.
     15    <%= t('push.ios_hint') %>
    1916  </div>
    2017
    2118  <section style="border:1px solid var(--border,#333);border-radius:12px;padding:1rem 1.1rem;margin:0 0 1.2rem">
    22     <p style="margin:0 0 .6rem"><strong>Dit apparaat:</strong> <span id="np-state">controleren…</span></p>
     19    <p style="margin:0 0 .6rem"><strong><%= t('push.this_device') %></strong> <span id="np-state"><%= t('push.checking') %></span></p>
    2320    <div style="display:flex;gap:.6rem;flex-wrap:wrap">
    24       <button type="button" id="np-on" class="btn btn-primary" hidden>Zet aan op dit apparaat</button>
    25       <button type="button" id="np-off" class="btn" hidden>Zet uit</button>
    26       <button type="button" id="np-test" class="btn" hidden>Stuur testmelding</button>
     21      <button type="button" id="np-on" class="btn btn-primary" hidden><%= t('push.enable') %></button>
     22      <button type="button" id="np-off" class="btn" hidden><%= t('push.disable') %></button>
     23      <button type="button" id="np-test" class="btn" hidden><%= t('push.test') %></button>
    2724    </div>
    2825    <fieldset id="np-alerts" hidden style="border:none;padding:0;margin:1rem 0 0">
    29       <legend style="font-weight:600;margin-bottom:.4rem">Waarvoor wil je een melding?</legend>
    30       <label style="display:block"><input type="checkbox" data-alert="follow"> Nieuwe volger</label>
    31       <label style="display:block"><input type="checkbox" data-alert="reply"> Reactie of vermelding</label>
    32       <label style="display:block"><input type="checkbox" data-alert="like"> Waardering (ster)</label>
    33       <label style="display:block"><input type="checkbox" data-alert="boost"> Boost</label>
    34       <label style="display:block"><input type="checkbox" data-alert="dm"> Priv&eacute;bericht</label>
    35       <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0">Opgeslagen.</p>
     26      <legend style="font-weight:600;margin-bottom:.4rem"><%= t('push.what') %></legend>
     27      <label style="display:block"><input type="checkbox" data-alert="follow"> <%= t('push.a_follow') %></label>
     28      <label style="display:block"><input type="checkbox" data-alert="reply"> <%= t('push.a_reply') %></label>
     29      <label style="display:block"><input type="checkbox" data-alert="like"> <%= t('push.a_like') %></label>
     30      <label style="display:block"><input type="checkbox" data-alert="boost"> <%= t('push.a_boost') %></label>
     31      <label style="display:block"><input type="checkbox" data-alert="dm"> <%= t('push.a_dm') %></label>
     32      <p id="np-saved" hidden style="color:var(--accent);margin:.4rem 0 0"><%= t('push.saved') %></p>
    3633    </fieldset>
    3734  </section>
    3835
    3936  <% if (subscriptions.length) { %>
    40     <h2 style="font-size:1.05rem;margin:0 0 .5rem">Gekoppelde apparaten</h2>
     37    <h2 style="font-size:1.05rem;margin:0 0 .5rem"><%= t('push.devices') %></h2>
    4138    <ul style="list-style:none;padding:0;margin:0 0 1.2rem">
    4239      <% subscriptions.forEach(function (s) { %>
    4340        <li data-endpoint="<%= s.endpoint %>" style="display:flex;justify-content:space-between;gap:.8rem;align-items:center;padding:.45rem 0;border-bottom:1px solid var(--border,#2a2a2a)">
    44           <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || 'Apparaat' %> <small style="color:var(--ink-soft,#888)">sinds <%= String(s.created_at || '').slice(0, 10) %></small></span>
    45           <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>">Verwijder</button>
     41          <span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><%= s.ua_label || t('push.device') %> <small style="color:var(--ink-soft,#888)"><%= t('push.since') %> <%= String(s.created_at || '').slice(0, 10) %></small></span>
     42          <button type="button" class="btn np-remove" data-endpoint="<%= s.endpoint %>"><%= t('push.remove') %></button>
    4643        </li>
    4744      <% }); %>
     
    5148  <% } %>
    5249
    53   <p style="margin-top:1.5rem"><a href="/admin">&larr; Terug naar Beheer</a></p>
     50  <p style="margin-top:1.5rem"><a href="/admin">&larr; <%= t('admin.back') %></a></p>
    5451</div>
    5552
     
    5956  alerts: defaultAlerts,
    6057  saved: subscriptions.reduce(function (m, s) { try { m[s.endpoint] = JSON.parse(s.alert_types || '{}'); } catch (e) { m[s.endpoint] = {}; } return m; }, {}),
     58  i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
    6159}).replace(/</g, '\\u003c') %></script>
    6260<script>
     
    7472  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
    7573    document.getElementById('np-unsupported').hidden = false;
    76     elState.textContent = 'niet ondersteund';
     74    elState.textContent = cfg.i18n.unsupported;
    7775    return;
    7876  }
     
    107105  function render(sub) {
    108106    currentEndpoint = sub ? sub.endpoint : null;
    109     elState.textContent = sub ? 'meldingen staan aan' : (Notification.permission === 'denied' ? 'geblokkeerd in de browserinstellingen' : 'meldingen staan uit');
     107    elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
    110108    btnOn.hidden = !!sub || Notification.permission === 'denied';
    111109    btnOff.hidden = !sub;
     
    120118    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
    121119    render(sub);
    122   }).catch(function () { elState.textContent = 'status onbekend'; });
     120  }).catch(function () { elState.textContent = cfg.i18n.unknown; });
    123121
    124122  btnOn.addEventListener('click', function () {
     
    131129        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
    132130          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
    133       }).catch(function () { btnOn.disabled = false; elState.textContent = 'aanzetten mislukt'; });
     131      }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
    134132    });
    135133  });
  • src/views/pages/admin.ejs

    r96c714f r9a00f28  
    1919  <% if (tenancy === 'hub') { %>
    2020    <p class="admin-tagline"><%= t('admin.tagline_hub') %></p>
     21  <% } else if (typeof circlesOn !== 'undefined' && circlesOn) { %>
     22    <p class="admin-tagline"><%= t('admin.tagline_cirkels') %></p>
    2123  <% } else { %>
    2224    <p class="admin-tagline"><%= t('admin.tagline_solo') %></p>
     
    4648    <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    4749      <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a>
    48       <a href="/admin/paid" class="btn">Betaalde posts</a>
     50      <a href="/admin/paid" class="btn"><%= t('admin.b_paid') %></a>
    4951      <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a>
    5052      <% if (tenancy !== 'hub' && primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>
     
    5557    <%# Fediverse pages (News / Notifications / My replies / Blocking) are reached via the
    5658        "Fediverse" nav button + the bell + the tabbed section — not duplicated here. %>
    57     <a href="/admin/push" class="btn">Notificaties</a>
     59    <a href="/admin/push" class="btn"><%= t('admin.b_push') %></a>
    5860    <a href="/admin/updates" class="btn"><%= t('admin.b_updates') %></a>
    5961    <a href="/admin/handleiding" class="btn"><%= t('admin.b_help') %></a>
  • src/views/pages/fedi-notifications.ejs

    r96c714f r9a00f28  
    3434              <% if (n.type === 'mention' && n.note_url) { %><a class="nt-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    3535            </div>
    36             <% if (n.type === 'report' && n.content) { %><div class="nt-content"><%= n.content %></div>
     36            <% if (n.type === 'report') { %>
     37              <% if (n.content) { %><div class="nt-content"><%= n.content %></div><% } else { %><div class="nt-content nt-noreason"><%= t('notif.report_noreason') %></div><% } %>
     38              <% if (n.objects && n.objects.length) { %>
     39                <div class="nt-report-about">
     40                  <% n.objects.forEach(function (o) { %>
     41                    <a class="nt-post" href="/<%= o.slug %>"><%= t('notif.report_about') %>: <%= o.title %></a>
     42                  <% }); %>
     43                </div>
     44              <% } %>
    3745            <% } else if ((n.type === 'reply' || n.type === 'mention') && n.content) { %><div class="nt-content"><%- n.content %></div><% } %>
    3846          </div>
     
    5866    display: inline-flex; align-items: center; justify-content: center; }
    5967  .nt-icon svg { width: 18px; height: 18px; }
     68  .nt-report-about { margin-top: .35rem; display: flex; flex-direction: column; gap: .15rem; }
     69  .nt-noreason { font-style: italic; opacity: .7; }
    6070  .nt-like .nt-icon { background: color-mix(in srgb, #e8b04b 16%, transparent); color: #e8b04b; }
    6171  .nt-boost .nt-icon { background: color-mix(in srgb, #2fa85a 16%, transparent); color: #2fa85a; }
  • src/views/pages/paid-gate.ejs

    r96c714f r9a00f28  
    1313  <section class="pg-card">
    1414    <div class="pg-lock">💶</div>
    15     <h2 class="pg-h2">Voor supporters</h2>
     15    <h2 class="pg-h2"><%= t('pgate.h') %></h2>
    1616    <p class="pg-sub">
    17       Deze post is voor supporters van deze site
    18       <% if (typeof pgCents !== 'undefined' && pgCents) { %>(vanaf &euro;<%= (pgCents/100).toFixed(2) %> per maand op Patreon)<% } %>.
    19       Word supporter en ontgrendel 'm daarna met een passkey. Geen account op deze site, geen cookie.
     17      <% if (typeof pgCents !== 'undefined' && pgCents) { %><%= t('pgate.sub_cents', { eur: (pgCents/100).toFixed(2) }) %><% } else { %><%= t('pgate.sub') %><% } %>
    2018    </p>
    2119
    2220    <div class="pg-actions">
    2321      <% if (_hasPatron) { %>
    24         <a class="pg-btn" href="<%= pgPatronUrl %>" target="_blank" rel="noopener">Word supporter op Patreon</a>
    25         <button type="button" id="pg-unlock" class="pg-btn pg-btn-ghost">Al supporter? Ontgrendelen</button>
     22        <a class="pg-btn" href="<%= pgPatronUrl %>" target="_blank" rel="noopener"><%= t('pgate.join') %></a>
     23        <button type="button" id="pg-unlock" class="pg-btn pg-btn-ghost"><%= t('pgate.unlock_have') %></button>
    2624      <% } else { %>
    27         <button type="button" id="pg-unlock" class="pg-btn">Ontgrendelen met Patreon</button>
     25        <button type="button" id="pg-unlock" class="pg-btn"><%= t('pgate.unlock') %></button>
    2826      <% } %>
    2927    </div>
     
    3836  var slug = "<%= pgSlug %>";
    3937  var hasPatron = <%= _hasPatron ? 'true' : 'false' %>;
     38  var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" };
    4039  var btn = document.getElementById('pg-unlock');
    4140  var status = document.getElementById('pg-status');
     
    4847  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
    4948    if (hasPatron) { btn.style.display = 'none'; }
    50     else { btn.textContent = 'Word supporter'; btn.addEventListener('click', toLink); }
     49    else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
    5150    return;
    5251  }
     
    5453  btn.addEventListener('click', function () {
    5554    btn.disabled = true;
    56     say('Bevestig met je passkey…');
     55    say(I.confirm);
    5756    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
    5857      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
     
    7574          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
    7675        } else {
    77           btn.disabled = false; say('Ontgrendelen mislukt. Probeer opnieuw.', true);
     76          btn.disabled = false; say(I.failed, true);
    7877        }
    7978      })
     
    8180        if (e && e.link) { toLink(); return; }
    8281        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
    83         btn.disabled = false; say('Er ging iets mis. Probeer opnieuw.', true);
     82        btn.disabled = false; say(I.error, true);
    8483      });
    8584  });
  • src/views/pages/paid-passkey.ejs

    r96c714f r9a00f28  
    22  <div class="pk-card">
    33    <div class="pk-ic">🔑</div>
    4     <h1 class="pk-h1">Je bent supporter, mooi.</h1>
     4    <h1 class="pk-h1"><%= t('ppk.h') %></h1>
    55    <p class="pk-sub">
    6       Maak nu een passkey aan. Die wordt je sleutel voor betaalde posts, zonder
    7       account en zonder cookie. We bewaren geen naam of e-mailadres.
     6      <%= t('ppk.sub') %>
    87    </p>
    9     <button type="button" id="pk-go" class="pk-btn">Maak passkey</button>
     8    <button type="button" id="pk-go" class="pk-btn"><%= t('ppk.make') %></button>
    109    <p id="pk-status" class="pk-status" hidden></p>
    1110  </div>
     
    1716  var options = <%- optionsJson %>;
    1817  var blob = "<%= regBlob %>";
     18  var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" };
    1919  var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
    2020  var btn = document.getElementById('pk-go');
     
    2424  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
    2525    btn.disabled = true;
    26     say('Passkeys worden niet ondersteund in deze browser.', true);
     26    say(I.unsupported, true);
    2727    return;
    2828  }
    2929  btn.addEventListener('click', function () {
    3030    btn.disabled = true;
    31     say('Volg de vraag van je apparaat…');
     31    say(I.follow);
    3232    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
    3333      .then(function (response) {
     
    3939      .then(function (r) { return r.json(); })
    4040      .then(function (j) {
    41         if (j && j.ok) { say('Gelukt. Je passkey is aangemaakt.'); setTimeout(function () { location.href = postUrl; }, 900); }
    42         else { btn.disabled = false; say('Aanmaken mislukt (' + ((j && j.error) || 'onbekend') + '). Probeer opnieuw.', true); }
     41        if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); }
     42        else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); }
    4343      })
    44       .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? 'Geannuleerd.' : 'Er ging iets mis. Probeer opnieuw.', true); });
     44      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
    4545  });
    4646})();
  • src/views/pages/paid-result.ejs

    r96c714f r9a00f28  
    88    <div class="pr-ic">🔒</div>
    99    <% if (reason === 'notpatron') { %>
    10       <h1 class="pr-h1">Nog geen supporter</h1>
    11       <p class="pr-sub">Je bent (nog) geen actieve supporter van deze site op Patreon. Word supporter en probeer het daarna opnieuw vanaf de post.</p>
     10      <h1 class="pr-h1"><%= t('pres.notpatron_h') %></h1>
     11      <p class="pr-sub"><%= t('pres.notpatron_p') %></p>
    1212    <% } else if (reason === 'tier') { %>
    13       <h1 class="pr-h1">Een niveau hoger nodig</h1>
    14       <p class="pr-sub">
    15         Deze post vraagt vanaf &euro;<%= ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2) %>.
    16         Jouw steun is nu &euro;<%= ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) %>. Verhoog je steun en probeer opnieuw.
    17       </p>
     13      <h1 class="pr-h1"><%= t('pres.tier_h') %></h1>
     14      <p class="pr-sub"><%= t('pres.tier_p', { need: ((typeof neededCents !== 'undefined' ? neededCents : 0)/100).toFixed(2), have: ((typeof haveCents !== 'undefined' ? haveCents : 0)/100).toFixed(2) }) %></p>
    1815    <% } else if (reason === 'expired') { %>
    19       <h1 class="pr-h1">Aanvraag verlopen</h1>
    20       <p class="pr-sub">Deze ontgrendel-link is verlopen of al gebruikt. Ga terug naar de post en probeer het opnieuw.</p>
     16      <h1 class="pr-h1"><%= t('pres.expired_h') %></h1>
     17      <p class="pr-sub"><%= t('pres.expired_p') %></p>
    2118    <% } else { %>
    22       <h1 class="pr-h1">Ontgrendelen afgebroken</h1>
    23       <p class="pr-sub">Er is niets gekoppeld. Je kunt het opnieuw proberen vanaf de post.</p>
     19      <h1 class="pr-h1"><%= t('pres.declined_h') %></h1>
     20      <p class="pr-sub"><%= t('pres.declined_p') %></p>
    2421    <% } %>
    2522
    2623    <div class="pr-actions">
    2724      <% if ((reason === 'notpatron' || reason === 'tier') && _patron) { %>
    28         <a class="pr-btn" href="<%= _patron %>" target="_blank" rel="noopener">Word supporter op Patreon</a>
     25        <a class="pr-btn" href="<%= _patron %>" target="_blank" rel="noopener"><%= t('pres.join') %></a>
    2926      <% } %>
    3027      <% if (_slug) { %>
    31         <a class="pr-btn pr-btn-ghost" href="<%= _base %>/<%= _slug %>">Terug naar de post</a>
     28        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/<%= _slug %>"><%= t('pres.back_post') %></a>
    3229      <% } else { %>
    33         <a class="pr-btn pr-btn-ghost" href="<%= _base %>/">Terug naar de site</a>
     30        <a class="pr-btn pr-btn-ghost" href="<%= _base %>/"><%= t('pres.back_site') %></a>
    3431      <% } %>
    3532    </div>
Note: See TracChangeset for help on using the changeset viewer.