Changes in / [952baf3:f85b2c3] in Klonkt


Ignore:
Files:
48 deleted
65 edited

Legend:

Unmodified
Added
Removed
  • CHANGELOG.de.md

    r952baf3 rf85b2c3  
    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.
    797
    808## [1.6.0] · 2026-07-31
  • CHANGELOG.md

    r952baf3 rf85b2c3  
    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.
    717
    728## [1.6.0] · 2026-07-31
  • CHANGELOG.nl.md

    r952baf3 rf85b2c3  
    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.
    747
    758## [1.6.0] · 2026-07-31
  • deploy/MULTI-INSTANCE.md

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

    r952baf3 rf85b2c3  
    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 
    85 The inbox read can hold the answer until there is something new, so a client does
    86 not have to poll. It is the **same call and the same response** — no separate
    87 endpoint and no separate "there is news" shape, because a second shape is a
    88 second description of a post that can drift from the first.
    89 
    90 ```
    91 GET /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 
    98 Two 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
    106 this 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
    108 round trip.
    109 
    110 A server that has not run the feed-state migration yet cannot tell change from
    111 stillness, and answers `200` with the collection every time rather than `304`
    112 forever. Slower, never wrong.
    113 
    114 The cursor moves for **all four** sources this read merges: the timeline,
    115 messages, replies on your own posts, and your own sent notes.
    116 
    117 Limits 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 
    126 This works while your app is in the foreground, and only there. iOS freezes
    127 network activity the moment an app is backgrounded, and a held-open connection
    128 does not survive it.
    129 
    130 **There is no background channel to fall back on for a native app.** Klonkt's
    131 web push uses VAPID, which a self-hosted instance can generate and send entirely
    132 on its own — but only to a browser or an installed PWA. A native app receives
    133 push through APNs (or FCM), signed with the *application's* key, which a
    134 self-hosted Klonkt does not have and should not have: whoever holds it can push
    135 to every user of that app. Closing that gap needs a relay run by the app's
    136 publisher, and that is a decision about money and control, not a protocol
    137 detail.
    138 
    139 So for a self-hosted setup this is not a stopgap until push arrives. For now it
    140 is the freshness channel.
    14182| `GET /ap/users/:slug/followers` | see below |
    14283| `GET /ap/users/:slug/following` | see below |
  • package-lock.json

    r952baf3 rf85b2c3  
    11{
    22  "name": "klonkt",
    3   "version": "1.7.0",
     3  "version": "1.6.0-dev",
    44  "lockfileVersion": 3,
    55  "requires": true,
     
    77    "": {
    88      "name": "klonkt",
    9       "version": "1.7.0",
     9      "version": "1.6.0-dev",
    1010      "license": "AGPL-3.0-or-later",
    1111      "dependencies": {
     
    2828        "multer": "^1.4.5-lts.1",
    2929        "node-webpmux": "^3.2.1",
    30         "nodemailer": "^9.0.5",
     30        "nodemailer": "^6.9.15",
    3131        "qrcode": "^1.5.4",
    3232        "sanitize-html": "^2.17.3",
     
    680680    },
    681681    "node_modules/body-parser": {
    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==",
     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==",
    685685      "license": "MIT",
    686686      "dependencies": {
     
    704704    },
    705705    "node_modules/brace-expansion": {
    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==",
     706      "version": "2.1.0",
     707      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
     708      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
    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     },
    942936    "node_modules/debug": {
    943937      "version": "2.6.9",
     
    12121206    },
    12131207    "node_modules/es-object-atoms": {
    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==",
     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==",
    12171211      "license": "MIT",
    12181212      "dependencies": {
     
    12601254    },
    12611255    "node_modules/express": {
    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==",
     1256      "version": "4.22.1",
     1257      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
     1258      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
    12651259      "license": "MIT",
    12661260      "dependencies": {
    12671261        "accepts": "~1.3.8",
    12681262        "array-flatten": "1.1.1",
    1269         "body-parser": "~1.20.5",
     1263        "body-parser": "~1.20.3",
    12701264        "content-disposition": "~0.5.4",
    12711265        "content-type": "~1.0.4",
     
    12861280        "path-to-regexp": "~0.1.12",
    12871281        "proxy-addr": "~2.0.7",
    1288         "qs": "~6.15.1",
     1282        "qs": "~6.14.0",
    12891283        "range-parser": "~1.2.1",
    12901284        "safe-buffer": "5.2.1",
     
    13411335        "type": "opencollective",
    13421336        "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"
    13431352      }
    13441353    },
     
    15341543    },
    15351544    "node_modules/hasown": {
    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==",
     1545      "version": "2.0.3",
     1546      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
     1547      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
    15391548      "license": "MIT",
    15401549      "dependencies": {
     
    17741783      }
    17751784    },
    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.17",
    1962       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
    1963       "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
     1961      "version": "3.3.11",
     1962      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
     1963      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
    19641964      "funding": [
    19651965        {
     
    20102010    },
    20112011    "node_modules/nodemailer": {
    2012       "version": "9.0.5",
    2013       "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
    2014       "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
     2012      "version": "6.10.1",
     2013      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
     2014      "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
    20152015      "license": "MIT-0",
    20162016      "engines": {
     
    21562156    },
    21572157    "node_modules/postcss": {
    2158       "version": "8.5.26",
    2159       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
    2160       "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
     2158      "version": "8.5.12",
     2159      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
     2160      "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
    21612161      "funding": [
    21622162        {
     
    21752175      "license": "MIT",
    21762176      "dependencies": {
    2177         "nanoid": "^3.3.17",
     2177        "nanoid": "^3.3.11",
    21782178        "picocolors": "^1.1.1",
    21792179        "source-map-js": "^1.2.1"
     
    22842284    },
    22852285    "node_modules/qs": {
    2286       "version": "6.15.3",
    2287       "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
    2288       "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
     2286      "version": "6.15.1",
     2287      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
     2288      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
    22892289      "license": "BSD-3-Clause",
    22902290      "dependencies": {
    2291         "es-define-property": "^1.0.1",
    2292         "side-channel": "^1.1.1"
     2291        "side-channel": "^1.1.0"
    22932292      },
    22942293      "engines": {
     
    24092408    },
    24102409    "node_modules/sanitize-html": {
    2411       "version": "2.17.5",
    2412       "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
    2413       "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
     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==",
    24142413      "license": "MIT",
    24152414      "dependencies": {
     
    24182417        "htmlparser2": "^10.1.0",
    24192418        "is-plain-object": "^5.0.0",
    2420         "launder": "^1.7.1",
    24212419        "parse-srcset": "^1.0.2",
    24222420        "postcss": "^8.3.11"
     
    24932491    },
    24942492    "node_modules/side-channel": {
    2495       "version": "1.1.1",
    2496       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
    2497       "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
     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==",
    24982496      "license": "MIT",
    24992497      "dependencies": {
    25002498        "es-errors": "^1.3.0",
    2501         "object-inspect": "^1.13.4",
    2502         "side-channel-list": "^1.0.1",
     2499        "object-inspect": "^1.13.3",
     2500        "side-channel-list": "^1.0.0",
    25032501        "side-channel-map": "^1.0.1",
    25042502        "side-channel-weakmap": "^1.0.2"
  • package.json

    r952baf3 rf85b2c3  
    11{
    22  "name": "klonkt",
    3   "version": "1.7.0",
     3  "version": "1.6.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": "^9.0.5",
     35    "nodemailer": "^6.9.15",
    3636    "qrcode": "^1.5.4",
    3737    "sanitize-html": "^2.17.3",
  • scripts/klonkt-refresh-updater.sh

    r952baf3 rf85b2c3  
    3030D="${KLONKT_DIR}"
    3131B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
    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.
    37 if [ "\$(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
    40 fi
    41 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch origin ${BRANCH}
     32runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH}
    4233runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD
    4334A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
     
    6354else
    6455  echo "Klonkt updated (\$A) + restarted \$N instance(s)."
    65 fi
    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.
    68 if [ -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@*')"
    7456fi
    7557EOF
  • src/assets/css/guardian.css

    r952baf3 rf85b2c3  
    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

    r952baf3 rf85b2c3  
    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; }
    6551
    6652  function helpCard(h) {
     
    8672      card.appendChild(a);
    8773    }
    88     card.appendChild(helpState(h));
    8974    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; });
    16475  }
    16576
     
    16778    var list = document.getElementById('help-list');
    16879    list.textContent = '';
    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 
     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    }
    20085    var badge = document.getElementById('help-count');
    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);
     86    badge.textContent = help.length; badge.hidden = help.length === 0;
     87    show('help-empty', help.length === 0);
    20588  }
    20689
     
    468351  }
    469352
    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 
    530353  function wardPanel(w) {
    531354    var uri = w.other_uri;
     
    535358    var set = el('div', 'g-panel-sec');
    536359    set.appendChild(el('h3', null, T.settings_title || 'Settings'));
    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     }
     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    });
    558384    panel.appendChild(set);
    559385
     
    594420      T.panel_follow_empty || '', followCard);
    595421
    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.
    599422    sectionInto(panel, T.panel_help || 'Calls for help',
    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); }),
     423      (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
    602424      T.panel_help_empty || '', helpCard);
    603425
     
    631453    actRow.appendChild(rel);
    632454    act.appendChild(actRow);
    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);
     455    panel.appendChild(act);
    638456    return panel;
    639457  }
     
    649467      row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
    650468      // Counts on the row: whatever is waiting must be visible with the panel shut.
    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;
     469      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
    654470      var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
    655471      if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
     
    737553    card.appendChild(head);
    738554    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 || '';
    743555    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.
    746556      var d = document.createElement('details');
    747557      var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
    748       var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
     558      var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);
    749559      body.appendChild(d);
    750560    } else {
    751       body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
     561      body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
    752562    }
    753563    card.appendChild(body);
     
    890700
    891701  renderAll(); pushState(); loadFeed(); loadFollowReqs();
    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(); });
     702  setInterval(refresh, 45000);   // live-ish while open
    907703  } catch (e) {
    908704    fatal((e && e.message) || String(e));
  • src/config/database.js

    r952baf3 rf85b2c3  
    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');
    117108  ensureColumn('sites', 'profile_photo', 'TEXT');
    118109  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
     
    478469      UNIQUE(slug, actor_uri)
    479470    );
    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);
    492471    CREATE TABLE IF NOT EXISTS ap_timeline (
    493472      id TEXT NOT NULL,              -- the remote note's AP id
     
    735714  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
    736715  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)');
    759716  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
    760717  ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
     
    802759  ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
    803760  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  */
    831 function 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   }
    885761}
    886762
  • src/middleware/render.js

    r952baf3 rf85b2c3  
    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 || '') : '',
    256251    socialDescr: data.socialDescr || '',
    257252    socialImage: data.socialImage || '',
     
    291286        ? _site.accent : '#e8b04b';
    292287      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();
    297288      const triggerJson = JSON.stringify({
    298         pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
     289        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
    299290        pcmsPostSwap: data.post ? {
    300291          title: data.post.title,
  • src/routes/activitypub.js

    r952baf3 rf85b2c3  
    253253}
    254254queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
    255 queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
     255queueRoute('follows', (id) => Guardianship.followsCollection(id));
    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', async (req, res) => {
     270router.get('/ap/users/:slug/inbox', (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   }
    306274  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
    307275  // world outside the fediverse is the guardians' call. The gate is applied
     
    317285  const playbackAllowed = embedsAllowed
    318286    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
    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) => ({
     287  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
    325288    id: `${t.id}#create`,
    326289    type: 'Create',
     
    370333      // Whether THIS account already liked/boosted the note, so the app's
    371334      // detail-view buttons show the current state (and can toggle/undo).
    372       'shaer:liked': !!(reacties.get(t.id) || {}).liked,
    373       'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
     335      'shaer:liked': !!t.liked,
     336      'shaer:boosted': !!t.boosted,
    374337      // An external (non-fediverse) embed, thumbnail-only and never an iframe.
    375338      // Omitted entirely when the gate is closed (see above).
     
    476439      'shaer:externalLinks': playbackAllowed,
    477440    },
    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),
    482441    totalItems: items.length,
    483442    orderedItems: items,
    484443  });
    485   return undefined;
    486444});
    487445
  • src/routes/admin-audio.js

    r952baf3 rf85b2c3  
    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',
    115113    pageTitleKey: 'admin.t_audio',
    116114    bodyClass: 'on-admin',
  • src/routes/admin-epk.js

    r952baf3 rf85b2c3  
    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',
    3635    pageTitleKey: 'admin.t_epk',
    3736    bodyClass: 'on-admin',
  • src/routes/admin-media.js

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

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

    r952baf3 rf85b2c3  
    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',
    6664    pageTitleKey: 'admin.t_playlists',
    6765    playlists,
  • src/routes/admin-push.js

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

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

    r952baf3 rf85b2c3  
    200200router.get('/new', requireGod, (req, res) => {
    201201  renderPage(req, res, 'pages/admin-site-edit', {
    202     pageJs: 'admin-site-edit',
    203202    pageTitleKey: 'admin.t_newsite',
    204203    bodyClass: 'on-admin',
     
    284283
    285284  renderPage(req, res, 'pages/admin-site-edit', {
    286     pageJs: 'admin-site-edit',
    287285    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
    288286    bodyClass: 'on-admin',
  • src/routes/admin.js

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

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

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

    r952baf3 rf85b2c3  
    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',
    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'];
     59    'panel_guards_far'];
    7060  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
    7161  s.wave = i18nT(L, 'guardian.wave');
     
    8171            emoji_json, actor_emoji_json, media_json, quote_json, embed_json
    8272     FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
    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) => ({
     73  ).all(site.slug).map((h) => ({
    8974    ...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 },
    9375    // The dashboard is built in the browser, so it gets the body finished: the
    9476    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
     
    121103        status: Guardianship.gated.sentStatus(p, Date.now()),
    122104      })),
    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),
    127105    })),
    128106    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
     
    135113      id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
    136114    })),
    137     help: helpItems,
     115    help,
    138116    strings: uiStrings(L),
    139117  };
     
    214192  const site = siteForUser(req);
    215193  if (!site) return res.status(404).json({ error: 'no_site' });
    216   const L = resolveLang(req);
    217194  ensureWardConnections(site);
    218195  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
     
    232209      cw: p.cw || null,
    233210      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),
    240211    }));
    241212  res.json({ items, following: wardUris.size });
     
    348319  if (!r) return res.status(502).json({ error: 'delivery' });
    349320  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.
    361 router.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 });
    388321});
    389322
     
    598531/** The playback gate of a ward we host (5.6): the heavier sibling. */
    599532function 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  */
    608 function 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 
    625533function wardGateSetting(uri, column) {
    626534  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
  • src/routes/paid.js

    r952baf3 rf85b2c3  
    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',
    7978    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
    8079    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
  • src/routes/posts.js

    r952baf3 rf85b2c3  
    256256
    257257  renderPage(req, res, 'pages/post-edit', {
    258     // post-edit neemt de playlist-editor op.
    259     pageJs: 'post-edit playlist-editor',
    260258    post: {
    261259      id: uuid(),
     
    797795  }
    798796  renderPage(req, res, 'pages/authorize-interaction', {
    799     pageJs: 'authorize-interaction',
    800797    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    801798    bodyClass: 'on-special',
     
    809806    liked: !!req.query.liked,
    810807    boosted: !!req.query.boosted,
    811     reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
     808    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
    812809    siteTitle: site ? site.title : '',
    813810  });
     
    842839  let on = false;
    843840  if (site && uri) {
    844     on = !ActivityPubService.getReaction(site.slug, uri).liked;
     841    on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
    845842    ActivityPubService.resolveRemoteNote(uri)
    846843      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
    847844      .catch((e) => console.warn('[AP] remote like failed:', e.message));
    848     // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
    849     ActivityPubService.setReaction(site.slug, uri, 'like', on);
     845    ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
    850846  }
    851847  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    860856  let on = false;
    861857  if (site && uri) {
    862     on = !ActivityPubService.getReaction(site.slug, uri).boosted;
     858    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
    863859    ActivityPubService.resolveRemoteNote(uri)
    864860      .then((note) => {
     
    866862        const id = note.object_uri || uri;
    867863        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
    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 }));
     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));
    872867      })
    873868      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
    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);
     869    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
    879870  }
    880871  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    939930    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
    940931  renderPage(req, res, 'pages/messages', {
    941     pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
     932    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
    942933    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
    943934    success: req.query.success || null, error: req.query.error || null,
     
    974965  } catch { /* fall through */ }
    975966  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).
    986 router.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');
    1024967});
    1025968
     
    11961139  }
    11971140  renderPage(req, res, 'pages/news', {
    1198     pageJs: 'news',
    11991141    pageTitle: 'News', bodyClass: 'on-special',
    12001142    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     
    12871229  let on = false;
    12881230  if (site && note) {
    1289     on = !ActivityPubService.getReaction(site.slug, note).liked;
     1231    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
    12901232    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1291     ActivityPubService.setReaction(site.slug, note, 'like', on);
     1233    if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
    12921234  }
    12931235  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    13011243  let on = false;
    13021244  if (site && note) {
    1303     on = !ActivityPubService.getReaction(site.slug, note).boosted;
     1245    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
    13041246    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
    13061247    if (on) {
     1248      ActivityPubService.markBoosted(site.slug, note); // instant UI state
    13071249      // Fire-and-forget: re-resolve the note so the cached row is refreshed
    13081250      // (cover/content) — boosting again heals a stale copy from EVERY boost
    13091251      // path, not just the interact page.
    13101252      ActivityPubService.resolveRemoteNote(note)
    1311         .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
     1253        .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
    13121254        .catch(() => { /* best-effort */ });
     1255    } else {
     1256      ActivityPubService.unmarkBoosted(site.slug, note);
    13131257    }
    13141258  }
     
    13941338    const { newerPost, olderPost } = postNeighbors(site, post);
    13951339    return renderPage(req, res, 'pages/paid-gate', {
    1396     pageJs: 'paid-gate',
    13971340      pageTitle: post.title || 'Voor supporters',
    13981341      bodyClass: 'on-special',
     
    15111454
    15121455  renderPage(req, res, 'pages/post', {
    1513     pageJs: 'post',
    15141456    post,
    15151457    poll: ActivityPubService.ownPollView(post),
     
    15631505  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
    15641506  if (parent && parent.post_id === post.id && parent.object_uri) {
    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);
     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    }
    15801520  }
    15811521  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
  • src/server.js

    r952baf3 rf85b2c3  
    6565import apRoutes from './routes/activitypub.js';
    6666import oauthRoutes from './routes/oauth.js';
    67 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
     67import { apWants, startDeliveryWorker, selfHealTimeline } 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.
    202 migrateReactions();
    203199selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
    204200
     
    546542  try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
    547543  const title = d.title || 'Klonkt';
    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   ]));
     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  }));
    564551});
    565552self.addEventListener('notificationclick', e => {
  • src/services/ActivityPubService.js

    r952baf3 rf85b2c3  
    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. */
    632 function 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 
    644629export function getMessages(slug, limit, offset) {
    645630  const off = Math.max(0, offset || 0);
     
    653638    for (const m of listOutbox(slug).slice(0, need)) {
    654639      items.push({
    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),
     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,
    664642      });
    665643    }
    666644  } 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 */ }
    681645  items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
    682646  const out = [];
     
    692656    out.push(it);
    693657  }
    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.
    705 const 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  */
    721 export 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  */
    745 export 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;
     658  return out.slice(off, off + lim);
    802659}
    803660
     
    11861043  const siteIcon = (site && site.profile_photo) || null;
    11871044
    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 
    12001045  const nodes = [];
    12011046  for (const r of rows) {
    12021047    if (r.kind !== 'reply') continue;
    1203     const ik = mijnReactie(r.object_uri);
    12041048    nodes.push({
    12051049      noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
     
    12081052      actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
    12091053      emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
    1210       acted_boost: ik.boosted, acted_like: ik.liked,
     1054      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
    12111055      children: [],
    12121056    });
     
    12971141}
    12981142
    1299 export 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;
     1143export async function fetchActor(url) {
    13191144  try {
    13201145    const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
    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;
     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; }
    13341151}
    13351152
     
    14081225}
    14091226
    1410 /** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
    1411  *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
    1412 let _signSlug;
    1413 function 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 
    14201227// Best-effort verification of an incoming signed request. Returns the sender's
    14211228// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     
    14231230// federating servers with drifting clocks; an operator can widen it via env.
    14241231const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
    1425 export async function verifyRequest(req, asSlug = null) {
     1232export async function verifyRequest(req) {
    14261233  const sigH = req.headers['signature'];
    14271234  if (!sigH) return null;
    14281235  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
    14291236  if (!p.keyId || !p.signature) return null;
    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() });
     1237  const actor = await fetchActor(p.keyId.split('#')[0]);
    14351238  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
    14361239  if (!pem) return null;
     
    16921495}
    16931496
    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. */
    1723 function 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  */
    1753 const SEEN_NOTES_DAYS = 30;
    1754 let _seenSinceSnoei = 0;
    1755 function 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 }
    1769 const 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.
    1776 const _derefMiss = new Map();
    1777 const DEREF_MISS_MS = 30 * 60 * 1000;
    1778 function 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 }
    1784 function 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 
    1792 async 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 
    18471497// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
    18481498export async function handleInbox(req, slugParam, preVerified = null) {
     
    18581508  // keeps everything below identical, including the actor-versus-signer check,
    18591509  // which is exactly the check that must not be skipped for being local.
    1860   const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
     1510  const verified = preVerified || await verifyRequest(req).catch(() => null);
    18611511
    18621512  // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
     
    18681518  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
    18691519  if (GATED.includes(type)) {
    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);
     1520    if (!verified || !claimedActor || verified.id !== claimedActor) {
     1521      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
    18901522      return 401;
    18911523    }
     
    21771809      }
    21781810    }
    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     }
    21891811    // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
    21901812    // above): store a mention notification for each of our actors named in the Mention tags.
    21911813    // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
    21921814    // 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     }
    22061815    if (actorUri && !isLocalActor && o.id) {
    22071816      const slugs = localMentionSlugs(o.tag, base);
     
    28882497        const kind = type === 'Announce' ? 'boost' : 'like';
    28892498        await sendInteraction(site, kind, objUri, authorUri);
    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.
     2499        setMyReaction(site.slug, targetUri, kind, true);
     2500        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
    29032501        return { status: 202, url: objUri };
    29042502      }
     
    29492547          const objUri = (note && note.object_uri) || innerTarget;
    29502548          await sendInteraction(site, kind, objUri, note && note.actor_uri);
    2951           try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
    2952           catch { /* non-fatal */ }
     2549          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
     2550          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
    29532551          return { status: 202, url: objUri };
    29542552        }
     
    33152913}
    33162914export function listOutbox(siteSlug) {
    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')
     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')
    33222916    .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
    33232917}
     
    34573051  return { ins: _insTl, list: _listTl, del: _delTl };
    34583052}
    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  */
    3472 export 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 }
     3053export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
    34823054
    34833055/**
     
    35153087}
    35163088
    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  */
    3533 export 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  */
    3549 export 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.
    3560 const FEED_WAIT_MAX = 4;
    3561 const _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  */
    3572 export 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 
    35993089export function getDirectMessages(slug, limit) {
    36003090  try {
     
    37533243    if (!_cirkelPosts) _cirkelPosts = db.prepare(`
    37543244      SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
    3755              t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
    3756              (rb.target_uri IS NOT NULL) AS boosted
     3245             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
    37573246      FROM ap_timeline t
    37583247      LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
    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)
     3248      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
    37643249      ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
    37653250      LIMIT ? OFFSET ?`);
     
    37703255  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 []; }
    37713256}
    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.
     3257// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
    37823258let _markBoost, _unmarkBoost, _boostedCount;
    37833259export function markBoosted(slug, noteId) {
     
    37943270  try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
    37953271}
    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.
    3820 const 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  */
    3839 export 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  */
    3934 export 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  */
    3948 export 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  */
    3963 export 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 
    3982 export 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 
    39993272export function getTimelineReaction(slug, noteId) {
    40003273  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 }; }
     
    40303303}
    40313304export function boostedCount(slug) {
    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; }
     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; }
    40403306}
    40413307
     
    47524018    await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
    47534019  }
    4754   console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
     4020  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
    47554021  return { ok: true, target: target.id, inboxes: inboxes.length };
    47564022}
     
    49324198      const wardDoc = await fetchActor(wardUri).catch(() => null);
    49334199      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    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       });
     4200      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
    49484201      const L = pushLang(gslug);
    49494202      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` });
     
    50484301  try {
    50494302    const rows = db.prepare(`
    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,
     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,
    50514304             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
    50524305             p.slug AS post_slug, p.title AS post_title
     
    50574310    for (const r of rows) out.push({
    50584311      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,
    50624312      content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
    50634313      // When the post was written, for display. created_at (when it reached us)
     
    52934543  getOutboxRow: (id) => iStmts().getO.get(id),
    52944544  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),
    52994545});
    53004546/**
     
    53334579  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
    53344580  // 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.
    53384581  onEvent: (slug, ev) => {
    5339     const p = guardianEventPush(slug, ev);
    5340     if (p) pushEvent(slug, p);
     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 });
    53414596  },
    53424597});
    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  */
    5352 export 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.
    5383 const GATE_LABEL = {
    5384   'shaer:externalEmbeds': 'guardian.prop_embeds',
    5385   'shaer:externalPlayback': 'guardian.prop_play',
    5386 };
    5387 const GATE_OUTCOME = {
    5388   accepted: 'guardian.prop_st_accepted',
    5389   rejected: 'guardian.prop_st_rejected',
    5390   expired: 'guardian.prop_st_expired',
    5391 };
    53924598
    53934599// The notification duty of FEP-633c 3.6.2, wired once for every place a
     
    54194625  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    54204626  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    5421   feedCursor, feedChangesSince, waitForFeedChange,
    5422   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
     4627  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
    54234628  listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
    54244629  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
     
    54264631  gateOutgoingFollow, performApprovedFollow,
    54274632  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
    5428   autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
     4633  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
    54294634  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
    54304635  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
  • src/services/guardianship/delivery.js

    r952baf3 rf85b2c3  
    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, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
     43export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
    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 : '';
    9185  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
    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>`;
     86  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
    9787  const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    9888  // Attachments: same rules as deliverReply (own /media/ uploads only,
     
    10999  const row = getOutboxRow(id);
    110100  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   }
    118101  const create = {
    119102    '@context': AP_CONTEXT,
  • src/services/guardianship/follows.js

    r952baf3 rf85b2c3  
    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,
    84          direction, target_uri, target_handle, created_at)
    85         VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
     83        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
     84        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
    8685      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
    8786      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
     
    9291}
    9392
    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  */
    10293export function recordReview(guardianSlug, r) {
    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);
     94  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
    10695  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  */
    113 export 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. */
    143 export function listReviewsByDirection(guardianSlug, direction) {
    144   return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
    14596}
    14697export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
     
    151102  recordPending, getPending, listForWard, decide, remove,
    152103  recordReview, getReview, listReviews, removeReview,
    153   listReviewsByDirection, reviewQueueItem,
    154104};
  • src/services/guardianship/gated.js

    r952baf3 rf85b2c3  
    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  */
    80 export 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  */
    97 export 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 
    12262export function featureColumn(feature) {
    12363  return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
     
    299239
    300240export default {
    301   GATE_CATALOGUE, gateRows,
    302241  tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
    303242  parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
  • src/services/guardianship/index.js

    r952baf3 rf85b2c3  
    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)
    35 export * as help from './help.js';
  • src/services/guardianship/queues.js

    r952baf3 rf85b2c3  
    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 ON my wards (§5.3), Fase 2 (shaer-jdb)
     8 *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
    99 *  - wards:   my committed wards
    1010 */
     
    1313import * as availability from './availability.js';
    1414import * as outgoing from './outgoing.js';
    15 import * as follows from './follows.js';
    1615import * as handshake from './handshake.js';
    1716
     
    3635}
    3736
    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  */
    49 export 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);
     37/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
     38export function followsCollection(id) {
     39  return collection(id, []);
    6540}
    6641
    67 /** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
    68 function 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  */
     42/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
    8543export function outgoingFollowsCollection(id, slug, 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);
     44  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
    9145}
    9246
  • src/services/i18n.js

    r952baf3 rf85b2c3  
    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.', '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.',
     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.',
    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.', '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.',
     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.',
    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.', '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.',
     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.',
    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

    r952baf3 rf85b2c3  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    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') } }) %>
     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>
  • src/views/pages/admin-epk.ejs

    r952baf3 rf85b2c3  
    9090  .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
    9191</style>
    92 <%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-help.ejs

    r952baf3 rf85b2c3  
    145145  .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
    146146</style>
    147 <%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-media.ejs

    r952baf3 rf85b2c3  
    4141</div>
    4242
    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') } }) %>
     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>
  • src/views/pages/admin-paid.ejs

    r952baf3 rf85b2c3  
    7272</div>
    7373
    74 <%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
     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>
  • src/views/pages/admin-playlists.ejs

    r952baf3 rf85b2c3  
    222222</style>
    223223
    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') } }) %>
     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>
  • src/views/pages/admin-push.ejs

    r952baf3 rf85b2c3  
    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 <%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
     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>
    61163<% } %>
  • src/views/pages/admin-settings.ejs

    r952baf3 rf85b2c3  
    2727      <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
    2828    </form>
    29     <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
     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>
    3041  </section>
    3142
  • src/views/pages/admin-site-edit.ejs

    r952baf3 rf85b2c3  
    126126    <%# Live preview: selecting an accent / theme / palette re-themes this page
    127127        instantly (reverts on reload; persists on save). %>
    128     <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
     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>
    129149
    130150    <fieldset>
     
    255275</div>
    256276
    257 
     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>
    258377
    259378<style>
  • src/views/pages/admin-videos.ejs

    r952baf3 rf85b2c3  
    4040</div>
    4141
    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') } }) %>
     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>
  • src/views/pages/auth-register.ejs

    r952baf3 rf85b2c3  
    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 <%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
     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>
  • src/views/pages/authorize-interaction.ejs

    r952baf3 rf85b2c3  
    1212      <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
    1313    </div>
    14     <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
     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>
    1522    <% if (!manage.length) { %>
    1623      <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
     
    4956        <% }); %>
    5057      </ul>
    51      
     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>
    5273    <% } %>
    5374    <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
     
    310331</style>
    311332
    312 
     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>
  • src/views/pages/download.ejs

    r952baf3 rf85b2c3  
    2626      <p class="dl-sub"><%= _ready_sub %></p>
    2727      <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
    28       <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     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>
    2932    <% } else { %>
    3033      <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
     
    5457  .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; }
    5558</style>
    56 <%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
  • src/views/pages/messages.ejs

    r952baf3 rf85b2c3  
    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 === '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><% } %>
     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><% } %>
    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. %>
    3027    <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>
    3129    <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
    3230    <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
    3331    <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>
    3433  </div>
    3534  <div class="msg-search">
     
    6059</div>
    6160
    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). %>
     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>
    66119
    67120<style>
     
    86139  .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
    87140
    88 
    89141  .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
    90142    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent);
     
    96148    background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
    97149  .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   }
    172150
    173151  .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
  • src/views/pages/news.ejs

    r952baf3 rf85b2c3  
    4141    .tl-paste-go { background: var(--accent); color: var(--paper); }
    4242  </style>
    43   <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     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>
    4481  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
    4582  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
     
    147184  .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
    148185</style>
    149 <%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
     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>
  • src/views/pages/paid-gate.ejs

    r952baf3 rf85b2c3  
    3131
    3232<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    3487
    3588<style>
     
    52105  .pg-status.is-err { color: #c0392b; }
    53106</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

    r952baf3 rf85b2c3  
    1212
    1313<script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
    14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    1548
    1649<style>
     
    2558  .pk-status.is-err { color: #c0392b; }
    2659</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

    r952baf3 rf85b2c3  
    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           <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
    275280        <% } %>
    276        
     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>
    277290        <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
    278291           var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
     
    322335          </select>
    323336        </div>
    324        
     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>
    325364        <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
    326365          <label class="pe-checkbox">
     
    338377            </label>
    339378          </div>
    340          
     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>
    341383          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    342384          <label class="pe-checkbox" style="margin-top:8px">
     
    350392            <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
    351393          </div>
    352          
     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>
    353452        <% } %>
    354453      </div>
     
    11821281</style>
    11831282
    1184 
     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>
    11852200
    11862201<%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
     
    12142229  <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    12152230<% } %>
    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') } }) %>
     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>
  • src/views/pages/post.ejs

    r952baf3 rf85b2c3  
    147147    </section>
    148148  <% } %>
    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. %>
     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>
    152196
    153197
  • src/views/partials/bottom-tab.ejs

    r952baf3 rf85b2c3  
    302302</style>
    303303
    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. %>
     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>
  • src/views/partials/msg-item.ejs

    r952baf3 rf85b2c3  
    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 { %>
    811<%
    822           var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
     
    855           var _who = n.name || n.handle || '';
    866           var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
    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'
     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'
    9612             : _t === 'report' ? 'mod'
     13             : (_t === 'mention' || _priv0) ? 'msgs'
    9714             : '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);
    10115           var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
    10216           var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
    10317      %>
    104         <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
     18        <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>">
    10519          <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
    10620            <% 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>
     
    12438              <% if (_t === 'sent') { %>
    12539                <span class="msg-who"><%= t('msg.you') %></span>
    126                 <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
     40                <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
    12741              <% } else { %>
    12842                <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
    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><% } %>
     43                <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %>
    13244              <% } %>
    13345              <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
     
    13850            </div>
    13951
    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) { %>
    14452            <div class="msg-what">
    14553              <% if (n.count && n.count > 1) { %>
     
    15260              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
    15361              <% } 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 %>
    15562              <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
    15663              <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
     
    15865              <% } else { %><%= t('notif.replied') %><% } %>
    15966              <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
    160               <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
     67              <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
    16168              <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
    16269              <% 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><% } %>
    16370            </div>
    164             <% } %>
    16571
    16672            <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
     
    210116          </div>
    211117        </li>
    212 <% } %>
  • src/views/partials/playlist-editor.ejs

    r952baf3 rf85b2c3  
    280280</style>
    281281
    282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     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>
  • src/views/partials/profile-header.ejs

    r952baf3 rf85b2c3  
    160160</div>
    161161<% } %>
    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). %>
     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>
    165219<% } %>
    166220
  • src/views/partials/profile-sheet.ejs

    r952baf3 rf85b2c3  
    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" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
     107          <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
    108108        </button>
    109109      </li>
     
    326326</style>
    327327
    328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
     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>
  • src/views/partials/topnav.ejs

    r952baf3 rf85b2c3  
    176176
    177177<!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
    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') }) %>">
     178<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
    182179  <div class="container">
    183180    <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
     
    194191</div>
    195192
    196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
     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>
    197274
    198275<style>
  • src/views/partials/track-editor.ejs

    r952baf3 rf85b2c3  
    284284</style>
    285285
    286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
     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>
  • src/views/shell.ejs

    r952baf3 rf85b2c3  
    339339</head>
    340340
    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 : '') %>">
     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 : '') %>">
    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 
    457451    var next = d.bodyClass;
    458452    if (!next) return;
     
    473467  //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
    474468  //  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);
    512469})();
    513470</script>
  • test/authorized-fetch.test.js

    r952baf3 rf85b2c3  
    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 
     1// FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch.
    122import { test } from 'node:test';
    133import assert from 'node:assert/strict';
    144
    155process.env.DATABASE_PATH = ':memory:';
    16 process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
     6process.env.PUBLIC_BASE_URL = 'https://test.example';
    177
    188const dbMod = await import('../src/config/database.js');
    199const db = dbMod.default;
    2010dbMod.initializeDatabase();
    21 const AP = await import('../src/services/ActivityPubService.js');
     11const AP = (await import('../src/services/ActivityPubService.js')).default;
    2212
    23 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
    24   .run('u1', 'u1', 'u1@test', 'x', 'god');
    25 db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
     13db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
     14db.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.
     16const MOM = 'https://mom.example/ap/users/mom';
     17db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM);
    2618
    27 // IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
    28 const OPEN_ACTOR = 'https://203.0.113.30/users/open';       // gewone instance
    29 const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
    30 
    31 let verzoeken = [];
    32 const echteFetch = globalThis.fetch;
    33 globalThis.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 
    48 test('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');
     19test('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);
    5423});
    55 
    56 test('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 
    63 test('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 
    68 test('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 
    82 test('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 
    105 test('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 
    109 test.after(() => { globalThis.fetch = echteFetch; });
  • test/co-location.test.js

    r952baf3 rf85b2c3  
    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)',
    234229  };
    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   ];
     230  const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
    244231  // Only top-level declarations name a scope; an indented `const base = ...` is
    245232  // a local and would otherwise take the blame for its enclosing function. A
  • test/messages.test.js

    r952baf3 rf85b2c3  
    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.
    84//
    95// Run: npm test   (= node --test)
     
    4642const msgs = AP.getMessages('me', 50);
    4743
    48 test('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');
     44test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
     45  const sent = msgs.find((m) => m.type === 'sent');
    5346  assert.ok(sent, 'sent-item ontbreekt');
    5447  assert.equal(sent.outboxId, 'out1');
    5548  assert.equal(sent.to_handle, '@dana@r.test');
    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');
     49  assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan');
    6050});
    6151
     
    6858
    6959test('privé-reply draagt visibility voor de badge en heeft post-context', () => {
    70   const thread = msgs.find((m) => m.type === 'thread');
    71   const reply = thread.messages.find((m) => m.type === 'reply');
     60  const reply = msgs.find((m) => m.type === 'reply');
    7261  assert.equal(reply.visibility, 'direct');
    7362  assert.equal(reply.post_slug, 'mijn-post');
    7463  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' });
    7764});
    7865
Note: See TracChangeset for help on using the changeset viewer.