Changes in / [f85b2c3:952baf3] in Klonkt


Ignore:
Files:
48 added
65 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    rf85b2c3 r952baf3  
    55
    66## [Unreleased]
     7
     8## [1.7.0] · 2026-08-07
     9
     10### Hinzugefügt
     11- **Nachrichten ist eine einzige Gesprächsansicht.** Nachrichten, Gespräche und
     12  Gesendet waren drei getrennte Filter, wodurch ein Austausch auseinanderfiel:
     13  was du gesendet hast, stand unter Gesendet, was zurückkam unter einem der
     14  beiden anderen, und um einem Verlauf zu folgen, musstest du hin und her
     15  wechseln. Es ist jetzt eine Gesprächsansicht, in der Gesendetes und
     16  Empfangenes im selben Verlauf stehen, das Älteste oben, mit deinen eigenen
     17  Beiträgen markiert. Vier Filter bleiben: Alle, Gespräche, Aktivität und
     18  Moderation.
     19- **Ein Gespräch sagt, worum es geht.** Hängt ein Verlauf an einem deiner
     20  Beiträge, verlinkt die Kopfzeile darauf. Ohne diesen Zusammenhang lässt sich
     21  eine Antwort in einer Liste nicht einordnen. Verläufe ohne Beitrag laufen
     22  stattdessen pro Person.
     23- **Beiträge in Nachrichten sehen aus wie Beiträge.** Formatierung, Bilder,
     24  Audio, Video, Zitatkarten und Link-Vorschauen werden jetzt genauso dargestellt
     25  wie in den Nachrichten der Zeitung — auch bei dem, was du selbst gesendet hast:
     26  ein von dir angehängtes Foto kam auf dem eigenen Bildschirm als nackter Text
     27  an, während alle anderen das Bild sahen.
     28- **Die Guardian-App zeigt Beiträge deines Wards vollständig.** Dieselbe Lücke
     29  steckte in der Guardian-Ansicht: ein Beitrag deines Wards kam ohne Medien,
     30  Zitatkarte oder Emoji an — genau der Beitrag, den ein Guardian beurteilen
     31  können muss. Eine Inhaltswarnung bleibt dort wie bisher eingeklappt.
     32- **Aus einem Gespräch heraus antworten.** Unter einem Verlauf steht ein eigenes
     33  Antwortfeld, der reichhaltige Editor mit Formatierung, Medien und Sprachwahl.
     34  Winken bleibt eine eigene Schaltfläche daneben: ein Winken ist ein Zeichen,
     35  keine Antwort.
     36
     37### Behoben
     38- **Menschen auf datenschutzstrengen Servern können dir wieder folgen.** Manche
     39  Server geben den öffentlichen Schlüssel eines Kontos nur an eine signierte
     40  Anfrage heraus. Klonkt fragte ohne Signatur, wurde abgewiesen und konnte die
     41  gerade eingegangene Folge-Anfrage deshalb nicht prüfen — sie wurde abgelehnt,
     42  und der andere Server versuchte es tagelang erneut. Klonkt signiert diese
     43  Abfrage jetzt, und solche Follows kommen durch. Das betraf auch alles andere,
     44  was von so einem Server geholt wurde: Profile, Beiträge und Antworten.
     45- **Antworten anderer kommen jetzt im Verlauf an.** Antwortete jemand auf einen
     46  Beitrag in einem Gespräch, an dem du beteiligt warst, leitete dessen Server die
     47  Antwort an dich weiter — und sie wurde abgewiesen, weil ein weiterleitender
     48  Server mit seinem eigenen Schlüssel signiert und nicht mit dem des Autors.
     49  Verläufe waren auf deiner Seite dadurch stillschweigend unvollständig. Eine
     50  solche Antwort wird jetzt an der Quelle geprüft statt abgewiesen: der Beitrag
     51  wird bei dem Server geholt, der ihn beherbergt, und nur das von dort wird
     52  gespeichert. Eine weitergeleitete Löschung wird weiterhin abgewiesen, denn ein
     53  gelöschter Beitrag lässt sich nicht prüfen.
     54- **Ein Like oder Boost sieht überall gleich aus.** Derselbe Beitrag konnte in
     55  der Zeitung als geliked und auf der Interact-Seite als nicht geliked
     56  erscheinen, weil beide eine eigene Buchführung hatten. Es gibt jetzt nur noch
     57  eine, also stimmen die Schaltflächen überein — auch für alles, worauf du vor
     58  dieser Version schon reagiert hast; das wird beim Aktualisieren der Seite
     59  automatisch übernommen.
     60- **Ein Like aus einer App bleibt jetzt bestehen.** Ein Like aus Shaer wurde
     61  zwar gespeichert, aber die App bekam das nie zurück, sodass das Herz beim
     62  nächsten Laden wieder aussprang — und weil die App das Like nie sah, konnte sie
     63  es nur erneut senden und nie zurücknehmen. Das Zurücknehmen aus einer App
     64  funktioniert jetzt.
     65
     66### Sicherheit
     67
     68- **Aktualisierte Komponenten schließen sieben Sicherheitshinweise.** Der
     69  schwerwiegendste steckte in der Bibliothek, die von außen eintreffende
     70  Beiträge säubert: ein sorgfältig gebauter Beitrag konnte ein Skript daran
     71  vorbeischmuggeln, und ein Skript, das auf deiner Seite läuft, kann in deinem
     72  Namen handeln. Gleichzeitig ging der Mailversand drei Hauptversionen weiter
     73  und schloss damit eine Reihe von Lücken — darunter eine, bei der ein
     74  präparierter Name Befehle in das Gespräch mit dem Mailserver einschleusen
     75  konnte, und eine, bei der eine Nachricht bei einer anderen Domain landen
     76  konnte als der adressierten. An Aussehen und Verhalten von Klonkt ändert das
     77  nichts. Ein Hinweis bleibt bewusst offen: er betrifft eine Art, Bezeichner zu
     78  erzeugen, die Klonkt nicht verwendet.
    779
    880## [1.6.0] · 2026-07-31
  • CHANGELOG.md

    rf85b2c3 r952baf3  
    55
    66## [Unreleased]
     7
     8## [1.7.0] · 2026-08-07
     9
     10### Added
     11- **Messages is one conversation view.** Messages, Conversations and Sent were
     12  three separate filters, so a single exchange fell apart: what you sent sat
     13  under Sent, what came back under one of the other two, and you had to switch
     14  filters to follow a thread. They are now one Conversations view in which sent
     15  and received sit in the same thread, oldest at the top, with your own
     16  contributions marked. Four filters remain: All, Conversations, Activity and
     17  Moderation.
     18- **A conversation says what it is about.** When a thread hangs off one of your
     19  posts, its header links to that post. Without it a reply in a list is
     20  impossible to place. Threads that are not about a post run per person instead.
     21- **Posts in Messages look like posts.** Formatting, images, audio, video, quote
     22  cards and link previews now render the same way they do in News, including on
     23  the messages you sent yourself — a photo you attached used to arrive as bare
     24  text on your own screen while everyone else saw the picture.
     25- **The Guardian app shows a ward's posts in full.** The same gap sat in the
     26  guardian view: a post from your ward arrived without its media, quote card or
     27  emoji, which is exactly the post a guardian needs to be able to judge. A
     28  content warning still stays collapsed there, as before.
     29- **Reply from inside a conversation.** A thread has its own reply editor, the
     30  rich one with formatting, media and a language picker. Waving stays a separate
     31  button next to it: a wave is a nudge, not an answer.
     32
     33### Fixed
     34- **People on privacy-strict servers can follow you again.** Some servers only
     35  hand out an account's public key to a signed request. Klonkt asked without
     36  signing, got turned away, and could therefore not check the follow request that
     37  had just arrived — so it was refused, and the other server kept retrying for
     38  days. Klonkt now signs that lookup, and those follows go through. This also
     39  affected everything else fetched from such a server: profiles, posts and
     40  replies.
     41- **Replies from other people now arrive in threads.** When someone replied to a
     42  post in a conversation you were part of, their server forwarded that reply to
     43  you — and it was turned away, because the forwarding server signs with its own
     44  key rather than the author's. Threads were quietly incomplete on your side.
     45  Such a reply is now checked at the source instead of being refused: the post is
     46  fetched from the server that hosts it, and only what comes back from there is
     47  stored. A forwarded delete is still refused, because a deleted post cannot be
     48  checked.
     49- **A like or boost looks the same everywhere.** The same post could show as
     50  liked in News and as not liked on the interact page, because both kept their
     51  own record. There is one record now, so the buttons agree — including for
     52  everything you reacted to before this release, which is carried over
     53  automatically when the site updates.
     54- **A like from an app now sticks.** Liking a post from Shaer was stored, but
     55  the app never got that back, so the heart popped off again on the next reload
     56  — and because the app never saw the like, it could only offer "like" again and
     57  never undo it. Un-liking from an app now works.
     58
     59### Security
     60
     61- **Updated components close seven security advisories.** The heaviest one sat
     62  in the library that cleans up posts arriving from elsewhere: a carefully built
     63  post could slip a script past it, and a script running on your page can act as
     64  if it were you. At the same time mail handling moved three major versions on,
     65  closing a cluster of holes around sending — among them one where a prepared
     66  name could smuggle commands into the conversation with the mail server, and
     67  one where a message could end up at a different domain than the one addressed.
     68  None of this changes how Klonkt looks or behaves. One advisory is knowingly
     69  left open: it concerns a way of generating identifiers that Klonkt does not
     70  use.
    771
    872## [1.6.0] · 2026-07-31
  • CHANGELOG.nl.md

    rf85b2c3 r952baf3  
    55
    66## [Unreleased]
     7
     8## [1.7.0] · 2026-08-07
     9
     10### Toegevoegd
     11- **Berichten is één gesprekkenweergave.** Berichten, Gesprekken en Verzonden
     12  waren drie losse filters, waardoor één uitwisseling uit elkaar viel: wat jij
     13  stuurde stond onder Verzonden, wat terugkwam onder een van de andere twee, en
     14  om een draad te volgen moest je heen en weer klikken. Het is nu één
     15  Gesprekken-weergave waarin verzonden en ontvangen in dezelfde draad staan,
     16  oudste bovenaan, met jouw eigen bijdragen gemarkeerd. Er blijven vier filters
     17  over: Alles, Gesprekken, Activiteit en Moderatie.
     18- **Een gesprek vertelt waar het over gaat.** Hangt een draad aan een van je
     19  posts, dan staat er een link naar die post in de kop. Zonder die context is
     20  een antwoord in een lijst niet te plaatsen. Draden die niet over een post gaan
     21  lopen per persoon.
     22- **Posts in Berichten zien eruit als posts.** Opmaak, afbeeldingen, geluid,
     23  video, quote-kaarten en linkvoorbeelden worden nu net zo getoond als in de
     24  Krant, ook bij de berichten die je zelf stuurde — een foto die jij meestuurde
     25  kwam op je eigen scherm als kale tekst binnen terwijl de ander wel een plaatje
     26  zag.
     27- **De Guardian-app toont posts van je ward compleet.** Hetzelfde gat zat in de
     28  guardian-weergave: een post van je ward kwam binnen zonder media, quote-kaart
     29  of emoji, terwijl dat juist de post is die je als guardian wilt kunnen
     30  beoordelen. Een content warning blijft daar zoals eerder dichtgeklapt.
     31- **Antwoorden vanuit een gesprek.** Onder een draad staat een eigen
     32  antwoordvenster, de rijke editor met opmaak, media en een taalkeuze. Zwaaien
     33  blijft een aparte knop ernaast: een zwaai is een seintje, geen antwoord.
     34
     35### Opgelost
     36- **Mensen op privacy-strenge servers kunnen je weer volgen.** Sommige servers
     37  geven de publieke sleutel van een account alleen aan een ondertekend verzoek.
     38  Klonkt vroeg zonder handtekening, werd geweigerd, en kon daardoor het
     39  volgverzoek dat net binnenkwam niet controleren — dus werd dat afgewezen, en
     40  bleef de andere server het dagenlang opnieuw proberen. Klonkt ondertekent die
     41  navraag nu, en die volgers komen erdoor. Dit raakte ook al het andere dat bij
     42  zo'n server werd opgehaald: profielen, posts en reacties.
     43- **Reacties van anderen komen nu in de draad aan.** Reageerde iemand op een
     44  post in een gesprek waar jij bij zat, dan stuurde hun server die reactie naar
     45  je door — en werd hij geweigerd, omdat een doorsturende server met zijn eigen
     46  sleutel ondertekent en niet met die van de schrijver. Threads waren daardoor
     47  aan jouw kant stilletjes onvolledig. Zo'n reactie wordt nu bij de bron
     48  gecontroleerd in plaats van afgewezen: de post wordt opgehaald bij de server
     49  die hem herbergt, en alleen wat daarvandaan komt wordt bewaard. Een
     50  doorgestuurde verwijdering wordt nog steeds geweigerd, want een verwijderde
     51  post valt niet te controleren.
     52- **Een like of boost ziet er overal hetzelfde uit.** Dezelfde post kon in de
     53  Krant als geliket verschijnen en op de interact-pagina als niet-geliket, omdat
     54  allebei een eigen administratie bijhielden. Er is er nu één, dus de knoppen
     55  zijn het eens — ook voor alles wat je vóór deze versie al had gereageerd; dat
     56  wordt bij het bijwerken van de site automatisch meegenomen.
     57- **Een like vanuit een app blijft nu staan.** Een like die je in Shaer gaf werd
     58  wel opgeslagen, maar de app kreeg dat nooit terug, dus het hartje sprong bij de
     59  eerste herlaadbeurt weer uit — en omdat de app de like nooit zag, kon hij hem
     60  alleen opnieuw geven en nooit intrekken. Un-liken vanuit een app werkt nu.
     61
     62### Beveiliging
     63
     64- **Bijgewerkte onderdelen sluiten zeven beveiligingsadviezen.** Het zwaarste
     65  zat in de bibliotheek die berichten van elders opschoont: een zorgvuldig
     66  opgebouwd bericht kon daar een script langs krijgen, en een script dat op jouw
     67  pagina draait kan doen alsof het jou is. Tegelijk ging de mailafhandeling drie
     68  hoofdversies vooruit, wat een reeks gaten rond verzenden dichtte — waaronder
     69  een waarbij een geprepareerde naam commando's het gesprek met de mailserver in
     70  kon smokkelen, en een waarbij een bericht bij een ander domein kon belanden
     71  dan het geadresseerde. Aan hoe Klonkt eruitziet of werkt verandert dit niets.
     72  Eén advies blijft bewust staan: het gaat over een manier om identifiers te
     73  maken die Klonkt niet gebruikt.
    774
    875## [1.6.0] · 2026-07-31
  • deploy/MULTI-INSTANCE.md

    rf85b2c3 r952baf3  
    195195Then remove its block from the web server config and reload it.
    196196
     197## Hosting for other people
     198
     199Everything here is about the machine. The moment an instance belongs to someone
     200who is not you, there is a second set of questions — what you are holding, what
     201you owe them, and how they leave. See [HOSTING.md](HOSTING.md).
     202
    197203## Everyday commands
    198204
  • docs/shaer-c2s-api.md

    rf85b2c3 r952baf3  
    8080| `GET /ap/users/:slug/outbox` | OrderedCollection of the account's own `Create(Note)` |
    8181| `GET /ap/users/:slug/inbox` | OrderedCollection of recent inbound posts (accounts you follow) as `Create(Note)`. Owner only; `403` for anyone else. The unified home feed is outbox + inbox merged. |
     82
     83### Waiting for news on the inbox read
     84
     85The inbox read can hold the answer until there is something new, so a client does
     86not have to poll. It is the **same call and the same response** — no separate
     87endpoint and no separate "there is news" shape, because a second shape is a
     88second description of a post that can drift from the first.
     89
     90```
     91GET /ap/users/:slug/inbox?since=<cursor>&wait=25
     92```
     93
     94- `since` is the `shaer:cursor` from your previous answer. Treat it as opaque.
     95- `wait` is seconds, capped at 50 — well under what a proxy will hold.
     96- Send neither and the route behaves exactly as it always did.
     97
     98Two possible answers:
     99
     100| | |
     101|---|---|
     102| **`200`** | something changed. The full, current collection with a fresh `shaer:cursor`. |
     103| **`304`** | nothing changed within `wait`. **No body.** Keep the cursor you have and ask again. |
     104
     105`304` is not an error — it is the normal answer to a quiet minute, and it is why
     106this costs nothing while nothing happens. Sending the whole timeline back every
     107`wait` seconds just to say "still nothing" would be a poor trade for saving one
     108round trip.
     109
     110A server that has not run the feed-state migration yet cannot tell change from
     111stillness, and answers `200` with the collection every time rather than `304`
     112forever. Slower, never wrong.
     113
     114The cursor moves for **all four** sources this read merges: the timeline,
     115messages, replies on your own posts, and your own sent notes.
     116
     117Limits worth knowing as a client author:
     118
     119- four waiting connections per account. A fifth gets the current state
     120  immediately rather than an error, so a broken reconnect loop degrades to
     121  ordinary polling instead of locking the account out.
     122- hanging up ends the wait; there is no cost to abandoning a request.
     123- the bearer is checked *before* any waiting, so an unauthenticated caller can
     124  never hold a connection open.
     125
     126This works while your app is in the foreground, and only there. iOS freezes
     127network activity the moment an app is backgrounded, and a held-open connection
     128does not survive it.
     129
     130**There is no background channel to fall back on for a native app.** Klonkt's
     131web push uses VAPID, which a self-hosted instance can generate and send entirely
     132on its own — but only to a browser or an installed PWA. A native app receives
     133push through APNs (or FCM), signed with the *application's* key, which a
     134self-hosted Klonkt does not have and should not have: whoever holds it can push
     135to every user of that app. Closing that gap needs a relay run by the app's
     136publisher, and that is a decision about money and control, not a protocol
     137detail.
     138
     139So for a self-hosted setup this is not a stopgap until push arrives. For now it
     140is the freshness channel.
    82141| `GET /ap/users/:slug/followers` | see below |
    83142| `GET /ap/users/:slug/following` | see below |
  • package-lock.json

    rf85b2c3 r952baf3  
    11{
    22  "name": "klonkt",
    3   "version": "1.6.0-dev",
     3  "version": "1.7.0",
    44  "lockfileVersion": 3,
    55  "requires": true,
     
    77    "": {
    88      "name": "klonkt",
    9       "version": "1.6.0-dev",
     9      "version": "1.7.0",
    1010      "license": "AGPL-3.0-or-later",
    1111      "dependencies": {
     
    2828        "multer": "^1.4.5-lts.1",
    2929        "node-webpmux": "^3.2.1",
    30         "nodemailer": "^6.9.15",
     30        "nodemailer": "^9.0.5",
    3131        "qrcode": "^1.5.4",
    3232        "sanitize-html": "^2.17.3",
     
    680680    },
    681681    "node_modules/body-parser": {
    682       "version": "1.20.5",
    683       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
    684       "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
     682      "version": "1.20.6",
     683      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
     684      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
    685685      "license": "MIT",
    686686      "dependencies": {
     
    704704    },
    705705    "node_modules/brace-expansion": {
    706       "version": "2.1.0",
    707       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
    708       "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
     706      "version": "2.1.4",
     707      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
     708      "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
    709709      "license": "MIT",
    710710      "dependencies": {
     
    934934      "license": "MIT"
    935935    },
     936    "node_modules/dayjs": {
     937      "version": "1.11.21",
     938      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
     939      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
     940      "license": "MIT"
     941    },
    936942    "node_modules/debug": {
    937943      "version": "2.6.9",
     
    12061212    },
    12071213    "node_modules/es-object-atoms": {
    1208       "version": "1.1.1",
    1209       "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
    1210       "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
     1214      "version": "1.1.2",
     1215      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
     1216      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
    12111217      "license": "MIT",
    12121218      "dependencies": {
     
    12541260    },
    12551261    "node_modules/express": {
    1256       "version": "4.22.1",
    1257       "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
    1258       "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
     1262      "version": "4.22.2",
     1263      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
     1264      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
    12591265      "license": "MIT",
    12601266      "dependencies": {
    12611267        "accepts": "~1.3.8",
    12621268        "array-flatten": "1.1.1",
    1263         "body-parser": "~1.20.3",
     1269        "body-parser": "~1.20.5",
    12641270        "content-disposition": "~0.5.4",
    12651271        "content-type": "~1.0.4",
     
    12801286        "path-to-regexp": "~0.1.12",
    12811287        "proxy-addr": "~2.0.7",
    1282         "qs": "~6.14.0",
     1288        "qs": "~6.15.1",
    12831289        "range-parser": "~1.2.1",
    12841290        "safe-buffer": "5.2.1",
     
    13351341        "type": "opencollective",
    13361342        "url": "https://opencollective.com/express"
    1337       }
    1338     },
    1339     "node_modules/express/node_modules/qs": {
    1340       "version": "6.14.2",
    1341       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
    1342       "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
    1343       "license": "BSD-3-Clause",
    1344       "dependencies": {
    1345         "side-channel": "^1.1.0"
    1346       },
    1347       "engines": {
    1348         "node": ">=0.6"
    1349       },
    1350       "funding": {
    1351         "url": "https://github.com/sponsors/ljharb"
    13521343      }
    13531344    },
     
    15431534    },
    15441535    "node_modules/hasown": {
    1545       "version": "2.0.3",
    1546       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
    1547       "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
     1536      "version": "2.0.4",
     1537      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
     1538      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
    15481539      "license": "MIT",
    15491540      "dependencies": {
     
    17831774      }
    17841775    },
     1776    "node_modules/launder": {
     1777      "version": "1.7.1",
     1778      "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
     1779      "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
     1780      "license": "MIT",
     1781      "dependencies": {
     1782        "dayjs": "^1.11.7"
     1783      }
     1784    },
    17851785    "node_modules/locate-path": {
    17861786      "version": "5.0.0",
     
    19591959    },
    19601960    "node_modules/nanoid": {
    1961       "version": "3.3.11",
    1962       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
    1963       "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
     1961      "version": "3.3.17",
     1962      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
     1963      "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
    19641964      "funding": [
    19651965        {
     
    20102010    },
    20112011    "node_modules/nodemailer": {
    2012       "version": "6.10.1",
    2013       "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
    2014       "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
     2012      "version": "9.0.5",
     2013      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
     2014      "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
    20152015      "license": "MIT-0",
    20162016      "engines": {
     
    21562156    },
    21572157    "node_modules/postcss": {
    2158       "version": "8.5.12",
    2159       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
    2160       "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
     2158      "version": "8.5.26",
     2159      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
     2160      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
    21612161      "funding": [
    21622162        {
     
    21752175      "license": "MIT",
    21762176      "dependencies": {
    2177         "nanoid": "^3.3.11",
     2177        "nanoid": "^3.3.17",
    21782178        "picocolors": "^1.1.1",
    21792179        "source-map-js": "^1.2.1"
     
    22842284    },
    22852285    "node_modules/qs": {
    2286       "version": "6.15.1",
    2287       "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
    2288       "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
     2286      "version": "6.15.3",
     2287      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
     2288      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
    22892289      "license": "BSD-3-Clause",
    22902290      "dependencies": {
    2291         "side-channel": "^1.1.0"
     2291        "es-define-property": "^1.0.1",
     2292        "side-channel": "^1.1.1"
    22922293      },
    22932294      "engines": {
     
    24082409    },
    24092410    "node_modules/sanitize-html": {
    2410       "version": "2.17.3",
    2411       "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
    2412       "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
     2411      "version": "2.17.5",
     2412      "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
     2413      "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
    24132414      "license": "MIT",
    24142415      "dependencies": {
     
    24172418        "htmlparser2": "^10.1.0",
    24182419        "is-plain-object": "^5.0.0",
     2420        "launder": "^1.7.1",
    24192421        "parse-srcset": "^1.0.2",
    24202422        "postcss": "^8.3.11"
     
    24912493    },
    24922494    "node_modules/side-channel": {
    2493       "version": "1.1.0",
    2494       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
    2495       "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
     2495      "version": "1.1.1",
     2496      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
     2497      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
    24962498      "license": "MIT",
    24972499      "dependencies": {
    24982500        "es-errors": "^1.3.0",
    2499         "object-inspect": "^1.13.3",
    2500         "side-channel-list": "^1.0.0",
     2501        "object-inspect": "^1.13.4",
     2502        "side-channel-list": "^1.0.1",
    25012503        "side-channel-map": "^1.0.1",
    25022504        "side-channel-weakmap": "^1.0.2"
  • package.json

    rf85b2c3 r952baf3  
    11{
    22  "name": "klonkt",
    3   "version": "1.6.0",
     3  "version": "1.7.0",
    44  "description": "Klonkt — self-hosted multi-site music & blog platform (solo, hub or circles). Node + SQLite + htmx.",
    55  "license": "AGPL-3.0-or-later",
     
    3333    "multer": "^1.4.5-lts.1",
    3434    "node-webpmux": "^3.2.1",
    35     "nodemailer": "^6.9.15",
     35    "nodemailer": "^9.0.5",
    3636    "qrcode": "^1.5.4",
    3737    "sanitize-html": "^2.17.3",
  • scripts/klonkt-refresh-updater.sh

    rf85b2c3 r952baf3  
    3030D="${KLONKT_DIR}"
    3131B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
    32 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH}
     32# Deepen a shallow checkout ONCE. \`fetch --depth 1\` keeps only the newest commit
     33# and \`checkout -f\` throws away the tree that was there, so after an update the
     34# previous version existed nowhere on the machine: a bad release could not be
     35# undone without the network, and only if you knew which commit to ask for.
     36# One deepening buys back the history; every later fetch keeps it.
     37if [ "\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
     38  echo "deepening the checkout once so updates can be rolled back..."
     39  runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --unshallow origin ${BRANCH} || true
     40fi
     41runuser -u ${KLONKT_USER} -- git -C "\$D" fetch origin ${BRANCH}
    3342runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD
    3443A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
     
    5463else
    5564  echo "Klonkt updated (\$A) + restarted \$N instance(s)."
     65fi
     66# History alone is not a rollback; at three in the morning you also need the
     67# command. Print it while the previous commit is still known.
     68if [ -n "\$B" ]; then
     69  echo
     70  echo "Previous version: \$B"
     71  echo "To go back:"
     72  echo "  runuser -u ${KLONKT_USER} -- git -C \$D checkout -qf -B ${BRANCH} \$B"
     73  echo "  then restart the instances (systemctl restart 'klonkt@*')"
    5674fi
    5775EOF
  • src/assets/css/guardian.css

    rf85b2c3 r952baf3  
    168168.g-card.gated { border-left: 3px solid var(--accent); }
    169169.g-card.gated .row { margin-top: 8px; gap: 8px; }
     170
     171/* Het gate-paneel per ward (shaer-ahy.1). Een rij per gate, met het soort en de
     172   drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna
     173   een plek om iets voor te stellen. */
     174.g-gate { padding: .5rem 0; border-top: 1px solid var(--g-line, rgba(0,0,0,.08)); }
     175.g-gate:first-of-type { border-top: 0; }
     176.g-gate-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
     177.g-gate-name { font-weight: 600; }
     178/* Het soort is een fluistering, geen kop: het hoort erbij te staan zonder de
     179   naam van de gate te overstemmen. */
     180.g-gate-kind { font-size: .78em; opacity: .65; }
     181.g-gate-meta { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .15rem; }
     182.g-gate-meta .g-dim { opacity: .55; }
     183/* Onomkeerbaar krijgt gewicht, want dat is het enige in dit paneel dat je niet
     184   kunt terugdraaien. */
     185.g-gate-warn { font-weight: 600; }
     186.g-gate-wait { font-weight: 600; }
     187
     188/* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm,
     189   het moet alleen onmogelijk te missen zijn wie er al op af is. */
     190.g-help-state { margin-top: .5rem; }
     191.g-help-pick { font-size: .9em; }
     192.g-help-age { opacity: .6; }        /* oud, maar niet weg */
     193.g-help-done { font-weight: 600; }
     194.g-confirm { margin-top: .5rem; }
     195
     196/* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */
     197.g-help-archive { margin-top: .5rem; opacity: .75; }
  • src/assets/js/guardian.js

    rf85b2c3 r952baf3  
    4949  // child's panel.
    5050  var HELP_TOP = 5;
     51
     52  /**
     53   * Wacht deze hulpvraag nog op iemand?
     54   *
     55   * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
     56   * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
     57   * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
     58   * hoort hij hier langs te komen.
     59   *
     60   * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
     61   * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
     62   * is hier de gevaarlijke.
     63   */
     64  function helpOpen(h) { return !h || !h.state || h.state.open; }
    5165
    5266  function helpCard(h) {
     
    7286      card.appendChild(a);
    7387    }
     88    card.appendChild(helpState(h));
    7489    return card;
     90  }
     91
     92  /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
     93  function ago(ms) {
     94    var u = Math.floor(ms / 3600000);
     95    if (u < 1) return T.help_just_now || 'just now';
     96    if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
     97    return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
     98  }
     99
     100  /**
     101   * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
     102   * is afgesloten, en de twee knoppen.
     103   *
     104   * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
     105   * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
     106   * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
     107   * onaangeroerd uit terwijl er iemand mee bezig is.
     108   */
     109  function helpState(h) {
     110    var st = h.state || { open: true, pickedUpBy: [], handled: null, ageMs: null };
     111    var box = el('div', 'g-help-state');
     112
     113    if (st.handled) {
     114      var wie = st.handled.handle || handleOf(st.handled.uri);
     115      box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
     116      // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
     117      box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
     118      return box;
     119    }
     120
     121    (st.pickedUpBy || []).forEach(function (p) {
     122      var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
     123      box.appendChild(el('div', 'g-help-pick', line));
     124    });
     125    // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
     126    // 'er was ooit iemand mee bezig'.
     127    if (st.ageMs != null && st.ageMs > 3600000) {
     128      box.appendChild(el('div', 'g-help-age small', ago(st.ageMs)));
     129    }
     130
     131    var row = el('div', 'row');
     132    var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
     133    if (!mij) {
     134      var pick = el('button', 'small', T.help_pick || 'I am on it');
     135      pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
     136      row.appendChild(pick);
     137    }
     138    var done = el('button', 'quiet small', T.help_close || 'Mark handled');
     139    // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
     140    // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
     141    // van een ward.
     142    done.addEventListener('click', function () {
     143      done.hidden = true;
     144      var ask = el('div', 'g-confirm');
     145      ask.appendChild(el('p', 'small', T.help_close_ask || ''));
     146      var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
     147      ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
     148      var nee = el('button', 'quiet small', T.release_no || 'No');
     149      nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
     150      ask.appendChild(ja); ask.appendChild(nee);
     151      box.appendChild(ask);
     152    });
     153    row.appendChild(done);
     154    box.appendChild(row);
     155    return box;
     156  }
     157
     158  function markHelp(h, kind, btn) {
     159    if (btn) btn.disabled = true;
     160    fetch('/guardian/api/help/' + kind, {
     161      method: 'POST', headers: { 'Content-Type': 'application/json' },
     162      body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
     163    }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
    75164  }
    76165
     
    78167    var list = document.getElementById('help-list');
    79168    list.textContent = '';
    80     var help = S.help || [];
    81     help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
    82     if (help.length > HELP_TOP) {
    83       list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
    84     }
     169    var alle = S.help || [];
     170    // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
     171    // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
     172    // is, en neemt een gesloten vraag de ruimte in van een open vraag.
     173    var open = alle.filter(helpOpen);
     174    var klaar = alle.filter(function (h) { return !helpOpen(h); });
     175
     176    open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
     177    if (open.length > HELP_TOP) {
     178      list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
     179    }
     180
     181    // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
     182    // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
     183    // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
     184    // in het paneel van dat kind.
     185    if (klaar.length) {
     186      var doos = el('div', 'g-help-archive');
     187      doos.hidden = true;
     188      klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
     189      var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
     190      knop.addEventListener('click', function () {
     191        doos.hidden = !doos.hidden;
     192        knop.textContent = doos.hidden
     193          ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
     194          : (T.help_archive_hide || 'hide');
     195      });
     196      list.appendChild(knop);
     197      list.appendChild(doos);
     198    }
     199
    85200    var badge = document.getElementById('help-count');
    86     badge.textContent = help.length; badge.hidden = help.length === 0;
    87     show('help-empty', help.length === 0);
     201    badge.textContent = open.length; badge.hidden = open.length === 0;
     202    // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
     203    // er wacht dan immers niets.
     204    show('help-empty', open.length === 0);
    88205  }
    89206
     
    351468  }
    352469
     470  /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
     471  function gateLabel(feature) {
     472    return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
     473  }
     474
     475  /**
     476   * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
     477   * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
     478   * verzetten valt.
     479   *
     480   * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
     481   * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
     482   * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
     483   * zouden dat verschil wegpoetsen.
     484   */
     485  function gateRow(w, g) {
     486    var row = el('div', 'g-gate');
     487    var head = el('div', 'g-gate-head');
     488    head.appendChild(el('span', 'g-gate-name', gateLabel(g.feature)));
     489    head.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
     490    row.appendChild(head);
     491
     492    // De stand. NULL is onbekend en dat is iets anders dan uit: bij een ward op
     493    // een andere server wordt die kolom daar bijgehouden.
     494    var stand = g.value === null || g.value === undefined
     495      ? (T.gate_unknown || 'unknown')
     496      : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
     497    var meta = el('div', 'g-gate-meta small');
     498    meta.appendChild(el('span', null, stand));
     499    if (g.threshold) {
     500      meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
     501        .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
     502    } else {
     503      // Geen drempel verzinnen die we niet kennen.
     504      meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
     505    }
     506    if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
     507    if (g.waiting) {
     508      meta.appendChild(el('span', 'g-gate-wait', (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting)));
     509    }
     510    row.appendChild(meta);
     511
     512    if (g.proposal) {
     513      row.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
     514        (T.prop_line || 'Proposal {what} {value}: {status}')
     515          .replace('{what}', gateLabel(g.feature))
     516          .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
     517          .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
     518    }
     519    if (g.blockedBy) {
     520      row.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
     521        .replace('{what}', gateLabel(g.blockedBy))));
     522    }
     523    if (g.adjustable) {
     524      row.appendChild(gateButton(w, g.feature, g.value, T.gate_propose || T.embeds_propose,
     525        T.prop_on || 'on', T.prop_off || 'off'));
     526    }
     527    return row;
     528  }
     529
    353530  function wardPanel(w) {
    354531    var uri = w.other_uri;
     
    358535    var set = el('div', 'g-panel-sec');
    359536    set.appendChild(el('h3', null, T.settings_title || 'Settings'));
    360     var setRow = el('div', 'row');
    361     setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
    362     // The heavier sibling (5.6): seeing that a video exists is one decision,
    363     // letting a third party's player run inside the app is another. Hidden
    364     // only when previews are known-OFF: for a ward on another server the
    365     // value is unknown, and unknown is not off. Hiding it there meant a
    366     // guardian elsewhere could never even propose playback, which is how a
    367     // whole proposal round went into the wrong gate. The ward's server
    368     // enforces play-needs-previews at serve time regardless.
    369     if (w.embeds !== false) {
    370       setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
    371     }
    372     set.appendChild(setRow);
    373     // What this guardian proposed and how it stands (5.6). This used to be a
    374     // button caption that vanished on refresh, so a running decision was
    375     // invisible: you could not tell "waiting", "done" and "expired" apart.
    376     (w.proposals || []).forEach(function (p) {
    377       var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
    378       var line = (T.prop_line || 'Proposal {what} {value}: {status}')
    379         .replace('{what}', what)
    380         .replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
    381         .replace('{status}', T['prop_st_' + p.status] || p.status);
    382       set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line));
    383     });
     537    // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
     538    // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
     539    // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
     540    // is op "wat mag dit kind".
     541    (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); });
     542    // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
     543    // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
     544    if (!(w.gates || []).length) {
     545      var setRow = el('div', 'row');
     546      setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
     547      if (w.embeds !== false) {
     548        setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
     549      }
     550      set.appendChild(setRow);
     551      (w.proposals || []).forEach(function (p) {
     552        var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
     553        set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
     554          .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
     555          .replace('{status}', T['prop_st_' + p.status] || p.status)));
     556      });
     557    }
    384558    panel.appendChild(set);
    385559
     
    420594      T.panel_follow_empty || '', followCard);
    421595
     596    // Hier juist de VOLLEDIGE geschiedenis van dit kind -- dat is waar dit
     597    // paneel voor is. Wel wat nog wacht bovenaan, want dat is waar je naar zoekt
     598    // als je het opent.
    422599    sectionInto(panel, T.panel_help || 'Calls for help',
    423       (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
     600      (S.help || []).filter(function (h) { return h.actor_uri === uri; })
     601        .sort(function (a, b) { return (helpOpen(b) ? 1 : 0) - (helpOpen(a) ? 1 : 0); }),
    424602      T.panel_help_empty || '', helpCard);
    425603
     
    453631    actRow.appendChild(rel);
    454632    act.appendChild(actRow);
    455     panel.appendChild(act);
     633    // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
     634    // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
     635    // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
     636    // vijf secties te scrollen.
     637    panel.insertBefore(act, panel.firstChild);
    456638    return panel;
    457639  }
     
    467649      row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
    468650      // Counts on the row: whatever is waiting must be visible with the panel shut.
    469       var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
     651      // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
     652      // afgehandelde vraag hoorde de boei niet te laten staan.
     653      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
    470654      var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
    471655      if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
     
    553737    card.appendChild(head);
    554738    var body = el('div', 'feed-body');
     739    // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
     740    // media, quote-kaart en embed, precies als in de Krant en in Berichten.
     741    // Valt terug op de kale content voor een client uit de cache.
     742    var html = p.body_html || p.content || '';
    555743    if (p.cw) {
     744      // De content warning blijft van de PWA zelf: note-body versluiert alleen
     745      // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
    556746      var d = document.createElement('details');
    557747      var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
    558       var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
     748      var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
    559749      body.appendChild(d);
    560750    } else {
    561       body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
     751      body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
    562752    }
    563753    card.appendChild(body);
     
    700890
    701891  renderAll(); pushState(); loadFeed(); loadFollowReqs();
    702   setInterval(refresh, 45000);   // live-ish while open
     892
     893  // De push die de melding brengt is meteen het teken dat de staat veranderd is.
     894  // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
     895  // werk, en het werkt ook als de app dicht is.
     896  if ('serviceWorker' in navigator) {
     897    navigator.serviceWorker.addEventListener('message', function (e) {
     898      if (e.data && e.data.klonkt === 'push') refresh();
     899    });
     900  }
     901  // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
     902  // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
     903  // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
     904  // meteen een keer, want dan is de kans op nieuws het grootst.
     905  setInterval(function () { if (!document.hidden) refresh(); }, 45000);
     906  document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
    703907  } catch (e) {
    704908    fatal((e && e.message) || String(e));
  • src/config/database.js

    rf85b2c3 r952baf3  
    106106    PRIMARY KEY (guardian_slug, id)
    107107  )`);
     108  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
     109  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
     110  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
     111  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
     112  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
     113  // zinnigs te tonen, hoe je de wachtrij ook vult.
     114  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
     115  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
     116  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
    108117  ensureColumn('sites', 'profile_photo', 'TEXT');
    109118  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
     
    469478      UNIQUE(slug, actor_uri)
    470479    );
     480    -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
     481    -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
     482    -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
     483    -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
     484    -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
     485    -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
     486    -- antwoord, dus langer bewaren levert niets op.
     487    CREATE TABLE IF NOT EXISTS ap_seen_notes (
     488      uri TEXT PRIMARY KEY,
     489      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     490    );
     491    CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
    471492    CREATE TABLE IF NOT EXISTS ap_timeline (
    472493      id TEXT NOT NULL,              -- the remote note's AP id
     
    714735  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
    715736  ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
     737  // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
     738  // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
     739  // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
     740  // kolom op de mention zou alleen over onszelf gaan.
     741  //
     742  // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
     743  // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
     744  // ander het "geclaimd" had, wel.
     745  //
     746  // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
     747  // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
     748  // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
     749  // herschreven, er wordt toegevoegd.
     750  db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
     751    note_uri TEXT NOT NULL,
     752    guardian_uri TEXT NOT NULL,
     753    kind TEXT NOT NULL,                 -- pickup | handled
     754    guardian_handle TEXT,
     755    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
     756    PRIMARY KEY (note_uri, guardian_uri, kind)
     757  )`);
     758  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
    716759  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
    717760  ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
     
    759802  ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
    760803  ensureColumn('ap_followers', 'icon', 'TEXT');    // avatar URL
     804  feedStateTriggers();
     805}
     806
     807/**
     808 * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
     809 *
     810 * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
     811 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
     812 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
     813 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
     814 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
     815 * ap_timeline.liked (shaer-9e9).
     816 *
     817 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
     818 * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
     819 * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
     820 *
     821 * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
     822 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
     823 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
     824 * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
     825 * bij de tabel, en niet verspreid.
     826 *
     827 * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
     828 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
     829 * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
     830 */
     831function feedStateTriggers() {
     832  try {
     833    db.exec(`
     834      CREATE TABLE IF NOT EXISTS ap_feed_state (
     835        slug TEXT NOT NULL,
     836        object_uri TEXT NOT NULL,
     837        rev INTEGER NOT NULL,
     838        kind TEXT NOT NULL,             -- new | updated | deleted
     839        at DATETIME DEFAULT CURRENT_TIMESTAMP,
     840        PRIMARY KEY (slug, object_uri)
     841      );
     842      CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
     843      -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
     844      -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
     845      -- client dat er niets gebeurd is.
     846      CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
     847    `);
     848    if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
     849      db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
     850    }
     851    // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
     852    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
     853      DROP TRIGGER IF EXISTS ${naam};
     854      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
     855        UPDATE ap_feed_rev SET n = n + 1;
     856        INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
     857          ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
     858          ON CONFLICT(slug, object_uri) DO UPDATE
     859            SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
     860      END;`;
     861    const joinPosts = (uri, kind) => `
     862          SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
     863            FROM posts p JOIN sites s ON s.id = p.site_id`;
     864    db.exec([
     865      zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
     866      zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
     867      zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
     868      zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
     869      zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
     870      zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
     871      zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
     872      zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
     873      zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
     874      // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
     875      // en vandaar dat deze drie niet in de gewone vorm passen.
     876      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
     877      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`),
     878      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
     879    ].join('\n'));
     880  } catch (e) {
     881    // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
     882    // volmaken", en dat is traag maar niet stuk.
     883    console.error('❌ feed-state triggers:', e.message);
     884  }
    761885}
    762886
  • src/middleware/render.js

    rf85b2c3 r952baf3  
    249249    appVersion: APP_VERSION,
    250250    bodyClass: data.bodyClass || 'on-home',
     251    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
     252    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
     253    // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
     254    // pad.
     255    pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
    251256    socialDescr: data.socialDescr || '',
    252257    socialImage: data.socialImage || '',
     
    286291        ? _site.accent : '#e8b04b';
    287292      const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
     293      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
     294      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
     295      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
     296      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
    288297      const triggerJson = JSON.stringify({
    289         pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
     298        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
    290299        pcmsPostSwap: data.post ? {
    291300          title: data.post.title,
  • src/routes/activitypub.js

    rf85b2c3 r952baf3  
    253253}
    254254queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
    255 queueRoute('follows', (id) => Guardianship.followsCollection(id));
     255queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
    256256// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
    257257// waiting on its guardians. Owner-only like the rest — who a child wants to
     
    268268// they follow) as Create(Note) items, so an app (Shaer) can build a unified
    269269// feed. Anyone else gets 403; the inbox stays write-only for the public.
    270 router.get('/ap/users/:slug/inbox', (req, res) => {
     270router.get('/ap/users/:slug/inbox', async (req, res) => {
    271271  const auth = OAuth.verifyBearer(req.headers.authorization);
    272272  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    273273  const base = baseUrl(req);
     274  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
     275  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
     276  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
     277  // gedraagt de route zich exact zoals altijd.
     278  //
     279  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
     280  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
     281  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
     282  // ronde.
     283  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
     284  if (req.query.since && wachtS > 0) {
     285    const afbreken = new AbortController();
     286    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
     287    const uit = await AP.waitForFeedChange(auth.site.slug, {
     288      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
     289    });
     290    if (res.writableEnded || afbreken.signal.aborted) return undefined;
     291    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
     292    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
     293    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
     294    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
     295    // dat voor nieuws twee rondjes nodig heeft.
     296    //
     297    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
     298    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
     299    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
     300    // een lege merksteen sturen we dus gewoon de collectie.
     301    if (!uit.changed && uit.cursor !== '0') {
     302      res.set('Vary', 'Authorization');
     303      return res.status(304).end();
     304    }
     305  }
    274306  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
    275307  // world outside the fediverse is the guardians' call. The gate is applied
     
    285317  const playbackAllowed = embedsAllowed
    286318    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
    287   const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     319  const rows = AP.getTimeline(auth.site.slug, 60);
     320  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
     321  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
     322  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
     323  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
     324  const posts = rows.map((t) => ({
    288325    id: `${t.id}#create`,
    289326    type: 'Create',
     
    333370      // Whether THIS account already liked/boosted the note, so the app's
    334371      // detail-view buttons show the current state (and can toggle/undo).
    335       'shaer:liked': !!t.liked,
    336       'shaer:boosted': !!t.boosted,
     372      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
     373      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
    337374      // An external (non-fediverse) embed, thumbnail-only and never an iframe.
    338375      // Omitted entirely when the gate is closed (see above).
     
    439476      'shaer:externalLinks': playbackAllowed,
    440477    },
     478    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
     479    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
     480    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
     481    'shaer:cursor': AP.feedCursor(auth.site.slug),
    441482    totalItems: items.length,
    442483    orderedItems: items,
    443484  });
     485  return undefined;
    444486});
    445487
  • src/routes/admin-audio.js

    rf85b2c3 r952baf3  
    111111  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
    112112  renderPage(req, res, 'pages/admin-audio', {
     113    // admin-audio neemt de track-editor op, dus die module hoort erbij.
     114    pageJs: 'admin-audio track-editor',
    113115    pageTitleKey: 'admin.t_audio',
    114116    bodyClass: 'on-admin',
  • src/routes/admin-epk.js

    rf85b2c3 r952baf3  
    3333  try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
    3434  renderPage(req, res, 'pages/admin-epk', {
     35    pageJs: 'admin-epk',
    3536    pageTitleKey: 'admin.t_epk',
    3637    bodyClass: 'on-admin',
  • src/routes/admin-media.js

    rf85b2c3 r952baf3  
    8181    .sort((a, b) => b._mtime - a._mtime); // newest first
    8282  renderPage(req, res, 'pages/admin-media', {
     83    pageJs: 'admin-media',
    8384    pageTitleKey: 'admin.t_media',
    8485    bodyClass: 'on-admin',
     
    122123  if (!site) return res.status(404).send('Site required');
    123124  renderPage(req, res, 'pages/admin-videos', {
     125    pageJs: 'admin-videos',
    124126    pageTitleKey: 'admin.t_media',
    125127    bodyClass: 'on-admin',
  • src/routes/admin-paid.js

    rf85b2c3 r952baf3  
    3636  if (!gate(req, res)) return;
    3737  renderPage(req, res, 'pages/admin-paid', {
     38    pageJs: 'admin-paid',
    3839    pageTitleKey: 'apaid.t',
    3940    bodyClass: 'on-admin',
  • src/routes/admin-playlists.js

    rf85b2c3 r952baf3  
    6262  const playlists = PlaylistService.list(site.id);
    6363  renderPage(req, res, 'pages/admin-playlists', {
     64    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
     65    pageJs: 'admin-playlists playlist-editor',
    6466    pageTitleKey: 'admin.t_playlists',
    6567    playlists,
  • src/routes/admin-push.js

    rf85b2c3 r952baf3  
    1212router.get('/', requireSiteManager, async (req, res) => {
    1313  renderPage(req, res, 'pages/admin-push', {
     14    pageJs: 'admin-push',
    1415    pageTitleKey: 'push.t',
    1516    bodyClass: 'on-admin',
  • src/routes/admin-settings.js

    rf85b2c3 r952baf3  
    6666router.get('/', requireGod, (req, res) => {
    6767  renderPage(req, res, 'pages/admin-settings', {
     68    pageJs: 'admin-settings',
    6869    pageTitleKey: 'admin.t_settings',
    6970    bodyClass: 'on-admin',
  • src/routes/admin-sites.js

    rf85b2c3 r952baf3  
    200200router.get('/new', requireGod, (req, res) => {
    201201  renderPage(req, res, 'pages/admin-site-edit', {
     202    pageJs: 'admin-site-edit',
    202203    pageTitleKey: 'admin.t_newsite',
    203204    bodyClass: 'on-admin',
     
    283284
    284285  renderPage(req, res, 'pages/admin-site-edit', {
     286    pageJs: 'admin-site-edit',
    285287    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
    286288    bodyClass: 'on-admin',
  • src/routes/admin.js

    rf85b2c3 r952baf3  
    8787router.get('/handleiding', requireAuth, (req, res) => {
    8888  renderPage(req, res, 'pages/admin-help', {
     89    pageJs: 'admin-help',
    8990    pageTitleKey: 'admin.t_manual',
    9091    bodyClass: 'on-admin',
  • src/routes/auth.js

    rf85b2c3 r952baf3  
    115115  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
    116116  renderPage(req, res, 'pages/auth-register', {
     117    pageJs: 'auth-register',
    117118    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    118119    error: null, username: '', email: '', siteName: '', next,
     
    124125  const next = safeNext(req.body.next) || '';
    125126  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
     127    pageJs: 'auth-register',
    126128    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    127129    error, username: username || '', email: email || '', siteName: siteName || '', next,
     
    205207      } else {
    206208        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
    207         console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
     209        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
    208210      }
    209211    }
  • src/routes/download.js

    rf85b2c3 r952baf3  
    8585  const fan = req.session && req.session.user;
    8686  renderPage(req, res, 'pages/download', {
     87    pageJs: 'download',
    8788    pageTitle: track.title + ' — download',
    8889    bodyClass: 'on-download',
  • src/routes/guardian.js

    rf85b2c3 r952baf3  
    5757    'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
    5858    'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
    59     'panel_guards_far'];
     59    'panel_guards_far',
     60    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
     61    // de drempel erbij. De namen volgen de catalogus in gated.js.
     62    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows',
     63    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
     64    'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
     65    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
     66    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
     67    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
     68    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days',
     69    'help_archive', 'help_archive_hide'];
    6070  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
    6171  s.wave = i18nT(L, 'guardian.wave');
     
    7181            emoji_json, actor_emoji_json, media_json, quote_json, embed_json
    7282     FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
    73   ).all(site.slug).map((h) => ({
     83  ).all(site.slug);
     84  // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is
     85  // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies
     86  // het scherm dat een guardian in een haast openslaat.
     87  const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri));
     88  const helpItems = help.map((h) => ({
    7489    ...h,
     90    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
     91    // niet is, is de gevaarlijke fout -- niet andersom.
     92    state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },
    7593    // The dashboard is built in the browser, so it gets the body finished: the
    7694    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
     
    103121        status: Guardianship.gated.sentStatus(p, Date.now()),
    104122      })),
     123      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
     124      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
     125      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
     126      gates: wardGates(site.slug, w.other_uri),
    105127    })),
    106128    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
     
    113135      id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
    114136    })),
    115     help,
     137    help: helpItems,
    116138    strings: uiStrings(L),
    117139  };
     
    192214  const site = siteForUser(req);
    193215  if (!site) return res.status(404).json({ error: 'no_site' });
     216  const L = resolveLang(req);
    194217  ensureWardConnections(site);
    195218  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
     
    209232      cw: p.cw || null,
    210233      media: p.media_json ? JSON.parse(p.media_json) : [],
     234      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
     235      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
     236      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
     237      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
     238      // een client die nog uit de cache draait.
     239      body_html: renderNoteBody(p, L),
    211240    }));
    212241  res.json({ items, following: wardUris.size });
     
    319348  if (!r) return res.status(502).json({ error: 'delivery' });
    320349  res.json({ ok: true, delivered: r.delivered });
     350});
     351
     352// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
     353// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
     354// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
     355// loopt, zodat niemand denkt dat de ander het al doet.
     356//
     357// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
     358// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
     359// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
     360// een ward: nooit een window.confirm.
     361router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
     362  const site = siteForUser(req);
     363  if (!site) return res.status(404).json({ error: 'no_site' });
     364  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
     365  const noteUri = String(req.body?.note || '').trim();
     366  const wardUri = String(req.body?.ward || '').trim();
     367  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
     368  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
     369  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
     370  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
     371
     372  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
     373  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
     374  // degene die klikt hoort niet te liegen omdat een andere server traag is.
     375  Guardianship.help.record(noteUri, me, kind, null);
     376
     377  const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
     378  const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
     379  const r = await AP.deliverDirectNote(site, {
     380    recipients: ontvangers,
     381    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
     382    helpMark: { kind, noteUri },
     383  }).catch(() => null);
     384  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
     385  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
     386  // stilte.
     387  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
    321388});
    322389
     
    531598/** The playback gate of a ward we host (5.6): the heavier sibling. */
    532599function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
     600/**
     601 * De gate-rijen van een ward voor het paneel.
     602 *
     603 * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
     604 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
     605 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
     606 * getal wordt er geen drempel verzonnen.
     607 */
     608function wardGates(mySlug, wardUri) {
     609  const statuses = wardGuardianStatuses(wardUri);
     610  const wachtend = Guardianship.follows.listReviewsByDirection(mySlug, 'incoming')
     611    .filter((r) => r.ward_uri === wardUri).length;
     612  return Guardianship.gated.gateRows({
     613    settings: {
     614      'shaer:externalEmbeds': wardEmbedSetting(wardUri),
     615      'shaer:externalPlayback': wardPlaybackSetting(wardUri),
     616    },
     617    guardianCount: statuses ? statuses.length : null,
     618    proposals: Guardianship.gated.listSent(mySlug, wardUri).map((p) => ({
     619      feature: p.feature, value: !!p.value, status: Guardianship.gated.sentStatus(p, Date.now()),
     620    })),
     621    waiting: { 'shaer:follows': wachtend || undefined },
     622  });
     623}
     624
    533625function wardGateSetting(uri, column) {
    534626  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
  • src/routes/paid.js

    rf85b2c3 r952baf3  
    7676  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
    7777  renderPage(req, res, 'pages/paid-passkey', {
     78    pageJs: 'paid-passkey',
    7879    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
    7980    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
  • src/routes/posts.js

    rf85b2c3 r952baf3  
    256256
    257257  renderPage(req, res, 'pages/post-edit', {
     258    // post-edit neemt de playlist-editor op.
     259    pageJs: 'post-edit playlist-editor',
    258260    post: {
    259261      id: uuid(),
     
    795797  }
    796798  renderPage(req, res, 'pages/authorize-interaction', {
     799    pageJs: 'authorize-interaction',
    797800    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    798801    bodyClass: 'on-special',
     
    806809    liked: !!req.query.liked,
    807810    boosted: !!req.query.boosted,
    808     reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
     811    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
    809812    siteTitle: site ? site.title : '',
    810813  });
     
    839842  let on = false;
    840843  if (site && uri) {
    841     on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
     844    on = !ActivityPubService.getReaction(site.slug, uri).liked;
    842845    ActivityPubService.resolveRemoteNote(uri)
    843846      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
    844847      .catch((e) => console.warn('[AP] remote like failed:', e.message));
    845     ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
     848    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
     849    ActivityPubService.setReaction(site.slug, uri, 'like', on);
    846850  }
    847851  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    856860  let on = false;
    857861  if (site && uri) {
    858     on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
     862    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
    859863    ActivityPubService.resolveRemoteNote(uri)
    860864      .then((note) => {
     
    862866        const id = note.object_uri || uri;
    863867        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
    864           // Boost → store the post in the timeline (even if you don't follow the author) so it
    865           // surfaces in the Cirkel; unboost → just clear the flag.
    866           .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
     868          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
     869          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
     870          // de Cirkel verschijnt.
     871          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
    867872      })
    868873      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
    869     ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
     874    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
     875    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
     876    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
     877    // note, zodat de post ook in je tijdlijn belandt.
     878    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
    870879  }
    871880  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    930939    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
    931940  renderPage(req, res, 'pages/messages', {
    932     pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
     941    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
    933942    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
    934943    success: req.query.success || null, error: req.query.error || null,
     
    965974  } catch { /* fall through */ }
    966975  res.redirect(back + '?error=quickreply');
     976});
     977
     978// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
     979// bepaalt de draad zelf (zie groupConversations → replyTo):
     980//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
     981//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
     982//   - hangt hij aan een persoon, dan is het een direct bericht terug.
     983// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
     984// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
     985// als enige stuurt).
     986router.post('/messages/reply', requireSiteManager, async (req, res) => {
     987  const site = res.locals.site;
     988  const back = `${res.locals.siteUrlBase || ''}/messages`;
     989  if (!site) return res.status(404).send('Site required');
     990  const text = String(req.body.text || '');
     991  const html = String(req.body.content || '');
     992  let attachments = [];
     993  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     994  let mentions;
     995  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
     996  const language = String(req.body.language || '');
     997  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
     998  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
     999
     1000  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
     1001  const postSlug = String(req.body.post_slug || '');
     1002  const toActor = String(req.body.to || '');
     1003  try {
     1004    if (interactionId && postSlug) {
     1005      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
     1006      const parent = ActivityPubService.getInteractionById(interactionId);
     1007      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
     1008      // formulier een antwoord onder andermans draad kunnen hangen.
     1009      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
     1010      await ActivityPubService.deliverReply(site, {
     1011        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
     1012      });
     1013    } else if (/^https?:\/\//i.test(toActor)) {
     1014      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
     1015      if (!r) return res.redirect(back + '?error=reply_failed');
     1016    } else {
     1017      return res.redirect(back + '?error=reply_target');
     1018    }
     1019  } catch (e) {
     1020    console.warn('[AP] reply from Berichten failed:', e.message);
     1021    return res.redirect(back + '?error=reply_failed');
     1022  }
     1023  res.redirect(back + '?success=reply_sent');
    9671024});
    9681025
     
    11391196  }
    11401197  renderPage(req, res, 'pages/news', {
     1198    pageJs: 'news',
    11411199    pageTitle: 'News', bodyClass: 'on-special',
    11421200    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     
    12291287  let on = false;
    12301288  if (site && note) {
    1231     on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
     1289    on = !ActivityPubService.getReaction(site.slug, note).liked;
    12321290    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1233     if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
     1291    ActivityPubService.setReaction(site.slug, note, 'like', on);
    12341292  }
    12351293  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    12431301  let on = false;
    12441302  if (site && note) {
    1245     on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
     1303    on = !ActivityPubService.getReaction(site.slug, note).boosted;
    12461304    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     1305    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
    12471306    if (on) {
    1248       ActivityPubService.markBoosted(site.slug, note); // instant UI state
    12491307      // Fire-and-forget: re-resolve the note so the cached row is refreshed
    12501308      // (cover/content) — boosting again heals a stale copy from EVERY boost
    12511309      // path, not just the interact page.
    12521310      ActivityPubService.resolveRemoteNote(note)
    1253         .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
     1311        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
    12541312        .catch(() => { /* best-effort */ });
    1255     } else {
    1256       ActivityPubService.unmarkBoosted(site.slug, note);
    12571313    }
    12581314  }
     
    13381394    const { newerPost, olderPost } = postNeighbors(site, post);
    13391395    return renderPage(req, res, 'pages/paid-gate', {
     1396    pageJs: 'paid-gate',
    13401397      pageTitle: post.title || 'Voor supporters',
    13411398      bodyClass: 'on-special',
     
    14541511
    14551512  renderPage(req, res, 'pages/post', {
     1513    pageJs: 'post',
    14561514    post,
    14571515    poll: ActivityPubService.ownPollView(post),
     
    15051563  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
    15061564  if (parent && parent.post_id === post.id && parent.object_uri) {
    1507     if (kind === 'boost') {
    1508       // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
    1509       const on = !parent.acted_boost;
    1510       ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
    1511         .catch((e) => console.warn('[AP] reaction failed:', e.message));
    1512       ActivityPubService.setInteractionBoosted(parent.id, on);
    1513     } else {
    1514       // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
    1515       const on = !parent.acted_like;
    1516       ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
    1517         .catch((e) => console.warn('[AP] reaction failed:', e.message));
    1518       ActivityPubService.setInteractionLiked(parent.id, on);
    1519     }
     1565    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
     1566    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
     1567    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
     1568    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
     1569    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
     1570    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
     1571    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
     1572      .catch((e) => console.warn('[AP] reaction failed:', e.message));
     1573    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
     1574    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
     1575    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
     1576    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
     1577    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
     1578    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
     1579    else ActivityPubService.setInteractionLiked(parent.id, on);
    15201580  }
    15211581  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
  • src/server.js

    rf85b2c3 r952baf3  
    6565import apRoutes from './routes/activitypub.js';
    6666import oauthRoutes from './routes/oauth.js';
    67 import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
     67import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
    6868
    6969// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
     
    197197startScheduler(); // release planning: publish scheduled posts when publish_at is reached
    198198startDeliveryWorker(); // retry failed fediverse deliveries with backoff
     199// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
     200// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
     201// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
     202migrateReactions();
    199203selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
    200204
     
    542546  try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
    543547  const title = d.title || 'Klonkt';
    544   e.waitUntil(self.registration.showNotification(title, {
    545     body: d.body || '',
    546     icon: '/favicon.svg',
    547     badge: '/favicon.svg',
    548     tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
    549     data: { url: d.url || '/' },
    550   }));
     548  e.waitUntil(Promise.all([
     549    self.registration.showNotification(title, {
     550      body: d.body || '',
     551      icon: '/favicon.svg',
     552      badge: '/favicon.svg',
     553      tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
     554      data: { url: d.url || '/' },
     555    }),
     556    // Wek ook een pagina die al openstaat. De push IS het teken dat er iets
     557    // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog
     558    // eens doen -- en die tweede zou alleen werken zolang de app open is,
     559    // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen.
     560    self.clients.matchAll({ type: 'window', includeUncontrolled: true })
     561      .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); })
     562      .catch(() => { /* geen open venster: niets te wekken */ }),
     563  ]));
    551564});
    552565self.addEventListener('notificationclick', e => {
  • src/services/ActivityPubService.js

    rf85b2c3 r952baf3  
    627627// outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
    628628// one grouped item (actors list + count) so activity doesn't drown out conversations.
     629/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
     630 *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
     631 *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
     632function outboxMediaJson(attachments) {
     633  if (!attachments) return null;
     634  try {
     635    const list = JSON.parse(attachments);
     636    if (!Array.isArray(list) || !list.length) return null;
     637    const media = list
     638      .filter((a) => a && a.url)
     639      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
     640    return media.length ? JSON.stringify(media) : null;
     641  } catch { return null; }
     642}
     643
    629644export function getMessages(slug, limit, offset) {
    630645  const off = Math.max(0, offset || 0);
     
    638653    for (const m of listOutbox(slug).slice(0, need)) {
    639654      items.push({
    640         type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
    641         content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
     655        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
     656        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
     657        editable: m.editable, language: m.language, created_at: m.created_at,
     658        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
     659        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
     660        // media uit media_json met een `type`, terwijl ap_outbox ze als
     661        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
     662        // een foto die JIJ meestuurde als kale tekst binnen.
     663        media_json: outboxMediaJson(m.attachments),
    642664      });
    643665    }
    644666  } catch { /* ignore */ }
     667  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
     668  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
     669  // zei alleen een slug, dus vullen we ze in één query aan.
     670  try {
     671    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
     672    if (missing.length) {
     673      const rows = db.prepare(
     674        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
     675           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
     676      ).all(...missing, slug);
     677      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
     678      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
     679    }
     680  } catch { /* zonder titel valt de draad terug op de slug */ }
    645681  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    646682  const out = [];
     
    656692    out.push(it);
    657693  }
    658   return out.slice(off, off + lim);
     694  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
     695  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
     696  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
     697  // gesprekken, niet door losse zinnen.
     698  return groupConversations(out).slice(off, off + lim);
     699}
     700
     701// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
     702// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
     703// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
     704// moest dus tussen chips heen en weer, terwijl het één draad is.
     705const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
     706
     707/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
     708 *
     709 *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
     710 *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
     711 *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
     712 *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
     713 *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
     714 *     loopt per tegenpartij.
     715 *
     716 *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
     717 *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
     718 *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
     719 *  door.
     720 */
     721export function threadKey(it) {
     722  if (!it || !CONV_TYPES.has(it.type)) return null;
     723  if (it.post_slug) return `post:${it.post_slug}`;
     724  let who = it.handle || it.to_handle || '';
     725  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
     726  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
     727  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
     728  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
     729  if (!who && it.to_actors) {
     730    try {
     731      const first = JSON.parse(it.to_actors)[0];
     732      if (first) who = deriveHandle(first);
     733    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
     734  }
     735  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
     736  return norm ? `actor:${norm}` : null;
     737}
     738
     739/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
     740 *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
     741 *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
     742 *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
     743 *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
     744 */
     745export function groupConversations(items) {
     746  const threads = new Map();
     747  const out = [];
     748  for (const it of items || []) {
     749    const key = threadKey(it);
     750    if (!key) { out.push(it); continue; }
     751    let t = threads.get(key);
     752    if (!t) {
     753      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
     754      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
     755      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
     756      threads.set(key, t);
     757      out.push(t);
     758    }
     759    t.messages.push(it);
     760    // De context bij de draad: gaat het over een post, dan hoort de link
     761    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
     762    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
     763    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
     764    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
     765    // dan kan het ontvangen antwoord eronder de echte titel niet meer
     766    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
     767    if (it.post_slug) {
     768      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
     769      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
     770    }
     771  }
     772  for (const t of threads.values()) {
     773    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
     774    t.count = t.messages.length;
     775    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
     776    const seen = new Set();
     777    for (const m of t.messages) {
     778      if (m.type === 'sent') continue;
     779      const h = m.handle || m.name;
     780      if (!h || seen.has(h)) continue;
     781      seen.add(h);
     782      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
     783    }
     784    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
     785    // onbeantwoord bericht leest.
     786    t.mine = t.messages.some((m) => m.type === 'sent');
     787    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
     788    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
     789    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
     790    // een direct bericht aan de tegenpartij.
     791    const inkomend = t.messages.filter((m) => m.type !== 'sent');
     792    const laatste = inkomend[inkomend.length - 1];
     793    t.replyTo = {
     794      interactionId: (laatste && laatste.interactionId) || null,
     795      postSlug: (t.post && t.post.slug) || null,
     796      actorUri: (laatste && (laatste.actorUri || laatste.url))
     797        || (t.messages.find((m) => m.to_actor) || {}).to_actor
     798        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
     799    };
     800  }
     801  return out;
    659802}
    660803
     
    10431186  const siteIcon = (site && site.profile_photo) || null;
    10441187
     1188  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
     1189  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
     1190  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
     1191  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
     1192  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
     1193  const mijnSleutel = new Map();
     1194  for (const r of rows) {
     1195    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
     1196  }
     1197  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
     1198  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
     1199
    10451200  const nodes = [];
    10461201  for (const r of rows) {
    10471202    if (r.kind !== 'reply') continue;
     1203    const ik = mijnReactie(r.object_uri);
    10481204    nodes.push({
    10491205      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     
    10521208      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
    10531209      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
    1054       acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
     1210      acted_boost: ik.boosted, acted_like: ik.liked,
    10551211      children: [],
    10561212    });
     
    11411297}
    11421298
    1143 export async function fetchActor(url) {
     1299export async function fetchActor(url, opts = {}) {
     1300  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
     1301  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
     1302  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
     1303  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
     1304  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
     1305  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
     1306  // onbetekende GET (shaer-afq).
     1307  //
     1308  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
     1309  // actor-document op, en dat serveert Klonkt publiek.
     1310  //
     1311  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
     1312  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
     1313  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
     1314  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
     1315  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
     1316  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
     1317  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
     1318  let doc = null;
    11441319  try {
    11451320    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    1146     if (!r.ok) return null;
    1147     const len = Number(r.headers.get('content-length') || 0);
    1148     if (len > 2_000_000) return null; // refuse oversized actor docs
    1149     return await r.json();
    1150   } catch { return null; }
     1321    if (r.ok) {
     1322      const len = Number(r.headers.get('content-length') || 0);
     1323      if (len > 2_000_000) return null; // refuse oversized actor docs
     1324      doc = await r.json();
     1325    }
     1326  } catch { /* val door naar de ondertekende poging */ }
     1327  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
     1328  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
     1329  // telt als mislukt.
     1330  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
     1331  if (!opts.asSlug) return doc;
     1332  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
     1333  return (signed && signed.id) ? signed : doc;
    11511334}
    11521335
     
    12251408}
    12261409
     1410/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
     1411 *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
     1412let _signSlug;
     1413function anySigningSlug() {
     1414  if (_signSlug !== undefined) return _signSlug;
     1415  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
     1416  catch { _signSlug = null; }
     1417  return _signSlug;
     1418}
     1419
    12271420// Best-effort verification of an incoming signed request. Returns the sender's
    12281421// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    12301423// federating servers with drifting clocks; an operator can widen it via env.
    12311424const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1232 export async function verifyRequest(req) {
     1425export async function verifyRequest(req, asSlug = null) {
    12331426  const sigH = req.headers['signature'];
    12341427  if (!sigH) return null;
    12351428  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    12361429  if (!p.keyId || !p.signature) return null;
    1237   const actor = await fetchActor(p.keyId.split('#')[0]);
     1430  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
     1431  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
     1432  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
     1433  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
     1434  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
    12381435  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    12391436  if (!pem) return null;
     
    14951692}
    14961693
     1694/**
     1695 * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
     1696 *
     1697 * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
     1698 * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
     1699 * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
     1700 * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
     1701 * binnen, zonder dat iemand een fout zag.
     1702 *
     1703 * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
     1704 * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
     1705 * bezorgde inhoud NIET en halen het object op bij de bron.
     1706 *
     1707 * Vier voorwaarden, en geen ervan is optioneel:
     1708 *
     1709 *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
     1710 *     dereferencen -- het object is weg -- dus die blijft geweigerd.
     1711 *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
     1712 *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
     1713 *     attributedTo alles kan beweren.
     1714 *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
     1715 *     levert een doorsturer een echt id met verdraaide inhoud.
     1716 *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
     1717 *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
     1718 */
     1719/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
     1720 *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
     1721 *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
     1722 *  doorstuurt; iets anders is dat niet. */
     1723function knownNoteUri(uri) {
     1724  if (!uri || typeof uri !== 'string') return false;
     1725  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     1726  try {
     1727    if (base && uri.startsWith(`${base}/ap/notes/`)) {
     1728      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
     1729      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
     1730      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
     1731    }
     1732    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
     1733    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
     1734    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
     1735    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
     1736  } catch { /* bij twijfel niet ophalen */ }
     1737  return false;
     1738}
     1739
     1740/**
     1741 * Onthoud dat we dit bericht al eens bezorgd kregen.
     1742 *
     1743 * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
     1744 * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
     1745 * stelt voordat er iets bij de bron wordt opgehaald.
     1746 *
     1747 * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
     1748 * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
     1749 * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
     1750 * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
     1751 * schrijvers die je zelf volgt.
     1752 */
     1753const SEEN_NOTES_DAYS = 30;
     1754let _seenSinceSnoei = 0;
     1755function rememberNoteUri(uri) {
     1756  if (!uri || typeof uri !== 'string') return;
     1757  try {
     1758    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
     1759    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
     1760    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
     1761    // ouder is dan een maand beantwoordt geen enkele vraag meer.
     1762    if (++_seenSinceSnoei >= 500) {
     1763      _seenSinceSnoei = 0;
     1764      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
     1765      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
     1766    }
     1767  } catch { /* niet fataal */ }
     1768}
     1769const isFollowedActor = (uri) => {
     1770  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
     1771};
     1772
     1773// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
     1774// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
     1775// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
     1776const _derefMiss = new Map();
     1777const DEREF_MISS_MS = 30 * 60 * 1000;
     1778function derefRecentlyFailed(uri) {
     1779  const t = _derefMiss.get(uri);
     1780  if (t === undefined) return false;
     1781  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
     1782  return true;
     1783}
     1784function noteDerefFailure(uri) {
     1785  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
     1786    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
     1787    for (const [k] of oud) _derefMiss.delete(k);
     1788  }
     1789  _derefMiss.set(uri, Date.now());
     1790}
     1791
     1792async function dereferenceForwarded(act, claimedActor, type, slugParam) {
     1793  // Every exit states its reason. Five of the six used to return silently, so a
     1794  // rejection count could not be told apart from a narrowing that closed too far
     1795  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
     1796  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
     1797  const skipped = (reason, detail) => {
     1798    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
     1799    return null;
     1800  };
     1801  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
     1802  const o = act && act.object;
     1803  const objId = typeof o === 'string' ? o : (o && o.id);
     1804  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
     1805  try {
     1806    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
     1807  } catch { return skipped('unparsable id', objId); }
     1808  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
     1809  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
     1810  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
     1811  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
     1812  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
     1813  // niets aan legitiem verkeer waarvan we de ouder kennen.
     1814  const parent = typeof o === 'object' && o
     1815    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
     1816    : null;
     1817  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
     1818  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
     1819  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
     1820  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
     1821  // reden als bij fetchActor sinds efe5633.
     1822  let fetched = await apGetJson(objId).catch(() => null);
     1823  if (!fetched || fetched.id !== objId) {
     1824    // The signer used to be slugParam, which is null on the shared inbox — and
     1825    // that is where forwarded traffic lands, because we advertise a sharedInbox.
     1826    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
     1827    // secure mode could never be dereferenced at all. Same fix verifyRequest got
     1828    // in shaer-afq: any local actor is a valid signer.
     1829    const asSlug = slugParam || anySigningSlug();
     1830    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
     1831  }
     1832  const attributed = fetched && (typeof fetched.attributedTo === 'string'
     1833    ? fetched.attributedTo
     1834    : (fetched.attributedTo && fetched.attributedTo.id));
     1835  if (!fetched || fetched.id !== objId) {
     1836    noteDerefFailure(objId);
     1837    return skipped('fetch failed', objId);
     1838  }
     1839  if (attributed !== claimedActor) {
     1840    // Not a transport hiccup: the source itself says someone else wrote this.
     1841    noteDerefFailure(objId);
     1842    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
     1843  }
     1844  return fetched;
     1845}
     1846
    14971847// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    14981848export async function handleInbox(req, slugParam, preVerified = null) {
     
    15081858  // keeps everything below identical, including the actor-versus-signer check,
    15091859  // which is exactly the check that must not be skipped for being local.
    1510   const verified = preVerified || await verifyRequest(req).catch(() => null);
     1860  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
    15111861
    15121862  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    15181868  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    15191869  if (GATED.includes(type)) {
    1520     if (!verified || !claimedActor || verified.id !== claimedActor) {
    1521       console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
     1870    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
     1871    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
     1872    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
     1873    let forwarded = null;
     1874    if (verified && claimedActor && verified.id !== claimedActor) {
     1875      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
     1876      if (forwarded) {
     1877        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
     1878        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
     1879      }
     1880    }
     1881    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
     1882      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
     1883      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
     1884      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
     1885      // of geldig ondertekend door iemand anders.
     1886      const reden = verified ? '(signer mismatch)'
     1887        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
     1888        : '(no signature)';
     1889      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
    15221890      return 401;
    15231891    }
     
    18092177      }
    18102178    }
     2179    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
     2180    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
     2181    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
     2182    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
     2183    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
     2184    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
     2185    // moet iemand zijn die je zelf bent gaan volgen.
     2186    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
     2187      rememberNoteUri(o.id);
     2188    }
    18112189    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    18122190    // above): store a mention notification for each of our actors named in the Mention tags.
    18132191    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    18142192    // someone else's actor, not ours.
     2193    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
     2194    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
     2195    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
     2196    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
     2197    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
     2198    if (actorUri && !isLocalActor) {
     2199      const mark = Guardianship.help.parseMarker(o);
     2200      if (mark) {
     2201        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
     2202        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
     2203        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
     2204      }
     2205    }
    18152206    if (actorUri && !isLocalActor && o.id) {
    18162207      const slugs = localMentionSlugs(o.tag, base);
     
    24972888        const kind = type === 'Announce' ? 'boost' : 'like';
    24982889        await sendInteraction(site, kind, objUri, authorUri);
    2499         setMyReaction(site.slug, targetUri, kind, true);
    2500         if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
     2890        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
     2891        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
     2892        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
     2893        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
     2894        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
     2895        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
     2896        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
     2897        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
     2898        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
     2899        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
     2900        // Anders dan bij een boost geen upsert: een like hoort een post niet
     2901        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
     2902        // terecht een no-op.
    25012903        return { status: 202, url: objUri };
    25022904      }
     
    25472949          const objUri = (note && note.object_uri) || innerTarget;
    25482950          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2549           setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
    2550           if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
     2951          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
     2952          catch { /* non-fatal */ }
    25512953          return { status: 202, url: objUri };
    25522954        }
     
    29133315}
    29143316export function listOutbox(siteSlug) {
    2915   return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     3317  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
     3318  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
     3319  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
     3320  // elkaar in "Verzonden" en "Gesprekken".
     3321  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
    29163322    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    29173323}
     
    30513457  return { ins: _insTl, list: _listTl, del: _delTl };
    30523458}
    3053 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
     3459/**
     3460 * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
     3461 *
     3462 * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
     3463 * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
     3464 * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
     3465 * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
     3466 * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
     3467 *
     3468 * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
     3469 * heten en hangt het van de driver af welke wint. Eén extra query per pagina
     3470 * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
     3471 */
     3472export function getTimeline(slug, limit, offset) {
     3473  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
     3474  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
     3475  for (const r of rows) {
     3476    const x = reacties.get(r.id);
     3477    r.liked = !!(x && x.liked);
     3478    r.boosted = !!(x && x.boosted);
     3479  }
     3480  return rows;
     3481}
    30543482
    30553483/**
     
    30873515}
    30883516
     3517/**
     3518 * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
     3519 * (shaer-n05).
     3520 *
     3521 * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
     3522 * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
     3523 * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
     3524 * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
     3525 *
     3526 * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
     3527 * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
     3528 * andere server niet te vertrouwen is.
     3529 *
     3530 * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
     3531 * mee; de vorm mag veranderen zonder dat dat iets breekt.
     3532 */
     3533export function feedCursor(slug) {
     3534  try {
     3535    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
     3536    return String((r && r.n) || 0);
     3537  } catch { return '0'; }
     3538}
     3539
     3540/**
     3541 * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
     3542 * bewerkt, of weg.
     3543 *
     3544 * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
     3545 * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
     3546 * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
     3547 * plek waar ze samen te lezen zijn.
     3548 */
     3549export function feedChangesSince(slug, rev, limit = 200) {
     3550  try {
     3551    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
     3552                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
     3553      .all(slug, parseInt(rev, 10) || 0, limit);
     3554  } catch { return []; }
     3555}
     3556
     3557// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
     3558// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
     3559// krijgen gewoon meteen antwoord in plaats van een fout.
     3560const FEED_WAIT_MAX = 4;
     3561const _wachters = new Map();
     3562
     3563/**
     3564 * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
     3565 *
     3566 * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
     3567 * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
     3568 * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
     3569 * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
     3570 * vertraging.
     3571 */
     3572export async function waitForFeedChange(slug, opts = {}) {
     3573  const tickMs = Math.max(50, opts.tickMs || 1000);
     3574  const waitMs = Math.max(0, opts.waitMs || 0);
     3575  const since = String(opts.since || '');
     3576  let cursor = feedCursor(slug);
     3577  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
     3578  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
     3579
     3580  const bezet = _wachters.get(slug) || 0;
     3581  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
     3582  _wachters.set(slug, bezet + 1);
     3583  try {
     3584    const einde = Date.now() + waitMs;
     3585    while (Date.now() < einde) {
     3586      if (opts.signal && opts.signal.aborted) break;   // client hing op
     3587      const rest = Math.min(tickMs, einde - Date.now());
     3588      await new Promise((r) => setTimeout(r, rest));
     3589      cursor = feedCursor(slug);
     3590      if (cursor !== since) return { cursor, changed: true, waited: true };
     3591    }
     3592    return { cursor, changed: false, waited: true };
     3593  } finally {
     3594    const n = (_wachters.get(slug) || 1) - 1;
     3595    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
     3596  }
     3597}
     3598
    30893599export function getDirectMessages(slug, limit) {
    30903600  try {
     
    32433753    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    32443754      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    3245              t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
     3755             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
     3756             (rb.target_uri IS NOT NULL) AS boosted
    32463757      FROM ap_timeline t
    32473758      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
    3248       WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
     3759      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
     3760      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
     3761      -- deze join kan geen rijen verdubbelen.
     3762      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
     3763      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
    32493764      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
    32503765      LIMIT ? OFFSET ?`);
     
    32553770  try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
    32563771}
    3257 // Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
     3772// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
     3773// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
     3774// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
     3775// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
     3776//
     3777// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
     3778// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
     3779// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
     3780// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
     3781// terugdraaien een code-revert blijft in plaats van dataherstel.
    32583782let _markBoost, _unmarkBoost, _boostedCount;
    32593783export function markBoosted(slug, noteId) {
     
    32703794  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    32713795}
     3796/**
     3797 * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
     3798 * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
     3799 * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
     3800 * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
     3801 * dat leverde de shaer:liked-bug op (04aca12).
     3802 *
     3803 * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
     3804 * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
     3805 * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
     3806 * boost nergens -- ook niet in de Cirkel.
     3807 *
     3808 * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
     3809 * worden: de tussentabel op de URI die de client stuurde, de vlag op de
     3810 * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
     3811 * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
     3812 * werk voor fase 2, mét datamigratie.
     3813 */
     3814// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
     3815// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
     3816// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
     3817// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
     3818// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
     3819// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
     3820const REACTIONS_MIGRATION_VERSION = 2;
     3821
     3822/**
     3823 * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
     3824 *
     3825 * Twee stappen, en ze zijn allebei nodig:
     3826 *
     3827 *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
     3828 *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
     3829 *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
     3830 *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
     3831 *     wanneer je reageerde, bij aanvullen niet.
     3832 *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
     3833 *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
     3834 *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
     3835 *     fediverse in als gevolg.
     3836 *
     3837 * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
     3838 */
     3839export function migrateReactions(opts = {}) {
     3840  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
     3841  try {
     3842    if (!opts.force) {
     3843      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
     3844      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
     3845      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
     3846    }
     3847  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
     3848
     3849  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
     3850  const wees = `
     3851    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
     3852     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
     3853  const scheef = (kind, kolom) => `
     3854    FROM ap_timeline t
     3855     WHERE t.${kolom} = 1
     3856       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
     3857                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
     3858  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
     3859  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
     3860  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
     3861  //    dus die uitsluiting verliest per constructie niets.
     3862  const acted = (kind, kolom) => `
     3863    FROM ap_interactions i
     3864     JOIN posts p ON p.id = i.post_id
     3865     JOIN sites s ON s.id = p.site_id
     3866     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
     3867       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
     3868                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
     3869
     3870  if (opts.dryRun) {
     3871    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
     3872    uit.hersleuteld = tel(wees);
     3873    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
     3874    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
     3875    return uit;
     3876  }
     3877
     3878  try {
     3879    db.transaction(() => {
     3880      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
     3881      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
     3882      //    oplevert en nooit een verdwenen reactie.
     3883      uit.hersleuteld = db.prepare(`
     3884        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
     3885        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
     3886      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
     3887
     3888      // 2. Aanvullen vanuit de kolommen.
     3889      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
     3890        uit.aangevuld += db.prepare(`
     3891          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
     3892          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
     3893      }
     3894
     3895      // 3. En vanuit acted_* op de reacties onder je eigen posts.
     3896      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
     3897        uit.reacties += db.prepare(`
     3898          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
     3899          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
     3900      }
     3901    })();
     3902    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
     3903      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
     3904    }
     3905    if (!opts.force) {
     3906      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
     3907        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
     3908    }
     3909  } catch (e) {
     3910    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
     3911    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
     3912    console.warn('[AP] reaction migration failed:', e.message);
     3913  }
     3914  return uit;
     3915}
     3916
     3917/**
     3918 * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
     3919 *
     3920 * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
     3921 * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
     3922 * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
     3923 * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
     3924 * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
     3925 *
     3926 * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
     3927 * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
     3928 * highlight. Vandaar hier, en niet later.
     3929 *
     3930 * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
     3931 * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
     3932 * een reactie op iets buiten je tijdlijn moet gewoon werken.
     3933 */
     3934export function canonicalReactionUri(slug, uri) {
     3935  if (!slug || !uri) return uri;
     3936  try {
     3937    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
     3938    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
     3939    return (row && row.id) || uri;
     3940  } catch { return uri; }
     3941}
     3942
     3943/**
     3944 * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
     3945 * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
     3946 * dezelfde vraag beantwoordden uit twee verschillende bronnen.
     3947 */
     3948export function getReaction(slug, uri) {
     3949  try {
     3950    const key = canonicalReactionUri(slug, uri);
     3951    const rows = (slug && key)
     3952      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
     3953      : [];
     3954    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
     3955  } catch { return { liked: false, boosted: false }; }
     3956}
     3957
     3958/**
     3959 * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
     3960 * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
     3961 * een consistentiebug geruild voor een traagheidsbug.
     3962 */
     3963export function getReactionsFor(slug, uris) {
     3964  const out = new Map();
     3965  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
     3966  if (!slug || !list.length) return out;
     3967  try {
     3968    const rows = db.prepare(
     3969      `SELECT target_uri, kind FROM ap_my_reactions
     3970        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
     3971    ).all(slug, ...list);
     3972    for (const r of rows) {
     3973      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
     3974      if (r.kind === 'like') cur.liked = true;
     3975      if (r.kind === 'boost') cur.boosted = true;
     3976      out.set(r.target_uri, cur);
     3977    }
     3978  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
     3979  return out;
     3980}
     3981
     3982export function setReaction(slug, uri, kind, on, opts = {}) {
     3983  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
     3984  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
     3985  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
     3986  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
     3987  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
     3988  // maakte dezelfde like onvindbaar vanaf de andere pagina.
     3989  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
     3990  setMyReaction(slug, flagUri, kind, !!on);
     3991  if (kind === 'boost') {
     3992    if (!on) unmarkBoosted(slug, flagUri);
     3993    else if (opts.note) upsertBoostedNote(slug, opts.note);
     3994    else markBoosted(slug, flagUri);
     3995  } else if (on) markLiked(slug, flagUri);
     3996  else unmarkLiked(slug, flagUri);
     3997}
     3998
    32723999export function getTimelineReaction(slug, noteId) {
    32734000  try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
     
    33034030}
    33044031export function boostedCount(slug) {
    3305   try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
     4032  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
     4033  // bevatten van iets dat er (nog) niet in staat.
     4034  try {
     4035    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
     4036      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
     4037      WHERE r.site_slug = ? AND r.kind = 'boost'`);
     4038    return _boostedCount.get(slug).n;
     4039  } catch { return 0; }
    33064040}
    33074041
     
    40184752    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    40194753  }
    4020   console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
     4754  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
    40214755  return { ok: true, target: target.id, inboxes: inboxes.length };
    40224756}
     
    41984932      const wardDoc = await fetchActor(wardUri).catch(() => null);
    41994933      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    4200       Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
     4934      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
     4935      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
     4936      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
     4937      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
     4938      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
     4939      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
     4940      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
     4941      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
     4942      Guardianship.follows.recordReview(gslug, {
     4943        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
     4944        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
     4945        direction: uitgaand ? 'outgoing' : 'incoming',
     4946        target: doel || null, targetHandle: dai ? dai.handle : null,
     4947      });
    42014948      const L = pushLang(gslug);
    42024949      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
     
    43015048  try {
    43025049    const rows = db.prepare(`
    4303       SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
     5050      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
    43045051             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    43055052             p.slug AS post_slug, p.title AS post_title
     
    43105057    for (const r of rows) out.push({
    43115058      type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
     5059      // Waar een antwoord uit de draad heen moet: het id is de parent voor
     5060      // deliverReply, de uri het adres voor een direct bericht.
     5061      interactionId: r.interaction_id, actorUri: r.actor_uri,
    43125062      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    43135063      // When the post was written, for display. created_at (when it reached us)
     
    45435293  getOutboxRow: (id) => iStmts().getO.get(id),
    45445294  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
     5295  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
     5296  // een antwoord uit Berichten door precies één poort gaat.
     5297  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
     5298  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
    45455299});
    45465300/**
     
    45795333  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
    45805334  // own Berichten; an existing guardian and a commit land in the PWA.
     5335  //
     5336  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
     5337  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
    45815338  onEvent: (slug, ev) => {
    4582     const L = pushLang(slug);
    4583     const texts = {
    4584       offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
    4585       offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
    4586       committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
    4587       // §3.2: a guardian ended the relation. The ward hears that someone who
    4588       // was looking after them has gone; a co-guardian hears they are one fewer.
    4589       guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
    4590       coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
    4591     }[ev.kind];
    4592     if (!texts) return;
    4593     const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
    4594     const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
    4595     pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
     5339    const p = guardianEventPush(slug, ev);
     5340    if (p) pushEvent(slug, p);
    45965341  },
    45975342});
     5343
     5344/**
     5345 * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
     5346 *
     5347 * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
     5348 * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
     5349 * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
     5350 * erbij te halen.
     5351 */
     5352export function guardianEventPush(slug, ev) {
     5353  const L = pushLang(slug);
     5354  const texts = {
     5355    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
     5356    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
     5357    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     5358    // §3.2: a guardian ended the relation. The ward hears that someone who
     5359    // was looking after them has gone; a co-guardian hears they are one fewer.
     5360    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
     5361    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
     5362    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
     5363    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
     5364    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
     5365    // geen drempel.
     5366    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
     5367    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
     5368  }[ev.kind];
     5369  if (!texts) return null;
     5370  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
     5371  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
     5372  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
     5373  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
     5374  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
     5375  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
     5376  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
     5377  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
     5378}
     5379
     5380// Van een gated feature naar het woord dat het Guardian-paneel er al voor
     5381// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
     5382// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
     5383const GATE_LABEL = {
     5384  'shaer:externalEmbeds': 'guardian.prop_embeds',
     5385  'shaer:externalPlayback': 'guardian.prop_play',
     5386};
     5387const GATE_OUTCOME = {
     5388  accepted: 'guardian.prop_st_accepted',
     5389  rejected: 'guardian.prop_st_rejected',
     5390  expired: 'guardian.prop_st_expired',
     5391};
    45985392
    45995393// The notification duty of FEP-633c 3.6.2, wired once for every place a
     
    46255419  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    46265420  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    4627   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
     5421  feedCursor, feedChangesSince, waitForFeedChange,
     5422  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    46285423  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    46295424  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     
    46315426  gateOutgoingFollow, performApprovedFollow,
    46325427  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    4633   autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     5428  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    46345429  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    46355430  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • src/services/guardianship/delivery.js

    rf85b2c3 r952baf3  
    4141// guardian on any instance receives it as a private mention (the ward
    4242// call-for-help path).
    43 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
     43export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
    4444  const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
    4545          getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
     
    8383    return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
    8484  }).join('');
     85  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
     86  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
     87  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
     88  // een leeggepoetste editor mag geen leeg bericht versturen.
     89  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
     90  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
    8591  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
    86   const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
     92  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
     93  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
     94  const content = rich
     95    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
     96    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
    8797  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    8898  // Attachments: same rules as deliverReply (own /media/ uploads only,
     
    99109  const row = getOutboxRow(id);
    100110  const note = buildReplyNote(base, site, row);
     111  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
     112  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
     113  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
     114  // mede-guardians er staat aan over.
     115  if (helpMark && helpMark.noteUri) {
     116    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
     117  }
    101118  const create = {
    102119    '@context': AP_CONTEXT,
  • src/services/guardianship/follows.js

    rf85b2c3 r952baf3  
    8181    _r = {
    8282      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
    83         (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
    84         VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
     83        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
     84         direction, target_uri, target_handle, created_at)
     85        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
    8586      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
    8687      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
     
    9192}
    9293
     94/**
     95 * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
     96 *
     97 * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
     98 * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
     99 * follower en staat het doel in het Follow-object -- die werd hiervoor
     100 * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
     101 */
    93102export function recordReview(guardianSlug, r) {
    94   rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
     103  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
     104  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
     105    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
    95106  return rstmts().get.get(guardianSlug, r.id);
     107}
     108
     109/**
     110 * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
     111 * lezen (offers en outgoing-follows doen het net zo).
     112 */
     113export function reviewQueueItem(r, me, guardianCount) {
     114  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
     115  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
     116  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
     117  const stemmen = (() => {
     118    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
     119    catch { return []; }
     120  })();
     121  const uitgaand = r.direction === 'outgoing';
     122  return {
     123    id: r.id,
     124    type: 'Follow',
     125    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
     126    actor: uitgaand ? r.ward_uri : r.follower_uri,
     127    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
     128    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
     129    'shaer:ward': r.ward_uri,
     130    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
     131    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
     132    'shaer:follower': uitgaand ? undefined : r.follower_uri,
     133    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
     134    'shaer:quorum': 'all',
     135    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
     136    'shaer:guardianCount': guardianCount || undefined,
     137    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
     138    published: r.created_at,
     139  };
     140}
     141
     142/** De openstaande reviews van een guardian, per richting. */
     143export function listReviewsByDirection(guardianSlug, direction) {
     144  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
    96145}
    97146export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
     
    102151  recordPending, getPending, listForWard, decide, remove,
    103152  recordReview, getReview, listReviews, removeReview,
     153  listReviewsByDirection, reviewQueueItem,
    104154};
  • src/services/guardianship/gated.js

    rf85b2c3 r952baf3  
    6060  'shaer:externalPlayback': 'external_playback',
    6161};
     62/**
     63 * De gates die deze Klonkt kent, met hun SOORT.
     64 *
     65 * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
     66 * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
     67 * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
     68 * te zijn en geen nieuw stuk scherm.
     69 *
     70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
     71 * een guardian niet hoeven raden:
     72 *
     73 *   setting     een stand, aan of uit, terug te draaien
     74 *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
     75 *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
     76 *
     77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
     78 * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
     79 */
     80export const GATE_CATALOGUE = [
     81  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
     82  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
     83  // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
     84  // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
     85  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
     86];
     87
     88/**
     89 * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
     90 * getoetst kunnen worden zonder database of scherm.
     91 *
     92 * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
     93 * @param guardianCount  aantal guardians, of null als we het niet weten
     94 * @param proposals      [{feature, value, status}] lopende voorstellen
     95 * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
     96 */
     97export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
     98  return GATE_CATALOGUE.map((g) => {
     99    const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
     100    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
     101    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
     102    // ooit dat een voorstel nooit geopend kon worden.
     103    const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
     104    return {
     105      feature: g.feature,
     106      kind: g.kind,
     107      reversible: !!g.reversible,
     108      value,
     109      // Vast staat vast: tonen mag, verzetten niet.
     110      adjustable: !g.fixed && !blockedBy,
     111      blockedBy: blockedBy || undefined,
     112      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
     113      // leest als een feit, en dit is precies waar een guardian op afgaat.
     114      threshold: (guardianCount && guardianCount > 0)
     115        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
     116      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
     117      waiting: waiting[g.feature] || undefined,
     118    };
     119  });
     120}
     121
    62122export function featureColumn(feature) {
    63123  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
     
    239299
    240300export default {
     301  GATE_CATALOGUE, gateRows,
    241302  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
    242303  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
  • src/services/guardianship/index.js

    rf85b2c3 r952baf3  
    3232// §5.6 gated settings (decided by the guardians, enforced by the ward's server)
    3333export * as gated from './gated.js';
     34// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
     35export * as help from './help.js';
  • src/services/guardianship/queues.js

    rf85b2c3 r952baf3  
    66 *  - offers:  pending handshake offers where I am a party (§3), with the full
    77 *             accept tally so the client shows the right action
    8  *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
     8 *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
    99 *  - wards:   my committed wards
    1010 */
     
    1313import * as availability from './availability.js';
    1414import * as outgoing from './outgoing.js';
     15import * as follows from './follows.js';
    1516import * as handshake from './handshake.js';
    1617
     
    3536}
    3637
    37 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
    38 export function followsCollection(id) {
    39   return collection(id, []);
     38/**
     39 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
     40 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
     41 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
     42 *
     43 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
     44 * op deze:
     45 *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
     46 *   - ap_pending_follows: een ward op deze instance
     47 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
     48 */
     49export function followsCollection(id, slug, me) {
     50  const items = follows.listReviewsByDirection(slug, 'incoming')
     51    .map((r) => follows.reviewQueueItem(r, me));
     52  for (const w of relations.listWards(slug)) {
     53    const wardSlug = slugOf(w.other_uri);
     54    if (!wardSlug) continue;
     55    for (const p of follows.listForWard(wardSlug)) {
     56      items.push({
     57        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
     58        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
     59        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
     60        'shaer:quorum': p.quorum || 'any', published: p.created_at,
     61      });
     62    }
     63  }
     64  return collection(id, items);
    4065}
    4166
    42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
     67/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
     68function slugOf(uri) {
     69  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     70  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
     71  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
     72}
     73
     74/**
     75 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
     76 * guardian wederzijds uitsluit: je bent het een of het ander.
     77 *
     78 *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
     79 *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
     80 *
     81 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
     82 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
     83 * follow" kon nooit iets tonen.
     84 */
    4385export function outgoingFollowsCollection(id, slug, me) {
    44   return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
     86  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
     87  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
     88    items.push(follows.reviewQueueItem(r, me));
     89  }
     90  return collection(id, items);
    4591}
    4692
  • src/services/i18n.js

    rf85b2c3 r952baf3  
    6767    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
    6868    '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.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', '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.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', '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.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', '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_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', '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.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', '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.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', '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.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', '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_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', '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}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
    7070    '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',
    7171    '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.',
     
    10171017    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
    10181018    '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',
    1019     '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.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', '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.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You 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. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', '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.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', '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_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', '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.',
     1019    '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.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be 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.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You 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. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', '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.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', '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_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', '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', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
    10201020    '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',
    10211021    '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.',
     
    19611961    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
    19621962    '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',
    1963     '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.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', '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.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', '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.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', '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_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', '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.',
     1963    '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.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', '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.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', '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.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', '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_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', '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', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
    19641964    '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',
    19651965    '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/admin-audio.ejs

    rf85b2c3 r952baf3  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    575 <script>
    576 (function() {
    577 
    578   // ── Live filename display for custom file inputs ──────────────
    579   document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
    580     const nameEl = input.parentElement.querySelector('.ax-file-name');
    581     if (!nameEl) return;
    582     input.addEventListener('change', () => {
    583       if (input.files && input.files[0]) {
    584         nameEl.textContent = input.files[0].name;
    585         nameEl.removeAttribute('data-empty');
    586       } else {
    587         nameEl.textContent = '<%= t('aaud.no_file') %>';
    588         nameEl.setAttribute('data-empty', '');
    589       }
    590     });
    591   });
    592 
    593   // ── Inline track preview (routed through the global mini-player) ──
    594   // Each .ax-track-play button is a thin wrapper around the global
    595   // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
    596   // .is-playing) is synced from the player's own audio element so it
    597   // stays accurate even when the user uses the mini-player's controls.
    598   (function setupTrackPreview() {
    599     const buttons = document.querySelectorAll('.ax-track-play');
    600     if (!buttons.length) return;
    601 
    602     function setIcon(btn, playing) {
    603       const icon = btn.querySelector('.ax-track-play-icon');
    604       if (icon) icon.textContent = playing ? '⏸' : '▶';
    605       btn.classList.toggle('is-playing', playing);
    606       btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
    607     }
    608 
    609     // Resync ALL preview buttons against the current audio element state.
    610     // Called on every play/pause/ended event so admins always see the right
    611     // icon — including the case where they hit pause on the mini-player
    612     // bar instead of the row's button.
    613     function resyncAll() {
    614       const audio = document.getElementById('audio-element');
    615       const player = window.pcmsAudioPlayer;
    616       const playing = audio && !audio.paused && !audio.ended;
    617       // audio.src is now a blob: URL (Spotify-style playback), so compare
    618       // against the player's logical track URL, not the element src.
    619       const cur = player && player.currentTrack();
    620       const curUrl = cur ? cur.url : '';
    621       buttons.forEach(b => {
    622         const isThisOne = playing && curUrl === b.dataset.streamUrl;
    623         setIcon(b, isThisOne);
    624       });
    625     }
    626 
    627     buttons.forEach(btn => {
    628       btn.addEventListener('click', e => {
    629         e.preventDefault();
    630         const player = window.pcmsAudioPlayer;
    631         if (!player) {
    632           console.warn('[admin-audio] miniplayer not available');
    633           return;
    634         }
    635         const url = btn.dataset.streamUrl;
    636         if (!url) return;
    637 
    638         // Build track metadata from the row's DOM so the mini-player shows
    639         // useful info (title/artist/album/cover) without an extra API call.
    640         const row = btn.closest('.ax-track');
    641         const titleEl  = row && row.querySelector('[data-cell="title"]');
    642         const artistEl = row && row.querySelector('[data-cell="artist"]');
    643         const albumEl  = row && row.querySelector('[data-cell="album"]');
    644         const coverImg = row && row.querySelector('img[data-cover-thumb]');
    645         const track = {
    646           url,
    647           title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
    648           artist: artistEl ? artistEl.textContent.trim() : '',
    649           album:  albumEl  ? albumEl.textContent.trim()  : '',
    650           cover:  coverImg ? coverImg.src                : '',
    651         };
    652 
    653         // If this exact track is already current, toggle pause/play instead
    654         // of restarting from zero. Compare logical URLs (audio.src is a blob:).
    655         const cur = player.currentTrack();
    656         if (cur && cur.url === url) {
    657           if (player.isPlaying()) player.pause();
    658           else                    player.play();
    659           return;
    660         }
    661 
    662         player.setQueue([track], 0);
    663       });
    664     });
    665 
    666     // Hook the global audio element's events to keep the row buttons synced.
    667     // We attach lazily after the player has built its DOM. The script in
    668     // shell.ejs runs at page-load so #audio-element exists by the time
    669     // this IIFE fires (script tag is below the body content).
    670     const audio = document.getElementById('audio-element');
    671     if (audio) {
    672       ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
    673         audio.addEventListener(ev, resyncAll);
    674       });
    675       // Initial state on page load (e.g. user navigated back while a track
    676       // was already playing — buttons should reflect that).
    677       resyncAll();
    678     }
    679   })();
    680 
    681 
    682   // ── Bulk upload (drag-drop + sequential transcoding) ─────────
    683   // Files dropped or picked are queued (not uploaded immediately) so the
    684   // user can review the list, set shared metadata, then hit "Start upload".
    685   // The loop POSTs one file at a time to /admin/audio/upload with
    686   // Accept: application/json so the server returns structured per-file
    687   // results instead of redirecting.
    688   const dropzone   = document.getElementById('audio-dropzone');
    689   const fileInput  = document.getElementById('audio-files');
    690   const queueEl    = document.getElementById('upload-queue');
    691   const actionsEl  = document.getElementById('upload-actions');
    692   const startBtn   = document.getElementById('start-upload-btn');
    693   const clearBtn   = document.getElementById('clear-queue-btn');
    694   const artistInput= document.getElementById('batch-artist');
    695   const albumInput = document.getElementById('batch-album');
    696   const coverInput = document.getElementById('batch-cover');
    697 
    698   /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
    699   const queue = [];
    700 
    701   function fmtBytes(n) {
    702     if (n < 1024) return n + ' B';
    703     if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
    704     return (n / 1024 / 1024).toFixed(1) + ' MB';
    705   }
    706 
    707   function setItemStatus(item, status, label) {
    708     const labels = {
    709       queued:      '<%= t('aaud.st_queued') %>',
    710       uploading:   '<%= t('aaud.st_uploading') %>',
    711       transcoding: '<%= t('aaud.st_transcoding') %>',
    712       done:        '✓ <%= t('aaud.st_done') %>',
    713       error:       '✗ <%= t('aaud.st_error') %>',
    714     };
    715     item.status = status;
    716     const badge = item.el.querySelector('.ax-queue-status');
    717     badge.className = 'ax-queue-status ax-queue-status--' + status;
    718     badge.textContent = label || labels[status] || status;
    719     // Restyle the row
    720     item.el.classList.remove(
    721       'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
    722     );
    723     if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
    724     else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
    725     else if (status === 'error') item.el.classList.add('ax-queue-item--error');
    726   }
    727 
    728   function addFiles(files) {
    729     let added = 0;
    730     for (const file of files) {
    731       if (!file.type.startsWith('audio/') &&
    732           !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
    733         // Silently skip non-audio drops; keeps the UX uncluttered.
    734         continue;
    735       }
    736       const li = document.createElement('li');
    737       li.className = 'ax-queue-item';
    738       li.innerHTML =
    739         '<div class="ax-queue-name"></div>' +
    740         '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
    741       // Use textContent to avoid HTML-injection if a filename contains markup.
    742       li.querySelector('.ax-queue-name').textContent = file.name;
    743       // Append size hint inline
    744       const size = document.createElement('span');
    745       size.className = 'ax-queue-size';
    746       size.textContent = fmtBytes(file.size);
    747       li.querySelector('.ax-queue-name').appendChild(size);
    748       queueEl.appendChild(li);
    749       queue.push({ file, el: li, status: 'queued' });
    750       added++;
    751     }
    752     if (added) {
    753       queueEl.hidden = false;
    754       actionsEl.hidden = false;
    755     }
    756   }
    757 
    758   // ── Drop-zone events ─────────────────────────────────────────
    759   // Page-level guard: a dropped file outside the zone would otherwise
    760   // make the browser navigate to it (e.g. opening the audio inline), which
    761   // discards typed metadata. We swallow drops anywhere unless the dropzone
    762   // explicitly handles them.
    763   ['dragover', 'drop'].forEach(ev => {
    764     window.addEventListener(ev, e => {
    765       // Allow drops INSIDE the dropzone — its own listener handles those.
    766       if (dropzone.contains(e.target)) return;
    767       e.preventDefault();
    768     });
    769   });
    770 
    771   ['dragenter', 'dragover'].forEach(ev => {
    772     dropzone.addEventListener(ev, e => {
    773       e.preventDefault();
    774       dropzone.classList.add('is-dragover');
    775     });
    776   });
    777   ['dragleave', 'drop'].forEach(ev => {
    778     dropzone.addEventListener(ev, e => {
    779       e.preventDefault();
    780       dropzone.classList.remove('is-dragover');
    781     });
    782   });
    783   dropzone.addEventListener('drop', e => {
    784     if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
    785   });
    786   fileInput.addEventListener('change', () => {
    787     addFiles(fileInput.files);
    788     // Reset so the same file can be picked again later if user wants
    789     fileInput.value = '';
    790   });
    791 
    792   // ── Clear queue button ───────────────────────────────────────
    793   clearBtn.addEventListener('click', () => {
    794     // Only remove items that aren't currently uploading (anything queued
    795     // or already finished). An in-progress upload finishes, then its row
    796     // would also disappear once we re-render — but we keep it simple and
    797     // just refuse to clear during an active run.
    798     if (startBtn.disabled) return;
    799     queue.length = 0;
    800     queueEl.innerHTML = '';
    801     queueEl.hidden = true;
    802     actionsEl.hidden = true;
    803   });
    804 
    805   // ── Sequential upload loop ───────────────────────────────────
    806   startBtn.addEventListener('click', async () => {
    807     if (startBtn.disabled) return;
    808     startBtn.disabled = true;
    809     clearBtn.disabled = true;
    810     dropzone.style.pointerEvents = 'none';
    811     dropzone.style.opacity = '0.5';
    812 
    813     const sharedArtist = artistInput.value.trim();
    814     const sharedAlbum  = albumInput.value.trim();
    815     const sharedCover  = coverInput.files && coverInput.files[0];
    816 
    817     // Process queued items one at a time. We iterate via index so that
    818     // if more files get dropped during the run they ALSO get processed
    819     // (queue.push above mutates the same array we're iterating).
    820     for (let i = 0; i < queue.length; i++) {
    821       const item = queue[i];
    822       if (item.status !== 'queued') continue;
    823       try {
    824         await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
    825       } catch (err) {
    826         console.error('upload failed for', item.file.name, err);
    827         setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
    828       }
    829     }
    830 
    831     startBtn.disabled = false;
    832     clearBtn.disabled = false;
    833     dropzone.style.pointerEvents = '';
    834     dropzone.style.opacity = '';
    835 
    836     // Reload the page so the new tracks appear in the list below.
    837     // Could also fetch them and inject, but a full reload is simpler and
    838     // ensures position indexes / album-grouping are correct.
    839     const anyDone = queue.some(q => q.status === 'done');
    840     if (anyDone) {
    841       setTimeout(() => location.reload(), 700);
    842     }
    843   });
    844 
    845   async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
    846     setItemStatus(item, 'uploading');
    847 
    848     const fd = new FormData();
    849     fd.append('audio', item.file);
    850     if (sharedArtist) fd.append('artist', sharedArtist);
    851     if (sharedAlbum)  fd.append('album',  sharedAlbum);
    852     if (sharedCover)  fd.append('cover',  sharedCover);
    853     // Title is intentionally omitted — server uses filename fallback.
    854 
    855     // We can't reliably distinguish "still uploading bytes" from
    856     // "uploading done, ffmpeg running" without progress events, but the
    857     // status flips to "Converteren…" once the request is past upload phase.
    858     // We approximate this by waiting until the response arrives — by then
    859     // both phases are complete on the server side. For a smoother feel we
    860     // briefly show "transcoding" near the end of the request lifecycle.
    861     const transcodeHint = setTimeout(() => {
    862       if (item.status === 'uploading') setItemStatus(item, 'transcoding');
    863     }, 1500);
    864 
    865     try {
    866       const res = await fetch('/admin/audio/upload', {
    867         method: 'POST',
    868         headers: { 'Accept': 'application/json' },
    869         body: fd,
    870         credentials: 'same-origin',
    871       });
    872       clearTimeout(transcodeHint);
    873 
    874       // Server responds with JSON for our Accept header. If it didn't
    875       // (e.g. session expired and got an HTML login page), surface that.
    876       let data;
    877       try { data = await res.json(); }
    878       catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
    879 
    880       if (!res.ok || !data.ok) {
    881         throw new Error(data.error || ('HTTP ' + res.status));
    882       }
    883 
    884       setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
    885     } catch (err) {
    886       clearTimeout(transcodeHint);
    887       throw err;
    888     }
    889   }
    890 
    891   // ── Click-to-copy embed codes ─────────────────────────────────
    892   document.querySelectorAll('[data-copy]').forEach(el => {
    893     el.addEventListener('click', async () => {
    894       const text = el.dataset.copy;
    895       try {
    896         await navigator.clipboard.writeText(text);
    897         el.classList.add('is-copied');
    898         const original = el.textContent;
    899         el.textContent = '✓ <%= t('aaud.copied') %>';
    900         setTimeout(() => {
    901           el.classList.remove('is-copied');
    902           el.textContent = original;
    903         }, 1200);
    904       } catch (_) { /* fall through — selection still works */ }
    905     });
    906   });
    907 
    908   // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
    909   const addLinkBtn = document.getElementById('add-link-track-btn');
    910   if (addLinkBtn) {
    911     addLinkBtn.addEventListener('click', async () => {
    912       addLinkBtn.disabled = true;
    913       try {
    914         const r = await fetch('/admin/audio/create-link', {
    915           method: 'POST', credentials: 'same-origin',
    916           headers: { 'Content-Type': 'application/json' },
    917           body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
    918         });
    919         const j = await r.json();
    920         if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
    921         if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
    922         window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
    923       } catch (err) {
    924         alert('<%= t('aaud.create_failed') %>: ' + err.message);
    925       } finally {
    926         addLinkBtn.disabled = false;
    927       }
    928     });
    929   }
    930 
    931   // ── Wire all "Edit" buttons to the track-editor modal ─────────
    932   // After save we patch the row in-place rather than reloading,
    933   // so the user keeps their scroll position on long lists.
    934   document.querySelectorAll('[data-track-edit]').forEach(btn => {
    935     btn.addEventListener('click', () => {
    936       if (typeof window.openTrackEditor !== 'function') {
    937         alert('<%= t('aaud.editor_not_loaded') %>');
    938         return;
    939       }
    940       const id = btn.dataset.id;
    941       window.openTrackEditor({
    942         id,
    943         onSaved: (track) => {
    944           const row = document.querySelector('li[data-track-id="' + id + '"]');
    945           if (!row) return;
    946           // Update visible cells
    947           const titleEl  = row.querySelector('[data-cell="title"]');
    948           const artistEl = row.querySelector('[data-cell="artist"]');
    949           const albumEl  = row.querySelector('[data-cell="album"]');
    950           if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
    951           if (artistEl) artistEl.textContent = track.artist || '—';
    952           if (albumEl)  {
    953             albumEl.textContent = track.album || '';
    954             if (track.album) albumEl.removeAttribute('hidden');
    955             else albumEl.setAttribute('hidden', '');
    956           }
    957           // Update cover thumb (replace element if type changed)
    958           const oldThumb = row.querySelector('[data-cover-thumb]');
    959           if (oldThumb) {
    960             const parent = oldThumb.parentElement;
    961             if (track.cover_url) {
    962               const img = document.createElement('img');
    963               img.className = 'ax-track-cover';
    964               img.src = track.cover_url;
    965               img.alt = '';
    966               img.dataset.coverThumb = '';
    967               parent.replaceChild(img, oldThumb);
    968             } else {
    969               const sp = document.createElement('span');
    970               sp.className = 'ax-track-cover ax-track-cover-empty';
    971               sp.textContent = '♫';
    972               sp.dataset.coverThumb = '';
    973               parent.replaceChild(sp, oldThumb);
    974             }
    975           }
    976         },
    977       });
    978     });
    979   });
    980 
    981   // Download-voor-email toggle — AJAX (geen pagina-reload meer).
    982   document.querySelectorAll('[data-track-dl]').forEach(btn => {
    983     btn.addEventListener('click', async () => {
    984       if (btn.disabled) return;
    985       const want = btn.dataset.on === '1' ? 0 : 1;
    986       btn.disabled = true;
    987       try {
    988         const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
    989           method: 'POST',
    990           headers: { 'Content-Type': 'application/json' },
    991           body: JSON.stringify({ downloadable: !!want }),
    992         });
    993         if (!res.ok) throw new Error('HTTP ' + res.status);
    994         btn.dataset.on = String(want);
    995         btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
    996         btn.style.opacity = want ? '1' : '.5';
    997         btn.title = want
    998           ? '<%= t('aaud.dl_on') %>'
    999           : '<%= t('aaud.dl_off') %>';
    1000       } catch (e) {
    1001         alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
    1002       } finally {
    1003         btn.disabled = false;
    1004       }
    1005     });
    1006   });
    1007 })();
    1008 </script>
     575<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     576<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
  • src/views/pages/admin-epk.ejs

    rf85b2c3 r952baf3  
    9090  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
    9191</style>
    92 <script>
    93 (function () {
    94   var box = document.querySelector('.epk-track-pick');
    95   if (!box) return;
    96   var max = parseInt(box.dataset.max, 10) || 5;
    97   function sync() {
    98     var checks = box.querySelectorAll('input[type=checkbox]');
    99     var n = box.querySelectorAll('input[type=checkbox]:checked').length;
    100     checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
    101   }
    102   box.addEventListener('change', sync);
    103   sync();
    104 })();
    105 </script>
     92<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
  • src/views/pages/admin-help.ejs

    rf85b2c3 r952baf3  
    145145  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
    146146</style>
    147 <script>
    148 (function () {
    149   var q = document.getElementById('hb-q');
    150   var items = [].slice.call(document.querySelectorAll('.hb-item'));
    151   var empty = document.getElementById('hb-empty');
    152   var count = document.getElementById('hb-count');
    153   if (!q) return;
    154   // Bewaar de originele tekst per item (voor highlight-reset).
    155   items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
    156   function run() {
    157     var term = q.value.trim().toLowerCase();
    158     var shown = 0;
    159     items.forEach(function (it) {
    160       var hit = !term || it._txt.indexOf(term) >= 0;
    161       it.classList.toggle('hb-hidden', !hit);
    162       if (hit) shown++;
    163     });
    164     empty.hidden = shown !== 0;
    165     count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
    166   }
    167   q.addEventListener('input', run);
    168 })();
    169 </script>
     147<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
  • src/views/pages/admin-media.ejs

    rf85b2c3 r952baf3  
    4141</div>
    4242
    43 <script>
    44 (function () {
    45   if (window.__mediaWired) return; window.__mediaWired = true;
    46   var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>;
    47   document.addEventListener('click', function (e) {
    48     var c = e.target.closest('[data-copy]');
    49     if (c) {
    50       var u = location.origin + c.getAttribute('data-copy');
    51       var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
    52       if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
    53       else window.prompt('URL', u);
    54       return;
    55     }
    56     var d = e.target.closest('[data-del]');
    57     if (d) {
    58       if (!window.confirm(T.delC)) return;
    59       fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
    60         .then(function (r) { return r.json(); })
    61         .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); })
    62         .catch(function () { window.alert('Error'); });
    63       return;
    64     }
    65     if (e.target.closest('#media-cleanup')) {
    66       if (!window.confirm(T.cleanC)) return;
    67       fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' })
    68         .then(function (r) { return r.json(); })
    69         .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); })
    70         .catch(function () { window.alert('Error'); });
    71     }
    72   });
    73 })();
    74 </script>
     43<%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     44<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %>
  • src/views/pages/admin-paid.ejs

    rf85b2c3 r952baf3  
    7272</div>
    7373
    74 <script>
    75 (function () {
    76   var field = document.getElementById('pd-redirect');
    77   var btn = document.getElementById('pd-copy');
    78   if (!field || !btn) return;
    79   field.addEventListener('focus', function () { field.select(); });
    80   btn.addEventListener('click', function () {
    81     field.select();
    82     var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
    83     if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
    84     else { try { document.execCommand('copy'); done(); } catch (e) {} }
    85   });
    86 })();
    87 </script>
     74<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
  • src/views/pages/admin-playlists.ejs

    rf85b2c3 r952baf3  
    222222</style>
    223223
    224 <script>
    225 (function() {
    226   const csrf = '<%= _csrf %>';
    227 
    228   document.getElementById('pl-new-btn')?.addEventListener('click', () => {
    229     if (typeof window.openPlaylistEditor === 'function') {
    230       window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
    231     }
    232   });
    233 
    234   // Click-to-copy on shortcodes
    235   document.querySelectorAll('[data-copy]').forEach(el => {
    236     el.addEventListener('click', async () => {
    237       try {
    238         await navigator.clipboard.writeText(el.dataset.copy);
    239         el.classList.add('is-copied');
    240         const original = el.textContent;
    241         el.textContent = '✓ <%= t('apl.copied') %>';
    242         setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
    243       } catch (_) { /* fall back to selection */ }
    244     });
    245   });
    246 
    247   document.querySelectorAll('[data-pl-edit]').forEach(btn => {
    248     btn.addEventListener('click', () => {
    249       if (typeof window.openPlaylistEditor === 'function') {
    250         window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
    251       }
    252     });
    253   });
    254 
    255   document.querySelectorAll('[data-pl-delete]').forEach(btn => {
    256     btn.addEventListener('click', async () => {
    257       const id = btn.dataset.id;
    258       const title = btn.dataset.title || id;
    259       if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return;
    260       try {
    261         const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
    262           method: 'POST',
    263           headers: { 'X-CSRF-Token': csrf },
    264           credentials: 'same-origin',
    265         });
    266         const j = await r.json();
    267         if (j.ok) location.reload();
    268         else alert('<%= t('apl.delete_failed') %>: ' + (j.error || ''));
    269       } catch (err) {
    270         alert('<%= t('apl.delete_failed') %>: ' + err.message);
    271       }
    272     });
    273   });
    274 
    275   // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
    276   // openPlaylistEditor is defined synchronously by the included partial, so
    277   // it's available by the time this IIFE runs.
    278   (function deepLinkEdit() {
    279     const params = new URLSearchParams(location.search);
    280     const editId = params.get('edit');
    281     if (!editId) return;
    282     if (typeof window.openPlaylistEditor !== 'function') return;
    283     // Strip the query param immediately so reload after save doesn't re-open.
    284     history.replaceState({}, '', location.pathname);
    285     window.openPlaylistEditor({
    286       mode: 'edit',
    287       id: editId,
    288       onSaved: () => location.reload(),
    289     });
    290   })();
    291 })();
    292 </script>
     224<%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     225<%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %>
  • src/views/pages/admin-push.ejs

    rf85b2c3 r952baf3  
    5858  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') },
    5959}).replace(/</g, '\\u003c') %></script>
    60 <script>
    61 (function () {
    62   var cfg = JSON.parse(document.getElementById('np-data').textContent);
    63   var elState = document.getElementById('np-state');
    64   var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
    65   var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
    66   var currentEndpoint = null;
    67 
    68   var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
    69   var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
    70   if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
    71 
    72   if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
    73     document.getElementById('np-unsupported').hidden = false;
    74     elState.textContent = cfg.i18n.unsupported;
    75     return;
    76   }
    77 
    78   function b64ToU8(s) {
    79     var pad = '='.repeat((4 - (s.length % 4)) % 4);
    80     var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
    81     var out = new Uint8Array(raw.length);
    82     for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
    83     return out;
    84   }
    85   function post(url, body) {
    86     return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
    87   }
    88   function deviceLabel() {
    89     var ua = navigator.userAgent;
    90     var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
    91     var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
    92     return (browser + (os ? ' op ' + os : ''));
    93   }
    94   function readAlertBoxes() {
    95     var out = {};
    96     alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
    97     return out;
    98   }
    99   function setAlertBoxes(alerts) {
    100     alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
    101       cb.checked = !!alerts[cb.getAttribute('data-alert')];
    102     });
    103   }
    104 
    105   function render(sub) {
    106     currentEndpoint = sub ? sub.endpoint : null;
    107     elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
    108     btnOn.hidden = !!sub || Notification.permission === 'denied';
    109     btnOff.hidden = !sub;
    110     btnTest.hidden = !sub;
    111     alertsBox.hidden = !sub;
    112   }
    113 
    114   navigator.serviceWorker.ready.then(function (reg) {
    115     return reg.pushManager.getSubscription();
    116   }).then(function (sub) {
    117     // Show this device's SAVED prefs when we know them, defaults otherwise.
    118     setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
    119     render(sub);
    120   }).catch(function () { elState.textContent = cfg.i18n.unknown; });
    121 
    122   btnOn.addEventListener('click', function () {
    123     btnOn.disabled = true;
    124     Notification.requestPermission().then(function (perm) {
    125       if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
    126       navigator.serviceWorker.ready.then(function (reg) {
    127         return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
    128       }).then(function (sub) {
    129         return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
    130           .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
    131       }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
    132     });
    133   });
    134 
    135   btnOff.addEventListener('click', function () {
    136     navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
    137       if (!sub) { render(null); return; }
    138       var ep = sub.endpoint;
    139       sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
    140         .then(function () { location.reload(); });
    141     });
    142   });
    143 
    144   btnTest.addEventListener('click', function () {
    145     btnTest.disabled = true;
    146     post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
    147   });
    148 
    149   alertsBox.addEventListener('change', function () {
    150     if (!currentEndpoint) return;
    151     post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
    152       if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
    153     });
    154   });
    155 
    156   document.querySelectorAll('.np-remove').forEach(function (btn) {
    157     btn.addEventListener('click', function () {
    158       post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
    159     });
    160   });
    161 })();
    162 </script>
     60<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
    16361<% } %>
  • src/views/pages/admin-settings.ejs

    rf85b2c3 r952baf3  
    2727      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
    2828    </form>
    29     <script>
    30     (function () {
    31       var f = document.getElementById('mode-form');
    32       if (!f || f.__age18wired) return; f.__age18wired = true;
    33       f.addEventListener('submit', function (e) {
    34         var sel = f.querySelector('input[name=mode]:checked');
    35         if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
    36           if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
    37         }
    38       });
    39     })();
    40     </script>
     29    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
    4130  </section>
    4231
  • src/views/pages/admin-site-edit.ejs

    rf85b2c3 r952baf3  
    126126    <%# Live preview: selecting an accent / theme / palette re-themes this page
    127127        instantly (reverts on reload; persists on save). %>
    128     <script>
    129     (function(){
    130       if (window.__themePreviewWired) return; window.__themePreviewWired = true;
    131       var html = document.documentElement;
    132       function applyAccent(hex){
    133         if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
    134         var sa = document.getElementById('pcms-site-accent');
    135         if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
    136         sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
    137       }
    138       document.addEventListener('change', function(e){
    139         var t = e.target; if(!t || !t.name) return;
    140         if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
    141         else if(t.name === 'accent'){ applyAccent(t.value); }
    142         else if(t.name === 'theme_override'){
    143           if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
    144           else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
    145         }
    146       });
    147     })();
    148     </script>
     128    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
    149129
    150130    <fieldset>
     
    275255</div>
    276256
    277 <script>
    278 (function() {
    279   // Slug URL field: show the real host as a dimmed prefix (the slug itself is
    280   // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
    281   var sh = document.getElementById('slug-host');
    282   if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
    283 })();
    284 (function() {
    285   // Profile-links repeater: add row from <template>, remove on click.
    286   var rows = document.getElementById('profile-links-rows');
    287   var tpl  = document.getElementById('profile-link-template');
    288   var add  = document.getElementById('profile-link-add');
    289   if (!rows || !tpl || !add) return;
    290 
    291   add.addEventListener('click', function() {
    292     var clone = tpl.content.cloneNode(true);
    293     rows.appendChild(clone);
    294   });
    295   rows.addEventListener('click', function(e) {
    296     if (e.target && e.target.classList.contains('pl-remove')) {
    297       var row = e.target.closest('.profile-link-row');
    298       if (row) row.remove();
    299     }
    300   });
    301 })();
    302 
    303 // P63 — Profile photo picker: upload via /admin/sites/upload-photo,
    304 // then write the returned URL into the visible input. Live thumb preview.
    305 (function() {
    306   var picker  = document.getElementById('photo-picker');
    307   if (!picker) return;
    308   var thumb   = document.getElementById('photo-thumb');
    309   var preview = document.getElementById('photo-preview');
    310   var urlEl   = document.getElementById('photo-url');
    311   var trigger = document.getElementById('photo-upload-trigger');
    312   var clear   = document.getElementById('photo-clear');
    313   var field   = document.getElementById('photo-upload-field');
    314   var status  = document.getElementById('photo-status');
    315 
    316   function showPreview(url) {
    317     if (url) {
    318       preview.src = url;
    319       preview.hidden = false;
    320       thumb.removeAttribute('data-empty');
    321       clear.hidden = false;
    322     } else {
    323       preview.src = '';
    324       preview.hidden = true;
    325       thumb.setAttribute('data-empty', '');
    326       clear.hidden = true;
    327     }
    328   }
    329 
    330   if (urlEl) {
    331     urlEl.addEventListener('input', function() {
    332       showPreview(urlEl.value.trim());
    333     });
    334   }
    335 
    336   if (trigger && field) {
    337     trigger.addEventListener('click', function() { field.click(); });
    338     field.addEventListener('change', async function() {
    339       var file = field.files && field.files[0];
    340       if (!file) return;
    341       status.classList.remove('is-error');
    342       status.textContent = 'Uploaden…';
    343       try {
    344         var fd = new FormData();
    345         fd.append('photo', file);
    346         var r = await fetch('/admin/sites/upload-photo', {
    347           method: 'POST',
    348           body: fd,
    349           credentials: 'same-origin',
    350         });
    351         var j = await r.json();
    352         if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
    353         urlEl.value = j.url;
    354         showPreview(j.url);
    355         status.textContent = 'Geüpload ✓';
    356         setTimeout(function() { status.textContent = ''; }, 2000);
    357       } catch (e) {
    358         status.classList.add('is-error');
    359         status.textContent = 'Mislukt: ' + e.message;
    360       } finally {
    361         field.value = '';
    362       }
    363     });
    364   }
    365 
    366   if (clear) {
    367     clear.addEventListener('click', function() {
    368       urlEl.value = '';
    369       showPreview('');
    370       status.textContent = '';
    371       // Note: doesn't delete the file from disk — saving the form with empty
    372       // URL leaves the file orphaned on the server. Acceptable for now.
    373     });
    374   }
    375 })();
    376 </script>
     257
    377258
    378259<style>
  • src/views/pages/admin-videos.ejs

    rf85b2c3 r952baf3  
    4040</div>
    4141
    42 <script>
    43 (function () {
    44   if (window.__videosWired) return; window.__videosWired = true;
    45   var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>;
    46   document.addEventListener('click', function (e) {
    47     var c = e.target.closest('[data-copy]');
    48     if (c) {
    49       var u = location.origin + c.getAttribute('data-copy');
    50       var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
    51       if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
    52       else window.prompt('URL', u);
    53       return;
    54     }
    55     var d = e.target.closest('[data-del]');
    56     if (d) {
    57       if (!window.confirm(T.delC)) return;
    58       fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
    59         .then(function (r) { return r.json(); })
    60         .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } })
    61         .catch(function () {});
    62     }
    63   });
    64 })();
    65 </script>
     42<%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     43<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %>
  • src/views/pages/auth-register.ejs

    rf85b2c3 r952baf3  
    4141  .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
    4242</style>
    43 <script>
    44 (function () {
    45   var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
    46   if (!u || !out) return;
    47   function upd() {
    48     var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
    49     out.textContent = '@' + v + '@' + location.host;
    50   }
    51   u.addEventListener('input', upd);
    52   upd();
    53 })();
    54 </script>
     43<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
  • src/views/pages/authorize-interaction.ejs

    rf85b2c3 r952baf3  
    1212      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
    1313    </div>
    14     <script>
    15     (function () {
    16       if (window.__fediBmWired) return; window.__fediBmWired = true;
    17       var a = document.getElementById('fedi-bm-btn'); if (!a) return;
    18       a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
    19       a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
    20     })();
    21     </script>
     14    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
    2215    <% if (!manage.length) { %>
    2316      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
     
    5649        <% }); %>
    5750      </ul>
    58       <script>
    59       (function () {
    60         if (window.__fediEditWired) return; window.__fediEditWired = true;
    61         document.addEventListener('click', function (e) {
    62           var b = e.target.closest && e.target.closest('.fedi-edit-btn');
    63           if (!b) return;
    64           var li = b.closest('.fedi-manage-item'); if (!li) return;
    65           var form = li.querySelector('.fedi-edit-form'); if (!form) return;
    66           var open = form.classList.toggle('is-open');
    67           b.classList.toggle('is-open', open);
    68           b.setAttribute('aria-expanded', open ? 'true' : 'false');
    69           if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
    70         });
    71       })();
    72       </script>
     51     
    7352    <% } %>
    7453    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
     
    331310</style>
    332311
    333 <script>
    334 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
    335 (function(){
    336   if (window.__fediReactWired) return; window.__fediReactWired = true;
    337   document.addEventListener('submit', function(e){
    338     var f = e.target.closest && e.target.closest('.fedi-react-form');
    339     if (!f) return;
    340     e.preventDefault();
    341     var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
    342     btn.disabled = true;
    343     var body = new URLSearchParams();
    344     new FormData(f).forEach(function(v, k){ body.append(k, v); });
    345     fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
    346       .then(function(r){ return r.ok ? r.json() : null; })
    347       .then(function(j){
    348         if (j) {
    349           var on = !!j.on;
    350           btn.classList.toggle('is-on', on);
    351           var lbl = btn.querySelector('.fedi-bigact-label');
    352           if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
    353         }
    354       })
    355       .catch(function(){})
    356       .then(function(){ btn.disabled = false; });
    357   });
    358 })();
    359 </script>
     312
  • src/views/pages/download.ejs

    rf85b2c3 r952baf3  
    2626      <p class="dl-sub"><%= _ready_sub %></p>
    2727      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
    28       <script>
    29         // Auto-start de download (zelfde-origin attachment-link).
    30         setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600);
    31       </script>
     28      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
    3229    <% } else { %>
    3330      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
     
    5754  .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
    5855</style>
     56<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
  • src/views/pages/messages.ejs

    rf85b2c3 r952baf3  
    22  <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
    33  <h1 class="msg-title"><%= t('msg.title') %></h1>
    4   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %>
    5   <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error %></div><% } %>
     4  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
     5  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
    66
    77  <%# FEP-633c: a pending guardianship offer is a special message: the kid
     
    2525
    2626  <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
     27    <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
     28        een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
     29        draad staan. Activiteit en Moderatie blijven wat ze waren. %>
    2730    <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
    28     <button type="button" class="msg-chip" data-show="msgs"><%= t('msg.filter_msgs') %></button>
    2931    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
    3032    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
    3133    <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
    32     <button type="button" class="msg-chip" data-show="sent"><%= t('msg.filter_sent') %></button>
    3334  </div>
    3435  <div class="msg-search">
     
    5960</div>
    6061
    61 <script>
    62 (function () {
    63   if (window.__msgWired) return; window.__msgWired = true;
    64 
    65   // Filtering: kind chip AND free-text search, combined in JS (search over the
    66   // sender and the rendered message text). Empty search + "all" = show all.
    67   var list = document.querySelector('.msg-list');
    68   var noMatch = document.querySelector('.msg-nomatch');
    69   var q = document.getElementById('msg-q');
    70   var kind = 'all';
    71   var items = [];
    72   function indexItem(li) {
    73     // Index once: sender + message body + linked post title + poll text.
    74     var body = li.querySelector('.msg-content');
    75     var post = li.querySelector('.msg-post');
    76     var poll = li.querySelector('.msg-poll');
    77     li._search = ((li.getAttribute('data-who') || '') + ' ' +
    78       (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
    79       (poll ? poll.textContent : '')).toLowerCase();
    80   }
    81   // Re-collect + index; called on load and after each "Load more" append so new
    82   // items join the filter/search (and inherit the active chip via apply()).
    83   function reindex() {
    84     items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : [];
    85     items.forEach(function (li) { if (!li._search) indexItem(li); });
    86   }
    87   reindex();
    88   function apply() {
    89     if (!list) return;
    90     var term = (q && q.value || '').trim().toLowerCase();
    91     var shown = 0;
    92     items.forEach(function (li) {
    93       var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
    94         (!term || li._search.indexOf(term) !== -1);
    95       li.style.display = ok ? '' : 'none';
    96       if (ok) shown++;
    97     });
    98     if (noMatch) noMatch.hidden = shown !== 0;
    99   }
    100   document.addEventListener('click', function (e) {
    101     var chip = e.target.closest('.msg-chip'); if (!chip) return;
    102     kind = chip.getAttribute('data-show');
    103     document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
    104     apply();
    105   });
    106   if (q) q.addEventListener('input', apply);
    107   // After a "Load more" append (htmx), index the new rows and re-apply the filter.
    108   document.body.addEventListener('htmx:afterSettle', function (e) {
    109     if (e.target && e.target.id === 'msg-list') { reindex(); apply(); }
    110   });
    111 
    112   var a = document.getElementById('fedi-bm-btn');
    113   if (a) {
    114     a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
    115     a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
    116   }
    117 })();
    118 </script>
     62<%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js.
     63    Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een
     64    link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op
     65    body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %>
    11966
    12067<style>
     
    13885
    13986  .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
     87
    14088
    14189  .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
     
    14896    background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
    14997  .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
     98
     99  /* ── Gesprekken ──────────────────────────────────────────────────────
     100     NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
     101     gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
     102     draadkop NAAST de bubbels te staan in plaats van erboven. */
     103  .msg-thread { display: block; }
     104  .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
     105  /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
     106     kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
     107     Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
     108  /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
     109     geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
     110     systeemblauw in 13px. */
     111  .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
     112    font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
     113    padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
     114    color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
     115    background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
     116    box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
     117    max-width: 100%; overflow: hidden; white-space: nowrap;
     118    /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
     119       niet te raken. De uitsparing blijft klein, het doel wordt groter. */
     120    min-height: 32px; }
     121  .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
     122  .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     123  .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
     124    transition: transform .18s ease; }
     125  /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
     126     naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
     127     waard. */
     128  .msg-thread.is-collapsed .msg-thread-msgs,
     129  .msg-thread.is-collapsed .msg-thread-foot { display: none; }
     130  /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
     131  .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
     132  .msg-thread.is-collapsed.is-search-open .msg-thread-foot { display: flex; }
     133  .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
     134  .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
     135  @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
     136  .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
     137    padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
     138  .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
     139    margin-inline-start: auto; min-width: 0; max-width: 100%;
     140    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
     141  .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
     142  .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
     143    border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
     144  .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
     145  .msg-sub > .msg-body { min-width: 0; }
     146  /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
     147     .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
     148     kaart zodra de regel smaller werd dan zijn inhoud. */
     149  .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
     150    background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
     151  /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
     152  .msg-sub .msg-line { flex-wrap: wrap; }
     153
     154  /* De voet van een gesprek: antwoorden en zwaaien naast elkaar, ingeklapt.
     155     Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
     156     wat deze weergave moest oplossen. */
     157  .msg-thread-foot { display: flex; align-items: flex-start; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; }
     158  .msg-reply { flex: 1 1 12rem; min-width: 0; }
     159  .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
     160  .msg-reply[open] > summary { margin-bottom: .4rem; }
     161  .msg-reply-form { min-width: 0; }
     162  .msg-wave button { line-height: 1.2; }
     163
     164  @media (max-width: 560px) {
     165    /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
     166       al naast, en de inhoud heeft de breedte harder nodig. */
     167    .msg-sub > .msg-av { display: none; }
     168    .msg-thread-msgs { padding-inline-start: .55rem; }
     169    .msg-thread-post { margin-inline-start: 0; }
     170    .msg-item { padding: .7rem .6rem; gap: .6rem; }
     171  }
    150172
    151173  .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
  • src/views/pages/news.ejs

    rf85b2c3 r952baf3  
    4141    .tl-paste-go { background: var(--accent); color: var(--paper); }
    4242  </style>
    43   <script>
    44   (function () {
    45     if (window.__tlPasteWired) return; window.__tlPasteWired = true;
    46     function modal() { return document.getElementById('tl-paste'); }
    47     function openM() {
    48       var m = modal(); if (!m) return;
    49       var inp = m.querySelector('.tl-paste-input');
    50       m.classList.add('is-open');
    51       if (inp) {
    52         inp.value = ''; inp.focus();
    53         if (navigator.clipboard && navigator.clipboard.readText) {
    54           navigator.clipboard.readText().then(function (t) {
    55             t = (t || '').trim();
    56             if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
    57           }).catch(function () {});
    58         }
    59       }
    60     }
    61     function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
    62     function go() {
    63       var m = modal(); if (!m) return;
    64       var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
    65       if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
    66       location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
    67     }
    68     document.addEventListener('click', function (e) {
    69       if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
    70       if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
    71       if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
    72       var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
    73     });
    74     document.addEventListener('keydown', function (e) {
    75       var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
    76       if (e.key === 'Escape') closeM();
    77       else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
    78     });
    79   })();
    80   </script>
     43  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
    8144  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    8245  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    184147  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
    185148</style>
    186 
    187 <script>
    188 (function(){
    189   if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
    190   var current = null, currentBtn = null;
    191   function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
    192   function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
    193   function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
    194   document.addEventListener('click', function(e){
    195     if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
    196     if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
    197     var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
    198     if (b) {
    199       e.preventDefault();
    200       if (currentBtn === b) { close(); return; }
    201       close();
    202       var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
    203       f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
    204       var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
    205       try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
    206       document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
    207       return;
    208     }
    209     if (current) close();
    210   });
    211   document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
    212   document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
    213   window.addEventListener('scroll', close, true);
    214 })();
    215 
    216 /* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
    217    longer real videos keep their controls. Decided on the actual duration once metadata loads. */
    218 (function(){
    219   document.querySelectorAll('.tl-media-video').forEach(function(v){
    220     if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
    221     var decide = function(){
    222       if (v.duration && v.duration <= 30) {
    223         v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
    224       }
    225     };
    226     if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
    227   });
    228 })();
    229 
    230 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
    231 (function(){
    232   if (window.__newsReactWired) return; window.__newsReactWired = true;
    233   document.addEventListener('submit', function(e){
    234     var f = e.target.closest && e.target.closest('.tl-react-form');
    235     if (!f) return;
    236     e.preventDefault();
    237     var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
    238     btn.disabled = true;
    239     var body = new URLSearchParams();
    240     new FormData(f).forEach(function(v, k){ body.append(k, v); });
    241     fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
    242       .then(function(r){ return r.ok ? r.json() : null; })
    243       .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
    244       .catch(function(){})
    245       .then(function(){ btn.disabled = false; });
    246   });
    247 })();
    248 </script>
    249 
    250 <script>
    251 // Collapse long post bodies to a max height with a "read more" toggle — only when the content
    252 // actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
    253 // prevents double-wiring.
    254 (function(){
    255   var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>';
    256   document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
    257     c.setAttribute('data-rm', '1');
    258     if (c.scrollHeight > 360) {
    259       c.classList.add('tl-clamp');
    260       var b = document.createElement('button');
    261       b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
    262       b.addEventListener('click', function(){
    263         b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
    264       });
    265       c.insertAdjacentElement('afterend', b);
    266     }
    267   });
    268 })();
    269 </script>
     149<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
  • src/views/pages/paid-gate.ejs

    rf85b2c3 r952baf3  
    3131
    3232<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    33 <script>
    34 (function () {
    35   var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
    36   var slug = "<%= pgSlug %>";
    37   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') %>" };
    39   var btn = document.getElementById('pg-unlock');
    40   var status = document.getElementById('pg-status');
    41   function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
    42   function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
    43 
    44   // No WebAuthn here: an assertion is impossible. With a Patreon page there is
    45   // already a "Word supporter" button, so hide the (dead) unlock button rather
    46   // than turn it into a second "Word supporter". Without one, this IS the button.
    47   if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
    48     if (hasPatron) { btn.style.display = 'none'; }
    49     else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
    50     return;
    51   }
    52 
    53   btn.addEventListener('click', function () {
    54     btn.disabled = true;
    55     say(I.confirm);
    56     fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
    57       .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
    58       .then(function (data) {
    59         return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
    60           .then(function (response) {
    61             return fetch(base + '/paid/unlock', {
    62               method: 'POST', headers: { 'Content-Type': 'application/json' },
    63               body: JSON.stringify({ response: response, blob: data.blob }),
    64             });
    65           });
    66       })
    67       .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
    68       .then(function (res) {
    69         if (res.j && res.j.ok && res.j.redirect) {
    70           // Reload the real post page via the one-shot unlock capability, so it
    71           // renders through its normal template (layout, styles, audio).
    72           location.href = res.j.redirect;
    73         } else if (res.status === 403) {
    74           toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
    75         } else {
    76           btn.disabled = false; say(I.failed, true);
    77         }
    78       })
    79       .catch(function (e) {
    80         if (e && e.link) { toLink(); return; }
    81         if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
    82         btn.disabled = false; say(I.error, true);
    83       });
    84   });
    85 })();
    86 </script>
     33<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
    8734
    8835<style>
     
    10552  .pg-status.is-err { color: #c0392b; }
    10653</style>
     54<%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %>
  • src/views/pages/paid-passkey.ejs

    rf85b2c3 r952baf3  
    1212
    1313<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    14 <script>
    15 (function () {
    16   var options = <%- optionsJson %>;
    17   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') %>" };
    19   var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
    20   var btn = document.getElementById('pk-go');
    21   var status = document.getElementById('pk-status');
    22   function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
    23 
    24   if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
    25     btn.disabled = true;
    26     say(I.unsupported, true);
    27     return;
    28   }
    29   btn.addEventListener('click', function () {
    30     btn.disabled = true;
    31     say(I.follow);
    32     window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
    33       .then(function (response) {
    34         return fetch('/paid/register', {
    35           method: 'POST', headers: { 'Content-Type': 'application/json' },
    36           body: JSON.stringify({ response: response, blob: blob }),
    37         });
    38       })
    39       .then(function (r) { return r.json(); })
    40       .then(function (j) {
    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); }
    43       })
    44       .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
    45   });
    46 })();
    47 </script>
     14<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
    4815
    4916<style>
     
    5825  .pk-status.is-err { color: #c0392b; }
    5926</style>
     27<%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %>
  • src/views/pages/post-edit.ejs

    rf85b2c3 r952baf3  
    272272        <% } else { %>
    273273          <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
    274           <script>
    275           (function () {
    276             var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
    277             if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
    278           })();
    279           </script>
     274          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
    280275        <% } %>
    281         <script>
    282         (function () {
    283           var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
    284           // Typing a warning text implies the post is sensitive → auto-tick NSFW.
    285           if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
    286             cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
    287           }
    288         })();
    289         </script>
     276       
    290277        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    291278           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    335322          </select>
    336323        </div>
    337         <script>
    338         (function () {
    339           var box = document.getElementById('pe-poll-fields');
    340           var tog = document.getElementById('pe-poll-toggle');
    341           var opts = document.getElementById('pe-poll-opts');
    342           var add = document.getElementById('pe-poll-add');
    343           if (!box || !opts) return;
    344           if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
    345           var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
    346           function rows() { return opts.querySelectorAll('.pe-poll-row'); }
    347           // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
    348           function refresh() {
    349             var n = rows().length;
    350             opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
    351             if (add) add.disabled = n >= 8;
    352           }
    353           function makeRow() {
    354             var row = document.createElement('div'); row.className = 'pe-poll-row';
    355             var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
    356             var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
    357             row.appendChild(i); row.appendChild(d); return row;
    358           }
    359           if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
    360           if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
    361           refresh();
    362         })();
    363         </script>
     324       
    364325        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    365326          <label class="pe-checkbox">
     
    377338            </label>
    378339          </div>
    379           <script>
    380             (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
    381               if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
    382           </script>
     340         
    383341          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    384342          <label class="pe-checkbox" style="margin-top:8px">
     
    392350            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    393351          </div>
    394           <script>
    395             (function () {
    396               var cb = document.getElementById('pe-sched-toggle');
    397               var box = document.getElementById('pe-sched-fields');
    398               if (!cb || !box) return;
    399               var inp = document.getElementById('pe-publish-at');
    400               var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local
    401               var pad = function (n) { return String(n).padStart(2, '0'); };
    402               // Offset (ms) between a timezone and UTC at a given moment.
    403               function tzOffset(date, tz) {
    404                 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
    405                 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
    406                 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
    407               }
    408               // datetime-local "wall time" (in the site zone) → UTC Date.
    409               function wallToUtc(wall) {
    410                 if (!SITE_TZ) return new Date(wall);
    411                 var guess = new Date(wall + ':00Z').getTime();
    412                 return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
    413               }
    414               // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
    415               function utcToWall(iso) {
    416                 var d = new Date(iso); if (isNaN(d)) return '';
    417                 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
    418                 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
    419                 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
    420                 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
    421               }
    422               // Prefill: stored UTC → wall time in the site zone.
    423               if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
    424               // "Scheduled for" in human-readable time in the site zone.
    425               var when = document.getElementById('pe-sched-when');
    426               if (when && when.dataset.iso) {
    427                 var dw = new Date(when.dataset.iso);
    428                 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
    429               }
    430               function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
    431               cb.addEventListener('change', sync); sync();
    432               // On save: wall time in the site zone → UTC-ISO via a hidden field.
    433               var form = cb.closest('form');
    434               if (form) {
    435                 form.addEventListener('submit', function () {
    436                   if (inp) inp.removeAttribute('name');
    437                   var old = form.querySelector('input[data-pa-utc]');
    438                   if (old) old.remove();
    439                   if (cb.checked && inp && inp.value) {
    440                     var d2 = wallToUtc(inp.value);
    441                     if (!isNaN(d2)) {
    442                       var h = document.createElement('input');
    443                       h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
    444                       h.value = d2.toISOString();
    445                       form.appendChild(h);
    446                     }
    447                   }
    448                 });
    449               }
    450             })();
    451           </script>
     352         
    452353        <% } %>
    453354      </div>
     
    12811182</style>
    12821183
    1283 <script>
    1284 (function() {
    1285 
    1286   // ── Cover upload ────────────────────────────────────────────────
    1287   const coverField   = document.getElementById('cover-upload-field');
    1288   const coverTrigger = document.getElementById('cover-upload-trigger');
    1289   const coverUrl     = document.getElementById('cover-url-field');
    1290   const coverVideo   = document.getElementById('cover-video-field');
    1291   const coverStatus  = document.getElementById('cover-upload-status');
    1292   const coverWrap    = document.getElementById('cover-preview-wrap');
    1293   const coverImg     = document.getElementById('cover-preview-img');
    1294 
    1295   async function uploadImage(file) {
    1296     const fd = new FormData();
    1297     fd.append('image', file);
    1298     const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
    1299     if (!res.ok) {
    1300       const j = await res.json().catch(() => ({}));
    1301       throw new Error(j.error || ('Upload failed (' + res.status + ')'));
    1302     }
    1303     return await res.json();   // {url, size, mime}
    1304   }
    1305 
    1306   // ── Image editor (rotate / crop / mirror) ──────────
    1307   // Lazy-load Cropper.js (locally vendored) on first use.
    1308   let _cropperReady = null;
    1309   function ensureCropper() {
    1310     if (window.Cropper) return Promise.resolve();
    1311     if (_cropperReady) return _cropperReady;
    1312     _cropperReady = new Promise((resolve, reject) => {
    1313       if (!document.querySelector('link[data-cropper-css]')) {
    1314         const l = document.createElement('link');
    1315         l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
    1316         document.head.appendChild(l);
    1317       }
    1318       const s = document.createElement('script');
    1319       s.src = '/assets/vendor/cropper.min.js';
    1320       s.onload = () => resolve();
    1321       s.onerror = () => reject(new Error('cropper load failed'));
    1322       document.head.appendChild(s);
    1323     });
    1324     return _cropperReady;
    1325   }
    1326 
    1327   // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
    1328   // the canvas editor, otherwise it'd be flattened to a single static frame.
    1329   async function isAnimatedWebpFile(file) {
    1330     if (!file || file.type !== 'image/webp') return false;
    1331     try {
    1332       const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
    1333       return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
    1334     } catch (_) { return false; }
    1335   }
    1336 
    1337   // Opens the editor for a chosen file; resolves with an edited File,
    1338   // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
    1339   // canvas editor (they would become static) — those upload directly.
    1340   async function openImageEditor(file) {
    1341     if (!file || !file.type || !file.type.startsWith('image/')) return file;
    1342     if (file.type === 'image/gif') return file;               // preserve animation
    1343     if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
    1344     try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
    1345 
    1346     return new Promise((resolve) => {
    1347       const back = document.createElement('div');
    1348       back.className = 'imed-backdrop';
    1349       back.innerHTML =
    1350         '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' +
    1351           '<div class="imed-stage"><img alt=""></div>' +
    1352           '<div class="imed-tools">' +
    1353             '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
    1354             '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
    1355             '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
    1356             '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
    1357             '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' +
    1358             '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' +
    1359             '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
    1360           '</div>' +
    1361           '<div class="imed-actions">' +
    1362             '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
    1363             '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' +
    1364           '</div>' +
    1365         '</div>';
    1366       document.body.appendChild(back);
    1367       const img = back.querySelector('img');
    1368       const url = URL.createObjectURL(file);
    1369       let cropper = null, sx = 1, sy = 1;
    1370 
    1371       function cleanup() {
    1372         try { if (cropper) cropper.destroy(); } catch (_) {}
    1373         URL.revokeObjectURL(url);
    1374         back.remove();
    1375         document.removeEventListener('keydown', onKey);
    1376       }
    1377       function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
    1378       document.addEventListener('keydown', onKey);
    1379 
    1380       img.onload = () => {
    1381         cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
    1382       };
    1383       img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
    1384       img.src = url;
    1385 
    1386       back.addEventListener('click', (e) => {
    1387         const btn = e.target.closest('[data-act]');
    1388         if (e.target === back) { cleanup(); resolve(null); return; }
    1389         if (!btn || !cropper) return;
    1390         const a = btn.getAttribute('data-act');
    1391         if (a === 'rl') cropper.rotate(-90);
    1392         else if (a === 'rr') cropper.rotate(90);
    1393         else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
    1394         else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
    1395         else if (a === 'zi') cropper.zoom(0.1);
    1396         else if (a === 'zo') cropper.zoom(-0.1);
    1397         else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
    1398         else if (a === 'cancel') { cleanup(); resolve(null); }
    1399         else if (a === 'apply') {
    1400           const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
    1401           const png = (file.type === 'image/png' || file.type === 'image/webp');
    1402           const mime = png ? 'image/png' : 'image/jpeg';
    1403           const ext = png ? '.png' : '.jpg';
    1404           canvas.toBlob((blob) => {
    1405             cleanup();
    1406             if (!blob) { resolve(file); return; }
    1407             const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
    1408             resolve(new File([blob], base + ext, { type: mime }));
    1409           }, mime, 0.92);
    1410         }
    1411       });
    1412     });
    1413   }
    1414 
    1415   function showCoverPreview(url) {
    1416     if (!coverWrap || !coverImg) return;
    1417     if (url) {
    1418       coverImg.src = url;
    1419       coverImg.hidden = false;
    1420       coverWrap.removeAttribute('data-empty');
    1421       const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
    1422       if (emptyIcon) emptyIcon.remove();
    1423     } else {
    1424       coverImg.hidden = true;
    1425       coverImg.src = '';
    1426       coverWrap.setAttribute('data-empty', '');
    1427       if (!coverWrap.querySelector('.pe-cover-empty')) {
    1428         const span = document.createElement('span');
    1429         span.className = 'pe-cover-empty';
    1430         span.textContent = '🖼';
    1431         coverWrap.appendChild(span);
    1432       }
    1433     }
    1434   }
    1435 
    1436   if (coverTrigger && coverField) {
    1437     coverTrigger.addEventListener('click', () => coverField.click());
    1438   }
    1439   if (coverField) {
    1440     coverField.addEventListener('change', async () => {
    1441       if (!coverField.files[0]) return;
    1442       const edited = await openImageEditor(coverField.files[0]);
    1443       coverField.value = '';
    1444       if (!edited) return; // cancelled
    1445       coverStatus.classList.remove('is-error');
    1446       coverStatus.textContent = '<%= t('pedit.js_uploading') %>';
    1447       try {
    1448         const j = await uploadImage(edited);
    1449         coverUrl.value = j.url;
    1450         if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
    1451         showCoverPreview(j.url);
    1452         coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓';
    1453         setTimeout(() => { coverStatus.textContent = ''; }, 2000);
    1454       } catch (e) {
    1455         coverStatus.classList.add('is-error');
    1456         coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
    1457       }
    1458     });
    1459   }
    1460   // Live-update preview when user pastes a URL manually
    1461   if (coverUrl) {
    1462     coverUrl.addEventListener('input', () => {
    1463       const v = coverUrl.value.trim();
    1464       if (v) showCoverPreview(v); else showCoverPreview('');
    1465     });
    1466   }
    1467 
    1468   // ── WYSIWYG editor (P58) ────────────────────────────────────────
    1469   // Architecture:
    1470   //   - Visible <div contenteditable> (`#content-editor`) is what the user
    1471   //     types in; it shows real HTML (formatted, not raw markup).
    1472   //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
    1473   //     On submit we serialize the editor's HTML into it, with shortcode
    1474   //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
    1475   //   - Initial content comes from a <script type="application/json"> tag
    1476   //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
    1477   //     and walk text nodes to render shortcode tokens as chips.
    1478   const contentField  = document.getElementById('content-upload-field');
    1479   const contentBtn    = document.getElementById('insert-image-btn');
    1480   const contentStatus = document.getElementById('content-upload-status');
    1481   const editor        = document.getElementById('content-editor');
    1482   const hiddenField   = document.getElementById('content-hidden');
    1483   const charCountEl   = document.getElementById('char-count');
    1484   const initialEl     = document.getElementById('initial-content');
    1485   const toolbar       = document.getElementById('pe-toolbar');
    1486   const form          = editor && editor.closest('form');
    1487 
    1488   if (!editor) return;
    1489 
    1490   // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
    1491   // immediately open the keyboard when the editor opens — not desired.
    1492   try {
    1493     const titleInput = form && form.querySelector('input[name="title"]');
    1494     if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
    1495       titleInput.focus({ preventScroll: true });
    1496     }
    1497   } catch (_) {}
    1498 
    1499   // ── Shortcode chip rendering / serialization ────────────────────
    1500   // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
    1501   // — but we DON'T want to chipify text the user is mid-typing inside an
    1502   // HTML attribute; since chipify only walks text nodes (never attribute
    1503   // values) that's already safe.
    1504   const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
    1505 
    1506   const SC_ICONS = {
    1507     track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
    1508     album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
    1509     playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
    1510     embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
    1511   };
    1512 
    1513   function chipLabel(kind, value) {
    1514     if (kind === 'track') {
    1515       // UUIDs are noisy — show a 6-char prefix for visual hint
    1516       const v = String(value || '');
    1517       return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
    1518     }
    1519     if (kind === 'album')    return '<%= t('pedit.chip_album') %> ' + value;
    1520     if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value;
    1521     if (kind === 'embed') {
    1522       const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
    1523       return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
    1524     }
    1525     return value;
    1526   }
    1527 
    1528   function makeChip(kind, value) {
    1529     const span = document.createElement('span');
    1530     span.className = 'sc-chip';
    1531     span.contentEditable = 'false';
    1532     span.setAttribute('data-sc', kind + ':' + value);
    1533     span.innerHTML =
    1534       '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
    1535       '<span class="sc-chip-label"></span>';
    1536     span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
    1537     return span;
    1538   }
    1539 
    1540   // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
    1541   function chipifyShortcodes(root) {
    1542     const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    1543     const targets = [];
    1544     while (walker.nextNode()) {
    1545       const n = walker.currentNode;
    1546       // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
    1547       if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
    1548       if (SC_RE.test(n.nodeValue)) targets.push(n);
    1549       SC_RE.lastIndex = 0;
    1550     }
    1551     for (const node of targets) {
    1552       const txt = node.nodeValue;
    1553       const frag = document.createDocumentFragment();
    1554       let last = 0;
    1555       let m;
    1556       SC_RE.lastIndex = 0;
    1557       while ((m = SC_RE.exec(txt)) !== null) {
    1558         if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
    1559         frag.appendChild(makeChip(m[1], m[2].trim()));
    1560         last = m.index + m[0].length;
    1561       }
    1562       if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
    1563       node.parentNode.replaceChild(frag, node);
    1564     }
    1565   }
    1566 
    1567   // Inverse of chipify: clone the editor, replace every chip with its text.
    1568   function serializeChips(rootClone) {
    1569     const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
    1570     for (const c of chips) {
    1571       const txt = '[[' + c.getAttribute('data-sc') + ']]';
    1572       c.replaceWith(document.createTextNode(txt));
    1573     }
    1574   }
    1575 
    1576   // ── Boot: load initial content as HTML, then render shortcodes as chips
    1577   try {
    1578     const initial = JSON.parse(initialEl.textContent || '""');
    1579     editor.innerHTML = initial || '';
    1580     chipifyShortcodes(editor);
    1581   } catch (e) {
    1582     console.error('[editor] could not parse initial content', e);
    1583     editor.innerHTML = '';
    1584   }
    1585 
    1586   // ── Char counter
    1587   function updateCharCount() {
    1588     const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
    1589     if (charCountEl) charCountEl.textContent = String(text.length);
    1590   }
    1591   updateCharCount();
    1592   editor.addEventListener('input', updateCharCount);
    1593 
    1594   // ── Toolbar wiring
    1595   // Lock the scroll position around an edit command. execCommand/insert scrolls
    1596   // the caret into view by default → the view "jumps" when clicking a formatting
    1597   // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
    1598   // + the page and restore them — sync and over a few frames, because Chrome
    1599   // sometimes scrolls a frame later. The user scrolls themselves.
    1600   function scrollableAncestors(el) {
    1601     const list = [];
    1602     let node = el;
    1603     while (node && node !== document.body && node !== document.documentElement) {
    1604       const oy = getComputedStyle(node).overflowY;
    1605       if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
    1606       node = node.parentElement;
    1607     }
    1608     return list;
    1609   }
    1610   function keepScroll(fn) {
    1611     // In fullscreen the page is locked (body overflow:hidden) and the field may
    1612     // scroll to the caret freely — no page jump possible, so nothing to fix.
    1613     const frame = document.querySelector('.pe-editor-frame');
    1614     if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
    1615     const wx = window.scrollX, wy = window.scrollY;
    1616     const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
    1617     const restore = function () {
    1618       window.scrollTo(wx, wy);
    1619       anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
    1620     };
    1621     fn();
    1622     restore();
    1623     requestAnimationFrame(restore);
    1624   }
    1625   function execCmd(cmd, arg) {
    1626     keepScroll(function () {
    1627       editor.focus({ preventScroll: true });
    1628       document.execCommand(cmd, false, arg);
    1629     });
    1630     updateToolbarState();
    1631     updateCharCount();
    1632   }
    1633   function wrapCode() {
    1634     const sel = window.getSelection();
    1635     if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
    1636     keepScroll(function () {
    1637       const range = sel.getRangeAt(0);
    1638       const code = document.createElement('code');
    1639       code.textContent = sel.toString();
    1640       range.deleteContents();
    1641       range.insertNode(code);
    1642       // Move caret after the new node
    1643       range.setStartAfter(code);
    1644       range.collapse(true);
    1645       sel.removeAllRanges();
    1646       sel.addRange(range);
    1647       editor.focus({ preventScroll: true });
    1648     });
    1649   }
    1650   function linkPrompt() {
    1651     const url = window.prompt('<%= t('pedit.js_link_prompt') %>');
    1652     if (!url) return;
    1653     execCmd('createLink', url);
    1654   }
    1655   // Is the current selection inside a <blockquote> within the editor? Return it.
    1656   function blockquoteAncestor() {
    1657     const sel = window.getSelection();
    1658     if (!sel || sel.rangeCount === 0) return null;
    1659     let node = sel.anchorNode;
    1660     while (node && node !== editor) {
    1661       if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
    1662       node = node.parentNode;
    1663     }
    1664     return null;
    1665   }
    1666   // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
    1667   // can never turn it OFF (browser quirk). If the caret is already in a quote →
    1668   // unwrap it; otherwise apply blockquote.
    1669   function toggleBlockquote() {
    1670     keepScroll(function () {
    1671       editor.focus({ preventScroll: true });
    1672       const bq = blockquoteAncestor();
    1673       if (bq) {
    1674         const parent = bq.parentNode;
    1675         // Extract content from the quote in place, then remove the empty wrapper.
    1676         const ref = bq;
    1677         let firstMoved = null;
    1678         while (bq.firstChild) {
    1679           const child = bq.firstChild;
    1680           if (!firstMoved) firstMoved = child;
    1681           parent.insertBefore(child, ref);
    1682         }
    1683         parent.removeChild(bq);
    1684         // Restore the caret inside the unwrapped content.
    1685         if (firstMoved) {
    1686           const sel = window.getSelection();
    1687           const range = document.createRange();
    1688           range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
    1689           range.collapse(false);
    1690           sel.removeAllRanges();
    1691           sel.addRange(range);
    1692         }
    1693       } else {
    1694         document.execCommand('formatBlock', false, 'blockquote');
    1695       }
    1696     });
    1697     updateToolbarState();
    1698     updateCharCount();
    1699   }
    1700 
    1701   if (toolbar) {
    1702     // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
    1703     // from the editor field. Without this the selection is lost on tap
    1704     // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
    1705     // and the browser scrolls the caret back into view (the "jump down"). preventDefault
    1706     // on mousedown keeps focus in the editor; the click still fires normally.
    1707     toolbar.addEventListener('mousedown', (e) => {
    1708       if (e.target.closest('button')) e.preventDefault();
    1709     });
    1710     toolbar.addEventListener('click', (e) => {
    1711       const btn = e.target.closest('button[data-cmd]');
    1712       if (!btn) return;
    1713       e.preventDefault();
    1714       const cmd = btn.dataset.cmd;
    1715       const arg = btn.dataset.arg || null;
    1716       if (cmd === 'link-prompt') linkPrompt();
    1717       else if (cmd === 'code-wrap') wrapCode();
    1718       else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
    1719       else execCmd(cmd, arg);
    1720     });
    1721   }
    1722 
    1723   // ── Full-screen writing mode: the writing field fills the whole page.
    1724   const fsBtn = document.getElementById('pe-fullscreen-btn');
    1725   const editorFrame = document.querySelector('.pe-editor-frame');
    1726   const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
    1727 
    1728   // On mobile the keyboard pushes the visible (visual) viewport up while
    1729   // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
    1730   // slides out of view. Keep the fullscreen frame aligned to the visual
    1731   // viewport (top + height) so the toolbar stays visible at the top.
    1732   function syncFsViewport() {
    1733     if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
    1734     const vv = window.visualViewport;
    1735     if (!vv) return;
    1736     editorFrame.style.top = vv.offsetTop + 'px';
    1737     editorFrame.style.height = vv.height + 'px';
    1738   }
    1739   function clearFsViewport() {
    1740     if (!editorFrame) return;
    1741     editorFrame.style.top = '';
    1742     editorFrame.style.height = '';
    1743   }
    1744   function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
    1745   function applyFs(on) {
    1746     if (!editorFrame) return;
    1747     editorFrame.classList.toggle('pe-fs', on);
    1748     document.body.classList.toggle('pe-fs-open', on);
    1749     document.documentElement.classList.toggle('pe-fs-open', on);
    1750     if (fsBtn) {
    1751       fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
    1752       fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>';
    1753     }
    1754     if (window.visualViewport) {
    1755       if (on) {
    1756         window.visualViewport.addEventListener('resize', syncFsViewport);
    1757         window.visualViewport.addEventListener('scroll', syncFsViewport);
    1758         syncFsViewport();
    1759       } else {
    1760         window.visualViewport.removeEventListener('resize', syncFsViewport);
    1761         window.visualViewport.removeEventListener('scroll', syncFsViewport);
    1762         clearFsViewport();
    1763       }
    1764     }
    1765     // On touch the field is NOT editable inline; only in fullscreen.
    1766     if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
    1767     if (on) {
    1768       editor.focus({ preventScroll: true });
    1769     } else {
    1770       if (isTouch) editor.blur();
    1771       // On close: scroll to the TOP of the content instead of staying
    1772       // somewhere at the bottom (footer).
    1773       requestAnimationFrame(function () {
    1774         try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
    1775       });
    1776     }
    1777   }
    1778   // The fullscreen writing "page": opening pushes a history state so the browser
    1779   // back button (and the Done button) closes it and returns you to the form — feels
    1780   // like a separate page, but all form fields remain intact (same DOM).
    1781   function openFs() {
    1782     if (isFs()) return;
    1783     try { history.pushState({ peFs: true }, ''); } catch (_) {}
    1784     applyFs(true);
    1785   }
    1786   function closeFs() {
    1787     if (!isFs()) return;
    1788     if (history.state && history.state.peFs) history.back(); // → popstate closes it
    1789     else applyFs(false);
    1790   }
    1791   function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
    1792   window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
    1793   if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
    1794   var fsDoneBtn = document.getElementById('pe-fs-done');
    1795   if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
    1796   document.addEventListener('keydown', (e) => {
    1797     if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
    1798   });
    1799 
    1800   // On mobile/tablet (touch): the content field is NOT editable inline — it is
    1801   // not a text field there. One tap → fullscreen, where it becomes editable
    1802   // (toggleFullscreen toggles contenteditable). This prevents inline typing.
    1803   if (isTouch) {
    1804     editor.setAttribute('contenteditable', 'false');
    1805     editor.classList.add('pe-tap-to-edit');
    1806     editor.addEventListener('click', function () {
    1807       if (!isFs()) openFs();
    1808     });
    1809   }
    1810 
    1811   // Reflect bold/italic/list state on the toolbar buttons
    1812   function updateToolbarState() {
    1813     if (!toolbar) return;
    1814     const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
    1815     for (const cmd of cmds) {
    1816       const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
    1817       if (!btn) continue;
    1818       try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
    1819     }
    1820     // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
    1821     const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
    1822     if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
    1823   }
    1824   document.addEventListener('selectionchange', () => {
    1825     if (document.activeElement === editor) updateToolbarState();
    1826   });
    1827 
    1828   // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
    1829   editor.addEventListener('keydown', (e) => {
    1830     const mod = e.ctrlKey || e.metaKey;
    1831     if (!mod) return;
    1832     const k = e.key.toLowerCase();
    1833     if (k === 'b') { e.preventDefault(); execCmd('bold'); }
    1834     else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
    1835     else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
    1836     else if (k === 'k') { e.preventDefault(); linkPrompt(); }
    1837   });
    1838 
    1839   // Paste: keep it simple — strip formatting unless user wants it. Default
    1840   // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
    1841   // styles from clipboard only when shift is held — otherwise plain text.
    1842   editor.addEventListener('paste', (e) => {
    1843     if (e.shiftKey) return; // user wants formatted paste
    1844     const text = (e.clipboardData || window.clipboardData).getData('text/plain');
    1845     if (text == null) return;
    1846     e.preventDefault();
    1847     document.execCommand('insertText', false, text);
    1848   });
    1849 
    1850   // ── Image upload (button + drag-drop into the editor)
    1851   async function uploadAndInsertImage(file) {
    1852     const edited = await openImageEditor(file);
    1853     if (!edited) return; // cancelled
    1854     contentStatus.classList.remove('is-error');
    1855     contentStatus.textContent = '<%= t('pedit.js_uploading') %>';
    1856     try {
    1857       const j = await uploadImage(edited);
    1858       const img = '<img src="' + j.url + '" alt="">';
    1859       editor.focus({ preventScroll: true });
    1860       document.execCommand('insertHTML', false, img);
    1861       contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓';
    1862       setTimeout(() => { contentStatus.textContent = ''; }, 2000);
    1863       updateCharCount();
    1864     } catch (e) {
    1865       contentStatus.classList.add('is-error');
    1866       contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message;
    1867     }
    1868   }
    1869 
    1870   if (contentBtn && contentField) {
    1871     contentBtn.addEventListener('click', () => contentField.click());
    1872     contentField.addEventListener('change', () => {
    1873       if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
    1874       contentField.value = '';
    1875     });
    1876 
    1877     editor.addEventListener('dragover', (e) => {
    1878       if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
    1879         e.preventDefault();
    1880         editor.classList.add('is-dragover');
    1881       }
    1882     });
    1883     editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
    1884     editor.addEventListener('drop', async (e) => {
    1885       editor.classList.remove('is-dragover');
    1886       const files = e.dataTransfer && e.dataTransfer.files;
    1887       if (!files || !files.length) return;
    1888       e.preventDefault();
    1889       for (const f of files) {
    1890         if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
    1891       }
    1892     });
    1893   }
    1894 
    1895   // ── Insert chip helpers (track / playlist)
    1896   function insertChip(kind, value) {
    1897     editor.focus({ preventScroll: true });
    1898     const chip = makeChip(kind, value);
    1899     // Insert at caret using the Selection API (execCommand insertNode)
    1900     const sel = window.getSelection();
    1901     if (sel && sel.rangeCount > 0) {
    1902       const range = sel.getRangeAt(0);
    1903       range.deleteContents();
    1904       range.insertNode(chip);
    1905       // Insert a trailing space so the user can keep typing after the chip
    1906       const space = document.createTextNode('\u00A0');
    1907       chip.after(space);
    1908       range.setStartAfter(space);
    1909       range.collapse(true);
    1910       sel.removeAllRanges();
    1911       sel.addRange(range);
    1912     } else {
    1913       editor.appendChild(chip);
    1914       editor.appendChild(document.createTextNode('\u00A0'));
    1915     }
    1916     updateCharCount();
    1917   }
    1918 
    1919   // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
    1920   //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
    1921   const embedBtn = document.getElementById('insert-embed-btn');
    1922   if (embedBtn) {
    1923     embedBtn.addEventListener('click', () => {
    1924       const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>');
    1925       if (!raw) return;
    1926       const url = raw.trim();
    1927       if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
    1928       insertChip('embed', url);
    1929     });
    1930   }
    1931 
    1932   // ── Track insert: opens the track-picker modal (P59)
    1933   const trackBtn = document.getElementById('insert-track-btn');
    1934   const trackPicker = document.getElementById('track-picker');
    1935   if (trackBtn && trackPicker) {
    1936     const tpList   = document.getElementById('tp-list');
    1937     const tpEmpty  = document.getElementById('tp-empty');
    1938     const tpSearch = document.getElementById('tp-search');
    1939     let tpCache = null;       // cached track list (fetched once per page load)
    1940     let tpLastFocus = null;   // element to restore focus to on close
    1941 
    1942     const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
    1943 
    1944     function fmtDur(sec) {
    1945       sec = Math.max(0, Math.floor(sec || 0));
    1946       const m = Math.floor(sec / 60), s = sec % 60;
    1947       return m + ':' + String(s).padStart(2, '0');
    1948     }
    1949     function escAttr(s) {
    1950       return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    1951         '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    1952       }[c]));
    1953     }
    1954 
    1955     function renderList(filter) {
    1956       if (!Array.isArray(tpCache)) return;
    1957       const q = (filter || '').trim().toLowerCase();
    1958       const filtered = q
    1959         ? tpCache.filter(t =>
    1960             (t.title  || '').toLowerCase().includes(q) ||
    1961             (t.artist || '').toLowerCase().includes(q))
    1962         : tpCache;
    1963 
    1964       if (!filtered.length) {
    1965         tpList.innerHTML = '';
    1966         tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>';
    1967         tpList.appendChild(tpEmpty);
    1968         return;
    1969       }
    1970 
    1971       tpList.innerHTML = filtered.map(t => {
    1972         const cov = t.cover
    1973           ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
    1974           : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
    1975         const dis = t.playable ? '' : ' aria-disabled="true"';
    1976         const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
    1977         return (
    1978           '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
    1979             cov +
    1980             '<span class="tp-meta">' +
    1981               '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
    1982               sub +
    1983             '</span>' +
    1984             '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
    1985           '</button>'
    1986         );
    1987       }).join('');
    1988     }
    1989 
    1990     async function loadTracks() {
    1991       if (Array.isArray(tpCache)) return tpCache;
    1992       tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>';
    1993       try {
    1994         const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
    1995         const j = await r.json();
    1996         tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
    1997       } catch (e) {
    1998         tpCache = [];
    1999         tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message;
    2000       }
    2001       return tpCache;
    2002     }
    2003 
    2004     function openPicker() {
    2005       tpLastFocus = document.activeElement;
    2006       trackPicker.hidden = false;
    2007       trackPicker.setAttribute('aria-hidden', 'false');
    2008       document.body.classList.add('tp-locked');
    2009       tpSearch.value = '';
    2010       renderList('');
    2011       // Defer focus so the open animation doesn't get jumped
    2012       setTimeout(() => tpSearch.focus(), 30);
    2013     }
    2014     function closePicker() {
    2015       trackPicker.hidden = true;
    2016       trackPicker.setAttribute('aria-hidden', 'true');
    2017       document.body.classList.remove('tp-locked');
    2018       if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
    2019         try { tpLastFocus.focus(); } catch(_) {}
    2020       }
    2021     }
    2022 
    2023     trackBtn.addEventListener('click', async () => {
    2024       openPicker();
    2025       await loadTracks();
    2026       renderList(tpSearch.value);
    2027     });
    2028 
    2029     // Close: backdrop click, [data-tp-close], or Escape
    2030     trackPicker.addEventListener('click', (e) => {
    2031       if (e.target.closest('[data-tp-close]')) {
    2032         closePicker();
    2033         return;
    2034       }
    2035       const row = e.target.closest('.tp-row[data-track-id]');
    2036       if (row) {
    2037         if (row.getAttribute('aria-disabled') === 'true') return;
    2038         const id = row.dataset.trackId;
    2039         if (id) {
    2040           insertChip('track', id);
    2041           closePicker();
    2042         }
    2043       }
    2044     });
    2045     document.addEventListener('keydown', (e) => {
    2046       if (!trackPicker.hidden && e.key === 'Escape') {
    2047         e.preventDefault();
    2048         closePicker();
    2049       }
    2050     });
    2051 
    2052     // Live filter
    2053     tpSearch.addEventListener('input', () => renderList(tpSearch.value));
    2054   }
    2055 
    2056   // ── Playlist insert (open existing or create new via modal)
    2057   const playlistBtn = document.getElementById('insert-playlist-btn');
    2058   if (playlistBtn) {
    2059     playlistBtn.addEventListener('click', async () => {
    2060       if (typeof window.openPlaylistEditor !== 'function') {
    2061         alert('<%= t('pedit.js_playlist_editor_missing') %>');
    2062         return;
    2063       }
    2064       try {
    2065         const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
    2066         const j = await r.json();
    2067         if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
    2068           const choice = prompt(
    2069             '<%= t('pedit.js_playlist_existing') %>\n\n' +
    2070             j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
    2071             '\n\n<%= t('pedit.js_playlist_choose') %>'
    2072           );
    2073           if (choice && /^\d+$/.test(choice.trim())) {
    2074             const idx = parseInt(choice.trim(), 10) - 1;
    2075             if (idx >= 0 && idx < j.playlists.length) {
    2076               insertChip('playlist', j.playlists[idx].id);
    2077               return;
    2078             }
    2079           }
    2080           if (choice === null) return;
    2081         }
    2082       } catch (_) { /* fall through to create */ }
    2083 
    2084       window.openPlaylistEditor({
    2085         mode: 'create',
    2086         onSaved: ({ id }) => insertChip('playlist', id),
    2087       });
    2088     });
    2089   }
    2090 
    2091   // ── Post type: segmented control + type-aware panels ──────────
    2092   (function () {
    2093     const typeInput = document.getElementById('pe-type-input');
    2094     const card = document.querySelector('.pe-type-card');
    2095     if (!typeInput || !card) return;
    2096     const seg = card.querySelector('.pe-typeseg');
    2097     const panels = card.querySelectorAll('.pe-type-panel');
    2098 
    2099     function applyType(tt) {
    2100       typeInput.value = tt;
    2101       seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
    2102         const on = b.dataset.type === tt;
    2103         b.classList.toggle('is-active', on);
    2104         b.setAttribute('aria-checked', on ? 'true' : 'false');
    2105       });
    2106       panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
    2107     }
    2108     seg.addEventListener('click', (e) => {
    2109       const btn = e.target.closest('.pe-typeseg-btn');
    2110       if (btn) applyType(btn.dataset.type);
    2111     });
    2112     applyType(typeInput.value || 'post');
    2113 
    2114     // Video URL → [[embed:url]] chip
    2115     const vBtn = document.getElementById('pe-video-insert');
    2116     const vUrl = document.getElementById('pe-video-url');
    2117     if (vBtn && vUrl) {
    2118       const doInsert = () => {
    2119         const url = (vUrl.value || '').trim();
    2120         if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; }
    2121         insertChip('embed', url);
    2122         vUrl.value = '';
    2123       };
    2124       vBtn.addEventListener('click', doInsert);
    2125       vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
    2126     }
    2127 
    2128     // Audio: inline upload → transcodes server-side → [[track:id]] chip
    2129     const drop = document.getElementById('pe-audio-drop');
    2130     const fileInput = document.getElementById('pe-audio-file');
    2131     const list = document.getElementById('pe-audio-list');
    2132     if (drop && fileInput && list) {
    2133       const pick = () => fileInput.click();
    2134       drop.addEventListener('click', pick);
    2135       drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
    2136       ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
    2137       ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
    2138       drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
    2139       fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
    2140 
    2141       function clientDuration(f) {
    2142         return new Promise((resolve) => {
    2143           try {
    2144             const u = URL.createObjectURL(f);
    2145             const a = document.createElement('audio');
    2146             a.preload = 'metadata';
    2147             a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
    2148             a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
    2149             a.src = u;
    2150           } catch (_) { resolve(null); }
    2151         });
    2152       }
    2153       async function handleFiles(files) {
    2154         for (const f of Array.from(files || [])) await uploadOne(f);
    2155       }
    2156       async function uploadOne(f) {
    2157         const li = document.createElement('li');
    2158         li.className = 'pe-audio-item';
    2159         const nameEl = document.createElement('span');
    2160         nameEl.className = 'pe-audio-item-name';
    2161         nameEl.textContent = f.name;
    2162         const stateEl = document.createElement('span');
    2163         stateEl.className = 'pe-audio-item-state';
    2164         stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>';
    2165         li.appendChild(nameEl); li.appendChild(stateEl);
    2166         list.appendChild(li);
    2167         try {
    2168           const dur = await clientDuration(f);
    2169           const fd = new FormData();
    2170           fd.append('audio', f);
    2171           if (dur) fd.append('duration', String(dur));
    2172           const res = await fetch('/admin/audio/upload', {
    2173             method: 'POST', body: fd,
    2174             headers: { 'Accept': 'application/json' },
    2175             credentials: 'same-origin',
    2176           });
    2177           const j = await res.json().catch(() => ({}));
    2178           if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
    2179           insertChip('track', j.id);
    2180           stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>';
    2181           li.classList.add('is-done');
    2182         } catch (err) {
    2183           stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message;
    2184           li.classList.add('is-fail');
    2185         }
    2186       }
    2187     }
    2188   })();
    2189 
    2190   // ── Submit: serialize editor contents into the hidden field
    2191   if (form && hiddenField) {
    2192     form.addEventListener('submit', () => {
    2193       const clone = editor.cloneNode(true);
    2194       serializeChips(clone);
    2195       hiddenField.value = clone.innerHTML;
    2196     });
    2197   }
    2198 })();
    2199 </script>
     1184
    22001185
    22011186<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    22291214  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    22301215<% } %>
    2231 
    2232 <script>
    2233 (function () {
    2234   // Pin: checkbox toggles the hidden rank field (0 = not pinned),
    2235   // ▲▼ shifts the position, with a readable description instead of a raw number.
    2236   var toggle = document.getElementById('pin-toggle');
    2237   var rank   = document.getElementById('pin-rank');
    2238   var pos    = document.getElementById('pin-pos');
    2239   var label  = document.getElementById('pin-label');
    2240   var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
    2241   var down   = document.getElementById('pin-down');
    2242   if (!toggle || !rank || !pos) return;
    2243 
    2244   function descr(n) {
    2245     n = Number(n) || 0;
    2246     if (n <= 1) return '<%= t('pedit.pin_top') %>';
    2247     return n + '<%= t('pedit.pin_nth_suffix') %>';
    2248   }
    2249   function render() {
    2250     var on = toggle.checked;
    2251     pos.hidden = !on;
    2252     if (on && Number(rank.value) < 1) rank.value = 1;
    2253     if (!on) rank.value = 0;
    2254     if (label) label.textContent = on ? descr(rank.value) : '';
    2255     if (up) up.disabled = Number(rank.value) <= 1;
    2256   }
    2257   toggle.addEventListener('change', render);
    2258   if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
    2259   if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
    2260   render();
    2261 })();
    2262 
    2263 (function () {
    2264   // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
    2265   // obstacles by setting a dynamic bottom offset = the greater of:
    2266   //  1) the height of the keyboard area NOT covered by the layout viewport
    2267   //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
    2268   //     due to interactive-widget=resizes-content → offset ≈ 0);
    2269   //  2) the height of the playing audio player (fixed, z-index 1000).
    2270   // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
    2271   var bar = document.querySelector('.pe-actions');
    2272   if (!bar) return;
    2273   var vv = window.visualViewport;
    2274   function position() {
    2275     var ap = document.querySelector('.audio-player');
    2276     var playing = document.body.classList.contains('has-audio-player') &&
    2277                   ap && getComputedStyle(ap).display !== 'none';
    2278     var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
    2279     var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
    2280     var offset = Math.max(audioOffset, kbCovered);
    2281     bar.style.bottom = offset ? offset + 'px' : '';
    2282   }
    2283   position();
    2284   window.addEventListener('resize', position);
    2285   if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
    2286   // has-audio-player is toggled via a body class → observe it.
    2287   try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
    2288 })();
    2289 </script>
     1216<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
  • src/views/pages/post.ejs

    rf85b2c3 r952baf3  
    147147    </section>
    148148  <% } %>
    149   <script>
    150   (function(){
    151     if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
    152     var current = null, currentBtn = null;
    153     function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
    154     function go(raw, uri){
    155       var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
    156       if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); }
    157     }
    158     function place(f, b){
    159       var r = b.getBoundingClientRect();
    160       f.style.top = (r.bottom + window.scrollY + 6) + 'px';
    161       f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px';
    162     }
    163     document.addEventListener('click', function(e){
    164       if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
    165       if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep
    166       var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
    167       if (b) {
    168         e.preventDefault();
    169         if (currentBtn === b) { close(); return; }   // toggle off
    170         close();
    171         var f = document.createElement('form');
    172         f.className = 'fedi-remote-form';
    173         f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
    174         f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
    175           + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
    176           + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
    177         var _inp = f.querySelector('input');
    178         _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
    179         try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){}
    180         document.body.appendChild(f);            // floating popover → no layout reflow
    181         place(f, b); current = f; currentBtn = b;
    182         _inp.focus(); _inp.select();
    183         return;
    184       }
    185       if (current) close(); // click anywhere else closes it
    186     });
    187     document.addEventListener('submit', function(e){
    188       var f = e.target.closest && e.target.closest('.fedi-remote-form');
    189       if (!f) return; e.preventDefault();
    190       go(f.querySelector('input').value, f.dataset.uri);
    191     });
    192     document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
    193     window.addEventListener('scroll', close, true);
    194   })();
    195   </script>
     149  <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt
     150    door de CSP geweigerd zodra je deze pagina via een link binnen de site opent
     151    -- zie shaer-0i6. %>
    196152
    197153
  • src/views/partials/bottom-tab.ejs

    rf85b2c3 r952baf3  
    302302</style>
    303303
    304 <script>
    305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking
    306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
    307 // during htmx navigation, so a once-captured reference goes stale
    308 // (button did nothing afterwards). Guard against double-wiring.
    309 (function() {
    310   if (window.__btSearchWired) return;
    311   window.__btSearchWired = true;
    312   document.addEventListener('click', function(e) {
    313     if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
    314     var o = document.getElementById('search-overlay');
    315     if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); }
    316   });
    317 })();
    318 </script>
     304<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
     305    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
     306    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
  • src/views/partials/msg-item.ejs

    rf85b2c3 r952baf3  
     1<% if (n.type === 'thread') { %>
     2<%
     3           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     4           // Een draad is nieuw zodra EEN bericht erin nieuw is.
     5           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
     6             return m.created_at && Date.parse(m.created_at) > _tseen;
     7           });
     8           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
     9           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
     10           // je schreef, niet een lege naam.
     11           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
     12           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
     13      %>
     14        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
     15          <div class="msg-thread-head">
     16            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
     17                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
     18
     19                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
     20                (Barts aanwijzing): tikken op de tegenpartij is waar je van
     21                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
     22                geen span met een klikhandler, want dan werkt hij ook met het
     23                toetsenbord en leest een schermlezer de stand voor.
     24
     25                Zonder JavaScript doet hij niets, en dan staat het gesprek
     26                gewoon open -- dat is ook de stand waar we mee beginnen. %>
     27            <button type="button" class="msg-thread-who" aria-expanded="true">
     28              <span class="msg-thread-name"><%= _tlabel %></span>
     29              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
     30              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
     31            </button>
     32            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
     33            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
     34                link is een antwoord in een lijst niet te plaatsen. %>
     35            <% if (n.post) { %>
     36              <a class="msg-thread-post" href="/<%= n.post.slug %>">
     37                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
     38                <%= n.post.title || n.post.slug %>
     39              </a>
     40            <% } %>
     41          </div>
     42          <ul class="msg-thread-msgs">
     43            <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
     44          </ul>
     45          <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor
     46              onder elk gesprek maakt de lijst onleesbaar -- juist het probleem
     47              dat deze weergave moest oplossen. De route kiest het pad: een
     48              draad aan een post krijgt een reply, een draad aan een persoon een
     49              direct bericht. %>
     50          <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %>
     51          <% if (_kan) { %>
     52            <div class="msg-thread-foot">
     53              <details class="msg-reply">
     54                <summary><%= t('fedi.reply') %></summary>
     55                <div class="msg-reply-form">
     56                  <%- include('../partials/reply-editor', {
     57                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
     58                    hiddenFields: [
     59                      { name: 'interaction_id', value: _rt.interactionId || '' },
     60                      { name: 'post_slug', value: _rt.postSlug || '' },
     61                      { name: 'to', value: _rt.actorUri || '' },
     62                    ],
     63                    placeholder: t('fedi.reply_ph'),
     64                    submitLabel: t('fedi.reply'),
     65                    rows: 2,
     66                  }) %>
     67                </div>
     68              </details>
     69              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
     70                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
     71              <% if (_rt.actorUri) { %>
     72                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave">
     73                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
     74                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
     75                </form>
     76              <% } %>
     77            </div>
     78          <% } %>
     79        </li>
     80<% } else { %>
    181<%
    282           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     
    585           var _who = n.name || n.handle || '';
    686           var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
    7            // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies;
    8            // Activity = likes/boosts/follows; Moderation = reports; Sent = your
    9            // replies; Conversations = every other (public) reply.
    10            var _kind = _t === 'sent' ? 'sent'
    11              : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
     87           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
     88           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
     89           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
     90           // samenvielen -- en dat is geen cosmetische hernoeming: een
     91           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
     92           // post, geen herkenbare tegenpartij) hield anders een data-kind
     93           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
     94           // behalve Alles onzichtbaar.
     95           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
    1296             : _t === 'report' ? 'mod'
    13              : (_t === 'mention' || _priv0) ? 'msgs'
    1497             : 'conv';
     98           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
     99           // de draad, niet op de losse zinnen erin.
     100           var _sub = (typeof inThread !== 'undefined' && inThread);
    15101           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
    16102           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
    17103      %>
    18         <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>">
     104        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
    19105          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
    20106            <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
     
    38124              <% if (_t === 'sent') { %>
    39125                <span class="msg-who"><%= t('msg.you') %></span>
    40                 <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
     126                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
    41127              <% } else { %>
    42128                <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
    43                 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %>
     129                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
     130                      De handle erachter niet -- die staat in de kop. */ %>
     131                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
    44132              <% } %>
    45133              <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
     
    50138            </div>
    51139
     140            <% var _hasWhat = !_sub || _priv
     141                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
     142                 || (_t === 'sent' && n.in_reply_to); %>
     143            <% if (_hasWhat) { %>
    52144            <div class="msg-what">
    53145              <% if (n.count && n.count > 1) { %>
     
    60152              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
    61153              <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
     154              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
    62155              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
    63156              <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
     
    65158              <% } else { %><%= t('notif.replied') %><% } %>
    66159              <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
    67               <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
     160              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
    68161              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    69162              <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    70163            </div>
     164            <% } %>
    71165
    72166            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
     
    116210          </div>
    117211        </li>
     212<% } %>
  • src/views/partials/playlist-editor.ejs

    rf85b2c3 r952baf3  
    280280</style>
    281281
    282 <script>
    283 (function() {
    284   // Idempotency: if window.openPlaylistEditor already defined (multiple
    285   // partial includes on a single page), skip re-binding.
    286   if (typeof window.openPlaylistEditor === 'function') return;
    287 
    288   const CSRF = '<%= _csrf %>';
    289 
    290   function esc(s) {
    291     return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
    292       '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    293     }[c]));
    294   }
    295   function fmtDur(sec) {
    296     if (!sec) return '';
    297     const m = Math.floor(sec / 60), s = sec % 60;
    298     return `${m}:${String(s).padStart(2, '0')}`;
    299   }
    300 
    301   async function api(method, url, body) {
    302     const opts = {
    303       method, credentials: 'same-origin',
    304       headers: { 'X-CSRF-Token': CSRF },
    305     };
    306     if (body !== undefined) {
    307       opts.headers['Content-Type'] = 'application/json';
    308       opts.body = JSON.stringify(body);
    309     }
    310     const r = await fetch(url, opts);
    311     return r.json();
    312   }
    313 
    314   /**
    315    * Public entry point: open the editor.
    316    *  opts: { mode: 'create'|'edit', id?, onSaved? }
    317    *  onSaved is called with { id, playlist } after a successful save.
    318    */
    319   window.openPlaylistEditor = async function(opts) {
    320     opts = opts || {};
    321     const mode = opts.mode === 'edit' ? 'edit' : 'create';
    322     const isEdit = mode === 'edit';
    323 
    324     // Load all audio tracks for the picker
    325     let tracks = [];
    326     try {
    327       const j = await api('GET', '/admin/playlists/api/tracks');
    328       if (Array.isArray(j.tracks)) tracks = j.tracks;
    329     } catch (e) {
    330       alert('Tracks ophalen mislukt');
    331       return;
    332     }
    333     if (!tracks.length) {
    334       alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
    335       return;
    336     }
    337 
    338     // Existing playlist data when editing
    339     let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
    340     if (isEdit && opts.id) {
    341       try {
    342         const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
    343         if (j.ok) initial = { ...initial, ...j.playlist };
    344       } catch (e) {}
    345     }
    346 
    347     // ── DOM ────────────────────────────────────────────────────────
    348     const backdrop = document.createElement('div');
    349     backdrop.className = 'pl-modal-backdrop';
    350     backdrop.innerHTML = `
    351       <div class="pl-modal" role="dialog" aria-label="Playlist editor">
    352         <div class="pl-modal-header">
    353           <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
    354           <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
    355         </div>
    356         <div class="pl-modal-body">
    357           <div class="pl-editor-cols">
    358             <div class="pl-editor-left">
    359               <div class="pl-meta-grid">
    360                 <label class="pl-field pl-field-full">
    361                   <span>Titel *</span>
    362                   <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
    363                 </label>
    364                 <label class="pl-field">
    365                   <span>Artiest</span>
    366                   <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
    367                 </label>
    368                 <label class="pl-field">
    369                   <span>Jaar</span>
    370                   <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
    371                 </label>
    372                 <label class="pl-field">
    373                   <span>Type</span>
    374                   <select id="pli-kind">
    375                     <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
    376                     <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
    377                   </select>
    378                 </label>
    379                 <div class="pl-field pl-field-full">
    380                   <span>Cover</span>
    381                   <div class="pl-cover-row">
    382                     <span class="pl-cover-thumb" id="pli-cover-thumb">
    383                       ${initial.cover
    384                         ? `<img src="${esc(initial.cover)}" alt="">`
    385                         : `<span class="pl-cover-empty">🎨</span>`}
    386                     </span>
    387                     <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
    388                   </div>
    389                   <div class="pl-cover-upload-row">
    390                     <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    391                     <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
    392                     <span class="pl-cover-status" id="pli-cover-status"></span>
    393                   </div>
    394                 </div>
    395               </div>
    396               <div class="pl-section-title">
    397                 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
    398                 <small>(sleep ⠿ om te ordenen)</small>
    399               </div>
    400               <div id="pli-selected" class="pl-selected-list"></div>
    401             </div>
    402             <div class="pl-editor-right">
    403               <div class="pl-section-title">Beschikbare tracks</div>
    404               <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
    405               <div id="pli-available" class="pl-available-list"></div>
    406             </div>
    407           </div>
    408         </div>
    409         <div class="pl-modal-footer">
    410           <button type="button" class="btn" id="pli-cancel">Annuleren</button>
    411           <button type="button" class="btn btn-primary" id="pli-save" disabled>
    412             ${isEdit ? 'Opslaan' : 'Aanmaken'}
    413           </button>
    414         </div>
    415       </div>
    416     `;
    417     document.body.appendChild(backdrop);
    418 
    419     const $ = sel => backdrop.querySelector(sel);
    420     const close = () => backdrop.remove();
    421     backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
    422     $('.pl-modal-close').addEventListener('click', close);
    423     $('#pli-cancel').addEventListener('click', close);
    424 
    425     // Index tracks by id for fast lookup
    426     const trackById = new Map(tracks.map(t => [t.id, t]));
    427     let selected = (initial.track_ids || []).filter(id => trackById.has(id));
    428 
    429     const titleEl = $('#pli-title');
    430     const artistEl = $('#pli-artist');
    431     const yearEl = $('#pli-year');
    432     const kindEl = $('#pli-kind');
    433     const coverEl = $('#pli-cover');
    434     const coverThumb = $('#pli-cover-thumb');
    435     const saveBtn = $('#pli-save');
    436     const selectedEl = $('#pli-selected');
    437     const availEl = $('#pli-available');
    438     const searchEl = $('#pli-search');
    439     const countEl = $('#pli-count');
    440 
    441     coverEl.addEventListener('input', () => {
    442       const u = coverEl.value.trim();
    443       coverThumb.innerHTML = u
    444         ? `<img src="${esc(u)}" alt="" data-fallback>`
    445         : `<span class="pl-cover-empty">🎨</span>`;
    446     });
    447 
    448     // ── Cover file upload (werkt in create ÉN edit) ───────────
    449     // We uploaden naar het generieke image-endpoint (/posts/upload-image,
    450     // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
    451     // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
    452     // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd.
    453     const coverFileInput = $('#pli-cover-file');
    454     const coverPickBtn   = $('#pli-cover-pick');
    455     const coverStatus    = $('#pli-cover-status');
    456     if (coverFileInput && coverPickBtn) {
    457       coverPickBtn.addEventListener('click', () => coverFileInput.click());
    458       coverFileInput.addEventListener('change', async (e) => {
    459         const file = e.target.files && e.target.files[0];
    460         coverFileInput.value = '';
    461         if (!file) return;
    462         if (!/^image\//.test(file.type)) {
    463           coverStatus.textContent = 'Alleen afbeeldingen';
    464           coverStatus.className = 'pl-cover-status is-error';
    465           return;
    466         }
    467         coverStatus.textContent = 'Uploaden…';
    468         coverStatus.className = 'pl-cover-status';
    469         const fd = new FormData();
    470         fd.append('image', file);
    471         try {
    472           const r = await fetch('/posts/upload-image',
    473             { method: 'POST', body: fd, credentials: 'same-origin' }
    474           );
    475           const j = await r.json();
    476           if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');
    477           const url = j.url || '';
    478           coverEl.value = url;
    479           coverThumb.innerHTML = url
    480             ? `<img src="${esc(url)}" alt="">`
    481             : `<span class="pl-cover-empty">🎨</span>`;
    482           coverStatus.textContent = '✓ Geüpload';
    483           coverStatus.className = 'pl-cover-status is-ok';
    484         } catch (err) {
    485           coverStatus.textContent = 'Mislukt: ' + err.message;
    486           coverStatus.className = 'pl-cover-status is-error';
    487         }
    488       });
    489     }
    490 
    491     function updateSaveBtn() {
    492       saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
    493     }
    494 
    495     function renderSelected() {
    496       countEl.textContent = selected.length;
    497       if (selected.length === 0) {
    498         selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
    499         updateSaveBtn();
    500         return;
    501       }
    502       selectedEl.innerHTML = selected.map((id, i) => {
    503         const t = trackById.get(id);
    504         if (!t) return '';
    505         const cover = t.cover
    506           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    507           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    508         return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
    509           <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
    510           <span class="pl-row-num">${i + 1}</span>
    511           ${cover}
    512           <span class="pl-row-info">
    513             <span class="pl-row-title">${esc(t.title)}</span>
    514             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
    515           </span>
    516           <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
    517         </div>`;
    518       }).join('');
    519 
    520       selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
    521         b.addEventListener('click', () => {
    522           selected = selected.filter(x => x !== b.dataset.id);
    523           renderSelected();
    524           renderAvailable();
    525         });
    526       });
    527       bindDrag();
    528       updateSaveBtn();
    529     }
    530 
    531     function renderAvailable() {
    532       const q = searchEl.value.trim().toLowerCase();
    533       const matches = tracks.filter(t => {
    534         if (!q) return true;
    535         return (t.title || '').toLowerCase().includes(q)
    536             || (t.artist || '').toLowerCase().includes(q);
    537       });
    538       if (matches.length === 0) {
    539         availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
    540         return;
    541       }
    542       availEl.innerHTML = matches.map(t => {
    543         const isAdded = selected.includes(t.id);
    544         const cls = ['pl-avail-row'];
    545         if (isAdded) cls.push('is-added');
    546         if (!t.playable) cls.push('is-unplayable');
    547         const cover = t.cover
    548           ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    549           : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    550         return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
    551           ${cover}
    552           <span class="pl-row-info">
    553             <span class="pl-row-title">${esc(t.title)}</span>
    554             ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
    555           </span>
    556           <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
    557         </div>`;
    558       }).join('');
    559 
    560       availEl.querySelectorAll('.pl-avail-row').forEach(row => {
    561         if (row.classList.contains('is-unplayable')) return;
    562         row.addEventListener('click', () => {
    563           const id = row.dataset.id;
    564           if (selected.includes(id)) selected = selected.filter(x => x !== id);
    565           else selected.push(id);
    566           renderSelected();
    567           renderAvailable();
    568         });
    569       });
    570     }
    571 
    572     // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
    573     function bindDrag() {
    574       selectedEl.querySelectorAll('.pl-row').forEach(row => {
    575         const handle = row.querySelector('.pl-row-handle');
    576         if (!handle) return;
    577         let dragging = false, originalIdx = -1;
    578 
    579         handle.addEventListener('pointerdown', e => {
    580           e.preventDefault();
    581           handle.setPointerCapture(e.pointerId);
    582           dragging = true;
    583           originalIdx = parseInt(row.dataset.pos, 10);
    584           row.classList.add('is-dragging');
    585         });
    586         handle.addEventListener('pointermove', e => {
    587           if (!dragging) return;
    588           e.preventDefault();
    589           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    590           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    591           let targetIdx = -1, above = false;
    592           for (let i = 0; i < rows.length; i++) {
    593             const r = rows[i]; if (r === row) continue;
    594             const rect = r.getBoundingClientRect();
    595             const mid = rect.top + rect.height / 2;
    596             if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
    597             if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
    598               targetIdx = i; above = e.clientY < mid; break;
    599             }
    600           }
    601           if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
    602         });
    603         const finish = e => {
    604           if (!dragging) return;
    605           dragging = false;
    606           try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
    607           row.classList.remove('is-dragging');
    608           const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
    609           let targetIdx = -1, above = false;
    610           for (let i = 0; i < rows.length; i++) {
    611             if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
    612             if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
    613           }
    614           rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
    615           if (targetIdx === -1 || targetIdx === originalIdx) return;
    616           const moved = selected[originalIdx];
    617           selected.splice(originalIdx, 1);
    618           let newIdx = targetIdx;
    619           if (originalIdx < targetIdx) newIdx--;
    620           if (!above) newIdx++;
    621           newIdx = Math.max(0, Math.min(selected.length, newIdx));
    622           selected.splice(newIdx, 0, moved);
    623           renderSelected();
    624         };
    625         handle.addEventListener('pointerup', finish);
    626         handle.addEventListener('pointercancel', () => {
    627           dragging = false;
    628           row.classList.remove('is-dragging');
    629           selectedEl.querySelectorAll('.drop-above, .drop-below')
    630             .forEach(r => r.classList.remove('drop-above', 'drop-below'));
    631         });
    632       });
    633     }
    634 
    635     titleEl.addEventListener('input', updateSaveBtn);
    636     searchEl.addEventListener('input', renderAvailable);
    637     renderSelected();
    638     renderAvailable();
    639 
    640     saveBtn.addEventListener('click', async () => {
    641       if (saveBtn.disabled) return;
    642       const orig = saveBtn.textContent;
    643       saveBtn.disabled = true;
    644       saveBtn.textContent = 'Opslaan...';
    645 
    646       const payload = {
    647         title:  titleEl.value.trim(),
    648         artist: artistEl.value.trim(),
    649         year:   parseInt(yearEl.value, 10) || 0,
    650         cover:  coverEl.value.trim(),
    651         kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
    652         tracks: selected.slice(),
    653       };
    654 
    655       try {
    656         const url = isEdit
    657           ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
    658           : '/admin/playlists/api';
    659         const j = await api('POST', url, payload);
    660         if (!j.ok) {
    661           alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
    662           saveBtn.disabled = false;
    663           saveBtn.textContent = orig;
    664           return;
    665         }
    666         const savedId = isEdit ? initial.id : j.id;
    667         close();
    668         if (typeof opts.onSaved === 'function') {
    669           opts.onSaved({ id: savedId, playlist: payload });
    670         }
    671       } catch (err) {
    672         alert('Opslaan mislukt: ' + err.message);
    673         saveBtn.disabled = false;
    674         saveBtn.textContent = orig;
    675       }
    676     });
    677 
    678     // ESC to close
    679     document.addEventListener('keydown', function onEsc(e) {
    680       if (e.key === 'Escape' && document.body.contains(backdrop)) {
    681         close();
    682         document.removeEventListener('keydown', onEsc);
    683       }
    684     });
    685   };
    686 })();
    687 </script>
     282<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
  • src/views/partials/profile-header.ejs

    rf85b2c3 r952baf3  
    160160</div>
    161161<% } %>
    162 <script>
    163 (function () {
    164   if (window.__pcmsFediFollowWired) return;
    165   window.__pcmsFediFollowWired = true;
    166   function modal() { return document.querySelector('.pf-follow'); }
    167   function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
    168   function go() {
    169     var m = modal(); if (!m) return;
    170     var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
    171     // Strip a full @user@host handle (and any scheme/path) down to the server host.
    172     var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
    173     if (!server) { if (inp) inp.focus(); return; }
    174     try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
    175     var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
    176     location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
    177   }
    178   document.addEventListener('click', function (e) {
    179     var b = e.target.closest && e.target.closest('.profile-fedi-link');
    180     if (b) { e.preventDefault();
    181       var m = modal(); if (!m) return;
    182       m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
    183       var inp = m.querySelector('.pf-follow-input');
    184       try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
    185       m.classList.add('is-open');
    186       setTimeout(function () { if (inp) inp.focus(); }, 30);
    187       return;
    188     }
    189     if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
    190     if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
    191     var open = document.querySelector('.pf-follow.is-open');
    192     if (open && e.target === open) closeM(); /* click on backdrop */
    193   });
    194   document.addEventListener('keydown', function (e) {
    195     var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
    196     if (e.key === 'Escape') { closeM(); }
    197     else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
    198   });
    199 })();
    200 
    201 /* Profile summary: click the avatar to open a modal with photo + details. */
    202 (function () {
    203   if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
    204   function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
    205   document.addEventListener('click', function (e) {
    206     if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
    207     var open = document.querySelector('.pf-summary.is-open');
    208     if (open && e.target === open) { close(); return; } /* click on backdrop */
    209     var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
    210     if (!t) return;
    211     e.preventDefault();
    212     var root = (t.closest && t.closest('#pcms-chrome')) || document;
    213     var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
    214     if (modal) modal.classList.add('is-open');
    215   });
    216   document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
    217 })();
    218 </script>
     162<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
     163    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
     164    (shaer-bqr). %>
    219165<% } %>
    220166
  • src/views/partials/profile-sheet.ejs

    rf85b2c3 r952baf3  
    105105          <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
    106106          <span class="psi-label"><%= t('nav.theme_label') %></span>
    107           <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
     107          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
    108108        </button>
    109109      </li>
     
    326326</style>
    327327
    328 <script>
    329 (function() {
    330   const sheet = document.getElementById('profile-sheet');
    331   if (!sheet) return;
    332 
    333   const backdrop  = document.getElementById('profile-sheet-backdrop');
    334   const panel     = sheet.querySelector('.profile-sheet-panel');
    335   const closeBtn  = document.getElementById('profile-sheet-close');
    336   const dragZone  = document.getElementById('profile-sheet-drag-zone');
    337   const themeBtn  = document.getElementById('profile-sheet-theme');
    338   const themeLbl  = document.getElementById('profile-sheet-theme-state');
    339 
    340   function openSheet() {
    341     sheet.classList.add('is-open');
    342     sheet.setAttribute('aria-hidden', 'false');
    343     document.body.classList.add('profile-sheet-locked');
    344     syncTheme();
    345   }
    346   function closeSheet() {
    347     sheet.classList.remove('is-open');
    348     sheet.setAttribute('aria-hidden', 'true');
    349     document.body.classList.remove('profile-sheet-locked');
    350     panel.style.removeProperty('--pcms-drag-y');
    351   }
    352 
    353   // Open: any element with [data-profile-sheet-toggle]
    354   document.addEventListener('click', function(e) {
    355     const trigger = e.target.closest('[data-profile-sheet-toggle]');
    356     if (trigger) {
    357       e.preventDefault();
    358       openSheet();
    359     }
    360   });
    361 
    362   // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
    363   if (backdrop) backdrop.addEventListener('click', closeSheet);
    364   if (closeBtn) closeBtn.addEventListener('click', closeSheet);
    365   document.addEventListener('keydown', (e) => {
    366     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
    367   });
    368 
    369   // Menu items that navigate: close synchronously before nav fires
    370   sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
    371     el.addEventListener('click', closeSheet);
    372   });
    373 
    374   // Drag-down-to-close on touch.
    375   // Uses --pcms-drag-y custom property rather than overwriting the panel's
    376   // transform string. This composes correctly with any horizontal centering
    377   // (currently none here, but the pattern matches audio-sheet for safety).
    378   let startY = 0, lastY = 0, dragging = false;
    379   function onDown(e) {
    380     if (e.pointerType !== 'touch') return;
    381     if (panel.scrollTop > 0) return;
    382     startY = lastY = e.clientY;
    383     dragging = true;
    384     panel.classList.add('is-dragging');
    385   }
    386   function onMove(e) {
    387     if (!dragging) return;
    388     lastY = e.clientY;
    389     const dy = Math.max(0, lastY - startY);
    390     panel.style.setProperty('--pcms-drag-y', dy + 'px');
    391   }
    392   function onUp() {
    393     if (!dragging) return;
    394     dragging = false;
    395     panel.classList.remove('is-dragging');
    396     const dy = lastY - startY;
    397     if (dy > 80) closeSheet();
    398     else panel.style.removeProperty('--pcms-drag-y');
    399   }
    400   if (window.PointerEvent && dragZone) {
    401     dragZone.addEventListener('pointerdown', onDown);
    402     document.addEventListener('pointermove', onMove);
    403     document.addEventListener('pointerup', onUp);
    404     document.addEventListener('pointercancel', onUp);
    405   }
    406 
    407   // Theme toggle inside sheet — syncs label
    408   function syncTheme() {
    409     if (!themeLbl) return;
    410     const t = document.documentElement.getAttribute('data-theme') || 'dark';
    411     themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>';
    412   }
    413   if (themeBtn) {
    414     themeBtn.addEventListener('click', function() {
    415       const cur = document.documentElement.getAttribute('data-theme') || 'dark';
    416       const next = cur === 'dark' ? 'light' : 'dark';
    417       document.documentElement.setAttribute('data-theme', next);
    418       try { localStorage.setItem('pcms-theme', next); } catch (e) {}
    419       syncTheme();
    420     });
    421   }
    422 })();
    423 </script>
     328<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
  • src/views/partials/topnav.ejs

    rf85b2c3 r952baf3  
    176176
    177177<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
    178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
     178<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
     179    module is een statisch bestand en kan geen vertaling interpoleren. %>
     180<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
     181     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
    179182  <div class="container">
    180183    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
     
    191194</div>
    192195
    193 <script>
    194 (function() {
    195   // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
    196   // → without this guard the script would stack EXTRA listeners on every navigation,
    197   // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
    198   // Everything below uses event delegation on body/document, so it also works for
    199   // buttons that appear after this run (OOB).
    200   if (window.__pcmsChromeWired) return;
    201   window.__pcmsChromeWired = true;
    202 
    203   function toggleTheme() {
    204     var cur = document.documentElement.getAttribute('data-theme') || 'dark';
    205     var next = cur === 'dark' ? 'light' : 'dark';
    206     document.documentElement.setAttribute('data-theme', next);
    207     try { localStorage.setItem('pcms-theme', next); } catch (e) {}
    208   }
    209   function overlay() { return document.getElementById('search-overlay'); }
    210   function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
    211   function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
    212 
    213   document.body.addEventListener('click', function(e) {
    214     if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
    215     if (e.target.closest('#search-toggle')) { openSearch(); return; }
    216     if (e.target.closest('#search-close')) { closeSearch(); return; }
    217     // Close open dropdowns (user menu + language picker) on click outside.
    218     document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
    219       if (!d.contains(e.target)) d.removeAttribute('open');
    220     });
    221   });
    222 
    223   document.addEventListener('keydown', function(e) {
    224     if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
    225   });
    226 
    227   // ── Live results while typing ───────────────────────────────────
    228   var _ST = {
    229     posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
    230     events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
    231     all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
    232   };
    233   function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
    234   function renderSuggest(box, d, q, ov) {
    235     var html = '';
    236     function grp(title, items, fmt) {
    237       if (!items || !items.length) return;
    238       html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
    239     }
    240     grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
    241     grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
    242     grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
    243     grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
    244     var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
    245     if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; }
    246     var action = (ov && ov.getAttribute('data-action')) || '/search';
    247     html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>';
    248     box.innerHTML = html;
    249   }
    250   var _sTimer;
    251   document.addEventListener('input', function(e) {
    252     var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
    253     if (!inp) return;
    254     var box = document.getElementById('search-suggest');
    255     if (!box) return;
    256     var q = inp.value.trim();
    257     clearTimeout(_sTimer);
    258     if (q.length < 2) { box.innerHTML = ''; return; }
    259     _sTimer = setTimeout(function() {
    260       var ov = document.getElementById('search-overlay');
    261       var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
    262       fetch(url + '?q=' + encodeURIComponent(q))
    263         .then(function(r){ return r.ok ? r.json() : null; })
    264         .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
    265         .catch(function(){});
    266     }, 200);
    267   });
    268   // Click on a suggestion → close the overlay (the link/boost handles navigation).
    269   document.addEventListener('click', function(e) {
    270     if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
    271   });
    272 })();
    273 </script>
     196<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
    274197
    275198<style>
  • src/views/partials/track-editor.ejs

    rf85b2c3 r952baf3  
    284284</style>
    285285
    286 <script>
    287 (function() {
    288 
    289   function esc(s) {
    290     return String(s == null ? '' : s)
    291       .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    292       .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    293   }
    294 
    295   async function api(method, url, body) {
    296     const opts = { method, credentials: 'same-origin', headers: {} };
    297     if (body !== undefined) {
    298       if (body instanceof FormData) {
    299         opts.body = body;
    300       } else {
    301         opts.headers['Content-Type'] = 'application/json';
    302         opts.body = JSON.stringify(body);
    303       }
    304     }
    305     const res = await fetch(url, opts);
    306     let data;
    307     try { data = await res.json(); }
    308     catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
    309     if (!res.ok) data.ok = false;
    310     return data;
    311   }
    312 
    313   /**
    314    * Open the track editor.
    315    * @param {object} opts
    316    * @param {string} opts.id        — track id to edit
    317    * @param {function?} opts.onSaved — called with updated track on success
    318    */
    319   window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
    320     if (!id) return;
    321     let track, albumSuggestions = [];
    322     try {
    323       const [trackJson, listJson] = await Promise.all([
    324         api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
    325         api('GET', '/admin/audio/api/albums'),
    326       ]);
    327       if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
    328       track = trackJson.track;
    329       if (listJson.ok && Array.isArray(listJson.albums)) {
    330         albumSuggestions = listJson.albums.filter(Boolean);
    331       }
    332     } catch (err) {
    333       alert('Track ophalen mislukt: ' + err.message);
    334       return;
    335     }
    336 
    337     const backdrop = document.createElement('div');
    338     backdrop.className = 'te-backdrop';
    339     backdrop.innerHTML = `
    340       <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
    341         <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
    342 
    343         <div class="te-header">
    344           <h3>✎ Track bewerken</h3>
    345           <button type="button" class="te-close" aria-label="Sluiten">×</button>
    346         </div>
    347 
    348         <div class="te-body">
    349 
    350           ${track.stream_url ? `
    351           <div class="te-preview">
    352             <div class="te-preview-cover">
    353               ${track.cover_url
    354                 ? `<img src="${esc(track.cover_url)}" alt="">`
    355                 : `🎵`}
    356             </div>
    357             <div class="te-preview-meta">
    358               <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
    359               <div class="te-preview-sub">
    360                 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
    361               </div>
    362             </div>
    363             <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
    364           </div>
    365           ` : ''}
    366 
    367           <div class="te-form">
    368 
    369             <label class="te-field">
    370               <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
    371               <input type="text" id="te-title" maxlength="200" required
    372                      autocomplete="off" autocapitalize="words" spellcheck="false"
    373                      value="${esc(track.title || '')}">
    374             </label>
    375 
    376             <div class="te-row te-row-2">
    377               <label class="te-field">
    378                 <span>Artiest</span>
    379                 <input type="text" id="te-artist" maxlength="200"
    380                        autocomplete="off" autocapitalize="words" spellcheck="false"
    381                        value="${esc(track.artist || '')}">
    382               </label>
    383               <label class="te-field">
    384                 <span>Album</span>
    385                 <input type="text" id="te-album" maxlength="200"
    386                        autocomplete="off" autocapitalize="words" spellcheck="false"
    387                        list="te-album-list" value="${esc(track.album || '')}">
    388                 <datalist id="te-album-list">
    389                   ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
    390                 </datalist>
    391               </label>
    392             </div>
    393 
    394             <label class="te-field">
    395               <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
    396               <input type="number" id="te-duration" min="0" step="1"
    397                      inputmode="numeric" pattern="[0-9]*"
    398                      value="${track.duration || ''}" placeholder="auto">
    399             </label>
    400 
    401             <div class="te-row te-row-2">
    402               <label class="te-field">
    403                 <span>Eigenaar / credit <small>(copyright-houder)</small></span>
    404                 <div class="te-credit-row">
    405                   <input type="text" id="te-credit" maxlength="200"
    406                          autocomplete="off" spellcheck="false"
    407                          placeholder="bv. © 2025 Mara Vos"
    408                          value="${esc(track.credit || '')}">
    409                   <button type="button" class="te-sym-btn" id="te-credit-copyr"
    410                           title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
    411                 </div>
    412               </label>
    413               <label class="te-field">
    414                 <span>Licentie</span>
    415                 <input type="text" id="te-license" maxlength="120"
    416                        autocomplete="off" spellcheck="false" list="te-license-list"
    417                        placeholder="Alle rechten voorbehouden"
    418                        value="${esc(track.license || '')}">
    419                 <datalist id="te-license-list">
    420                   <option value="Alle rechten voorbehouden"></option>
    421                   <option value="CC BY 4.0"></option>
    422                   <option value="CC BY-SA 4.0"></option>
    423                   <option value="CC BY-NC 4.0"></option>
    424                   <option value="CC BY-NC-SA 4.0"></option>
    425                   <option value="CC BY-ND 4.0"></option>
    426                   <option value="CC0 1.0 (publiek domein)"></option>
    427                 </datalist>
    428               </label>
    429             </div>
    430 
    431             <div class="te-field">
    432               <span>Open in <small>(links naar dezelfde track elders)</small></span>
    433               <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
    434                      placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
    435               <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
    436                      placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
    437               <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
    438                      placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
    439             </div>
    440 
    441             <div class="te-field">
    442               <span>Audiobestand</span>
    443               <div class="te-cover-btn-row">
    444                 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
    445                 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
    446                 <span class="te-cover-status" id="te-audio-status"></span>
    447               </div>
    448             </div>
    449 
    450             <div class="te-field">
    451               <span>Cover</span>
    452               <div class="te-cover-row">
    453                 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
    454                      aria-label="Klik om cover te kiezen">
    455                   ${track.cover_url
    456                     ? `<img src="${esc(track.cover_url)}" alt="">`
    457                     : `<span class="te-cover-empty">🎨</span>`}
    458                 </div>
    459                 <input type="file" id="te-cover-file"
    460                        accept="image/jpeg,image/png,image/webp,image/gif" hidden>
    461                 <div class="te-cover-actions">
    462                   <div class="te-cover-btn-row">
    463                     <button type="button" class="te-cover-btn" id="te-cover-pick">
    464                       📷 Foto kiezen
    465                     </button>
    466                     ${track.cover_url ? `
    467                     <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
    468                       × Verwijder
    469                     </button>` : ''}
    470                   </div>
    471                   <input type="text" id="te-cover-url" inputmode="url"
    472                          placeholder="/media/… of https://…"
    473                          autocomplete="off" autocapitalize="none" spellcheck="false"
    474                          value="${esc(track.cover_url || '')}">
    475                   <div class="te-cover-status" id="te-cover-status"></div>
    476                 </div>
    477               </div>
    478             </div>
    479 
    480           </div>
    481         </div>
    482 
    483         <div class="te-footer">
    484           <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
    485           <div class="te-footer-spacer"></div>
    486           <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
    487         </div>
    488       </div>
    489     `;
    490 
    491     document.body.appendChild(backdrop);
    492     document.body.classList.add('te-modal-open');
    493 
    494     const $ = sel => backdrop.querySelector(sel);
    495 
    496     // ── Inline preview player (routed through global mini-player) ──
    497     // We don't build our own <audio>; instead we tell the global
    498     // window.pcmsAudioPlayer to load this single track. Visual state
    499     // syncs against the global audio element so toggle works correctly
    500     // even if the user pauses from the mini-bar.
    501     const previewBtn = $('#te-preview-play');
    502     if (previewBtn) {
    503       const setPreviewState = (playing) => {
    504         previewBtn.textContent = playing ? '⏸' : '▶';
    505         previewBtn.classList.toggle('is-playing', playing);
    506         previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
    507       };
    508       const isOurTrack = () => {
    509         // audio.src is a blob: URL (Spotify-style playback) — compare against
    510         // the player's logical current-track URL instead.
    511         const player = window.pcmsAudioPlayer;
    512         const cur = player && player.currentTrack();
    513         return !!(cur && track.stream_url && cur.url === track.stream_url);
    514       };
    515       const resync = () => {
    516         const audio = document.getElementById('audio-element');
    517         const playing = audio && !audio.paused && !audio.ended && isOurTrack();
    518         setPreviewState(!!playing);
    519       };
    520 
    521       previewBtn.addEventListener('click', () => {
    522         const player = window.pcmsAudioPlayer;
    523         if (!player || !track.stream_url) {
    524           console.warn('preview: miniplayer or url missing');
    525           return;
    526         }
    527         const audio = document.getElementById('audio-element');
    528         if (audio && isOurTrack()) {
    529           // Same track loaded — toggle
    530           if (audio.paused) player.play(); else player.pause();
    531         } else {
    532           player.setQueue([{
    533             url:    track.stream_url,
    534             title:  track.title  || '(zonder titel)',
    535             artist: track.artist || '',
    536             album:  track.album  || '',
    537             cover:  track.cover_url || '',
    538           }], 0);
    539         }
    540       });
    541 
    542       const audio = document.getElementById('audio-element');
    543       if (audio) {
    544         const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
    545         evs.forEach(ev => audio.addEventListener(ev, resync));
    546         // Detach listeners on close so we don't leak them
    547         backdrop._previewCleanup = () => {
    548           evs.forEach(ev => audio.removeEventListener(ev, resync));
    549         };
    550         resync();   // initial state
    551       }
    552     }
    553 
    554     function close() {
    555       // Detach our resync listeners (mini-player stays running)
    556       if (backdrop._previewCleanup) backdrop._previewCleanup();
    557       document.body.classList.remove('te-modal-open');
    558       document.removeEventListener('keydown', onEsc);
    559       backdrop.remove();
    560     }
    561     function onEsc(e) { if (e.key === 'Escape') close(); }
    562     document.addEventListener('keydown', onEsc);
    563 
    564     // A click on the backdrop does NOT close the editor — too easy to lose edits by
    565     // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
    566     $('.te-close').addEventListener('click', close);
    567     $('#te-cancel').addEventListener('click', close);
    568 
    569     // ── Cover picking + URL paste + drag-drop ────────────────
    570     const thumb     = $('#te-cover-thumb');
    571     const fileInput = $('#te-cover-file');
    572     const urlInput  = $('#te-cover-url');
    573     const status    = $('#te-cover-status');
    574     const pickBtn   = $('#te-cover-pick');
    575     const removeBtn = $('#te-cover-remove');
    576 
    577     function setStatus(text, kind) {
    578       status.textContent = text || '';
    579       status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
    580     }
    581     function setThumb(url) {
    582       if (url) {
    583         thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
    584       } else {
    585         thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
    586       }
    587     }
    588 
    589     pickBtn.addEventListener('click', () => fileInput.click());
    590     thumb.addEventListener('click', () => fileInput.click());
    591     thumb.addEventListener('keydown', e => {
    592       if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
    593     });
    594 
    595     async function uploadCoverFile(file) {
    596       if (!file) return;
    597       if (!/^image\//.test(file.type)) {
    598         setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
    599       }
    600       setStatus('Uploaden…', null);
    601       const fd = new FormData();
    602       fd.append('cover', file);
    603       try {
    604         const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
    605           method: 'POST', body: fd, credentials: 'same-origin',
    606         });
    607         const j = await r.json();
    608         if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
    609         urlInput.value = j.cover_url || '';
    610         setThumb(j.cover_url);
    611         setStatus('✓ Geüpload', 'ok');
    612       } catch (err) {
    613         setStatus('Mislukt: ' + err.message, 'error');
    614       }
    615     }
    616 
    617     fileInput.addEventListener('change', e => {
    618       const f = e.target.files && e.target.files[0];
    619       if (f) uploadCoverFile(f);
    620       fileInput.value = '';
    621     });
    622 
    623     // Drag-drop on thumb (desktop nicety)
    624     ['dragenter', 'dragover'].forEach(ev =>
    625       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
    626     ['dragleave', 'drop'].forEach(ev =>
    627       thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
    628     thumb.addEventListener('drop', e => {
    629       const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    630       if (f) uploadCoverFile(f);
    631     });
    632 
    633     // URL paste auto-preview
    634     urlInput.addEventListener('input', () => {
    635       const v = urlInput.value.trim();
    636       setThumb(v);
    637     });
    638 
    639     if (removeBtn) {
    640       removeBtn.addEventListener('click', () => {
    641         urlInput.value = '';
    642         setThumb('');
    643         removeBtn.remove();
    644       });
    645     }
    646 
    647     // ── Replace the audio file of this track ──────────────────
    648     const aPick = $('#te-audio-pick');
    649     const aFile = $('#te-audio-file');
    650     const aStatus = $('#te-audio-status');
    651     if (aPick && aFile) {
    652       aPick.addEventListener('click', () => aFile.click());
    653       aFile.addEventListener('change', async (e) => {
    654         const f = e.target.files && e.target.files[0];
    655         aFile.value = '';
    656         if (!f) return;
    657         aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
    658         aPick.disabled = true;
    659         try {
    660           const fd = new FormData();
    661           fd.append('audio', f);
    662           const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
    663           if (!j.ok) throw new Error(j.error || 'mislukt');
    664           aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
    665           track.stream_url = j.stream_url || track.stream_url;
    666           if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
    667         } catch (err) {
    668           aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
    669         } finally { aPick.disabled = false; }
    670       });
    671     }
    672 
    673     // ── Insert © symbol into the credit field ─────────────────
    674     const copyrBtn = $('#te-credit-copyr');
    675     if (copyrBtn) {
    676       copyrBtn.addEventListener('click', () => {
    677         const inp = $('#te-credit');
    678         if (!inp) return;
    679         const sym = '© ';
    680         const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
    681         const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
    682         inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
    683         inp.focus();
    684         const pos = start + sym.length;
    685         try { inp.setSelectionRange(pos, pos); } catch (e) {}
    686       });
    687     }
    688 
    689     // ── Save ────────────────────────────────────────────────
    690     $('#te-save').addEventListener('click', async () => {
    691       const titleEl = $('#te-title');
    692       const title = titleEl.value.trim();
    693       if (!title) {
    694         titleEl.focus();
    695         alert('Titel is verplicht');
    696         return;
    697       }
    698       const saveBtn = $('#te-save');
    699       saveBtn.disabled = true;
    700       saveBtn.textContent = '⏳ Opslaan…';
    701 
    702       try {
    703         const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
    704           title,
    705           artist: $('#te-artist').value.trim() || null,
    706           album:  $('#te-album').value.trim() || null,
    707           credit:  $('#te-credit').value.trim() || null,
    708           license: $('#te-license').value.trim() || null,
    709           link_spotify:    $('#te-link-spotify').value.trim() || null,
    710           link_youtube:    $('#te-link-youtube').value.trim() || null,
    711           link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
    712           duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
    713           cover_url: urlInput.value.trim() || null,
    714         });
    715         if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
    716         if (typeof onSaved === 'function') onSaved(j.track || { id, title,
    717           artist: $('#te-artist').value.trim() || null,
    718           album:  $('#te-album').value.trim() || null,
    719           cover_url: urlInput.value.trim() || null });
    720         close();
    721       } catch (err) {
    722         alert('Opslaan mislukt: ' + err.message);
    723         saveBtn.disabled = false;
    724         saveBtn.textContent = '💾 Opslaan';
    725       }
    726     });
    727 
    728     // ── Auto-duration ───────────────────────────────────────────
    729     // The server already determines duration automatically on upload. This is the
    730     // fallback/UX layer: if an admin opens an existing track without a duration,
    731     // we read it from the audio metadata and fill the field — so you never need
    732     // to type seconds manually. An existing value is never overwritten. We fetch
    733     // the bytes via the same header gate as the player (X-Audio-Player).
    734     (async function autoDuration() {
    735       const durEl = $('#te-duration');
    736       if (!durEl || !track.stream_url) return;
    737       if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
    738       let objUrl = null;
    739       try {
    740         const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
    741         if (!r.ok) return;
    742         objUrl = URL.createObjectURL(await r.blob());
    743         const probe = new Audio();
    744         probe.preload = 'metadata';
    745         probe.addEventListener('loadedmetadata', () => {
    746           if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
    747             durEl.value = Math.round(probe.duration);
    748           }
    749           if (objUrl) URL.revokeObjectURL(objUrl);
    750         });
    751         probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
    752         probe.src = objUrl;
    753       } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
    754     })();
    755 
    756     // Focus title for fast typing
    757     setTimeout(() => $('#te-title').focus(), 60);
    758   };
    759 })();
    760 </script>
     286<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
  • src/views/shell.ejs

    rf85b2c3 r952baf3  
    339339</head>
    340340
    341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
     341<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
    342342
    343343<% if (typeof isViewer !== 'undefined' && isViewer) { %>
     
    449449    }
    450450
     451    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
     452    // dit zodra deze handler klaar is.
     453    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
     454      document.body.setAttribute('data-js', d.js);
     455    }
     456
    451457    var next = d.bodyClass;
    452458    if (!next) return;
     
    467473  //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
    468474  //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
     475})();
     476</script>
     477
     478<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
     479     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
     480     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
     481     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
     482     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
     483     wél de goede nonce heeft.
     484
     485     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
     486     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
     487<script>
     488(function () {
     489  if (window.__modBoot) return;
     490  window.__modBoot = true;
     491
     492  var loaded = {};
     493  function load() {
     494    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
     495    names.forEach(function (name) {
     496      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
     497      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
     498      // streep.
     499      if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return;
     500      loaded[name] = 1;
     501      import('/assets/js/mod/' + name + '.js').catch(function (e) {
     502        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
     503      });
     504    });
     505  }
     506  load();
     507  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
     508  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
     509  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
     510  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
     511  document.body.addEventListener('pcmsNav', load);
    469512})();
    470513</script>
  • test/authorized-fetch.test.js

    rf85b2c3 r952baf3  
    1 // FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch.
     1// Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn
     2// actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
     3// verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en
     4// wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu
     5// bleven daardoor vier accounts eindeloos hangen.
     6//
     7// De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of
     8// er wordt teruggevallen, niet om echte HTTP of crypto.
     9//
     10// Run: npm test
     11
    212import { test } from 'node:test';
    313import assert from 'node:assert/strict';
    414
    515process.env.DATABASE_PATH = ':memory:';
    6 process.env.PUBLIC_BASE_URL = 'https://test.example';
     16process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
    717
    818const dbMod = await import('../src/config/database.js');
    919const db = dbMod.default;
    1020dbMod.initializeDatabase();
    11 const AP = (await import('../src/services/ActivityPubService.js')).default;
     21const AP = await import('../src/services/ActivityPubService.js');
    1222
    13 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
    14 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
    15 // Commit a guardian relation: kid (ward) is guarded by mom.
    16 const MOM = 'https://mom.example/ap/users/mom';
    17 db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM);
     23db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
     24  .run('u1', 'u1', 'u1@test', 'x', 'god');
     25db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
    1826
    19 test('a committed guardian is recognised; a stranger is not', () => {
    20   assert.equal(AP.isWardGuardian('kid', MOM), true);
    21   assert.equal(AP.isWardGuardian('kid', 'https://x.example/ap/users/stranger'), false);
    22   assert.equal(AP.isWardGuardian('nosuch', MOM), false);
     27// IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
     28const OPEN_ACTOR = 'https://203.0.113.30/users/open';       // gewone instance
     29const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
     30
     31let verzoeken = [];
     32const echteFetch = globalThis.fetch;
     33globalThis.fetch = async (url, opts = {}) => {
     34  const u = String(url);
     35  const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
     36  verzoeken.push({ url: u, ondertekend });
     37  const doc = (id) => new Response(JSON.stringify({
     38    id, type: 'Person', preferredUsername: 'x', inbox: `${id}/inbox`,
     39    publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----' },
     40  }), { status: 200, headers: { 'content-type': 'application/activity+json' } });
     41
     42  if (u === OPEN_ACTOR) return doc(OPEN_ACTOR);
     43  // De kern van secure mode: onbetekend is het 401, ondertekend krijg je hem wel.
     44  if (u === SECURE_ACTOR) return ondertekend ? doc(SECURE_ACTOR) : new Response('unauthorized', { status: 401 });
     45  return new Response('not found', { status: 404 });
     46};
     47
     48test('een actor achter authorized fetch wordt nu wél opgehaald', async () => {
     49  verzoeken = [];
     50  const actor = await AP.fetchActor(SECURE_ACTOR, { asSlug: 'me' });
     51  assert.ok(actor, 'de actor hoort binnen te komen');
     52  assert.equal(actor.id, SECURE_ACTOR);
     53  assert.ok(verzoeken.some((v) => v.url === SECURE_ACTOR && v.ondertekend), 'het verzoek hoort ondertekend te zijn');
    2354});
     55
     56test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => {
     57  // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid
     58  // maakt in plaats van altijd mee te werken.
     59  const actor = await AP.fetchActor(SECURE_ACTOR);
     60  assert.equal(actor, null);
     61});
     62
     63test('een gewone instance blijft werken, ondertekend of niet', async () => {
     64  assert.ok(await AP.fetchActor(OPEN_ACTOR), 'onbetekend');
     65  assert.ok(await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }), 'ondertekend');
     66});
     67
     68test('een OPEN instance wordt NIET ondertekend opgehaald', () => {
     69  // De veiligheidskant van shaer-afq: verifyRequest haalt de keyId-URL op
     70  // voordat er iets geverifieerd is, en die URL komt uit een header die iedereen
     71  // mag sturen. Tekenden we standaard, dan kan een vreemde ons een ondertekend
     72  // verzoek naar een adres van zijn keuze laten sturen, met onze identiteit
     73  // eronder. Onbetekend eerst dus, en alleen tekenen als het anders niet lukt.
     74  verzoeken = [];
     75  return AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }).then((actor) => {
     76    assert.ok(actor);
     77    assert.equal(verzoeken.length, 1, 'één poging, geen tweede');
     78    assert.equal(verzoeken[0].ondertekend, false, 'en die was onbetekend');
     79  });
     80});
     81
     82test('een document zonder sleutel telt als mislukt en leidt tot een ondertekende poging', async () => {
     83  // Sommige instances serveren onbetekend wel iets, maar zonder publicKey. Voor
     84  // een verificatie hebben we daar niets aan.
     85  const KAAL = 'https://203.0.113.50/users/kaal';
     86  const stubOrig = globalThis.fetch;
     87  globalThis.fetch = async (url, opts = {}) => {
     88    const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
     89    if (String(url) === KAAL) {
     90      verzoeken.push({ url: String(url), ondertekend });
     91      const body = ondertekend
     92        ? { id: KAAL, type: 'Person', publicKey: { id: `${KAAL}#k`, owner: KAAL, publicKeyPem: 'x' } }
     93        : { id: KAAL, type: 'Person' };   // kaal: geen sleutel
     94      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/activity+json' } });
     95    }
     96    return stubOrig(url, opts);
     97  };
     98  verzoeken = [];
     99  const actor = await AP.fetchActor(KAAL, { asSlug: 'me' });
     100  globalThis.fetch = stubOrig;
     101  assert.ok(actor.publicKey && actor.publicKey.publicKeyPem, 'de ondertekende poging levert de sleutel');
     102  assert.deepEqual(verzoeken.map((v) => v.ondertekend), [false, true], 'eerst onbetekend, daarna pas ondertekend');
     103});
     104
     105test('een onbekende actor blijft null, ondertekend of niet', async () => {
     106  assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null);
     107});
     108
     109test.after(() => { globalThis.fetch = echteFetch; });
  • test/co-location.test.js

    rf85b2c3 r952baf3  
    227227    // of that job being done.
    228228    wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
     229    // Tweede plek van diezelfde sluiproute, aangekomen met Fase 2 (shaer-jdb):
     230    // followsCollection vult de wachtrij voor een ward op DEZE instance uit
     231    // ap_pending_follows. Hoort mee te verdwijnen met wardSlugsOf hierboven --
     232    // niet apart, want het is een sluiproute en geen tweede probleem.
     233    slugOf: 'TODO: idem, nu ook in de follows-wachtrij (shaer-h6u)',
    229234  };
    230   const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
     235  // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke
     236  // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten
     237  // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er
     238  // geruisloos doorheen kwam. Dat is precies hoe deze er kwam.
     239  const files = [
     240    ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js'))
     241      .map((f) => `src/services/guardianship/${f}`),
     242    'src/routes/guardian.js',
     243  ];
    231244  // Only top-level declarations name a scope; an indented `const base = ...` is
    232245  // a local and would otherwise take the blame for its enclosing function. A
  • test/messages.test.js

    rf85b2c3 r952baf3  
    22// in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft
    33// visibility door (voor de privé-badge). Besluit Robin+Bart 2026-07-16.
     4//
     5// Sinds Berichten gesprekken toont vouwen antwoorden, mentions en je eigen
     6// verzonden berichten samen tot draden (zie messages-conversations.test.js voor
     7// de groepeerlogica zelf); likes, boosts en follows blijven losse regels.
    48//
    59// Run: npm test   (= node --test)
     
    4246const msgs = AP.getMessages('me', 50);
    4347
    44 test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
    45   const sent = msgs.find((m) => m.type === 'sent');
     48test('eigen outbound reply en het ontvangen antwoord staan in EEN draad', () => {
     49  const thread = msgs.find((m) => m.type === 'thread');
     50  assert.ok(thread, 'draad ontbreekt');
     51  assert.equal(msgs[0], thread, 'de draad met het nieuwste bericht hoort bovenaan');
     52  const sent = thread.messages.find((m) => m.type === 'sent');
    4653  assert.ok(sent, 'sent-item ontbreekt');
    4754  assert.equal(sent.outboxId, 'out1');
    4855  assert.equal(sent.to_handle, '@dana@r.test');
    49   assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan');
     56  // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips
     57  // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad.
     58  assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']);
     59  assert.equal(thread.post.slug, 'mijn-post');
    5060});
    5161
     
    5868
    5969test('privé-reply draagt visibility voor de badge en heeft post-context', () => {
    60   const reply = msgs.find((m) => m.type === 'reply');
     70  const thread = msgs.find((m) => m.type === 'thread');
     71  const reply = thread.messages.find((m) => m.type === 'reply');
    6172  assert.equal(reply.visibility, 'direct');
    6273  assert.equal(reply.post_slug, 'mijn-post');
    6374  assert.equal(reply.post_title, 'Mijn post');
     75  // De context hangt ook aan de draad zelf: die voedt de link in de kop.
     76  assert.deepEqual(thread.post, { slug: 'mijn-post', title: 'Mijn post' });
    6477});
    6578
Note: See TracChangeset for help on using the changeset viewer.