Changeset 952baf3 in Klonkt
- Timestamp:
- 08/07/2026 05:15:52 PM (5 weeks ago)
- Branches:
- main
- Children:
- ba76bf5
- Parents:
- f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the(diff)links above to see all the changes relative to each parent. - Files:
-
- 48 added
- 65 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
deploy/HOSTING.md (added)
-
deploy/MULTI-INSTANCE.md (modified) (1 diff)
-
docs/EXPORT-FORMAT.md (added)
-
docs/shaer-c2s-api.md (modified) (1 diff)
-
package-lock.json (modified) (20 diffs)
-
package.json (modified) (2 diffs)
-
scripts/backfill-reactions.mjs (added)
-
scripts/export-archive.mjs (added)
-
scripts/import-archive.mjs (added)
-
scripts/klonkt-refresh-updater.sh (modified) (2 diffs)
-
scripts/recover-from-cache.mjs (added)
-
src/assets/css/guardian.css (modified) (1 diff)
-
src/assets/js/guardian.js (modified) (10 diffs)
-
src/assets/js/mod/admin-audio.js (added)
-
src/assets/js/mod/admin-epk.js (added)
-
src/assets/js/mod/admin-help.js (added)
-
src/assets/js/mod/admin-media.js (added)
-
src/assets/js/mod/admin-paid.js (added)
-
src/assets/js/mod/admin-playlists.js (added)
-
src/assets/js/mod/admin-push.js (added)
-
src/assets/js/mod/admin-settings.js (added)
-
src/assets/js/mod/admin-site-edit.js (added)
-
src/assets/js/mod/admin-videos.js (added)
-
src/assets/js/mod/auth-register.js (added)
-
src/assets/js/mod/authorize-interaction.js (added)
-
src/assets/js/mod/chrome.js (added)
-
src/assets/js/mod/download.js (added)
-
src/assets/js/mod/lib.js (added)
-
src/assets/js/mod/messages.js (added)
-
src/assets/js/mod/news.js (added)
-
src/assets/js/mod/paid-gate.js (added)
-
src/assets/js/mod/paid-passkey.js (added)
-
src/assets/js/mod/playlist-editor.js (added)
-
src/assets/js/mod/post-edit.js (added)
-
src/assets/js/mod/post.js (added)
-
src/assets/js/mod/track-editor.js (added)
-
src/config/database.js (modified) (4 diffs)
-
src/middleware/render.js (modified) (2 diffs)
-
src/routes/activitypub.js (modified) (5 diffs)
-
src/routes/admin-audio.js (modified) (1 diff)
-
src/routes/admin-epk.js (modified) (1 diff)
-
src/routes/admin-media.js (modified) (2 diffs)
-
src/routes/admin-paid.js (modified) (1 diff)
-
src/routes/admin-playlists.js (modified) (1 diff)
-
src/routes/admin-push.js (modified) (1 diff)
-
src/routes/admin-settings.js (modified) (1 diff)
-
src/routes/admin-sites.js (modified) (2 diffs)
-
src/routes/admin.js (modified) (1 diff)
-
src/routes/auth.js (modified) (3 diffs)
-
src/routes/download.js (modified) (1 diff)
-
src/routes/guardian.js (modified) (8 diffs)
-
src/routes/paid.js (modified) (1 diff)
-
src/routes/posts.js (modified) (14 diffs)
-
src/server.js (modified) (3 diffs)
-
src/services/ActivityPubService.js (modified) (29 diffs)
-
src/services/ArchiveExportService.js (added)
-
src/services/ArchiveImportService.js (added)
-
src/services/ArchiveRecoveryService.js (added)
-
src/services/guardianship/delivery.js (modified) (3 diffs)
-
src/services/guardianship/follows.js (modified) (3 diffs)
-
src/services/guardianship/gated.js (modified) (2 diffs)
-
src/services/guardianship/help.js (added)
-
src/services/guardianship/index.js (modified) (1 diff)
-
src/services/guardianship/queues.js (modified) (3 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/admin-audio.ejs (modified) (1 diff)
-
src/views/pages/admin-epk.ejs (modified) (1 diff)
-
src/views/pages/admin-help.ejs (modified) (1 diff)
-
src/views/pages/admin-media.ejs (modified) (1 diff)
-
src/views/pages/admin-paid.ejs (modified) (1 diff)
-
src/views/pages/admin-playlists.ejs (modified) (1 diff)
-
src/views/pages/admin-push.ejs (modified) (1 diff)
-
src/views/pages/admin-settings.ejs (modified) (1 diff)
-
src/views/pages/admin-site-edit.ejs (modified) (2 diffs)
-
src/views/pages/admin-videos.ejs (modified) (1 diff)
-
src/views/pages/auth-register.ejs (modified) (1 diff)
-
src/views/pages/authorize-interaction.ejs (modified) (3 diffs)
-
src/views/pages/download.ejs (modified) (2 diffs)
-
src/views/pages/messages.ejs (modified) (5 diffs)
-
src/views/pages/news.ejs (modified) (2 diffs)
-
src/views/pages/paid-gate.ejs (modified) (2 diffs)
-
src/views/pages/paid-passkey.ejs (modified) (2 diffs)
-
src/views/pages/post-edit.ejs (modified) (6 diffs)
-
src/views/pages/post.ejs (modified) (1 diff)
-
src/views/partials/bottom-tab.ejs (modified) (1 diff)
-
src/views/partials/msg-item.ejs (modified) (7 diffs)
-
src/views/partials/page-data.ejs (added)
-
src/views/partials/playlist-editor.ejs (modified) (1 diff)
-
src/views/partials/profile-header.ejs (modified) (1 diff)
-
src/views/partials/profile-sheet.ejs (modified) (2 diffs)
-
src/views/partials/topnav.ejs (modified) (2 diffs)
-
src/views/partials/track-editor.ejs (modified) (1 diff)
-
src/views/shell.ejs (modified) (3 diffs)
-
test/archive-export.test.js (added)
-
test/archive-import.test.js (added)
-
test/archive-recovery.test.js (added)
-
test/authorized-fetch.test.js (modified) (1 diff)
-
test/co-location.test.js (modified) (1 diff)
-
test/feed-wait.test.js (added)
-
test/forwarded-activities.test.js (added)
-
test/guardian-follow-queues.test.js (added)
-
test/guardian-gate-panel.test.js (added)
-
test/guardian-gate-push.test.js (added)
-
test/guardian-help-state.test.js (added)
-
test/interaction-reactions.test.js (added)
-
test/messages-conversations.test.js (added)
-
test/messages.test.js (modified) (3 diffs)
-
test/reactions-characterization.test.js (added)
-
test/seen-notes.test.js (added)
-
test/signature-verification.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
rf85b2c3 r952baf3 5 5 6 6 ## [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. 7 79 8 80 ## [1.6.0] · 2026-07-31 -
CHANGELOG.md
rf85b2c3 r952baf3 5 5 6 6 ## [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. 7 71 8 72 ## [1.6.0] · 2026-07-31 -
CHANGELOG.nl.md
rf85b2c3 r952baf3 5 5 6 6 ## [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. 7 74 8 75 ## [1.6.0] · 2026-07-31 -
deploy/MULTI-INSTANCE.md
rf85b2c3 r952baf3 195 195 Then remove its block from the web server config and reload it. 196 196 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 197 203 ## Everyday commands 198 204 -
docs/shaer-c2s-api.md
rf85b2c3 r952baf3 80 80 | `GET /ap/users/:slug/outbox` | OrderedCollection of the account's own `Create(Note)` | 81 81 | `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. 82 141 | `GET /ap/users/:slug/followers` | see below | 83 142 | `GET /ap/users/:slug/following` | see below | -
package-lock.json
rf85b2c3 r952baf3 1 1 { 2 2 "name": "klonkt", 3 "version": "1. 6.0-dev",3 "version": "1.7.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, … … 7 7 "": { 8 8 "name": "klonkt", 9 "version": "1. 6.0-dev",9 "version": "1.7.0", 10 10 "license": "AGPL-3.0-or-later", 11 11 "dependencies": { … … 28 28 "multer": "^1.4.5-lts.1", 29 29 "node-webpmux": "^3.2.1", 30 "nodemailer": "^ 6.9.15",30 "nodemailer": "^9.0.5", 31 31 "qrcode": "^1.5.4", 32 32 "sanitize-html": "^2.17.3", … … 680 680 }, 681 681 "node_modules/body-parser": { 682 "version": "1.20. 5",683 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20. 5.tgz",684 "integrity": "sha512- 3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",682 "version": "1.20.6", 683 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", 684 "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", 685 685 "license": "MIT", 686 686 "dependencies": { … … 704 704 }, 705 705 "node_modules/brace-expansion": { 706 "version": "2.1. 0",707 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1. 0.tgz",708 "integrity": "sha512- TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",706 "version": "2.1.4", 707 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", 708 "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", 709 709 "license": "MIT", 710 710 "dependencies": { … … 934 934 "license": "MIT" 935 935 }, 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 }, 936 942 "node_modules/debug": { 937 943 "version": "2.6.9", … … 1206 1212 }, 1207 1213 "node_modules/es-object-atoms": { 1208 "version": "1.1. 1",1209 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1. 1.tgz",1210 "integrity": "sha512- FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",1214 "version": "1.1.2", 1215 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", 1216 "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", 1211 1217 "license": "MIT", 1212 1218 "dependencies": { … … 1254 1260 }, 1255 1261 "node_modules/express": { 1256 "version": "4.22. 1",1257 "resolved": "https://registry.npmjs.org/express/-/express-4.22. 1.tgz",1258 "integrity": "sha512- F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",1262 "version": "4.22.2", 1263 "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", 1264 "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", 1259 1265 "license": "MIT", 1260 1266 "dependencies": { 1261 1267 "accepts": "~1.3.8", 1262 1268 "array-flatten": "1.1.1", 1263 "body-parser": "~1.20. 3",1269 "body-parser": "~1.20.5", 1264 1270 "content-disposition": "~0.5.4", 1265 1271 "content-type": "~1.0.4", … … 1280 1286 "path-to-regexp": "~0.1.12", 1281 1287 "proxy-addr": "~2.0.7", 1282 "qs": "~6.1 4.0",1288 "qs": "~6.15.1", 1283 1289 "range-parser": "~1.2.1", 1284 1290 "safe-buffer": "5.2.1", … … 1335 1341 "type": "opencollective", 1336 1342 "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"1352 1343 } 1353 1344 }, … … 1543 1534 }, 1544 1535 "node_modules/hasown": { 1545 "version": "2.0. 3",1546 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0. 3.tgz",1547 "integrity": "sha512- ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",1536 "version": "2.0.4", 1537 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", 1538 "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", 1548 1539 "license": "MIT", 1549 1540 "dependencies": { … … 1783 1774 } 1784 1775 }, 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 }, 1785 1785 "node_modules/locate-path": { 1786 1786 "version": "5.0.0", … … 1959 1959 }, 1960 1960 "node_modules/nanoid": { 1961 "version": "3.3.1 1",1962 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1 1.tgz",1963 "integrity": "sha512- N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",1961 "version": "3.3.17", 1962 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", 1963 "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", 1964 1964 "funding": [ 1965 1965 { … … 2010 2010 }, 2011 2011 "node_modules/nodemailer": { 2012 "version": " 6.10.1",2013 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer- 6.10.1.tgz",2014 "integrity": "sha512- Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",2012 "version": "9.0.5", 2013 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", 2014 "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", 2015 2015 "license": "MIT-0", 2016 2016 "engines": { … … 2156 2156 }, 2157 2157 "node_modules/postcss": { 2158 "version": "8.5. 12",2159 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5. 12.tgz",2160 "integrity": "sha512- W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",2158 "version": "8.5.26", 2159 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", 2160 "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", 2161 2161 "funding": [ 2162 2162 { … … 2175 2175 "license": "MIT", 2176 2176 "dependencies": { 2177 "nanoid": "^3.3.1 1",2177 "nanoid": "^3.3.17", 2178 2178 "picocolors": "^1.1.1", 2179 2179 "source-map-js": "^1.2.1" … … 2284 2284 }, 2285 2285 "node_modules/qs": { 2286 "version": "6.15. 1",2287 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15. 1.tgz",2288 "integrity": "sha512- 6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",2286 "version": "6.15.3", 2287 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", 2288 "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", 2289 2289 "license": "BSD-3-Clause", 2290 2290 "dependencies": { 2291 "side-channel": "^1.1.0" 2291 "es-define-property": "^1.0.1", 2292 "side-channel": "^1.1.1" 2292 2293 }, 2293 2294 "engines": { … … 2408 2409 }, 2409 2410 "node_modules/sanitize-html": { 2410 "version": "2.17. 3",2411 "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17. 3.tgz",2412 "integrity": "sha512- Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",2411 "version": "2.17.5", 2412 "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz", 2413 "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==", 2413 2414 "license": "MIT", 2414 2415 "dependencies": { … … 2417 2418 "htmlparser2": "^10.1.0", 2418 2419 "is-plain-object": "^5.0.0", 2420 "launder": "^1.7.1", 2419 2421 "parse-srcset": "^1.0.2", 2420 2422 "postcss": "^8.3.11" … … 2491 2493 }, 2492 2494 "node_modules/side-channel": { 2493 "version": "1.1. 0",2494 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1. 0.tgz",2495 "integrity": "sha512- ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",2495 "version": "1.1.1", 2496 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", 2497 "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", 2496 2498 "license": "MIT", 2497 2499 "dependencies": { 2498 2500 "es-errors": "^1.3.0", 2499 "object-inspect": "^1.13. 3",2500 "side-channel-list": "^1.0. 0",2501 "object-inspect": "^1.13.4", 2502 "side-channel-list": "^1.0.1", 2501 2503 "side-channel-map": "^1.0.1", 2502 2504 "side-channel-weakmap": "^1.0.2" -
package.json
rf85b2c3 r952baf3 1 1 { 2 2 "name": "klonkt", 3 "version": "1. 6.0",3 "version": "1.7.0", 4 4 "description": "Klonkt — self-hosted multi-site music & blog platform (solo, hub or circles). Node + SQLite + htmx.", 5 5 "license": "AGPL-3.0-or-later", … … 33 33 "multer": "^1.4.5-lts.1", 34 34 "node-webpmux": "^3.2.1", 35 "nodemailer": "^ 6.9.15",35 "nodemailer": "^9.0.5", 36 36 "qrcode": "^1.5.4", 37 37 "sanitize-html": "^2.17.3", -
scripts/klonkt-refresh-updater.sh
rf85b2c3 r952baf3 30 30 D="${KLONKT_DIR}" 31 31 B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true) 32 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH} 32 # Deepen a shallow checkout ONCE. \`fetch --depth 1\` keeps only the newest commit 33 # and \`checkout -f\` throws away the tree that was there, so after an update the 34 # previous version existed nowhere on the machine: a bad release could not be 35 # undone without the network, and only if you knew which commit to ask for. 36 # One deepening buys back the history; every later fetch keeps it. 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} 33 42 runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD 34 43 A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD) … … 54 63 else 55 64 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@*')" 56 74 fi 57 75 EOF -
src/assets/css/guardian.css
rf85b2c3 r952baf3 168 168 .g-card.gated { border-left: 3px solid var(--accent); } 169 169 .g-card.gated .row { margin-top: 8px; gap: 8px; } 170 171 /* Het gate-paneel per ward (shaer-ahy.1). Een rij per gate, met het soort en de 172 drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna 173 een plek om iets voor te stellen. */ 174 .g-gate { padding: .5rem 0; border-top: 1px solid var(--g-line, rgba(0,0,0,.08)); } 175 .g-gate:first-of-type { border-top: 0; } 176 .g-gate-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; } 177 .g-gate-name { font-weight: 600; } 178 /* Het soort is een fluistering, geen kop: het hoort erbij te staan zonder de 179 naam van de gate te overstemmen. */ 180 .g-gate-kind { font-size: .78em; opacity: .65; } 181 .g-gate-meta { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .15rem; } 182 .g-gate-meta .g-dim { opacity: .55; } 183 /* Onomkeerbaar krijgt gewicht, want dat is het enige in dit paneel dat je niet 184 kunt terugdraaien. */ 185 .g-gate-warn { font-weight: 600; } 186 .g-gate-wait { font-weight: 600; } 187 188 /* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm, 189 het moet alleen onmogelijk te missen zijn wie er al op af is. */ 190 .g-help-state { margin-top: .5rem; } 191 .g-help-pick { font-size: .9em; } 192 .g-help-age { opacity: .6; } /* oud, maar niet weg */ 193 .g-help-done { font-weight: 600; } 194 .g-confirm { margin-top: .5rem; } 195 196 /* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */ 197 .g-help-archive { margin-top: .5rem; opacity: .75; } -
src/assets/js/guardian.js
rf85b2c3 r952baf3 49 49 // child's panel. 50 50 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; } 51 65 52 66 function helpCard(h) { … … 72 86 card.appendChild(a); 73 87 } 88 card.appendChild(helpState(h)); 74 89 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; }); 75 164 } 76 165 … … 78 167 var list = document.getElementById('help-list'); 79 168 list.textContent = ''; 80 var help = S.help || []; 81 help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); }); 82 if (help.length > HELP_TOP) { 83 list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || ''))); 84 } 169 var alle = S.help || []; 170 // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet 171 // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af 172 // is, en neemt een gesloten vraag de ruimte in van een open vraag. 173 var open = alle.filter(helpOpen); 174 var klaar = alle.filter(function (h) { return !helpOpen(h); }); 175 176 open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); }); 177 if (open.length > HELP_TOP) { 178 list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || ''))); 179 } 180 181 // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan -- 182 // er wordt in dit systeem niets herschreven -- maar hij hoort achter een 183 // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso 184 // in het paneel van dat kind. 185 if (klaar.length) { 186 var doos = el('div', 'g-help-archive'); 187 doos.hidden = true; 188 klaar.forEach(function (h) { doos.appendChild(helpCard(h)); }); 189 var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length)); 190 knop.addEventListener('click', function () { 191 doos.hidden = !doos.hidden; 192 knop.textContent = doos.hidden 193 ? (T.help_archive || '{n} handled').replace('{n}', klaar.length) 194 : (T.help_archive_hide || 'hide'); 195 }); 196 list.appendChild(knop); 197 list.appendChild(doos); 198 } 199 85 200 var badge = document.getElementById('help-count'); 86 badge.textContent = help.length; badge.hidden = help.length === 0; 87 show('help-empty', help.length === 0); 201 badge.textContent = open.length; badge.hidden = open.length === 0; 202 // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is: 203 // er wacht dan immers niets. 204 show('help-empty', open.length === 0); 88 205 } 89 206 … … 351 468 } 352 469 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 353 530 function wardPanel(w) { 354 531 var uri = w.other_uri; … … 358 535 var set = el('div', 'g-panel-sec'); 359 536 set.appendChild(el('h3', null, T.settings_title || 'Settings')); 360 var setRow = el('div', 'row'); 361 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off)); 362 // The heavier sibling (5.6): seeing that a video exists is one decision, 363 // letting a third party's player run inside the app is another. Hidden 364 // only when previews are known-OFF: for a ward on another server the 365 // value is unknown, and unknown is not off. Hiding it there meant a 366 // guardian elsewhere could never even propose playback, which is how a 367 // whole proposal round went into the wrong gate. The ward's server 368 // enforces play-needs-previews at serve time regardless. 369 if (w.embeds !== false) { 370 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off)); 371 } 372 set.appendChild(setRow); 373 // What this guardian proposed and how it stands (5.6). This used to be a 374 // button caption that vanished on refresh, so a running decision was 375 // invisible: you could not tell "waiting", "done" and "expired" apart. 376 (w.proposals || []).forEach(function (p) { 377 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews'); 378 var line = (T.prop_line || 'Proposal {what} {value}: {status}') 379 .replace('{what}', what) 380 .replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off')) 381 .replace('{status}', T['prop_st_' + p.status] || p.status); 382 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line)); 383 }); 537 // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse 538 // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat 539 // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord 540 // is op "wat mag dit kind". 541 (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); }); 542 // Terugval voor een server die de catalogus nog niet stuurt: dan de twee 543 // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit. 544 if (!(w.gates || []).length) { 545 var setRow = el('div', 'row'); 546 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off)); 547 if (w.embeds !== false) { 548 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off)); 549 } 550 set.appendChild(setRow); 551 (w.proposals || []).forEach(function (p) { 552 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews'); 553 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}') 554 .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off')) 555 .replace('{status}', T['prop_st_' + p.status] || p.status))); 556 }); 557 } 384 558 panel.appendChild(set); 385 559 … … 420 594 T.panel_follow_empty || '', followCard); 421 595 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. 422 599 sectionInto(panel, T.panel_help || 'Calls for help', 423 (S.help || []).filter(function (h) { return h.actor_uri === uri; }), 600 (S.help || []).filter(function (h) { return h.actor_uri === uri; }) 601 .sort(function (a, b) { return (helpOpen(b) ? 1 : 0) - (helpOpen(a) ? 1 : 0); }), 424 602 T.panel_help_empty || '', helpCard); 425 603 … … 453 631 actRow.appendChild(rel); 454 632 act.appendChild(actRow); 455 panel.appendChild(act); 633 // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET. 634 // De rest van het paneel is lezen -- instellingen, wie er nog meer op let, 635 // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs 636 // vijf secties te scrollen. 637 panel.insertBefore(act, panel.firstChild); 456 638 return panel; 457 639 } … … 467 649 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle))); 468 650 // Counts on the row: whatever is waiting must be visible with the panel shut. 469 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length; 651 // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een 652 // afgehandelde vraag hoorde de boei niet te laten staan. 653 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length; 470 654 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length; 471 655 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp)); … … 553 737 card.appendChild(head); 554 738 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 || ''; 555 743 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. 556 746 var d = document.createElement('details'); 557 747 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum); 558 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner);748 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner); 559 749 body.appendChild(d); 560 750 } else { 561 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten)751 body.innerHTML = html; // server-sanitized HTML (same as Berichten) 562 752 } 563 753 card.appendChild(body); … … 700 890 701 891 renderAll(); pushState(); loadFeed(); loadFollowReqs(); 702 setInterval(refresh, 45000); // live-ish while open 892 893 // De push die de melding brengt is meteen het teken dat de staat veranderd is. 894 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het 895 // werk, en het werkt ook als de app dicht is. 896 if ('serviceWorker' in navigator) { 897 navigator.serviceWorker.addEventListener('message', function (e) { 898 if (e.data && e.data.klonkt === 'push') refresh(); 899 }); 900 } 901 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en 902 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand 903 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen 904 // meteen een keer, want dan is de kans op nieuws het grootst. 905 setInterval(function () { if (!document.hidden) refresh(); }, 45000); 906 document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); }); 703 907 } catch (e) { 704 908 fatal((e && e.message) || String(e)); -
src/config/database.js
rf85b2c3 r952baf3 106 106 PRIMARY KEY (guardian_slug, id) 107 107 )`); 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'); 108 117 ensureColumn('sites', 'profile_photo', 'TEXT'); 109 118 ensureColumn('audio_tracks', 'cover_url', 'TEXT'); … … 469 478 UNIQUE(slug, actor_uri) 470 479 ); 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); 471 492 CREATE TABLE IF NOT EXISTS ap_timeline ( 472 493 id TEXT NOT NULL, -- the remote note's AP id … … 714 735 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes 715 736 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)'); 716 759 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre) 717 760 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge) … … 759 802 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host 760 803 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 } 761 885 } 762 886 -
src/middleware/render.js
rf85b2c3 r952baf3 249 249 appVersion: APP_VERSION, 250 250 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 || '') : '', 251 256 socialDescr: data.socialDescr || '', 252 257 socialImage: data.socialImage || '', … … 286 291 ? _site.accent : '#e8b04b'; 287 292 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(); 288 297 const triggerJson = JSON.stringify({ 289 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },298 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs }, 290 299 pcmsPostSwap: data.post ? { 291 300 title: data.post.title, -
src/routes/activitypub.js
rf85b2c3 r952baf3 253 253 } 254 254 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me)); 255 queueRoute('follows', (id ) => Guardianship.followsCollection(id));255 queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me)); 256 256 // §5.3 turned around (shaer-p729): what this ward has asked to follow, still 257 257 // waiting on its guardians. Owner-only like the rest — who a child wants to … … 268 268 // they follow) as Create(Note) items, so an app (Shaer) can build a unified 269 269 // feed. Anyone else gets 403; the inbox stays write-only for the public. 270 router.get('/ap/users/:slug/inbox', (req, res) => {270 router.get('/ap/users/:slug/inbox', async (req, res) => { 271 271 const auth = OAuth.verifyBearer(req.headers.authorization); 272 272 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end(); 273 273 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 } 274 306 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's 275 307 // world outside the fediverse is the guardians' call. The gate is applied … … 285 317 const playbackAllowed = embedsAllowed 286 318 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard); 287 const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({ 319 const rows = AP.getTimeline(auth.site.slug, 60); 320 // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de 321 // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op 322 // ap_timeline. Per rij vragen zou hier een N+1 opleveren. 323 const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id)); 324 const posts = rows.map((t) => ({ 288 325 id: `${t.id}#create`, 289 326 type: 'Create', … … 333 370 // Whether THIS account already liked/boosted the note, so the app's 334 371 // detail-view buttons show the current state (and can toggle/undo). 335 'shaer:liked': !! t.liked,336 'shaer:boosted': !! t.boosted,372 'shaer:liked': !!(reacties.get(t.id) || {}).liked, 373 'shaer:boosted': !!(reacties.get(t.id) || {}).boosted, 337 374 // An external (non-fediverse) embed, thumbnail-only and never an iframe. 338 375 // Omitted entirely when the gate is closed (see above). … … 439 476 'shaer:externalLinks': playbackAllowed, 440 477 }, 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), 441 482 totalItems: items.length, 442 483 orderedItems: items, 443 484 }); 485 return undefined; 444 486 }); 445 487 -
src/routes/admin-audio.js
rf85b2c3 r952baf3 111 111 const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed'; 112 112 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', 113 115 pageTitleKey: 'admin.t_audio', 114 116 bodyClass: 'on-admin', -
src/routes/admin-epk.js
rf85b2c3 r952baf3 33 33 try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {} 34 34 renderPage(req, res, 'pages/admin-epk', { 35 pageJs: 'admin-epk', 35 36 pageTitleKey: 'admin.t_epk', 36 37 bodyClass: 'on-admin', -
src/routes/admin-media.js
rf85b2c3 r952baf3 81 81 .sort((a, b) => b._mtime - a._mtime); // newest first 82 82 renderPage(req, res, 'pages/admin-media', { 83 pageJs: 'admin-media', 83 84 pageTitleKey: 'admin.t_media', 84 85 bodyClass: 'on-admin', … … 122 123 if (!site) return res.status(404).send('Site required'); 123 124 renderPage(req, res, 'pages/admin-videos', { 125 pageJs: 'admin-videos', 124 126 pageTitleKey: 'admin.t_media', 125 127 bodyClass: 'on-admin', -
src/routes/admin-paid.js
rf85b2c3 r952baf3 36 36 if (!gate(req, res)) return; 37 37 renderPage(req, res, 'pages/admin-paid', { 38 pageJs: 'admin-paid', 38 39 pageTitleKey: 'apaid.t', 39 40 bodyClass: 'on-admin', -
src/routes/admin-playlists.js
rf85b2c3 r952baf3 62 62 const playlists = PlaylistService.list(site.id); 63 63 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', 64 66 pageTitleKey: 'admin.t_playlists', 65 67 playlists, -
src/routes/admin-push.js
rf85b2c3 r952baf3 12 12 router.get('/', requireSiteManager, async (req, res) => { 13 13 renderPage(req, res, 'pages/admin-push', { 14 pageJs: 'admin-push', 14 15 pageTitleKey: 'push.t', 15 16 bodyClass: 'on-admin', -
src/routes/admin-settings.js
rf85b2c3 r952baf3 66 66 router.get('/', requireGod, (req, res) => { 67 67 renderPage(req, res, 'pages/admin-settings', { 68 pageJs: 'admin-settings', 68 69 pageTitleKey: 'admin.t_settings', 69 70 bodyClass: 'on-admin', -
src/routes/admin-sites.js
rf85b2c3 r952baf3 200 200 router.get('/new', requireGod, (req, res) => { 201 201 renderPage(req, res, 'pages/admin-site-edit', { 202 pageJs: 'admin-site-edit', 202 203 pageTitleKey: 'admin.t_newsite', 203 204 bodyClass: 'on-admin', … … 283 284 284 285 renderPage(req, res, 'pages/admin-site-edit', { 286 pageJs: 'admin-site-edit', 285 287 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title }, 286 288 bodyClass: 'on-admin', -
src/routes/admin.js
rf85b2c3 r952baf3 87 87 router.get('/handleiding', requireAuth, (req, res) => { 88 88 renderPage(req, res, 'pages/admin-help', { 89 pageJs: 'admin-help', 89 90 pageTitleKey: 'admin.t_manual', 90 91 bodyClass: 'on-admin', -
src/routes/auth.js
rf85b2c3 r952baf3 115 115 if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '')); 116 116 renderPage(req, res, 'pages/auth-register', { 117 pageJs: 'auth-register', 117 118 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special', 118 119 error: null, username: '', email: '', siteName: '', next, … … 124 125 const next = safeNext(req.body.next) || ''; 125 126 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', { 127 pageJs: 'auth-register', 126 128 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special', 127 129 error, username: username || '', email: email || '', siteName: siteName || '', next, … … 205 207 } else { 206 208 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass. 207 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik'npm run reset-admin')`);209 console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`); 208 210 } 209 211 } -
src/routes/download.js
rf85b2c3 r952baf3 85 85 const fan = req.session && req.session.user; 86 86 renderPage(req, res, 'pages/download', { 87 pageJs: 'download', 87 88 pageTitle: track.title + ' — download', 88 89 bodyClass: 'on-download', -
src/routes/guardian.js
rf85b2c3 r952baf3 57 57 'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off', 58 58 'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired', 59 'panel_guards_far']; 59 'panel_guards_far', 60 // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en 61 // de drempel erbij. De namen volgen de catalogus in gated.js. 62 'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows', 63 'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover', 64 'gate_unknown', 'gate_threshold', 'gate_threshold_unknown', 65 'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose', 66 // Oppikken en afhandelen van een hulpvraag (shaer-lgo). 67 'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note', 68 'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days', 69 'help_archive', 'help_archive_hide']; 60 70 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)])); 61 71 s.wave = i18nT(L, 'guardian.wave'); … … 71 81 emoji_json, actor_emoji_json, media_json, quote_json, embed_json 72 82 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50` 73 ).all(site.slug).map((h) => ({ 83 ).all(site.slug); 84 // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is 85 // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies 86 // het scherm dat een guardian in een haast openslaat. 87 const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri)); 88 const helpItems = help.map((h) => ({ 74 89 ...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 }, 75 93 // The dashboard is built in the browser, so it gets the body finished: the 76 94 // same partial de Krant and Berichten use. A 🛟 often carries a screenshot … … 103 121 status: Guardianship.gated.sentStatus(p, Date.now()), 104 122 })), 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), 105 127 })), 106 128 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems, … … 113 135 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value, 114 136 })), 115 help ,137 help: helpItems, 116 138 strings: uiStrings(L), 117 139 }; … … 192 214 const site = siteForUser(req); 193 215 if (!site) return res.status(404).json({ error: 'no_site' }); 216 const L = resolveLang(req); 194 217 ensureWardConnections(site); 195 218 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri)); … … 209 232 cw: p.cw || null, 210 233 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), 211 240 })); 212 241 res.json({ items, following: wardUris.size }); … … 319 348 if (!r) return res.status(502).json({ error: 'delivery' }); 320 349 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 }); 321 388 }); 322 389 … … 531 598 /** The playback gate of a ward we host (5.6): the heavier sibling. */ 532 599 function 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 533 625 function wardGateSetting(uri, column) { 534 626 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); -
src/routes/paid.js
rf85b2c3 r952baf3 76 76 const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900); 77 77 renderPage(req, res, 'pages/paid-passkey', { 78 pageJs: 'paid-passkey', 78 79 pageTitleKey: 'ppk.t', bodyClass: 'on-post', 79 80 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post, -
src/routes/posts.js
rf85b2c3 r952baf3 256 256 257 257 renderPage(req, res, 'pages/post-edit', { 258 // post-edit neemt de playlist-editor op. 259 pageJs: 'post-edit playlist-editor', 258 260 post: { 259 261 id: uuid(), … … 795 797 } 796 798 renderPage(req, res, 'pages/authorize-interaction', { 799 pageJs: 'authorize-interaction', 797 800 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites 798 801 bodyClass: 'on-special', … … 806 809 liked: !!req.query.liked, 807 810 boosted: !!req.query.boosted, 808 reacted: (site && uri) ? ActivityPubService.get MyReactions(site.slug, uri) : { liked: false, boosted: false },811 reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false }, 809 812 siteTitle: site ? site.title : '', 810 813 }); … … 839 842 let on = false; 840 843 if (site && uri) { 841 on = !ActivityPubService.get MyReactions(site.slug, uri).liked;844 on = !ActivityPubService.getReaction(site.slug, uri).liked; 842 845 ActivityPubService.resolveRemoteNote(uri) 843 846 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri)) 844 847 .catch((e) => console.warn('[AP] remote like failed:', e.message)); 845 ActivityPubService.setMyReaction(site.slug, uri, 'like', on); 848 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag. 849 ActivityPubService.setReaction(site.slug, uri, 'like', on); 846 850 } 847 851 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 856 860 let on = false; 857 861 if (site && uri) { 858 on = !ActivityPubService.get MyReactions(site.slug, uri).boosted;862 on = !ActivityPubService.getReaction(site.slug, uri).boosted; 859 863 ActivityPubService.resolveRemoteNote(uri) 860 864 .then((note) => { … … 862 866 const id = note.object_uri || uri; 863 867 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri)) 864 // Boost → store the post in the timeline (even if you don't follow the author) so it 865 // surfaces in the Cirkel; unboost → just clear the flag. 866 .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id)); 868 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de 869 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in 870 // de Cirkel verschijnt. 871 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null })); 867 872 }) 868 873 .catch((e) => console.warn('[AP] remote boost failed:', e.message)); 869 ActivityPubService.setMyReaction(site.slug, uri, 'boost', on); 874 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via 875 // setReaction en niet via setMyReaction: ook dit korte moment mag geen 876 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de 877 // note, zodat de post ook in je tijdlijn belandt. 878 ActivityPubService.setReaction(site.slug, uri, 'boost', on); 870 879 } 871 880 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 930 939 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']); 931 940 renderPage(req, res, 'pages/messages', { 932 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,941 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt, 933 942 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers, 934 943 success: req.query.success || null, error: req.query.error || null, … … 965 974 } catch { /* fall through */ } 966 975 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'); 967 1024 }); 968 1025 … … 1139 1196 } 1140 1197 renderPage(req, res, 'pages/news', { 1198 pageJs: 'news', 1141 1199 pageTitle: 'News', bodyClass: 'on-special', 1142 1200 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase, … … 1229 1287 let on = false; 1230 1288 if (site && note) { 1231 on = !ActivityPubService.get TimelineReaction(site.slug, note).liked;1289 on = !ActivityPubService.getReaction(site.slug, note).liked; 1232 1290 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ } 1233 if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);1291 ActivityPubService.setReaction(site.slug, note, 'like', on); 1234 1292 } 1235 1293 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 1243 1301 let on = false; 1244 1302 if (site && note) { 1245 on = !ActivityPubService.get TimelineReaction(site.slug, note).boosted;1303 on = !ActivityPubService.getReaction(site.slug, note).boosted; 1246 1304 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 1247 1306 if (on) { 1248 ActivityPubService.markBoosted(site.slug, note); // instant UI state1249 1307 // Fire-and-forget: re-resolve the note so the cached row is refreshed 1250 1308 // (cover/content) — boosting again heals a stale copy from EVERY boost 1251 1309 // path, not just the interact page. 1252 1310 ActivityPubService.resolveRemoteNote(note) 1253 .then((n) => { if (n) ActivityPubService. upsertBoostedNote(site.slug, n); })1311 .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); }) 1254 1312 .catch(() => { /* best-effort */ }); 1255 } else {1256 ActivityPubService.unmarkBoosted(site.slug, note);1257 1313 } 1258 1314 } … … 1338 1394 const { newerPost, olderPost } = postNeighbors(site, post); 1339 1395 return renderPage(req, res, 'pages/paid-gate', { 1396 pageJs: 'paid-gate', 1340 1397 pageTitle: post.title || 'Voor supporters', 1341 1398 bodyClass: 'on-special', … … 1454 1511 1455 1512 renderPage(req, res, 'pages/post', { 1513 pageJs: 'post', 1456 1514 post, 1457 1515 poll: ActivityPubService.ownPollView(post), … … 1505 1563 const kind = req.body.kind === 'boost' ? 'boost' : 'like'; 1506 1564 if (parent && parent.post_id === post.id && parent.object_uri) { 1507 if (kind === 'boost') { 1508 // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted. 1509 const on = !parent.acted_boost; 1510 ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri) 1511 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1512 ActivityPubService.setInteractionBoosted(parent.id, on); 1513 } else { 1514 // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked. 1515 const on = !parent.acted_like; 1516 ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri) 1517 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1518 ActivityPubService.setInteractionLiked(parent.id, on); 1519 } 1565 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on. 1566 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit 1567 // de kolom en de knop uit de tussentabel, dan draait een divergentie de 1568 // richting om en stuur je een Undo voor iets dat nooit is verstuurd. 1569 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri); 1570 const on = kind === 'boost' ? !ik.boosted : !ik.liked; 1571 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri) 1572 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1573 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net 1574 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig 1575 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na 1576 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen. 1577 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on); 1578 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on); 1579 else ActivityPubService.setInteractionLiked(parent.id, on); 1520 1580 } 1521 1581 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`); -
src/server.js
rf85b2c3 r952baf3 65 65 import apRoutes from './routes/activitypub.js'; 66 66 import oauthRoutes from './routes/oauth.js'; 67 import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';67 import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js'; 68 68 69 69 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one … … 197 197 startScheduler(); // release planning: publish scheduled posts when publish_at is reached 198 198 startDeliveryWorker(); // 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(); 199 203 selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update 200 204 … … 542 546 try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ } 543 547 const title = d.title || 'Klonkt'; 544 e.waitUntil(self.registration.showNotification(title, { 545 body: d.body || '', 546 icon: '/favicon.svg', 547 badge: '/favicon.svg', 548 tag: d.type ? ('klonkt-' + d.type) : undefined, // collapse same-type bursts 549 data: { url: d.url || '/' }, 550 })); 548 e.waitUntil(Promise.all([ 549 self.registration.showNotification(title, { 550 body: d.body || '', 551 icon: '/favicon.svg', 552 badge: '/favicon.svg', 553 tag: d.type ? ('klonkt-' + d.type) : undefined, // collapse same-type bursts 554 data: { url: d.url || '/' }, 555 }), 556 // Wek ook een pagina die al openstaat. De push IS het teken dat er iets 557 // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog 558 // eens doen -- en die tweede zou alleen werken zolang de app open is, 559 // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen. 560 self.clients.matchAll({ type: 'window', includeUncontrolled: true }) 561 .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); }) 562 .catch(() => { /* geen open venster: niets te wekken */ }), 563 ])); 551 564 }); 552 565 self.addEventListener('notificationclick', e => { -
src/services/ActivityPubService.js
rf85b2c3 r952baf3 627 627 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into 628 628 // 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 629 644 export function getMessages(slug, limit, offset) { 630 645 const off = Math.max(0, offset || 0); … … 638 653 for (const m of listOutbox(slug).slice(0, need)) { 639 654 items.push({ 640 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to, 641 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at, 655 type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors, 656 in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content, 657 editable: m.editable, language: m.language, created_at: m.created_at, 658 // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander: 659 // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest 660 // media uit media_json met een `type`, terwijl ap_outbox ze als 661 // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam 662 // een foto die JIJ meestuurde als kale tekst binnen. 663 media_json: outboxMediaJson(m.attachments), 642 664 }); 643 665 } 644 666 } 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 */ } 645 681 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications) 646 682 const out = []; … … 656 692 out.push(it); 657 693 } 658 return out.slice(off, off + lim); 694 // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot 695 // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap 696 // telt een draad als één item voor de paginering, wat klopt: je scrolt door 697 // gesprekken, niet door losse zinnen. 698 return groupConversations(out).slice(off, off + lim); 699 } 700 701 // De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie 702 // aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van 703 // de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen 704 // moest dus tussen chips heen en weer, terwijl het één draad is. 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; 659 802 } 660 803 … … 1043 1186 const siteIcon = (site && site.profile_photo) || null; 1044 1187 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 1045 1200 const nodes = []; 1046 1201 for (const r of rows) { 1047 1202 if (r.kind !== 'reply') continue; 1203 const ik = mijnReactie(r.object_uri); 1048 1204 nodes.push({ 1049 1205 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id, … … 1052 1208 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at, 1053 1209 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (thread render) 1054 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,1210 acted_boost: ik.boosted, acted_like: ik.liked, 1055 1211 children: [], 1056 1212 }); … … 1141 1297 } 1142 1298 1143 export async function fetchActor(url) { 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; 1144 1319 try { 1145 1320 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } }); 1146 if (!r.ok) return null; 1147 const len = Number(r.headers.get('content-length') || 0); 1148 if (len > 2_000_000) return null; // refuse oversized actor docs 1149 return await r.json(); 1150 } catch { return null; } 1321 if (r.ok) { 1322 const len = Number(r.headers.get('content-length') || 0); 1323 if (len > 2_000_000) return null; // refuse oversized actor docs 1324 doc = await r.json(); 1325 } 1326 } catch { /* val door naar de ondertekende poging */ } 1327 // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document 1328 // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die 1329 // telt als mislukt. 1330 if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc; 1331 if (!opts.asSlug) return doc; 1332 const signed = await signedGetJson(opts.asSlug, url).catch(() => null); 1333 return (signed && signed.id) ? signed : doc; 1151 1334 } 1152 1335 … … 1225 1408 } 1226 1409 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 1227 1420 // Best-effort verification of an incoming signed request. Returns the sender's 1228 1421 // actor doc if the signature checks out, else null. (Not gating yet — MVP.) … … 1230 1423 // federating servers with drifting clocks; an operator can widen it via env. 1231 1424 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000; 1232 export async function verifyRequest(req ) {1425 export async function verifyRequest(req, asSlug = null) { 1233 1426 const sigH = req.headers['signature']; 1234 1427 if (!sigH) return null; 1235 1428 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); 1236 1429 if (!p.keyId || !p.signature) return null; 1237 const actor = await fetchActor(p.keyId.split('#')[0]); 1430 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized 1431 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we 1432 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige 1433 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is. 1434 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() }); 1238 1435 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem; 1239 1436 if (!pem) return null; … … 1495 1692 } 1496 1693 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 1497 1847 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 1498 1848 export async function handleInbox(req, slugParam, preVerified = null) { … … 1508 1858 // keeps everything below identical, including the actor-versus-signer check, 1509 1859 // which is exactly the check that must not be skipped for being local. 1510 const verified = preVerified || await verifyRequest(req ).catch(() => null);1860 const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null); 1511 1861 1512 1862 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very … … 1518 1868 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move']; 1519 1869 if (GATED.includes(type)) { 1520 if (!verified || !claimedActor || verified.id !== claimedActor) { 1521 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)'); 1870 // Een geldige handtekening van iemand anders dan de auteur is doorsturen, 1871 // geen vervalsing. Haal het object dan bij de bron op in plaats van het af 1872 // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder. 1873 let forwarded = null; 1874 if (verified && claimedActor && verified.id !== claimedActor) { 1875 forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null); 1876 if (forwarded) { 1877 act.object = forwarded; // de OPGEHAALDE inhoud, niet de bezorgde 1878 console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id); 1879 } 1880 } 1881 if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) { 1882 // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid" 1883 // heetten: geen handtekening meegestuurd, wel een handtekening maar niet 1884 // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is), 1885 // of geldig ondertekend door iemand anders. 1886 const reden = verified ? '(signer mismatch)' 1887 : (req.headers && req.headers.signature) ? '(signature present, unverifiable)' 1888 : '(no signature)'; 1889 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden); 1522 1890 return 401; 1523 1891 } … … 1809 2177 } 1810 2178 } 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 } 1811 2189 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned 1812 2190 // above): store a mention notification for each of our actors named in the Mention tags. 1813 2191 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is 1814 2192 // 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 } 1815 2206 if (actorUri && !isLocalActor && o.id) { 1816 2207 const slugs = localMentionSlugs(o.tag, base); … … 2497 2888 const kind = type === 'Announce' ? 'boost' : 'like'; 2498 2889 await sendInteraction(site, kind, objUri, authorUri); 2499 setMyReaction(site.slug, targetUri, kind, true); 2500 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } } 2890 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer. 2891 // De note gaat mee zodat een boost de post je tijdlijn in trekt. 2892 try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); } 2893 catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ } 2894 // Een Like uit een app moet ook in ap_timeline.liked landen, want dat 2895 // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd 2896 // de reactie wel opgeslagen (setMyReaction, de webroute leest die), 2897 // maar kreeg de app altijd liked:false terug: het hartje sprong bij de 2898 // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood 2899 // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like. 2900 // Anders dan bij een boost geen upsert: een like hoort een post niet 2901 // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit 2902 // terecht een no-op. 2501 2903 return { status: 202, url: objUri }; 2502 2904 } … … 2547 2949 const objUri = (note && note.object_uri) || innerTarget; 2548 2950 await sendInteraction(site, kind, objUri, note && note.actor_uri); 2549 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);2550 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ }}2951 try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); } 2952 catch { /* non-fatal */ } 2551 2953 return { status: 202, url: objUri }; 2552 2954 } … … 2913 3315 } 2914 3316 export function listOutbox(siteSlug) { 2915 return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 3317 // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel 3318 // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post 3319 // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit 3320 // elkaar in "Verzonden" en "Gesprekken". 3321 return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 2916 3322 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; }); 2917 3323 } … … 3051 3457 return { ins: _insTl, list: _listTl, del: _delTl }; 3052 3458 } 3053 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); } 3459 /** 3460 * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9). 3461 * 3462 * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee -- 3463 * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn 3464 * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en 3465 * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de 3466 * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp. 3467 * 3468 * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked` 3469 * heten en hangt het van de driver af welke wint. Eén extra query per pagina 3470 * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard. 3471 */ 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 } 3054 3482 3055 3483 /** … … 3087 3515 } 3088 3516 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 3089 3599 export function getDirectMessages(slug, limit) { 3090 3600 try { … … 3243 3753 if (!_cirkelPosts) _cirkelPosts = db.prepare(` 3244 3754 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url, 3245 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw 3755 t.content, t.url, t.published, t.media_json, t.nsfw, t.cw, 3756 (rb.target_uri IS NOT NULL) AS boosted 3246 3757 FROM ap_timeline t 3247 3758 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri 3248 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1) 3759 -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De 3760 -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus 3761 -- deze join kan geen rijen verdubbelen. 3762 LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost' 3763 WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL) 3249 3764 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC 3250 3765 LIMIT ? OFFSET ?`); … … 3255 3770 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 []; } 3256 3771 } 3257 // Mark a timeline post as boosted so it shows in the Cirkel (mixed by date). 3772 // AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd" 3773 // staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door 3774 // setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de 3775 // helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12). 3776 // 3777 // ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de 3778 // Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen 3779 // kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg 3780 // zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee 3781 // terugdraaien een code-revert blijft in plaats van dataherstel. 3258 3782 let _markBoost, _unmarkBoost, _boostedCount; 3259 3783 export function markBoosted(slug, noteId) { … … 3270 3794 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ } 3271 3795 } 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 3272 3999 export function getTimelineReaction(slug, noteId) { 3273 4000 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 }; } … … 3303 4030 } 3304 4031 export function boostedCount(slug) { 3305 try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; } 4032 // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost 4033 // bevatten van iets dat er (nog) niet in staat. 4034 try { 4035 if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r 4036 JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri 4037 WHERE r.site_slug = ? AND r.kind = 'boost'`); 4038 return _boostedCount.get(slug).n; 4039 } catch { return 0; } 3306 4040 } 3307 4041 … … 4018 4752 await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem); 4019 4753 } 4020 console.log('[AP] MOVE announced:', site.slug, '→', target.id, ' naar', inboxes.length, 'inbox(en)');4754 console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)'); 4021 4755 return { ok: true, target: target.id, inboxes: inboxes.length }; 4022 4756 } … … 4198 4932 const wardDoc = await fetchActor(wardUri).catch(() => null); 4199 4933 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower); 4200 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) }); 4934 // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande 4935 // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde 4936 // hier als "deze ward wil deze ward volgen" met het doel weggegooid. 4937 // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het 4938 // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden. 4939 const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri; 4940 const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null; 4941 const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null; 4942 Guardianship.follows.recordReview(gslug, { 4943 id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, 4944 follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo), 4945 direction: uitgaand ? 'outgoing' : 'incoming', 4946 target: doel || null, targetHandle: dai ? dai.handle : null, 4947 }); 4201 4948 const L = pushLang(gslug); 4202 4949 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` }); … … 4301 5048 try { 4302 5049 const rows = db.prepare(` 4303 SELECT i. kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,5050 SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility, 4304 5051 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json, 4305 5052 p.slug AS post_slug, p.title AS post_title … … 4310 5057 for (const r of rows) out.push({ 4311 5058 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, 4312 5062 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at, 4313 5063 // When the post was written, for display. created_at (when it reached us) … … 4543 5293 getOutboxRow: (id) => iStmts().getO.get(id), 4544 5294 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), 4545 5299 }); 4546 5300 /** … … 4579 5333 // Guardian PWA / Berichten push. The kid answers an incoming offer in its 4580 5334 // 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. 4581 5338 onEvent: (slug, ev) => { 4582 const L = pushLang(slug); 4583 const texts = { 4584 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward 4585 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward 4586 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'], 4587 // §3.2: a guardian ended the relation. The ward hears that someone who 4588 // was looking after them has gone; a co-guardian hears they are one fewer. 4589 guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'], 4590 coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'], 4591 }[ev.kind]; 4592 if (!texts) return; 4593 const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?'; 4594 const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian'; 4595 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url }); 5339 const p = guardianEventPush(slug, ev); 5340 if (p) pushEvent(slug, p); 4596 5341 }, 4597 5342 }); 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 }; 4598 5392 4599 5393 // The notification duty of FEP-633c 3.6.2, wired once for every place a … … 4625 5419 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured, 4626 5420 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 4627 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 5421 feedCursor, feedChangesSince, waitForFeedChange, 5422 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 4628 5423 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 4629 5424 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, … … 4631 5426 gateOutgoingFollow, performApprovedFollow, 4632 5427 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, 4633 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,5428 autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 4634 5429 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 4635 5430 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker, -
src/services/guardianship/delivery.js
rf85b2c3 r952baf3 41 41 // guardian on any instance receives it as a private mention (the ward 42 42 // call-for-help path). 43 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil}) {43 export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) { 44 44 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags, 45 45 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps; … … 83 83 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `; 84 84 }).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 : ''; 85 91 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>'); 86 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 92 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het 93 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint. 94 const content = rich 95 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}` 96 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 87 97 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null; 88 98 // Attachments: same rules as deliverReply (own /media/ uploads only, … … 99 109 const row = getOutboxRow(id); 100 110 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 } 101 118 const create = { 102 119 '@context': AP_CONTEXT, -
src/services/guardianship/follows.js
rf85b2c3 r952baf3 81 81 _r = { 82 82 ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews 83 (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at) 84 VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`), 83 (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, 84 direction, target_uri, target_handle, created_at) 85 VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`), 85 86 get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'), 86 87 bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"), … … 91 92 } 92 93 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 */ 93 102 export function recordReview(guardianSlug, r) { 94 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null); 103 const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming'; 104 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, 105 r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null); 95 106 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); 96 145 } 97 146 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); } … … 102 151 recordPending, getPending, listForWard, decide, remove, 103 152 recordReview, getReview, listReviews, removeReview, 153 listReviewsByDirection, reviewQueueItem, 104 154 }; -
src/services/guardianship/gated.js
rf85b2c3 r952baf3 60 60 'shaer:externalPlayback': 'external_playback', 61 61 }; 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 62 122 export function featureColumn(feature) { 63 123 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null; … … 239 299 240 300 export default { 301 GATE_CATALOGUE, gateRows, 241 302 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS, 242 303 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer, -
src/services/guardianship/index.js
rf85b2c3 r952baf3 32 32 // §5.6 gated settings (decided by the guardians, enforced by the ward's server) 33 33 export * 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
rf85b2c3 r952baf3 6 6 * - offers: pending handshake offers where I am a party (§3), with the full 7 7 * accept tally so the client shows the right action 8 * - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now8 * - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb) 9 9 * - wards: my committed wards 10 10 */ … … 13 13 import * as availability from './availability.js'; 14 14 import * as outgoing from './outgoing.js'; 15 import * as follows from './follows.js'; 15 16 import * as handshake from './handshake.js'; 16 17 … … 35 36 } 36 37 37 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */ 38 export function followsCollection(id) { 39 return collection(id, []); 38 /** 39 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2, 40 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar 41 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag. 42 * 43 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog) 44 * op deze: 45 * - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward 46 * - ap_pending_follows: een ward op deze instance 47 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan. 48 */ 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); 40 65 } 41 66 42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */ 67 /** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */ 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 */ 43 85 export function outgoingFollowsCollection(id, slug, me) { 44 return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me))); 86 const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)); 87 for (const r of follows.listReviewsByDirection(slug, 'outgoing')) { 88 items.push(follows.reviewQueueItem(r, me)); 89 } 90 return collection(id, items); 45 91 } 46 92 -
src/services/i18n.js
rf85b2c3 r952baf3 67 67 'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer', 68 68 'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter', 69 'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', ' guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',69 'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.', 70 70 'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd', 71 71 'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.', … … 1017 1017 'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin', 1018 1018 'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed', 1019 'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', ' guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',1019 'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.', 1020 1020 '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', 1021 1021 '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.', … … 1961 1961 'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung', 1962 1962 'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen', 1963 'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', ' guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',1963 'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.', 1964 1964 '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', 1965 1965 'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.', -
src/views/pages/admin-audio.ejs
rf85b2c3 r952baf3 573 573 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 574 574 575 <script> 576 (function() { 577 578 // ── Live filename display for custom file inputs ────────────── 579 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => { 580 const nameEl = input.parentElement.querySelector('.ax-file-name'); 581 if (!nameEl) return; 582 input.addEventListener('change', () => { 583 if (input.files && input.files[0]) { 584 nameEl.textContent = input.files[0].name; 585 nameEl.removeAttribute('data-empty'); 586 } else { 587 nameEl.textContent = '<%= t('aaud.no_file') %>'; 588 nameEl.setAttribute('data-empty', ''); 589 } 590 }); 591 }); 592 593 // ── Inline track preview (routed through the global mini-player) ── 594 // Each .ax-track-play button is a thin wrapper around the global 595 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ / 596 // .is-playing) is synced from the player's own audio element so it 597 // stays accurate even when the user uses the mini-player's controls. 598 (function setupTrackPreview() { 599 const buttons = document.querySelectorAll('.ax-track-play'); 600 if (!buttons.length) return; 601 602 function setIcon(btn, playing) { 603 const icon = btn.querySelector('.ax-track-play-icon'); 604 if (icon) icon.textContent = playing ? '⏸' : '▶'; 605 btn.classList.toggle('is-playing', playing); 606 btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>'); 607 } 608 609 // Resync ALL preview buttons against the current audio element state. 610 // Called on every play/pause/ended event so admins always see the right 611 // icon — including the case where they hit pause on the mini-player 612 // bar instead of the row's button. 613 function resyncAll() { 614 const audio = document.getElementById('audio-element'); 615 const player = window.pcmsAudioPlayer; 616 const playing = audio && !audio.paused && !audio.ended; 617 // audio.src is now a blob: URL (Spotify-style playback), so compare 618 // against the player's logical track URL, not the element src. 619 const cur = player && player.currentTrack(); 620 const curUrl = cur ? cur.url : ''; 621 buttons.forEach(b => { 622 const isThisOne = playing && curUrl === b.dataset.streamUrl; 623 setIcon(b, isThisOne); 624 }); 625 } 626 627 buttons.forEach(btn => { 628 btn.addEventListener('click', e => { 629 e.preventDefault(); 630 const player = window.pcmsAudioPlayer; 631 if (!player) { 632 console.warn('[admin-audio] miniplayer not available'); 633 return; 634 } 635 const url = btn.dataset.streamUrl; 636 if (!url) return; 637 638 // Build track metadata from the row's DOM so the mini-player shows 639 // useful info (title/artist/album/cover) without an extra API call. 640 const row = btn.closest('.ax-track'); 641 const titleEl = row && row.querySelector('[data-cell="title"]'); 642 const artistEl = row && row.querySelector('[data-cell="artist"]'); 643 const albumEl = row && row.querySelector('[data-cell="album"]'); 644 const coverImg = row && row.querySelector('img[data-cover-thumb]'); 645 const track = { 646 url, 647 title: titleEl ? titleEl.textContent.trim() : 'Track', 648 artist: artistEl ? artistEl.textContent.trim() : '', 649 album: albumEl ? albumEl.textContent.trim() : '', 650 cover: coverImg ? coverImg.src : '', 651 }; 652 653 // If this exact track is already current, toggle pause/play instead 654 // of restarting from zero. Compare logical URLs (audio.src is a blob:). 655 const cur = player.currentTrack(); 656 if (cur && cur.url === url) { 657 if (player.isPlaying()) player.pause(); 658 else player.play(); 659 return; 660 } 661 662 player.setQueue([track], 0); 663 }); 664 }); 665 666 // Hook the global audio element's events to keep the row buttons synced. 667 // We attach lazily after the player has built its DOM. The script in 668 // shell.ejs runs at page-load so #audio-element exists by the time 669 // this IIFE fires (script tag is below the body content). 670 const audio = document.getElementById('audio-element'); 671 if (audio) { 672 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => { 673 audio.addEventListener(ev, resyncAll); 674 }); 675 // Initial state on page load (e.g. user navigated back while a track 676 // was already playing — buttons should reflect that). 677 resyncAll(); 678 } 679 })(); 680 681 682 // ── Bulk upload (drag-drop + sequential transcoding) ───────── 683 // Files dropped or picked are queued (not uploaded immediately) so the 684 // user can review the list, set shared metadata, then hit "Start upload". 685 // The loop POSTs one file at a time to /admin/audio/upload with 686 // Accept: application/json so the server returns structured per-file 687 // results instead of redirecting. 688 const dropzone = document.getElementById('audio-dropzone'); 689 const fileInput = document.getElementById('audio-files'); 690 const queueEl = document.getElementById('upload-queue'); 691 const actionsEl = document.getElementById('upload-actions'); 692 const startBtn = document.getElementById('start-upload-btn'); 693 const clearBtn = document.getElementById('clear-queue-btn'); 694 const artistInput= document.getElementById('batch-artist'); 695 const albumInput = document.getElementById('batch-album'); 696 const coverInput = document.getElementById('batch-cover'); 697 698 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */ 699 const queue = []; 700 701 function fmtBytes(n) { 702 if (n < 1024) return n + ' B'; 703 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; 704 return (n / 1024 / 1024).toFixed(1) + ' MB'; 705 } 706 707 function setItemStatus(item, status, label) { 708 const labels = { 709 queued: '<%= t('aaud.st_queued') %>', 710 uploading: '<%= t('aaud.st_uploading') %>', 711 transcoding: '<%= t('aaud.st_transcoding') %>', 712 done: '✓ <%= t('aaud.st_done') %>', 713 error: '✗ <%= t('aaud.st_error') %>', 714 }; 715 item.status = status; 716 const badge = item.el.querySelector('.ax-queue-status'); 717 badge.className = 'ax-queue-status ax-queue-status--' + status; 718 badge.textContent = label || labels[status] || status; 719 // Restyle the row 720 item.el.classList.remove( 721 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error' 722 ); 723 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active'); 724 else if (status === 'done') item.el.classList.add('ax-queue-item--done'); 725 else if (status === 'error') item.el.classList.add('ax-queue-item--error'); 726 } 727 728 function addFiles(files) { 729 let added = 0; 730 for (const file of files) { 731 if (!file.type.startsWith('audio/') && 732 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) { 733 // Silently skip non-audio drops; keeps the UX uncluttered. 734 continue; 735 } 736 const li = document.createElement('li'); 737 li.className = 'ax-queue-item'; 738 li.innerHTML = 739 '<div class="ax-queue-name"></div>' + 740 '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>'; 741 // Use textContent to avoid HTML-injection if a filename contains markup. 742 li.querySelector('.ax-queue-name').textContent = file.name; 743 // Append size hint inline 744 const size = document.createElement('span'); 745 size.className = 'ax-queue-size'; 746 size.textContent = fmtBytes(file.size); 747 li.querySelector('.ax-queue-name').appendChild(size); 748 queueEl.appendChild(li); 749 queue.push({ file, el: li, status: 'queued' }); 750 added++; 751 } 752 if (added) { 753 queueEl.hidden = false; 754 actionsEl.hidden = false; 755 } 756 } 757 758 // ── Drop-zone events ───────────────────────────────────────── 759 // Page-level guard: a dropped file outside the zone would otherwise 760 // make the browser navigate to it (e.g. opening the audio inline), which 761 // discards typed metadata. We swallow drops anywhere unless the dropzone 762 // explicitly handles them. 763 ['dragover', 'drop'].forEach(ev => { 764 window.addEventListener(ev, e => { 765 // Allow drops INSIDE the dropzone — its own listener handles those. 766 if (dropzone.contains(e.target)) return; 767 e.preventDefault(); 768 }); 769 }); 770 771 ['dragenter', 'dragover'].forEach(ev => { 772 dropzone.addEventListener(ev, e => { 773 e.preventDefault(); 774 dropzone.classList.add('is-dragover'); 775 }); 776 }); 777 ['dragleave', 'drop'].forEach(ev => { 778 dropzone.addEventListener(ev, e => { 779 e.preventDefault(); 780 dropzone.classList.remove('is-dragover'); 781 }); 782 }); 783 dropzone.addEventListener('drop', e => { 784 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); 785 }); 786 fileInput.addEventListener('change', () => { 787 addFiles(fileInput.files); 788 // Reset so the same file can be picked again later if user wants 789 fileInput.value = ''; 790 }); 791 792 // ── Clear queue button ─────────────────────────────────────── 793 clearBtn.addEventListener('click', () => { 794 // Only remove items that aren't currently uploading (anything queued 795 // or already finished). An in-progress upload finishes, then its row 796 // would also disappear once we re-render — but we keep it simple and 797 // just refuse to clear during an active run. 798 if (startBtn.disabled) return; 799 queue.length = 0; 800 queueEl.innerHTML = ''; 801 queueEl.hidden = true; 802 actionsEl.hidden = true; 803 }); 804 805 // ── Sequential upload loop ─────────────────────────────────── 806 startBtn.addEventListener('click', async () => { 807 if (startBtn.disabled) return; 808 startBtn.disabled = true; 809 clearBtn.disabled = true; 810 dropzone.style.pointerEvents = 'none'; 811 dropzone.style.opacity = '0.5'; 812 813 const sharedArtist = artistInput.value.trim(); 814 const sharedAlbum = albumInput.value.trim(); 815 const sharedCover = coverInput.files && coverInput.files[0]; 816 817 // Process queued items one at a time. We iterate via index so that 818 // if more files get dropped during the run they ALSO get processed 819 // (queue.push above mutates the same array we're iterating). 820 for (let i = 0; i < queue.length; i++) { 821 const item = queue[i]; 822 if (item.status !== 'queued') continue; 823 try { 824 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover); 825 } catch (err) { 826 console.error('upload failed for', item.file.name, err); 827 setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>')); 828 } 829 } 830 831 startBtn.disabled = false; 832 clearBtn.disabled = false; 833 dropzone.style.pointerEvents = ''; 834 dropzone.style.opacity = ''; 835 836 // Reload the page so the new tracks appear in the list below. 837 // Could also fetch them and inject, but a full reload is simpler and 838 // ensures position indexes / album-grouping are correct. 839 const anyDone = queue.some(q => q.status === 'done'); 840 if (anyDone) { 841 setTimeout(() => location.reload(), 700); 842 } 843 }); 844 845 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) { 846 setItemStatus(item, 'uploading'); 847 848 const fd = new FormData(); 849 fd.append('audio', item.file); 850 if (sharedArtist) fd.append('artist', sharedArtist); 851 if (sharedAlbum) fd.append('album', sharedAlbum); 852 if (sharedCover) fd.append('cover', sharedCover); 853 // Title is intentionally omitted — server uses filename fallback. 854 855 // We can't reliably distinguish "still uploading bytes" from 856 // "uploading done, ffmpeg running" without progress events, but the 857 // status flips to "Converteren…" once the request is past upload phase. 858 // We approximate this by waiting until the response arrives — by then 859 // both phases are complete on the server side. For a smoother feel we 860 // briefly show "transcoding" near the end of the request lifecycle. 861 const transcodeHint = setTimeout(() => { 862 if (item.status === 'uploading') setItemStatus(item, 'transcoding'); 863 }, 1500); 864 865 try { 866 const res = await fetch('/admin/audio/upload', { 867 method: 'POST', 868 headers: { 'Accept': 'application/json' }, 869 body: fd, 870 credentials: 'same-origin', 871 }); 872 clearTimeout(transcodeHint); 873 874 // Server responds with JSON for our Accept header. If it didn't 875 // (e.g. session expired and got an HTML login page), surface that. 876 let data; 877 try { data = await res.json(); } 878 catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); } 879 880 if (!res.ok || !data.ok) { 881 throw new Error(data.error || ('HTTP ' + res.status)); 882 } 883 884 setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>')); 885 } catch (err) { 886 clearTimeout(transcodeHint); 887 throw err; 888 } 889 } 890 891 // ── Click-to-copy embed codes ───────────────────────────────── 892 document.querySelectorAll('[data-copy]').forEach(el => { 893 el.addEventListener('click', async () => { 894 const text = el.dataset.copy; 895 try { 896 await navigator.clipboard.writeText(text); 897 el.classList.add('is-copied'); 898 const original = el.textContent; 899 el.textContent = '✓ <%= t('aaud.copied') %>'; 900 setTimeout(() => { 901 el.classList.remove('is-copied'); 902 el.textContent = original; 903 }, 1200); 904 } catch (_) { /* fall through — selection still works */ } 905 }); 906 }); 907 908 // ── "+ Track zonder audio": maak een link-only stub + open de editor ── 909 const addLinkBtn = document.getElementById('add-link-track-btn'); 910 if (addLinkBtn) { 911 addLinkBtn.addEventListener('click', async () => { 912 addLinkBtn.disabled = true; 913 try { 914 const r = await fetch('/admin/audio/create-link', { 915 method: 'POST', credentials: 'same-origin', 916 headers: { 'Content-Type': 'application/json' }, 917 body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }), 918 }); 919 const j = await r.json(); 920 if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>'); 921 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; } 922 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() }); 923 } catch (err) { 924 alert('<%= t('aaud.create_failed') %>: ' + err.message); 925 } finally { 926 addLinkBtn.disabled = false; 927 } 928 }); 929 } 930 931 // ── Wire all "Edit" buttons to the track-editor modal ───────── 932 // After save we patch the row in-place rather than reloading, 933 // so the user keeps their scroll position on long lists. 934 document.querySelectorAll('[data-track-edit]').forEach(btn => { 935 btn.addEventListener('click', () => { 936 if (typeof window.openTrackEditor !== 'function') { 937 alert('<%= t('aaud.editor_not_loaded') %>'); 938 return; 939 } 940 const id = btn.dataset.id; 941 window.openTrackEditor({ 942 id, 943 onSaved: (track) => { 944 const row = document.querySelector('li[data-track-id="' + id + '"]'); 945 if (!row) return; 946 // Update visible cells 947 const titleEl = row.querySelector('[data-cell="title"]'); 948 const artistEl = row.querySelector('[data-cell="artist"]'); 949 const albumEl = row.querySelector('[data-cell="album"]'); 950 if (titleEl) titleEl.textContent = track.title || '<%= t('aaud.untitled') %>'; 951 if (artistEl) artistEl.textContent = track.artist || '—'; 952 if (albumEl) { 953 albumEl.textContent = track.album || ''; 954 if (track.album) albumEl.removeAttribute('hidden'); 955 else albumEl.setAttribute('hidden', ''); 956 } 957 // Update cover thumb (replace element if type changed) 958 const oldThumb = row.querySelector('[data-cover-thumb]'); 959 if (oldThumb) { 960 const parent = oldThumb.parentElement; 961 if (track.cover_url) { 962 const img = document.createElement('img'); 963 img.className = 'ax-track-cover'; 964 img.src = track.cover_url; 965 img.alt = ''; 966 img.dataset.coverThumb = ''; 967 parent.replaceChild(img, oldThumb); 968 } else { 969 const sp = document.createElement('span'); 970 sp.className = 'ax-track-cover ax-track-cover-empty'; 971 sp.textContent = '♫'; 972 sp.dataset.coverThumb = ''; 973 parent.replaceChild(sp, oldThumb); 974 } 975 } 976 }, 977 }); 978 }); 979 }); 980 981 // Download-voor-email toggle — AJAX (geen pagina-reload meer). 982 document.querySelectorAll('[data-track-dl]').forEach(btn => { 983 btn.addEventListener('click', async () => { 984 if (btn.disabled) return; 985 const want = btn.dataset.on === '1' ? 0 : 1; 986 btn.disabled = true; 987 try { 988 const res = await fetch('/admin/audio/api/' + btn.dataset.id, { 989 method: 'POST', 990 headers: { 'Content-Type': 'application/json' }, 991 body: JSON.stringify({ downloadable: !!want }), 992 }); 993 if (!res.ok) throw new Error('HTTP ' + res.status); 994 btn.dataset.on = String(want); 995 btn.style.color = want ? 'var(--accent,#6b8f71)' : ''; 996 btn.style.opacity = want ? '1' : '.5'; 997 btn.title = want 998 ? '<%= t('aaud.dl_on') %>' 999 : '<%= t('aaud.dl_off') %>'; 1000 } catch (e) { 1001 alert('<%= t('aaud.change_failed') %>: ' + (e.message || e)); 1002 } finally { 1003 btn.disabled = false; 1004 } 1005 }); 1006 }); 1007 })(); 1008 </script> 575 <%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 576 <%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %> -
src/views/pages/admin-epk.ejs
rf85b2c3 r952baf3 90 90 .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; } 91 91 </style> 92 <script> 93 (function () { 94 var box = document.querySelector('.epk-track-pick'); 95 if (!box) return; 96 var max = parseInt(box.dataset.max, 10) || 5; 97 function sync() { 98 var checks = box.querySelectorAll('input[type=checkbox]'); 99 var n = box.querySelectorAll('input[type=checkbox]:checked').length; 100 checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); }); 101 } 102 box.addEventListener('change', sync); 103 sync(); 104 })(); 105 </script> 92 <%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %> -
src/views/pages/admin-help.ejs
rf85b2c3 r952baf3 145 145 .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; } 146 146 </style> 147 <script> 148 (function () { 149 var q = document.getElementById('hb-q'); 150 var items = [].slice.call(document.querySelectorAll('.hb-item')); 151 var empty = document.getElementById('hb-empty'); 152 var count = document.getElementById('hb-count'); 153 if (!q) return; 154 // Bewaar de originele tekst per item (voor highlight-reset). 155 items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); }); 156 function run() { 157 var term = q.value.trim().toLowerCase(); 158 var shown = 0; 159 items.forEach(function (it) { 160 var hit = !term || it._txt.indexOf(term) >= 0; 161 it.classList.toggle('hb-hidden', !hit); 162 if (hit) shown++; 163 }); 164 empty.hidden = shown !== 0; 165 count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : ''; 166 } 167 q.addEventListener('input', run); 168 })(); 169 </script> 147 <%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %> -
src/views/pages/admin-media.ejs
rf85b2c3 r952baf3 41 41 </div> 42 42 43 <script> 44 (function () { 45 if (window.__mediaWired) return; window.__mediaWired = true; 46 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>; 47 document.addEventListener('click', function (e) { 48 var c = e.target.closest('[data-copy]'); 49 if (c) { 50 var u = location.origin + c.getAttribute('data-copy'); 51 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 52 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 53 else window.prompt('URL', u); 54 return; 55 } 56 var d = e.target.closest('[data-del]'); 57 if (d) { 58 if (!window.confirm(T.delC)) return; 59 fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 60 .then(function (r) { return r.json(); }) 61 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); }) 62 .catch(function () { window.alert('Error'); }); 63 return; 64 } 65 if (e.target.closest('#media-cleanup')) { 66 if (!window.confirm(T.cleanC)) return; 67 fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' }) 68 .then(function (r) { return r.json(); }) 69 .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); }) 70 .catch(function () { window.alert('Error'); }); 71 } 72 }); 73 })(); 74 </script> 43 <%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 44 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %> -
src/views/pages/admin-paid.ejs
rf85b2c3 r952baf3 72 72 </div> 73 73 74 <script> 75 (function () { 76 var field = document.getElementById('pd-redirect'); 77 var btn = document.getElementById('pd-copy'); 78 if (!field || !btn) return; 79 field.addEventListener('focus', function () { field.select(); }); 80 btn.addEventListener('click', function () { 81 field.select(); 82 var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); }; 83 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); } 84 else { try { document.execCommand('copy'); done(); } catch (e) {} } 85 }); 86 })(); 87 </script> 74 <%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %> -
src/views/pages/admin-playlists.ejs
rf85b2c3 r952baf3 222 222 </style> 223 223 224 <script> 225 (function() { 226 const csrf = '<%= _csrf %>'; 227 228 document.getElementById('pl-new-btn')?.addEventListener('click', () => { 229 if (typeof window.openPlaylistEditor === 'function') { 230 window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() }); 231 } 232 }); 233 234 // Click-to-copy on shortcodes 235 document.querySelectorAll('[data-copy]').forEach(el => { 236 el.addEventListener('click', async () => { 237 try { 238 await navigator.clipboard.writeText(el.dataset.copy); 239 el.classList.add('is-copied'); 240 const original = el.textContent; 241 el.textContent = '✓ <%= t('apl.copied') %>'; 242 setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200); 243 } catch (_) { /* fall back to selection */ } 244 }); 245 }); 246 247 document.querySelectorAll('[data-pl-edit]').forEach(btn => { 248 btn.addEventListener('click', () => { 249 if (typeof window.openPlaylistEditor === 'function') { 250 window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() }); 251 } 252 }); 253 }); 254 255 document.querySelectorAll('[data-pl-delete]').forEach(btn => { 256 btn.addEventListener('click', async () => { 257 const id = btn.dataset.id; 258 const title = btn.dataset.title || id; 259 if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return; 260 try { 261 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, { 262 method: 'POST', 263 headers: { 'X-CSRF-Token': csrf }, 264 credentials: 'same-origin', 265 }); 266 const j = await r.json(); 267 if (j.ok) location.reload(); 268 else alert('<%= t('apl.delete_failed') %>: ' + (j.error || '')); 269 } catch (err) { 270 alert('<%= t('apl.delete_failed') %>: ' + err.message); 271 } 272 }); 273 }); 274 275 // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor. 276 // openPlaylistEditor is defined synchronously by the included partial, so 277 // it's available by the time this IIFE runs. 278 (function deepLinkEdit() { 279 const params = new URLSearchParams(location.search); 280 const editId = params.get('edit'); 281 if (!editId) return; 282 if (typeof window.openPlaylistEditor !== 'function') return; 283 // Strip the query param immediately so reload after save doesn't re-open. 284 history.replaceState({}, '', location.pathname); 285 window.openPlaylistEditor({ 286 mode: 'edit', 287 id: editId, 288 onSaved: () => location.reload(), 289 }); 290 })(); 291 })(); 292 </script> 224 <%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 225 <%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %> -
src/views/pages/admin-push.ejs
rf85b2c3 r952baf3 58 58 i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') }, 59 59 }).replace(/</g, '\\u003c') %></script> 60 <script> 61 (function () { 62 var cfg = JSON.parse(document.getElementById('np-data').textContent); 63 var elState = document.getElementById('np-state'); 64 var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test'); 65 var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved'); 66 var currentEndpoint = null; 67 68 var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent); 69 var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; 70 if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false; 71 72 if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { 73 document.getElementById('np-unsupported').hidden = false; 74 elState.textContent = cfg.i18n.unsupported; 75 return; 76 } 77 78 function b64ToU8(s) { 79 var pad = '='.repeat((4 - (s.length % 4)) % 4); 80 var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/')); 81 var out = new Uint8Array(raw.length); 82 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); 83 return out; 84 } 85 function post(url, body) { 86 return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); 87 } 88 function deviceLabel() { 89 var ua = navigator.userAgent; 90 var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser'; 91 var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : ''; 92 return (browser + (os ? ' op ' + os : '')); 93 } 94 function readAlertBoxes() { 95 var out = {}; 96 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; }); 97 return out; 98 } 99 function setAlertBoxes(alerts) { 100 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { 101 cb.checked = !!alerts[cb.getAttribute('data-alert')]; 102 }); 103 } 104 105 function render(sub) { 106 currentEndpoint = sub ? sub.endpoint : null; 107 elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off); 108 btnOn.hidden = !!sub || Notification.permission === 'denied'; 109 btnOff.hidden = !sub; 110 btnTest.hidden = !sub; 111 alertsBox.hidden = !sub; 112 } 113 114 navigator.serviceWorker.ready.then(function (reg) { 115 return reg.pushManager.getSubscription(); 116 }).then(function (sub) { 117 // Show this device's SAVED prefs when we know them, defaults otherwise. 118 setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts); 119 render(sub); 120 }).catch(function () { elState.textContent = cfg.i18n.unknown; }); 121 122 btnOn.addEventListener('click', function () { 123 btnOn.disabled = true; 124 Notification.requestPermission().then(function (perm) { 125 if (perm !== 'granted') { btnOn.disabled = false; render(null); return; } 126 navigator.serviceWorker.ready.then(function (reg) { 127 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) }); 128 }).then(function (sub) { 129 return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() }) 130 .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); }); 131 }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; }); 132 }); 133 }); 134 135 btnOff.addEventListener('click', function () { 136 navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) { 137 if (!sub) { render(null); return; } 138 var ep = sub.endpoint; 139 sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); }) 140 .then(function () { location.reload(); }); 141 }); 142 }); 143 144 btnTest.addEventListener('click', function () { 145 btnTest.disabled = true; 146 post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); }); 147 }); 148 149 alertsBox.addEventListener('change', function () { 150 if (!currentEndpoint) return; 151 post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) { 152 if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); } 153 }); 154 }); 155 156 document.querySelectorAll('.np-remove').forEach(function (btn) { 157 btn.addEventListener('click', function () { 158 post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); }); 159 }); 160 }); 161 })(); 162 </script> 60 <%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %> 163 61 <% } %> -
src/views/pages/admin-settings.ejs
rf85b2c3 r952baf3 27 27 <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div> 28 28 </form> 29 <script> 30 (function () { 31 var f = document.getElementById('mode-form'); 32 if (!f || f.__age18wired) return; f.__age18wired = true; 33 f.addEventListener('submit', function (e) { 34 var sel = f.querySelector('input[name=mode]:checked'); 35 if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') { 36 if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); 37 } 38 }); 39 })(); 40 </script> 29 <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %> 41 30 </section> 42 31 -
src/views/pages/admin-site-edit.ejs
rf85b2c3 r952baf3 126 126 <%# Live preview: selecting an accent / theme / palette re-themes this page 127 127 instantly (reverts on reload; persists on save). %> 128 <script> 129 (function(){ 130 if (window.__themePreviewWired) return; window.__themePreviewWired = true; 131 var html = document.documentElement; 132 function applyAccent(hex){ 133 if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return; 134 var sa = document.getElementById('pcms-site-accent'); 135 if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); } 136 sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}'; 137 } 138 document.addEventListener('change', function(e){ 139 var t = e.target; if(!t || !t.name) return; 140 if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); } 141 else if(t.name === 'accent'){ applyAccent(t.value); } 142 else if(t.name === 'theme_override'){ 143 if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); } 144 else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); } 145 } 146 }); 147 })(); 148 </script> 128 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %> 149 129 150 130 <fieldset> … … 275 255 </div> 276 256 277 <script> 278 (function() { 279 // Slug URL field: show the real host as a dimmed prefix (the slug itself is 280 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>. 281 var sh = document.getElementById('slug-host'); 282 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); 283 })(); 284 (function() { 285 // Profile-links repeater: add row from <template>, remove on click. 286 var rows = document.getElementById('profile-links-rows'); 287 var tpl = document.getElementById('profile-link-template'); 288 var add = document.getElementById('profile-link-add'); 289 if (!rows || !tpl || !add) return; 290 291 add.addEventListener('click', function() { 292 var clone = tpl.content.cloneNode(true); 293 rows.appendChild(clone); 294 }); 295 rows.addEventListener('click', function(e) { 296 if (e.target && e.target.classList.contains('pl-remove')) { 297 var row = e.target.closest('.profile-link-row'); 298 if (row) row.remove(); 299 } 300 }); 301 })(); 302 303 // P63 — Profile photo picker: upload via /admin/sites/upload-photo, 304 // then write the returned URL into the visible input. Live thumb preview. 305 (function() { 306 var picker = document.getElementById('photo-picker'); 307 if (!picker) return; 308 var thumb = document.getElementById('photo-thumb'); 309 var preview = document.getElementById('photo-preview'); 310 var urlEl = document.getElementById('photo-url'); 311 var trigger = document.getElementById('photo-upload-trigger'); 312 var clear = document.getElementById('photo-clear'); 313 var field = document.getElementById('photo-upload-field'); 314 var status = document.getElementById('photo-status'); 315 316 function showPreview(url) { 317 if (url) { 318 preview.src = url; 319 preview.hidden = false; 320 thumb.removeAttribute('data-empty'); 321 clear.hidden = false; 322 } else { 323 preview.src = ''; 324 preview.hidden = true; 325 thumb.setAttribute('data-empty', ''); 326 clear.hidden = true; 327 } 328 } 329 330 if (urlEl) { 331 urlEl.addEventListener('input', function() { 332 showPreview(urlEl.value.trim()); 333 }); 334 } 335 336 if (trigger && field) { 337 trigger.addEventListener('click', function() { field.click(); }); 338 field.addEventListener('change', async function() { 339 var file = field.files && field.files[0]; 340 if (!file) return; 341 status.classList.remove('is-error'); 342 status.textContent = 'Uploaden…'; 343 try { 344 var fd = new FormData(); 345 fd.append('photo', file); 346 var r = await fetch('/admin/sites/upload-photo', { 347 method: 'POST', 348 body: fd, 349 credentials: 'same-origin', 350 }); 351 var j = await r.json(); 352 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')')); 353 urlEl.value = j.url; 354 showPreview(j.url); 355 status.textContent = 'Geüpload ✓'; 356 setTimeout(function() { status.textContent = ''; }, 2000); 357 } catch (e) { 358 status.classList.add('is-error'); 359 status.textContent = 'Mislukt: ' + e.message; 360 } finally { 361 field.value = ''; 362 } 363 }); 364 } 365 366 if (clear) { 367 clear.addEventListener('click', function() { 368 urlEl.value = ''; 369 showPreview(''); 370 status.textContent = ''; 371 // Note: doesn't delete the file from disk — saving the form with empty 372 // URL leaves the file orphaned on the server. Acceptable for now. 373 }); 374 } 375 })(); 376 </script> 257 377 258 378 259 <style> -
src/views/pages/admin-videos.ejs
rf85b2c3 r952baf3 40 40 </div> 41 41 42 <script> 43 (function () { 44 if (window.__videosWired) return; window.__videosWired = true; 45 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>; 46 document.addEventListener('click', function (e) { 47 var c = e.target.closest('[data-copy]'); 48 if (c) { 49 var u = location.origin + c.getAttribute('data-copy'); 50 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 51 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 52 else window.prompt('URL', u); 53 return; 54 } 55 var d = e.target.closest('[data-del]'); 56 if (d) { 57 if (!window.confirm(T.delC)) return; 58 fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 59 .then(function (r) { return r.json(); }) 60 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } }) 61 .catch(function () {}); 62 } 63 }); 64 })(); 65 </script> 42 <%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 43 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %> -
src/views/pages/auth-register.ejs
rf85b2c3 r952baf3 41 41 .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; } 42 42 </style> 43 <script> 44 (function () { 45 var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle'); 46 if (!u || !out) return; 47 function upd() { 48 var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username'; 49 out.textContent = '@' + v + '@' + location.host; 50 } 51 u.addEventListener('input', upd); 52 upd(); 53 })(); 54 </script> 43 <%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %> -
src/views/pages/authorize-interaction.ejs
rf85b2c3 r952baf3 12 12 <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p> 13 13 </div> 14 <script> 15 (function () { 16 if (window.__fediBmWired) return; window.__fediBmWired = true; 17 var a = document.getElementById('fedi-bm-btn'); if (!a) return; 18 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 19 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 20 })(); 21 </script> 14 <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %> 22 15 <% if (!manage.length) { %> 23 16 <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p> … … 56 49 <% }); %> 57 50 </ul> 58 <script> 59 (function () { 60 if (window.__fediEditWired) return; window.__fediEditWired = true; 61 document.addEventListener('click', function (e) { 62 var b = e.target.closest && e.target.closest('.fedi-edit-btn'); 63 if (!b) return; 64 var li = b.closest('.fedi-manage-item'); if (!li) return; 65 var form = li.querySelector('.fedi-edit-form'); if (!form) return; 66 var open = form.classList.toggle('is-open'); 67 b.classList.toggle('is-open', open); 68 b.setAttribute('aria-expanded', open ? 'true' : 'false'); 69 if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); } 70 }); 71 })(); 72 </script> 51 73 52 <% } %> 74 53 <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p> … … 331 310 </style> 332 311 333 <script> 334 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */ 335 (function(){ 336 if (window.__fediReactWired) return; window.__fediReactWired = true; 337 document.addEventListener('submit', function(e){ 338 var f = e.target.closest && e.target.closest('.fedi-react-form'); 339 if (!f) return; 340 e.preventDefault(); 341 var btn = f.querySelector('button'); if (!btn || btn.disabled) return; 342 btn.disabled = true; 343 var body = new URLSearchParams(); 344 new FormData(f).forEach(function(v, k){ body.append(k, v); }); 345 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) 346 .then(function(r){ return r.ok ? r.json() : null; }) 347 .then(function(j){ 348 if (j) { 349 var on = !!j.on; 350 btn.classList.toggle('is-on', on); 351 var lbl = btn.querySelector('.fedi-bigact-label'); 352 if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent); 353 } 354 }) 355 .catch(function(){}) 356 .then(function(){ btn.disabled = false; }); 357 }); 358 })(); 359 </script> 312 -
src/views/pages/download.ejs
rf85b2c3 r952baf3 26 26 <p class="dl-sub"><%= _ready_sub %></p> 27 27 <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p> 28 <script> 29 // Auto-start de download (zelfde-origin attachment-link). 30 setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600); 31 </script> 28 <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 32 29 <% } else { %> 33 30 <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1> … … 57 54 .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; } 58 55 </style> 56 <%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %> -
src/views/pages/messages.ejs
rf85b2c3 r952baf3 2 2 <%- include('../partials/fedi-tabs', { active: 'berichten' }) %> 3 3 <h1 class="msg-title"><%= t('msg.title') %></h1> 4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %>5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error%></div><% } %>4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %> 5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %> 6 6 7 7 <%# FEP-633c: a pending guardianship offer is a special message: the kid … … 25 25 26 26 <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. %> 27 30 <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>29 31 <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button> 30 32 <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button> 31 33 <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>33 34 </div> 34 35 <div class="msg-search"> … … 59 60 </div> 60 61 61 <script> 62 (function () { 63 if (window.__msgWired) return; window.__msgWired = true; 64 65 // Filtering: kind chip AND free-text search, combined in JS (search over the 66 // sender and the rendered message text). Empty search + "all" = show all. 67 var list = document.querySelector('.msg-list'); 68 var noMatch = document.querySelector('.msg-nomatch'); 69 var q = document.getElementById('msg-q'); 70 var kind = 'all'; 71 var items = []; 72 function indexItem(li) { 73 // Index once: sender + message body + linked post title + poll text. 74 var body = li.querySelector('.msg-content'); 75 var post = li.querySelector('.msg-post'); 76 var poll = li.querySelector('.msg-poll'); 77 li._search = ((li.getAttribute('data-who') || '') + ' ' + 78 (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' + 79 (poll ? poll.textContent : '')).toLowerCase(); 80 } 81 // Re-collect + index; called on load and after each "Load more" append so new 82 // items join the filter/search (and inherit the active chip via apply()). 83 function reindex() { 84 items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : []; 85 items.forEach(function (li) { if (!li._search) indexItem(li); }); 86 } 87 reindex(); 88 function apply() { 89 if (!list) return; 90 var term = (q && q.value || '').trim().toLowerCase(); 91 var shown = 0; 92 items.forEach(function (li) { 93 var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) && 94 (!term || li._search.indexOf(term) !== -1); 95 li.style.display = ok ? '' : 'none'; 96 if (ok) shown++; 97 }); 98 if (noMatch) noMatch.hidden = shown !== 0; 99 } 100 document.addEventListener('click', function (e) { 101 var chip = e.target.closest('.msg-chip'); if (!chip) return; 102 kind = chip.getAttribute('data-show'); 103 document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); }); 104 apply(); 105 }); 106 if (q) q.addEventListener('input', apply); 107 // After a "Load more" append (htmx), index the new rows and re-apply the filter. 108 document.body.addEventListener('htmx:afterSettle', function (e) { 109 if (e.target && e.target.id === 'msg-list') { reindex(); apply(); } 110 }); 111 112 var a = document.getElementById('fedi-bm-btn'); 113 if (a) { 114 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 115 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 116 } 117 })(); 118 </script> 62 <%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js. 63 Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een 64 link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op 65 body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %> 119 66 120 67 <style> … … 138 85 139 86 .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; } 87 140 88 141 89 .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start; … … 148 96 background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); } 149 97 .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 } 150 172 151 173 .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%; -
src/views/pages/news.ejs
rf85b2c3 r952baf3 41 41 .tl-paste-go { background: var(--accent); color: var(--paper); } 42 42 </style> 43 <script> 44 (function () { 45 if (window.__tlPasteWired) return; window.__tlPasteWired = true; 46 function modal() { return document.getElementById('tl-paste'); } 47 function openM() { 48 var m = modal(); if (!m) return; 49 var inp = m.querySelector('.tl-paste-input'); 50 m.classList.add('is-open'); 51 if (inp) { 52 inp.value = ''; inp.focus(); 53 if (navigator.clipboard && navigator.clipboard.readText) { 54 navigator.clipboard.readText().then(function (t) { 55 t = (t || '').trim(); 56 if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); } 57 }).catch(function () {}); 58 } 59 } 60 } 61 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 62 function go() { 63 var m = modal(); if (!m) return; 64 var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim(); 65 if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; } 66 location.href = '/authorize_interaction?uri=' + encodeURIComponent(v); 67 } 68 document.addEventListener('click', function (e) { 69 if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; } 70 if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; } 71 if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; } 72 var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM(); 73 }); 74 document.addEventListener('keydown', function (e) { 75 var m = document.querySelector('.tl-paste.is-open'); if (!m) return; 76 if (e.key === 'Escape') closeM(); 77 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); } 78 }); 79 })(); 80 </script> 43 <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 81 44 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %> 82 45 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %> … … 184 147 .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); } 185 148 </style> 186 187 <script> 188 (function(){ 189 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true; 190 var current = null, currentBtn = null; 191 function close(){ if (current) { current.remove(); current = null; currentBtn = null; } } 192 function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } } 193 function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; } 194 document.addEventListener('click', function(e){ 195 if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; } 196 if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; 197 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn'); 198 if (b) { 199 e.preventDefault(); 200 if (currentBtn === b) { close(); return; } 201 close(); 202 var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||''; 203 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">→</button><button type="button" class="fedi-remote-cancel" aria-label="x">×</button>'; 204 var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social'; 205 try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){} 206 document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select(); 207 return; 208 } 209 if (current) close(); 210 }); 211 document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); }); 212 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); }); 213 window.addEventListener('scroll', close, true); 214 })(); 215 216 /* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF; 217 longer real videos keep their controls. Decided on the actual duration once metadata loads. */ 218 (function(){ 219 document.querySelectorAll('.tl-media-video').forEach(function(v){ 220 if (v.dataset.gifWired) return; v.dataset.gifWired = '1'; 221 var decide = function(){ 222 if (v.duration && v.duration <= 30) { 223 v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){}); 224 } 225 }; 226 if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true }); 227 }); 228 })(); 229 230 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */ 231 (function(){ 232 if (window.__newsReactWired) return; window.__newsReactWired = true; 233 document.addEventListener('submit', function(e){ 234 var f = e.target.closest && e.target.closest('.tl-react-form'); 235 if (!f) return; 236 e.preventDefault(); 237 var btn = f.querySelector('button'); if (!btn || btn.disabled) return; 238 btn.disabled = true; 239 var body = new URLSearchParams(); 240 new FormData(f).forEach(function(v, k){ body.append(k, v); }); 241 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) 242 .then(function(r){ return r.ok ? r.json() : null; }) 243 .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); }) 244 .catch(function(){}) 245 .then(function(){ btn.disabled = false; }); 246 }); 247 })(); 248 </script> 249 250 <script> 251 // Collapse long post bodies to a max height with a "read more" toggle — only when the content 252 // actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag 253 // prevents double-wiring. 254 (function(){ 255 var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>'; 256 document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){ 257 c.setAttribute('data-rm', '1'); 258 if (c.scrollHeight > 360) { 259 c.classList.add('tl-clamp'); 260 var b = document.createElement('button'); 261 b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM; 262 b.addEventListener('click', function(){ 263 b.textContent = c.classList.toggle('tl-clamp') ? RM : SL; 264 }); 265 c.insertAdjacentElement('afterend', b); 266 } 267 }); 268 })(); 269 </script> 149 <%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %> -
src/views/pages/paid-gate.ejs
rf85b2c3 r952baf3 31 31 32 32 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 33 <script> 34 (function () { 35 var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>"; 36 var slug = "<%= pgSlug %>"; 37 var hasPatron = <%= _hasPatron ? 'true' : 'false' %>; 38 var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" }; 39 var btn = document.getElementById('pg-unlock'); 40 var status = document.getElementById('pg-status'); 41 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 42 function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); } 43 44 // No WebAuthn here: an assertion is impossible. With a Patreon page there is 45 // already a "Word supporter" button, so hide the (dead) unlock button rather 46 // than turn it into a second "Word supporter". Without one, this IS the button. 47 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 48 if (hasPatron) { btn.style.display = 'none'; } 49 else { btn.textContent = I.join; btn.addEventListener('click', toLink); } 50 return; 51 } 52 53 btn.addEventListener('click', function () { 54 btn.disabled = true; 55 say(I.confirm); 56 fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug)) 57 .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); }) 58 .then(function (data) { 59 return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options }) 60 .then(function (response) { 61 return fetch(base + '/paid/unlock', { 62 method: 'POST', headers: { 'Content-Type': 'application/json' }, 63 body: JSON.stringify({ response: response, blob: data.blob }), 64 }); 65 }); 66 }) 67 .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); }) 68 .then(function (res) { 69 if (res.j && res.j.ok && res.j.redirect) { 70 // Reload the real post page via the one-shot unlock capability, so it 71 // renders through its normal template (layout, styles, audio). 72 location.href = res.j.redirect; 73 } else if (res.status === 403) { 74 toLink(); // no valid passkey yet (or lapsed tier): link via Patreon 75 } else { 76 btn.disabled = false; say(I.failed, true); 77 } 78 }) 79 .catch(function (e) { 80 if (e && e.link) { toLink(); return; } 81 if (e && e.name === 'NotAllowedError') { toLink(); return; } // cancelled / no passkey -> link 82 btn.disabled = false; say(I.error, true); 83 }); 84 }); 85 })(); 86 </script> 33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 87 34 88 35 <style> … … 105 52 .pg-status.is-err { color: #c0392b; } 106 53 </style> 54 <%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %> -
src/views/pages/paid-passkey.ejs
rf85b2c3 r952baf3 12 12 13 13 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 14 <script> 15 (function () { 16 var options = <%- optionsJson %>; 17 var blob = "<%= regBlob %>"; 18 var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" }; 19 var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>"; 20 var btn = document.getElementById('pk-go'); 21 var status = document.getElementById('pk-status'); 22 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 23 24 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 25 btn.disabled = true; 26 say(I.unsupported, true); 27 return; 28 } 29 btn.addEventListener('click', function () { 30 btn.disabled = true; 31 say(I.follow); 32 window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options }) 33 .then(function (response) { 34 return fetch('/paid/register', { 35 method: 'POST', headers: { 'Content-Type': 'application/json' }, 36 body: JSON.stringify({ response: response, blob: blob }), 37 }); 38 }) 39 .then(function (r) { return r.json(); }) 40 .then(function (j) { 41 if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); } 42 else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); } 43 }) 44 .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); }); 45 }); 46 })(); 47 </script> 14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 48 15 49 16 <style> … … 58 25 .pk-status.is-err { color: #c0392b; } 59 26 </style> 27 <%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %> -
src/views/pages/post-edit.ejs
rf85b2c3 r952baf3 272 272 <% } else { %> 273 273 <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p> 274 <script> 275 (function () { 276 var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn'); 277 if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); } 278 })(); 279 </script> 274 <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 280 275 <% } %> 281 <script> 282 (function () { 283 var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw'); 284 // Typing a warning text implies the post is sensitive → auto-tick NSFW. 285 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; 286 cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; }); 287 } 288 })(); 289 </script> 276 290 277 <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen. 291 278 var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; } … … 335 322 </select> 336 323 </div> 337 <script> 338 (function () { 339 var box = document.getElementById('pe-poll-fields'); 340 var tog = document.getElementById('pe-poll-toggle'); 341 var opts = document.getElementById('pe-poll-opts'); 342 var add = document.getElementById('pe-poll-add'); 343 if (!box || !opts) return; 344 if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); } 345 var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || ''; 346 function rows() { return opts.querySelectorAll('.pe-poll-row'); } 347 // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8. 348 function refresh() { 349 var n = rows().length; 350 opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; }); 351 if (add) add.disabled = n >= 8; 352 } 353 function makeRow() { 354 var row = document.createElement('div'); row.className = 'pe-poll-row'; 355 var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH; 356 var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×'; 357 row.appendChild(i); row.appendChild(d); return row; 358 } 359 if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); } 360 if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); } 361 refresh(); 362 })(); 363 </script> 324 364 325 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 365 326 <label class="pe-checkbox"> … … 377 338 </label> 378 339 </div> 379 <script> 380 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); 381 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); 382 </script> 340 383 341 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %> 384 342 <label class="pe-checkbox" style="margin-top:8px"> … … 392 350 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div> 393 351 </div> 394 <script> 395 (function () { 396 var cb = document.getElementById('pe-sched-toggle'); 397 var box = document.getElementById('pe-sched-fields'); 398 if (!cb || !box) return; 399 var inp = document.getElementById('pe-publish-at'); 400 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 401 var pad = function (n) { return String(n).padStart(2, '0'); }; 402 // Offset (ms) between a timezone and UTC at a given moment. 403 function tzOffset(date, tz) { 404 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); 405 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; }); 406 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 407 } 408 // datetime-local "wall time" (in the site zone) → UTC Date. 409 function wallToUtc(wall) { 410 if (!SITE_TZ) return new Date(wall); 411 var guess = new Date(wall + ':00Z').getTime(); 412 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 413 } 414 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 415 function utcToWall(iso) { 416 var d = new Date(iso); if (isNaN(d)) return ''; 417 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 418 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); 419 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; }); 420 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 421 } 422 // Prefill: stored UTC → wall time in the site zone. 423 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); 424 // "Scheduled for" in human-readable time in the site zone. 425 var when = document.getElementById('pe-sched-when'); 426 if (when && when.dataset.iso) { 427 var dw = new Date(when.dataset.iso); 428 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined); 429 } 430 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 431 cb.addEventListener('change', sync); sync(); 432 // On save: wall time in the site zone → UTC-ISO via a hidden field. 433 var form = cb.closest('form'); 434 if (form) { 435 form.addEventListener('submit', function () { 436 if (inp) inp.removeAttribute('name'); 437 var old = form.querySelector('input[data-pa-utc]'); 438 if (old) old.remove(); 439 if (cb.checked && inp && inp.value) { 440 var d2 = wallToUtc(inp.value); 441 if (!isNaN(d2)) { 442 var h = document.createElement('input'); 443 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', ''); 444 h.value = d2.toISOString(); 445 form.appendChild(h); 446 } 447 } 448 }); 449 } 450 })(); 451 </script> 352 452 353 <% } %> 453 354 </div> … … 1281 1182 </style> 1282 1183 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 '&':'&','<':'<','>':'>','"':'"',"'":''' 1952 }[c])); 1953 } 1954 1955 function renderList(filter) { 1956 if (!Array.isArray(tpCache)) return; 1957 const q = (filter || '').trim().toLowerCase(); 1958 const filtered = q 1959 ? tpCache.filter(t => 1960 (t.title || '').toLowerCase().includes(q) || 1961 (t.artist || '').toLowerCase().includes(q)) 1962 : tpCache; 1963 1964 if (!filtered.length) { 1965 tpList.innerHTML = ''; 1966 tpEmpty.textContent = q ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.js_no_tracks_yet') %>'; 1967 tpList.appendChild(tpEmpty); 1968 return; 1969 } 1970 1971 tpList.innerHTML = filtered.map(t => { 1972 const cov = t.cover 1973 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>' 1974 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>'; 1975 const dis = t.playable ? '' : ' aria-disabled="true"'; 1976 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : ''; 1977 return ( 1978 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' + 1979 cov + 1980 '<span class="tp-meta">' + 1981 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' + 1982 sub + 1983 '</span>' + 1984 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' + 1985 '</button>' 1986 ); 1987 }).join(''); 1988 } 1989 1990 async function loadTracks() { 1991 if (Array.isArray(tpCache)) return tpCache; 1992 tpEmpty.textContent = '<%= t('pedit.js_tracks_loading') %>'; 1993 try { 1994 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' }); 1995 const j = await r.json(); 1996 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : []; 1997 } catch (e) { 1998 tpCache = []; 1999 tpEmpty.textContent = '<%= t('pedit.js_tracks_load_fail') %>: ' + e.message; 2000 } 2001 return tpCache; 2002 } 2003 2004 function openPicker() { 2005 tpLastFocus = document.activeElement; 2006 trackPicker.hidden = false; 2007 trackPicker.setAttribute('aria-hidden', 'false'); 2008 document.body.classList.add('tp-locked'); 2009 tpSearch.value = ''; 2010 renderList(''); 2011 // Defer focus so the open animation doesn't get jumped 2012 setTimeout(() => tpSearch.focus(), 30); 2013 } 2014 function closePicker() { 2015 trackPicker.hidden = true; 2016 trackPicker.setAttribute('aria-hidden', 'true'); 2017 document.body.classList.remove('tp-locked'); 2018 if (tpLastFocus && typeof tpLastFocus.focus === 'function') { 2019 try { tpLastFocus.focus(); } catch(_) {} 2020 } 2021 } 2022 2023 trackBtn.addEventListener('click', async () => { 2024 openPicker(); 2025 await loadTracks(); 2026 renderList(tpSearch.value); 2027 }); 2028 2029 // Close: backdrop click, [data-tp-close], or Escape 2030 trackPicker.addEventListener('click', (e) => { 2031 if (e.target.closest('[data-tp-close]')) { 2032 closePicker(); 2033 return; 2034 } 2035 const row = e.target.closest('.tp-row[data-track-id]'); 2036 if (row) { 2037 if (row.getAttribute('aria-disabled') === 'true') return; 2038 const id = row.dataset.trackId; 2039 if (id) { 2040 insertChip('track', id); 2041 closePicker(); 2042 } 2043 } 2044 }); 2045 document.addEventListener('keydown', (e) => { 2046 if (!trackPicker.hidden && e.key === 'Escape') { 2047 e.preventDefault(); 2048 closePicker(); 2049 } 2050 }); 2051 2052 // Live filter 2053 tpSearch.addEventListener('input', () => renderList(tpSearch.value)); 2054 } 2055 2056 // ── Playlist insert (open existing or create new via modal) 2057 const playlistBtn = document.getElementById('insert-playlist-btn'); 2058 if (playlistBtn) { 2059 playlistBtn.addEventListener('click', async () => { 2060 if (typeof window.openPlaylistEditor !== 'function') { 2061 alert('<%= t('pedit.js_playlist_editor_missing') %>'); 2062 return; 2063 } 2064 try { 2065 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' }); 2066 const j = await r.json(); 2067 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) { 2068 const choice = prompt( 2069 '<%= t('pedit.js_playlist_existing') %>\n\n' + 2070 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') + 2071 '\n\n<%= t('pedit.js_playlist_choose') %>' 2072 ); 2073 if (choice && /^\d+$/.test(choice.trim())) { 2074 const idx = parseInt(choice.trim(), 10) - 1; 2075 if (idx >= 0 && idx < j.playlists.length) { 2076 insertChip('playlist', j.playlists[idx].id); 2077 return; 2078 } 2079 } 2080 if (choice === null) return; 2081 } 2082 } catch (_) { /* fall through to create */ } 2083 2084 window.openPlaylistEditor({ 2085 mode: 'create', 2086 onSaved: ({ id }) => insertChip('playlist', id), 2087 }); 2088 }); 2089 } 2090 2091 // ── Post type: segmented control + type-aware panels ────────── 2092 (function () { 2093 const typeInput = document.getElementById('pe-type-input'); 2094 const card = document.querySelector('.pe-type-card'); 2095 if (!typeInput || !card) return; 2096 const seg = card.querySelector('.pe-typeseg'); 2097 const panels = card.querySelectorAll('.pe-type-panel'); 2098 2099 function applyType(tt) { 2100 typeInput.value = tt; 2101 seg.querySelectorAll('.pe-typeseg-btn').forEach(b => { 2102 const on = b.dataset.type === tt; 2103 b.classList.toggle('is-active', on); 2104 b.setAttribute('aria-checked', on ? 'true' : 'false'); 2105 }); 2106 panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); }); 2107 } 2108 seg.addEventListener('click', (e) => { 2109 const btn = e.target.closest('.pe-typeseg-btn'); 2110 if (btn) applyType(btn.dataset.type); 2111 }); 2112 applyType(typeInput.value || 'post'); 2113 2114 // Video URL → [[embed:url]] chip 2115 const vBtn = document.getElementById('pe-video-insert'); 2116 const vUrl = document.getElementById('pe-video-url'); 2117 if (vBtn && vUrl) { 2118 const doInsert = () => { 2119 const url = (vUrl.value || '').trim(); 2120 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 2121 insertChip('embed', url); 2122 vUrl.value = ''; 2123 }; 2124 vBtn.addEventListener('click', doInsert); 2125 vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } }); 2126 } 2127 2128 // Audio: inline upload → transcodes server-side → [[track:id]] chip 2129 const drop = document.getElementById('pe-audio-drop'); 2130 const fileInput = document.getElementById('pe-audio-file'); 2131 const list = document.getElementById('pe-audio-list'); 2132 if (drop && fileInput && list) { 2133 const pick = () => fileInput.click(); 2134 drop.addEventListener('click', pick); 2135 drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); 2136 ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); })); 2137 ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); })); 2138 drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); }); 2139 fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; }); 2140 2141 function clientDuration(f) { 2142 return new Promise((resolve) => { 2143 try { 2144 const u = URL.createObjectURL(f); 2145 const a = document.createElement('audio'); 2146 a.preload = 'metadata'; 2147 a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); }; 2148 a.onerror = () => { URL.revokeObjectURL(u); resolve(null); }; 2149 a.src = u; 2150 } catch (_) { resolve(null); } 2151 }); 2152 } 2153 async function handleFiles(files) { 2154 for (const f of Array.from(files || [])) await uploadOne(f); 2155 } 2156 async function uploadOne(f) { 2157 const li = document.createElement('li'); 2158 li.className = 'pe-audio-item'; 2159 const nameEl = document.createElement('span'); 2160 nameEl.className = 'pe-audio-item-name'; 2161 nameEl.textContent = f.name; 2162 const stateEl = document.createElement('span'); 2163 stateEl.className = 'pe-audio-item-state'; 2164 stateEl.textContent = '⏳ <%= t('pedit.audio_up_busy') %>'; 2165 li.appendChild(nameEl); li.appendChild(stateEl); 2166 list.appendChild(li); 2167 try { 2168 const dur = await clientDuration(f); 2169 const fd = new FormData(); 2170 fd.append('audio', f); 2171 if (dur) fd.append('duration', String(dur)); 2172 const res = await fetch('/admin/audio/upload', { 2173 method: 'POST', body: fd, 2174 headers: { 'Accept': 'application/json' }, 2175 credentials: 'same-origin', 2176 }); 2177 const j = await res.json().catch(() => ({})); 2178 if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status)); 2179 insertChip('track', j.id); 2180 stateEl.textContent = '✓ <%= t('pedit.audio_up_done') %>'; 2181 li.classList.add('is-done'); 2182 } catch (err) { 2183 stateEl.textContent = '✕ <%= t('pedit.audio_up_fail') %>: ' + err.message; 2184 li.classList.add('is-fail'); 2185 } 2186 } 2187 } 2188 })(); 2189 2190 // ── Submit: serialize editor contents into the hidden field 2191 if (form && hiddenField) { 2192 form.addEventListener('submit', () => { 2193 const clone = editor.cloneNode(true); 2194 serializeChips(clone); 2195 hiddenField.value = clone.innerHTML; 2196 }); 2197 } 2198 })(); 2199 </script> 1184 2200 1185 2201 1186 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small … … 2229 1214 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 2230 1215 <% } %> 2231 2232 <script> 2233 (function () { 2234 // Pin: checkbox toggles the hidden rank field (0 = not pinned), 2235 // ▲▼ shifts the position, with a readable description instead of a raw number. 2236 var toggle = document.getElementById('pin-toggle'); 2237 var rank = document.getElementById('pin-rank'); 2238 var pos = document.getElementById('pin-pos'); 2239 var label = document.getElementById('pin-label'); 2240 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 2241 var down = document.getElementById('pin-down'); 2242 if (!toggle || !rank || !pos) return; 2243 2244 function descr(n) { 2245 n = Number(n) || 0; 2246 if (n <= 1) return '<%= t('pedit.pin_top') %>'; 2247 return n + '<%= t('pedit.pin_nth_suffix') %>'; 2248 } 2249 function render() { 2250 var on = toggle.checked; 2251 pos.hidden = !on; 2252 if (on && Number(rank.value) < 1) rank.value = 1; 2253 if (!on) rank.value = 0; 2254 if (label) label.textContent = on ? descr(rank.value) : ''; 2255 if (up) up.disabled = Number(rank.value) <= 1; 2256 } 2257 toggle.addEventListener('change', render); 2258 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); }); 2259 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); }); 2260 render(); 2261 })(); 2262 2263 (function () { 2264 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible 2265 // obstacles by setting a dynamic bottom offset = the greater of: 2266 // 1) the height of the keyboard area NOT covered by the layout viewport 2267 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks 2268 // due to interactive-widget=resizes-content → offset ≈ 0); 2269 // 2) the height of the playing audio player (fixed, z-index 1000). 2270 // We stick with sticky (no fixed/top tricks → no bar floating in the middle). 2271 var bar = document.querySelector('.pe-actions'); 2272 if (!bar) return; 2273 var vv = window.visualViewport; 2274 function position() { 2275 var ap = document.querySelector('.audio-player'); 2276 var playing = document.body.classList.contains('has-audio-player') && 2277 ap && getComputedStyle(ap).display !== 'none'; 2278 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0; 2279 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0; 2280 var offset = Math.max(audioOffset, kbCovered); 2281 bar.style.bottom = offset ? offset + 'px' : ''; 2282 } 2283 position(); 2284 window.addEventListener('resize', position); 2285 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); } 2286 // has-audio-player is toggled via a body class → observe it. 2287 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 2288 })(); 2289 </script> 1216 <%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %> -
src/views/pages/post.ejs
rf85b2c3 r952baf3 147 147 </section> 148 148 <% } %> 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">→</button>' 176 + '<button type="button" class="fedi-remote-cancel" aria-label="x">×</button>'; 177 var _inp = f.querySelector('input'); 178 _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social'; 179 try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){} 180 document.body.appendChild(f); // floating popover → no layout reflow 181 place(f, b); current = f; currentBtn = b; 182 _inp.focus(); _inp.select(); 183 return; 184 } 185 if (current) close(); // click anywhere else closes it 186 }); 187 document.addEventListener('submit', function(e){ 188 var f = e.target.closest && e.target.closest('.fedi-remote-form'); 189 if (!f) return; e.preventDefault(); 190 go(f.querySelector('input').value, f.dataset.uri); 191 }); 192 document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); }); 193 window.addEventListener('scroll', close, true); 194 })(); 195 </script> 149 <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt 150 door de CSP geweigerd zodra je deze pagina via een link binnen de site opent 151 -- zie shaer-0i6. %> 196 152 197 153 -
src/views/partials/bottom-tab.ejs
rf85b2c3 r952baf3 302 302 </style> 303 303 304 <script> 305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking 306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced 307 // during htmx navigation, so a once-captured reference goes stale 308 // (button did nothing afterwards). Guard against double-wiring. 309 (function() { 310 if (window.__btSearchWired) return; 311 window.__btSearchWired = true; 312 document.addEventListener('click', function(e) { 313 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return; 314 var o = document.getElementById('search-overlay'); 315 if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } 316 }); 317 })(); 318 </script> 304 <%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js). 305 Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx 306 binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %> -
src/views/partials/msg-item.ejs
rf85b2c3 r952baf3 1 <% if (n.type === 'thread') { %> 2 <% 3 var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0; 4 // Een draad is nieuw zodra EEN bericht erin nieuw is. 5 var _tnew = !!_tseen && (n.messages || []).some(function (m) { 6 return m.created_at && Date.parse(m.created_at) > _tseen; 7 }); 8 var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; }); 9 // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan 10 // je schreef, niet een lege naam. 11 var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle; 12 var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you')); 13 %> 14 <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>"> 15 <div class="msg-thread-head"> 16 <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het 17 gesprek, geen kop erboven. Zo houdt de inhoud de ruimte. 18 19 Deze uitsparing is ook de knop die het gesprek in- en uitklapt 20 (Barts aanwijzing): tikken op de tegenpartij is waar je van 21 nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en 22 geen span met een klikhandler, want dan werkt hij ook met het 23 toetsenbord en leest een schermlezer de stand voor. 24 25 Zonder JavaScript doet hij niets, en dan staat het gesprek 26 gewoon open -- dat is ook de stand waar we mee beginnen. %> 27 <button type="button" class="msg-thread-who" aria-expanded="true"> 28 <span class="msg-thread-name"><%= _tlabel %></span> 29 <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %> 30 <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg> 31 </button> 32 <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> 33 <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze 34 link is een antwoord in een lijst niet te plaatsen. %> 35 <% if (n.post) { %> 36 <a class="msg-thread-post" href="/<%= n.post.slug %>"> 37 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> 38 <%= n.post.title || n.post.slug %> 39 </a> 40 <% } %> 41 </div> 42 <ul class="msg-thread-msgs"> 43 <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %> 44 </ul> 45 <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor 46 onder elk gesprek maakt de lijst onleesbaar -- juist het probleem 47 dat deze weergave moest oplossen. De route kiest het pad: een 48 draad aan een post krijgt een reply, een draad aan een persoon een 49 direct bericht. %> 50 <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %> 51 <% if (_kan) { %> 52 <div class="msg-thread-foot"> 53 <details class="msg-reply"> 54 <summary><%= t('fedi.reply') %></summary> 55 <div class="msg-reply-form"> 56 <%- include('../partials/reply-editor', { 57 action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply', 58 hiddenFields: [ 59 { name: 'interaction_id', value: _rt.interactionId || '' }, 60 { name: 'post_slug', value: _rt.postSlug || '' }, 61 { name: 'to', value: _rt.actorUri || '' }, 62 ], 63 placeholder: t('fedi.reply_ph'), 64 submitLabel: t('fedi.reply'), 65 rows: 2, 66 }) %> 67 </div> 68 </details> 69 <%# De zwaai blijft een eigen knop: het is geen antwoord maar een 70 seintje (FEP-633c), en die twee horen niet op een hoop. %> 71 <% if (_rt.actorUri) { %> 72 <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave"> 73 <input type="hidden" name="to" value="<%= _rt.actorUri %>"> 74 <button class="btn small" name="text" value="👋" title="<%= t('msg.wave_back') %>">👋</button> 75 </form> 76 <% } %> 77 </div> 78 <% } %> 79 </li> 80 <% } else { %> 1 81 <% 2 82 var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0; … … 5 85 var _who = n.name || n.handle || ''; 6 86 var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct'); 7 // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies; 8 // Activity = likes/boosts/follows; Moderation = reports; Sent = your 9 // replies; Conversations = every other (public) reply. 10 var _kind = _t === 'sent' ? 'sent' 11 : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 87 // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie = 88 // reports, en al het overige is gesprek. De oude emmers 'msgs' en 89 // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden 90 // samenvielen -- en dat is geen cosmetische hernoeming: een 91 // gespreksregel die om wat voor reden dan ook geen draad werd (geen 92 // post, geen herkenbare tegenpartij) hield anders een data-kind 93 // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip 94 // behalve Alles onzichtbaar. 95 var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 12 96 : _t === 'report' ? 'mod' 13 : (_t === 'mention' || _priv0) ? 'msgs'14 97 : '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); 15 101 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 16 102 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 17 103 %> 18 <li class=" msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>"data-who="<%= String(_who).toLowerCase() %>">104 <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>"> 19 105 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 20 106 <% 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> … … 38 124 <% if (_t === 'sent') { %> 39 125 <span class="msg-who"><%= t('msg.you') %></span> 40 <% if (n.to_handle ) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>126 <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %> 41 127 <% } else { %> 42 128 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a> 43 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %> 129 <% /* De naam blijft: binnen een draad moet je zien WIE wat zei. 130 De handle erachter niet -- die staat in de kop. */ %> 131 <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %> 44 132 <% } %> 45 133 <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> … … 50 138 </div> 51 139 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) { %> 52 144 <div class="msg-what"> 53 145 <% if (n.count && n.count > 1) { %> … … 60 152 <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %> 61 153 <% } 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 %> 62 155 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> 63 156 <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %> … … 65 158 <% } else { %><%= t('notif.replied') %><% } %> 66 159 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 67 <% if (n.post_slug ) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>160 <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 68 161 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 69 162 <% 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><% } %> 70 163 </div> 164 <% } %> 71 165 72 166 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> … … 116 210 </div> 117 211 </li> 212 <% } %> -
src/views/partials/playlist-editor.ejs
rf85b2c3 r952baf3 280 280 </style> 281 281 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 '&':'&','<':'<','>':'>','"':'"',"'":''' 293 }[c])); 294 } 295 function fmtDur(sec) { 296 if (!sec) return ''; 297 const m = Math.floor(sec / 60), s = sec % 60; 298 return `${m}:${String(s).padStart(2, '0')}`; 299 } 300 301 async function api(method, url, body) { 302 const opts = { 303 method, credentials: 'same-origin', 304 headers: { 'X-CSRF-Token': CSRF }, 305 }; 306 if (body !== undefined) { 307 opts.headers['Content-Type'] = 'application/json'; 308 opts.body = JSON.stringify(body); 309 } 310 const r = await fetch(url, opts); 311 return r.json(); 312 } 313 314 /** 315 * Public entry point: open the editor. 316 * opts: { mode: 'create'|'edit', id?, onSaved? } 317 * onSaved is called with { id, playlist } after a successful save. 318 */ 319 window.openPlaylistEditor = async function(opts) { 320 opts = opts || {}; 321 const mode = opts.mode === 'edit' ? 'edit' : 'create'; 322 const isEdit = mode === 'edit'; 323 324 // Load all audio tracks for the picker 325 let tracks = []; 326 try { 327 const j = await api('GET', '/admin/playlists/api/tracks'); 328 if (Array.isArray(j.tracks)) tracks = j.tracks; 329 } catch (e) { 330 alert('Tracks ophalen mislukt'); 331 return; 332 } 333 if (!tracks.length) { 334 alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.'); 335 return; 336 } 337 338 // Existing playlist data when editing 339 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] }; 340 if (isEdit && opts.id) { 341 try { 342 const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id)); 343 if (j.ok) initial = { ...initial, ...j.playlist }; 344 } catch (e) {} 345 } 346 347 // ── DOM ──────────────────────────────────────────────────────── 348 const backdrop = document.createElement('div'); 349 backdrop.className = 'pl-modal-backdrop'; 350 backdrop.innerHTML = ` 351 <div class="pl-modal" role="dialog" aria-label="Playlist editor"> 352 <div class="pl-modal-header"> 353 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3> 354 <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button> 355 </div> 356 <div class="pl-modal-body"> 357 <div class="pl-editor-cols"> 358 <div class="pl-editor-left"> 359 <div class="pl-meta-grid"> 360 <label class="pl-field pl-field-full"> 361 <span>Titel *</span> 362 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}"> 363 </label> 364 <label class="pl-field"> 365 <span>Artiest</span> 366 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}"> 367 </label> 368 <label class="pl-field"> 369 <span>Jaar</span> 370 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}"> 371 </label> 372 <label class="pl-field"> 373 <span>Type</span> 374 <select id="pli-kind"> 375 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option> 376 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option> 377 </select> 378 </label> 379 <div class="pl-field pl-field-full"> 380 <span>Cover</span> 381 <div class="pl-cover-row"> 382 <span class="pl-cover-thumb" id="pli-cover-thumb"> 383 ${initial.cover 384 ? `<img src="${esc(initial.cover)}" alt="">` 385 : `<span class="pl-cover-empty">🎨</span>`} 386 </span> 387 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1"> 388 </div> 389 <div class="pl-cover-upload-row"> 390 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden> 391 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button> 392 <span class="pl-cover-status" id="pli-cover-status"></span> 393 </div> 394 </div> 395 </div> 396 <div class="pl-section-title"> 397 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span> 398 <small>(sleep ⠿ om te ordenen)</small> 399 </div> 400 <div id="pli-selected" class="pl-selected-list"></div> 401 </div> 402 <div class="pl-editor-right"> 403 <div class="pl-section-title">Beschikbare tracks</div> 404 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input"> 405 <div id="pli-available" class="pl-available-list"></div> 406 </div> 407 </div> 408 </div> 409 <div class="pl-modal-footer"> 410 <button type="button" class="btn" id="pli-cancel">Annuleren</button> 411 <button type="button" class="btn btn-primary" id="pli-save" disabled> 412 ${isEdit ? 'Opslaan' : 'Aanmaken'} 413 </button> 414 </div> 415 </div> 416 `; 417 document.body.appendChild(backdrop); 418 419 const $ = sel => backdrop.querySelector(sel); 420 const close = () => backdrop.remove(); 421 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); 422 $('.pl-modal-close').addEventListener('click', close); 423 $('#pli-cancel').addEventListener('click', close); 424 425 // Index tracks by id for fast lookup 426 const trackById = new Map(tracks.map(t => [t.id, t])); 427 let selected = (initial.track_ids || []).filter(id => trackById.has(id)); 428 429 const titleEl = $('#pli-title'); 430 const artistEl = $('#pli-artist'); 431 const yearEl = $('#pli-year'); 432 const kindEl = $('#pli-kind'); 433 const coverEl = $('#pli-cover'); 434 const coverThumb = $('#pli-cover-thumb'); 435 const saveBtn = $('#pli-save'); 436 const selectedEl = $('#pli-selected'); 437 const availEl = $('#pli-available'); 438 const searchEl = $('#pli-search'); 439 const countEl = $('#pli-count'); 440 441 coverEl.addEventListener('input', () => { 442 const u = coverEl.value.trim(); 443 coverThumb.innerHTML = u 444 ? `<img src="${esc(u)}" alt="" data-fallback>` 445 : `<span class="pl-cover-empty">🎨</span>`; 446 }); 447 448 // ── Cover file upload (werkt in create ÉN edit) ─────────── 449 // We uploaden naar het generieke image-endpoint (/posts/upload-image, 450 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id 451 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het 452 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd. 453 const coverFileInput = $('#pli-cover-file'); 454 const coverPickBtn = $('#pli-cover-pick'); 455 const coverStatus = $('#pli-cover-status'); 456 if (coverFileInput && coverPickBtn) { 457 coverPickBtn.addEventListener('click', () => coverFileInput.click()); 458 coverFileInput.addEventListener('change', async (e) => { 459 const file = e.target.files && e.target.files[0]; 460 coverFileInput.value = ''; 461 if (!file) return; 462 if (!/^image\//.test(file.type)) { 463 coverStatus.textContent = 'Alleen afbeeldingen'; 464 coverStatus.className = 'pl-cover-status is-error'; 465 return; 466 } 467 coverStatus.textContent = 'Uploaden…'; 468 coverStatus.className = 'pl-cover-status'; 469 const fd = new FormData(); 470 fd.append('image', file); 471 try { 472 const r = await fetch('/posts/upload-image', 473 { method: 'POST', body: fd, credentials: 'same-origin' } 474 ); 475 const j = await r.json(); 476 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt'); 477 const url = j.url || ''; 478 coverEl.value = url; 479 coverThumb.innerHTML = url 480 ? `<img src="${esc(url)}" alt="">` 481 : `<span class="pl-cover-empty">🎨</span>`; 482 coverStatus.textContent = '✓ Geüpload'; 483 coverStatus.className = 'pl-cover-status is-ok'; 484 } catch (err) { 485 coverStatus.textContent = 'Mislukt: ' + err.message; 486 coverStatus.className = 'pl-cover-status is-error'; 487 } 488 }); 489 } 490 491 function updateSaveBtn() { 492 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0; 493 } 494 495 function renderSelected() { 496 countEl.textContent = selected.length; 497 if (selected.length === 0) { 498 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>'; 499 updateSaveBtn(); 500 return; 501 } 502 selectedEl.innerHTML = selected.map((id, i) => { 503 const t = trackById.get(id); 504 if (!t) return ''; 505 const cover = t.cover 506 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 507 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 508 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}"> 509 <span class="pl-row-handle" aria-label="Verslepen">⠿</span> 510 <span class="pl-row-num">${i + 1}</span> 511 ${cover} 512 <span class="pl-row-info"> 513 <span class="pl-row-title">${esc(t.title)}</span> 514 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''} 515 </span> 516 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button> 517 </div>`; 518 }).join(''); 519 520 selectedEl.querySelectorAll('.pl-row-x').forEach(b => { 521 b.addEventListener('click', () => { 522 selected = selected.filter(x => x !== b.dataset.id); 523 renderSelected(); 524 renderAvailable(); 525 }); 526 }); 527 bindDrag(); 528 updateSaveBtn(); 529 } 530 531 function renderAvailable() { 532 const q = searchEl.value.trim().toLowerCase(); 533 const matches = tracks.filter(t => { 534 if (!q) return true; 535 return (t.title || '').toLowerCase().includes(q) 536 || (t.artist || '').toLowerCase().includes(q); 537 }); 538 if (matches.length === 0) { 539 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>'; 540 return; 541 } 542 availEl.innerHTML = matches.map(t => { 543 const isAdded = selected.includes(t.id); 544 const cls = ['pl-avail-row']; 545 if (isAdded) cls.push('is-added'); 546 if (!t.playable) cls.push('is-unplayable'); 547 const cover = t.cover 548 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 549 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 550 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}> 551 ${cover} 552 <span class="pl-row-info"> 553 <span class="pl-row-title">${esc(t.title)}</span> 554 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''} 555 </span> 556 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span> 557 </div>`; 558 }).join(''); 559 560 availEl.querySelectorAll('.pl-avail-row').forEach(row => { 561 if (row.classList.contains('is-unplayable')) return; 562 row.addEventListener('click', () => { 563 const id = row.dataset.id; 564 if (selected.includes(id)) selected = selected.filter(x => x !== id); 565 else selected.push(id); 566 renderSelected(); 567 renderAvailable(); 568 }); 569 }); 570 } 571 572 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js. 573 function bindDrag() { 574 selectedEl.querySelectorAll('.pl-row').forEach(row => { 575 const handle = row.querySelector('.pl-row-handle'); 576 if (!handle) return; 577 let dragging = false, originalIdx = -1; 578 579 handle.addEventListener('pointerdown', e => { 580 e.preventDefault(); 581 handle.setPointerCapture(e.pointerId); 582 dragging = true; 583 originalIdx = parseInt(row.dataset.pos, 10); 584 row.classList.add('is-dragging'); 585 }); 586 handle.addEventListener('pointermove', e => { 587 if (!dragging) return; 588 e.preventDefault(); 589 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 590 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 591 let targetIdx = -1, above = false; 592 for (let i = 0; i < rows.length; i++) { 593 const r = rows[i]; if (r === row) continue; 594 const rect = r.getBoundingClientRect(); 595 const mid = rect.top + rect.height / 2; 596 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; } 597 if (e.clientY >= rect.top && e.clientY <= rect.bottom) { 598 targetIdx = i; above = e.clientY < mid; break; 599 } 600 } 601 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below'); 602 }); 603 const finish = e => { 604 if (!dragging) return; 605 dragging = false; 606 try { handle.releasePointerCapture(e.pointerId); } catch (_) {} 607 row.classList.remove('is-dragging'); 608 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 609 let targetIdx = -1, above = false; 610 for (let i = 0; i < rows.length; i++) { 611 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; } 612 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; } 613 } 614 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 615 if (targetIdx === -1 || targetIdx === originalIdx) return; 616 const moved = selected[originalIdx]; 617 selected.splice(originalIdx, 1); 618 let newIdx = targetIdx; 619 if (originalIdx < targetIdx) newIdx--; 620 if (!above) newIdx++; 621 newIdx = Math.max(0, Math.min(selected.length, newIdx)); 622 selected.splice(newIdx, 0, moved); 623 renderSelected(); 624 }; 625 handle.addEventListener('pointerup', finish); 626 handle.addEventListener('pointercancel', () => { 627 dragging = false; 628 row.classList.remove('is-dragging'); 629 selectedEl.querySelectorAll('.drop-above, .drop-below') 630 .forEach(r => r.classList.remove('drop-above', 'drop-below')); 631 }); 632 }); 633 } 634 635 titleEl.addEventListener('input', updateSaveBtn); 636 searchEl.addEventListener('input', renderAvailable); 637 renderSelected(); 638 renderAvailable(); 639 640 saveBtn.addEventListener('click', async () => { 641 if (saveBtn.disabled) return; 642 const orig = saveBtn.textContent; 643 saveBtn.disabled = true; 644 saveBtn.textContent = 'Opslaan...'; 645 646 const payload = { 647 title: titleEl.value.trim(), 648 artist: artistEl.value.trim(), 649 year: parseInt(yearEl.value, 10) || 0, 650 cover: coverEl.value.trim(), 651 kind: kindEl.value === 'playlist' ? 'playlist' : 'album', 652 tracks: selected.slice(), 653 }; 654 655 try { 656 const url = isEdit 657 ? '/admin/playlists/api/' + encodeURIComponent(initial.id) 658 : '/admin/playlists/api'; 659 const j = await api('POST', url, payload); 660 if (!j.ok) { 661 alert('Opslaan mislukt: ' + (j.error || 'onbekend')); 662 saveBtn.disabled = false; 663 saveBtn.textContent = orig; 664 return; 665 } 666 const savedId = isEdit ? initial.id : j.id; 667 close(); 668 if (typeof opts.onSaved === 'function') { 669 opts.onSaved({ id: savedId, playlist: payload }); 670 } 671 } catch (err) { 672 alert('Opslaan mislukt: ' + err.message); 673 saveBtn.disabled = false; 674 saveBtn.textContent = orig; 675 } 676 }); 677 678 // ESC to close 679 document.addEventListener('keydown', function onEsc(e) { 680 if (e.key === 'Escape' && document.body.contains(backdrop)) { 681 close(); 682 document.removeEventListener('keydown', onEsc); 683 } 684 }); 685 }; 686 })(); 687 </script> 282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> -
src/views/partials/profile-header.ejs
rf85b2c3 r952baf3 160 160 </div> 161 161 <% } %> 162 <script> 163 (function () { 164 if (window.__pcmsFediFollowWired) return; 165 window.__pcmsFediFollowWired = true; 166 function modal() { return document.querySelector('.pf-follow'); } 167 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 168 function go() { 169 var m = modal(); if (!m) return; 170 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : ''; 171 // Strip a full @user@host handle (and any scheme/path) down to the server host. 172 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 173 if (!server) { if (inp) inp.focus(); return; } 174 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {} 175 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || ''); 176 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor); 177 } 178 document.addEventListener('click', function (e) { 179 var b = e.target.closest && e.target.closest('.profile-fedi-link'); 180 if (b) { e.preventDefault(); 181 var m = modal(); if (!m) return; 182 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || ''); 183 var inp = m.querySelector('.pf-follow-input'); 184 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {} 185 m.classList.add('is-open'); 186 setTimeout(function () { if (inp) inp.focus(); }, 30); 187 return; 188 } 189 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; } 190 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; } 191 var open = document.querySelector('.pf-follow.is-open'); 192 if (open && e.target === open) closeM(); /* click on backdrop */ 193 }); 194 document.addEventListener('keydown', function (e) { 195 var m = document.querySelector('.pf-follow.is-open'); if (!m) return; 196 if (e.key === 'Escape') { closeM(); } 197 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); } 198 }); 199 })(); 200 201 /* Profile summary: click the avatar to open a modal with photo + details. */ 202 (function () { 203 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true; 204 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); } 205 document.addEventListener('click', function (e) { 206 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; } 207 var open = document.querySelector('.pf-summary.is-open'); 208 if (open && e.target === open) { close(); return; } /* click on backdrop */ 209 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]'); 210 if (!t) return; 211 e.preventDefault(); 212 var root = (t.closest && t.closest('#pcms-chrome')) || document; 213 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary'); 214 if (modal) modal.classList.add('is-open'); 215 }); 216 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 217 })(); 218 </script> 162 <%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial 163 zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd 164 (shaer-bqr). %> 219 165 <% } %> 220 166 -
src/views/partials/profile-sheet.ejs
rf85b2c3 r952baf3 105 105 <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> 106 106 <span class="psi-label"><%= t('nav.theme_label') %></span> 107 <span class="psi-meta" id="profile-sheet-theme-state" ><%= t('nav.dark') %></span>107 <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span> 108 108 </button> 109 109 </li> … … 326 326 </style> 327 327 328 <script> 329 (function() { 330 const sheet = document.getElementById('profile-sheet'); 331 if (!sheet) return; 332 333 const backdrop = document.getElementById('profile-sheet-backdrop'); 334 const panel = sheet.querySelector('.profile-sheet-panel'); 335 const closeBtn = document.getElementById('profile-sheet-close'); 336 const dragZone = document.getElementById('profile-sheet-drag-zone'); 337 const themeBtn = document.getElementById('profile-sheet-theme'); 338 const themeLbl = document.getElementById('profile-sheet-theme-state'); 339 340 function openSheet() { 341 sheet.classList.add('is-open'); 342 sheet.setAttribute('aria-hidden', 'false'); 343 document.body.classList.add('profile-sheet-locked'); 344 syncTheme(); 345 } 346 function closeSheet() { 347 sheet.classList.remove('is-open'); 348 sheet.setAttribute('aria-hidden', 'true'); 349 document.body.classList.remove('profile-sheet-locked'); 350 panel.style.removeProperty('--pcms-drag-y'); 351 } 352 353 // Open: any element with [data-profile-sheet-toggle] 354 document.addEventListener('click', function(e) { 355 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 356 if (trigger) { 357 e.preventDefault(); 358 openSheet(); 359 } 360 }); 361 362 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 363 if (backdrop) backdrop.addEventListener('click', closeSheet); 364 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 365 document.addEventListener('keydown', (e) => { 366 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 367 }); 368 369 // Menu items that navigate: close synchronously before nav fires 370 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 371 el.addEventListener('click', closeSheet); 372 }); 373 374 // Drag-down-to-close on touch. 375 // Uses --pcms-drag-y custom property rather than overwriting the panel's 376 // transform string. This composes correctly with any horizontal centering 377 // (currently none here, but the pattern matches audio-sheet for safety). 378 let startY = 0, lastY = 0, dragging = false; 379 function onDown(e) { 380 if (e.pointerType !== 'touch') return; 381 if (panel.scrollTop > 0) return; 382 startY = lastY = e.clientY; 383 dragging = true; 384 panel.classList.add('is-dragging'); 385 } 386 function onMove(e) { 387 if (!dragging) return; 388 lastY = e.clientY; 389 const dy = Math.max(0, lastY - startY); 390 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 391 } 392 function onUp() { 393 if (!dragging) return; 394 dragging = false; 395 panel.classList.remove('is-dragging'); 396 const dy = lastY - startY; 397 if (dy > 80) closeSheet(); 398 else panel.style.removeProperty('--pcms-drag-y'); 399 } 400 if (window.PointerEvent && dragZone) { 401 dragZone.addEventListener('pointerdown', onDown); 402 document.addEventListener('pointermove', onMove); 403 document.addEventListener('pointerup', onUp); 404 document.addEventListener('pointercancel', onUp); 405 } 406 407 // Theme toggle inside sheet — syncs label 408 function syncTheme() { 409 if (!themeLbl) return; 410 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 411 themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>'; 412 } 413 if (themeBtn) { 414 themeBtn.addEventListener('click', function() { 415 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 416 const next = cur === 'dark' ? 'light' : 'dark'; 417 document.documentElement.setAttribute('data-theme', next); 418 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 419 syncTheme(); 420 }); 421 } 422 })(); 423 </script> 328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %> -
src/views/partials/topnav.ejs
rf85b2c3 r952baf3 176 176 177 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>"> 179 182 <div class="container"> 180 183 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> … … 191 194 </div> 192 195 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 {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } 234 function renderSuggest(box, d, q, ov) { 235 var html = ''; 236 function grp(title, items, fmt) { 237 if (!items || !items.length) return; 238 html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>'; 239 } 240 grp(_ST.posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; }); 241 grp(_ST.tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; }); 242 grp(_ST.events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; }); 243 grp(_ST.pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; }); 244 var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length); 245 if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_ST.none) + '</div>'; return; } 246 var action = (ov && ov.getAttribute('data-action')) || '/search'; 247 html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_ST.all) + '</a>'; 248 box.innerHTML = html; 249 } 250 var _sTimer; 251 document.addEventListener('input', function(e) { 252 var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]'); 253 if (!inp) return; 254 var box = document.getElementById('search-suggest'); 255 if (!box) return; 256 var q = inp.value.trim(); 257 clearTimeout(_sTimer); 258 if (q.length < 2) { box.innerHTML = ''; return; } 259 _sTimer = setTimeout(function() { 260 var ov = document.getElementById('search-overlay'); 261 var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest'; 262 fetch(url + '?q=' + encodeURIComponent(q)) 263 .then(function(r){ return r.ok ? r.json() : null; }) 264 .then(function(d){ if (d) renderSuggest(box, d, q, ov); }) 265 .catch(function(){}); 266 }, 200); 267 }); 268 // Click on a suggestion → close the overlay (the link/boost handles navigation). 269 document.addEventListener('click', function(e) { 270 if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; } 271 }); 272 })(); 273 </script> 196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> 274 197 275 198 <style> -
src/views/partials/track-editor.ejs
rf85b2c3 r952baf3 284 284 </style> 285 285 286 <script> 287 (function() { 288 289 function esc(s) { 290 return String(s == null ? '' : s) 291 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 292 .replace(/"/g, '"').replace(/'/g, '''); 293 } 294 295 async function api(method, url, body) { 296 const opts = { method, credentials: 'same-origin', headers: {} }; 297 if (body !== undefined) { 298 if (body instanceof FormData) { 299 opts.body = body; 300 } else { 301 opts.headers['Content-Type'] = 'application/json'; 302 opts.body = JSON.stringify(body); 303 } 304 } 305 const res = await fetch(url, opts); 306 let data; 307 try { data = await res.json(); } 308 catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); } 309 if (!res.ok) data.ok = false; 310 return data; 311 } 312 313 /** 314 * Open the track editor. 315 * @param {object} opts 316 * @param {string} opts.id — track id to edit 317 * @param {function?} opts.onSaved — called with updated track on success 318 */ 319 window.openTrackEditor = async function openTrackEditor({ id, onSaved }) { 320 if (!id) return; 321 let track, albumSuggestions = []; 322 try { 323 const [trackJson, listJson] = await Promise.all([ 324 api('GET', '/admin/audio/api/' + encodeURIComponent(id)), 325 api('GET', '/admin/audio/api/albums'), 326 ]); 327 if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden'); 328 track = trackJson.track; 329 if (listJson.ok && Array.isArray(listJson.albums)) { 330 albumSuggestions = listJson.albums.filter(Boolean); 331 } 332 } catch (err) { 333 alert('Track ophalen mislukt: ' + err.message); 334 return; 335 } 336 337 const backdrop = document.createElement('div'); 338 backdrop.className = 'te-backdrop'; 339 backdrop.innerHTML = ` 340 <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken"> 341 <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div> 342 343 <div class="te-header"> 344 <h3>✎ Track bewerken</h3> 345 <button type="button" class="te-close" aria-label="Sluiten">×</button> 346 </div> 347 348 <div class="te-body"> 349 350 ${track.stream_url ? ` 351 <div class="te-preview"> 352 <div class="te-preview-cover"> 353 ${track.cover_url 354 ? `<img src="${esc(track.cover_url)}" alt="">` 355 : `🎵`} 356 </div> 357 <div class="te-preview-meta"> 358 <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div> 359 <div class="te-preview-sub"> 360 ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''} 361 </div> 362 </div> 363 <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button> 364 </div> 365 ` : ''} 366 367 <div class="te-form"> 368 369 <label class="te-field"> 370 <span>Titel <span class="te-required" aria-hidden="true">*</span></span> 371 <input type="text" id="te-title" maxlength="200" required 372 autocomplete="off" autocapitalize="words" spellcheck="false" 373 value="${esc(track.title || '')}"> 374 </label> 375 376 <div class="te-row te-row-2"> 377 <label class="te-field"> 378 <span>Artiest</span> 379 <input type="text" id="te-artist" maxlength="200" 380 autocomplete="off" autocapitalize="words" spellcheck="false" 381 value="${esc(track.artist || '')}"> 382 </label> 383 <label class="te-field"> 384 <span>Album</span> 385 <input type="text" id="te-album" maxlength="200" 386 autocomplete="off" autocapitalize="words" spellcheck="false" 387 list="te-album-list" value="${esc(track.album || '')}"> 388 <datalist id="te-album-list"> 389 ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')} 390 </datalist> 391 </label> 392 </div> 393 394 <label class="te-field"> 395 <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span> 396 <input type="number" id="te-duration" min="0" step="1" 397 inputmode="numeric" pattern="[0-9]*" 398 value="${track.duration || ''}" placeholder="auto"> 399 </label> 400 401 <div class="te-row te-row-2"> 402 <label class="te-field"> 403 <span>Eigenaar / credit <small>(copyright-houder)</small></span> 404 <div class="te-credit-row"> 405 <input type="text" id="te-credit" maxlength="200" 406 autocomplete="off" spellcheck="false" 407 placeholder="bv. © 2025 Mara Vos" 408 value="${esc(track.credit || '')}"> 409 <button type="button" class="te-sym-btn" id="te-credit-copyr" 410 title="© invoegen" aria-label="Copyright-teken invoegen">©</button> 411 </div> 412 </label> 413 <label class="te-field"> 414 <span>Licentie</span> 415 <input type="text" id="te-license" maxlength="120" 416 autocomplete="off" spellcheck="false" list="te-license-list" 417 placeholder="Alle rechten voorbehouden" 418 value="${esc(track.license || '')}"> 419 <datalist id="te-license-list"> 420 <option value="Alle rechten voorbehouden"></option> 421 <option value="CC BY 4.0"></option> 422 <option value="CC BY-SA 4.0"></option> 423 <option value="CC BY-NC 4.0"></option> 424 <option value="CC BY-NC-SA 4.0"></option> 425 <option value="CC BY-ND 4.0"></option> 426 <option value="CC0 1.0 (publiek domein)"></option> 427 </datalist> 428 </label> 429 </div> 430 431 <div class="te-field"> 432 <span>Open in <small>(links naar dezelfde track elders)</small></span> 433 <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false" 434 placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}"> 435 <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false" 436 placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}"> 437 <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false" 438 placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}"> 439 </div> 440 441 <div class="te-field"> 442 <span>Audiobestand</span> 443 <div class="te-cover-btn-row"> 444 <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden> 445 <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button> 446 <span class="te-cover-status" id="te-audio-status"></span> 447 </div> 448 </div> 449 450 <div class="te-field"> 451 <span>Cover</span> 452 <div class="te-cover-row"> 453 <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button" 454 aria-label="Klik om cover te kiezen"> 455 ${track.cover_url 456 ? `<img src="${esc(track.cover_url)}" alt="">` 457 : `<span class="te-cover-empty">🎨</span>`} 458 </div> 459 <input type="file" id="te-cover-file" 460 accept="image/jpeg,image/png,image/webp,image/gif" hidden> 461 <div class="te-cover-actions"> 462 <div class="te-cover-btn-row"> 463 <button type="button" class="te-cover-btn" id="te-cover-pick"> 464 📷 Foto kiezen 465 </button> 466 ${track.cover_url ? ` 467 <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove"> 468 × Verwijder 469 </button>` : ''} 470 </div> 471 <input type="text" id="te-cover-url" inputmode="url" 472 placeholder="/media/… of https://…" 473 autocomplete="off" autocapitalize="none" spellcheck="false" 474 value="${esc(track.cover_url || '')}"> 475 <div class="te-cover-status" id="te-cover-status"></div> 476 </div> 477 </div> 478 </div> 479 480 </div> 481 </div> 482 483 <div class="te-footer"> 484 <button type="button" class="te-btn" id="te-cancel">Annuleren</button> 485 <div class="te-footer-spacer"></div> 486 <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button> 487 </div> 488 </div> 489 `; 490 491 document.body.appendChild(backdrop); 492 document.body.classList.add('te-modal-open'); 493 494 const $ = sel => backdrop.querySelector(sel); 495 496 // ── Inline preview player (routed through global mini-player) ── 497 // We don't build our own <audio>; instead we tell the global 498 // window.pcmsAudioPlayer to load this single track. Visual state 499 // syncs against the global audio element so toggle works correctly 500 // even if the user pauses from the mini-bar. 501 const previewBtn = $('#te-preview-play'); 502 if (previewBtn) { 503 const setPreviewState = (playing) => { 504 previewBtn.textContent = playing ? '⏸' : '▶'; 505 previewBtn.classList.toggle('is-playing', playing); 506 previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen'); 507 }; 508 const isOurTrack = () => { 509 // audio.src is a blob: URL (Spotify-style playback) — compare against 510 // the player's logical current-track URL instead. 511 const player = window.pcmsAudioPlayer; 512 const cur = player && player.currentTrack(); 513 return !!(cur && track.stream_url && cur.url === track.stream_url); 514 }; 515 const resync = () => { 516 const audio = document.getElementById('audio-element'); 517 const playing = audio && !audio.paused && !audio.ended && isOurTrack(); 518 setPreviewState(!!playing); 519 }; 520 521 previewBtn.addEventListener('click', () => { 522 const player = window.pcmsAudioPlayer; 523 if (!player || !track.stream_url) { 524 console.warn('preview: miniplayer or url missing'); 525 return; 526 } 527 const audio = document.getElementById('audio-element'); 528 if (audio && isOurTrack()) { 529 // Same track loaded — toggle 530 if (audio.paused) player.play(); else player.pause(); 531 } else { 532 player.setQueue([{ 533 url: track.stream_url, 534 title: track.title || '(zonder titel)', 535 artist: track.artist || '', 536 album: track.album || '', 537 cover: track.cover_url || '', 538 }], 0); 539 } 540 }); 541 542 const audio = document.getElementById('audio-element'); 543 if (audio) { 544 const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied']; 545 evs.forEach(ev => audio.addEventListener(ev, resync)); 546 // Detach listeners on close so we don't leak them 547 backdrop._previewCleanup = () => { 548 evs.forEach(ev => audio.removeEventListener(ev, resync)); 549 }; 550 resync(); // initial state 551 } 552 } 553 554 function close() { 555 // Detach our resync listeners (mini-player stays running) 556 if (backdrop._previewCleanup) backdrop._previewCleanup(); 557 document.body.classList.remove('te-modal-open'); 558 document.removeEventListener('keydown', onEsc); 559 backdrop.remove(); 560 } 561 function onEsc(e) { if (e.key === 'Escape') close(); } 562 document.addEventListener('keydown', onEsc); 563 564 // A click on the backdrop does NOT close the editor — too easy to lose edits by 565 // mis-clicking outside. Close deliberately via ×, Cancel or Esc. 566 $('.te-close').addEventListener('click', close); 567 $('#te-cancel').addEventListener('click', close); 568 569 // ── Cover picking + URL paste + drag-drop ──────────────── 570 const thumb = $('#te-cover-thumb'); 571 const fileInput = $('#te-cover-file'); 572 const urlInput = $('#te-cover-url'); 573 const status = $('#te-cover-status'); 574 const pickBtn = $('#te-cover-pick'); 575 const removeBtn = $('#te-cover-remove'); 576 577 function setStatus(text, kind) { 578 status.textContent = text || ''; 579 status.className = 'te-cover-status' + (kind ? ' is-' + kind : ''); 580 } 581 function setThumb(url) { 582 if (url) { 583 thumb.innerHTML = `<img src="${esc(url)}" alt="">`; 584 } else { 585 thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`; 586 } 587 } 588 589 pickBtn.addEventListener('click', () => fileInput.click()); 590 thumb.addEventListener('click', () => fileInput.click()); 591 thumb.addEventListener('keydown', e => { 592 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } 593 }); 594 595 async function uploadCoverFile(file) { 596 if (!file) return; 597 if (!/^image\//.test(file.type)) { 598 setStatus('Alleen afbeeldingen toegestaan', 'error'); return; 599 } 600 setStatus('Uploaden…', null); 601 const fd = new FormData(); 602 fd.append('cover', file); 603 try { 604 const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', { 605 method: 'POST', body: fd, credentials: 'same-origin', 606 }); 607 const j = await r.json(); 608 if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt'); 609 urlInput.value = j.cover_url || ''; 610 setThumb(j.cover_url); 611 setStatus('✓ Geüpload', 'ok'); 612 } catch (err) { 613 setStatus('Mislukt: ' + err.message, 'error'); 614 } 615 } 616 617 fileInput.addEventListener('change', e => { 618 const f = e.target.files && e.target.files[0]; 619 if (f) uploadCoverFile(f); 620 fileInput.value = ''; 621 }); 622 623 // Drag-drop on thumb (desktop nicety) 624 ['dragenter', 'dragover'].forEach(ev => 625 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); })); 626 ['dragleave', 'drop'].forEach(ev => 627 thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); })); 628 thumb.addEventListener('drop', e => { 629 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; 630 if (f) uploadCoverFile(f); 631 }); 632 633 // URL paste auto-preview 634 urlInput.addEventListener('input', () => { 635 const v = urlInput.value.trim(); 636 setThumb(v); 637 }); 638 639 if (removeBtn) { 640 removeBtn.addEventListener('click', () => { 641 urlInput.value = ''; 642 setThumb(''); 643 removeBtn.remove(); 644 }); 645 } 646 647 // ── Replace the audio file of this track ────────────────── 648 const aPick = $('#te-audio-pick'); 649 const aFile = $('#te-audio-file'); 650 const aStatus = $('#te-audio-status'); 651 if (aPick && aFile) { 652 aPick.addEventListener('click', () => aFile.click()); 653 aFile.addEventListener('change', async (e) => { 654 const f = e.target.files && e.target.files[0]; 655 aFile.value = ''; 656 if (!f) return; 657 aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status'; 658 aPick.disabled = true; 659 try { 660 const fd = new FormData(); 661 fd.append('audio', f); 662 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd); 663 if (!j.ok) throw new Error(j.error || 'mislukt'); 664 aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok'; 665 track.stream_url = j.stream_url || track.stream_url; 666 if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; } 667 } catch (err) { 668 aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error'; 669 } finally { aPick.disabled = false; } 670 }); 671 } 672 673 // ── Insert © symbol into the credit field ───────────────── 674 const copyrBtn = $('#te-credit-copyr'); 675 if (copyrBtn) { 676 copyrBtn.addEventListener('click', () => { 677 const inp = $('#te-credit'); 678 if (!inp) return; 679 const sym = '© '; 680 const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length; 681 const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length; 682 inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end); 683 inp.focus(); 684 const pos = start + sym.length; 685 try { inp.setSelectionRange(pos, pos); } catch (e) {} 686 }); 687 } 688 689 // ── Save ──────────────────────────────────────────────── 690 $('#te-save').addEventListener('click', async () => { 691 const titleEl = $('#te-title'); 692 const title = titleEl.value.trim(); 693 if (!title) { 694 titleEl.focus(); 695 alert('Titel is verplicht'); 696 return; 697 } 698 const saveBtn = $('#te-save'); 699 saveBtn.disabled = true; 700 saveBtn.textContent = '⏳ Opslaan…'; 701 702 try { 703 const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), { 704 title, 705 artist: $('#te-artist').value.trim() || null, 706 album: $('#te-album').value.trim() || null, 707 credit: $('#te-credit').value.trim() || null, 708 license: $('#te-license').value.trim() || null, 709 link_spotify: $('#te-link-spotify').value.trim() || null, 710 link_youtube: $('#te-link-youtube').value.trim() || null, 711 link_soundcloud: $('#te-link-soundcloud').value.trim() || null, 712 duration: $('#te-duration').value ? Number($('#te-duration').value) : null, 713 cover_url: urlInput.value.trim() || null, 714 }); 715 if (!j.ok) throw new Error(j.error || 'Opslaan mislukt'); 716 if (typeof onSaved === 'function') onSaved(j.track || { id, title, 717 artist: $('#te-artist').value.trim() || null, 718 album: $('#te-album').value.trim() || null, 719 cover_url: urlInput.value.trim() || null }); 720 close(); 721 } catch (err) { 722 alert('Opslaan mislukt: ' + err.message); 723 saveBtn.disabled = false; 724 saveBtn.textContent = '💾 Opslaan'; 725 } 726 }); 727 728 // ── Auto-duration ─────────────────────────────────────────── 729 // The server already determines duration automatically on upload. This is the 730 // fallback/UX layer: if an admin opens an existing track without a duration, 731 // we read it from the audio metadata and fill the field — so you never need 732 // to type seconds manually. An existing value is never overwritten. We fetch 733 // the bytes via the same header gate as the player (X-Audio-Player). 734 (async function autoDuration() { 735 const durEl = $('#te-duration'); 736 if (!durEl || !track.stream_url) return; 737 if (durEl.value && Number(durEl.value) > 0) return; // already filled → leave it alone 738 let objUrl = null; 739 try { 740 const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } }); 741 if (!r.ok) return; 742 objUrl = URL.createObjectURL(await r.blob()); 743 const probe = new Audio(); 744 probe.preload = 'metadata'; 745 probe.addEventListener('loadedmetadata', () => { 746 if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) { 747 durEl.value = Math.round(probe.duration); 748 } 749 if (objUrl) URL.revokeObjectURL(objUrl); 750 }); 751 probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); }); 752 probe.src = objUrl; 753 } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); } 754 })(); 755 756 // Focus title for fast typing 757 setTimeout(() => $('#te-title').focus(), 60); 758 }; 759 })(); 760 </script> 286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %> -
src/views/shell.ejs
rf85b2c3 r952baf3 339 339 </head> 340 340 341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data- feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>"> 342 342 343 343 <% if (typeof isViewer !== 'undefined' && isViewer) { %> … … 449 449 } 450 450 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 451 457 var next = d.bodyClass; 452 458 if (!next) return; … … 467 473 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere 468 474 // 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); 469 512 })(); 470 513 </script> -
test/authorized-fetch.test.js
rf85b2c3 r952baf3 1 // FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch. 1 // Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn 2 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND 3 // verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en 4 // wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu 5 // bleven daardoor vier accounts eindeloos hangen. 6 // 7 // De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of 8 // er wordt teruggevallen, niet om echte HTTP of crypto. 9 // 10 // Run: npm test 11 2 12 import { test } from 'node:test'; 3 13 import assert from 'node:assert/strict'; 4 14 5 15 process.env.DATABASE_PATH = ':memory:'; 6 process.env.PUBLIC_BASE_URL = 'https:// test.example';16 process.env.PUBLIC_BASE_URL = 'https://klonkt.test'; 7 17 8 18 const dbMod = await import('../src/config/database.js'); 9 19 const db = dbMod.default; 10 20 dbMod.initializeDatabase(); 11 const AP = (await import('../src/services/ActivityPubService.js')).default;21 const AP = await import('../src/services/ActivityPubService.js'); 12 22 13 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god'); 14 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1'); 15 // Commit a guardian relation: kid (ward) is guarded by mom. 16 const MOM = 'https://mom.example/ap/users/mom'; 17 db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM); 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'); 18 26 19 test('a committed guardian is recognised; a stranger is not', () => { 20 assert.equal(AP.isWardGuardian('kid', MOM), true); 21 assert.equal(AP.isWardGuardian('kid', 'https://x.example/ap/users/stranger'), false); 22 assert.equal(AP.isWardGuardian('nosuch', MOM), false); 27 // IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline. 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'); 23 54 }); 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
rf85b2c3 r952baf3 227 227 // of that job being done. 228 228 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)', 229 234 }; 230 const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js']; 235 // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke 236 // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten 237 // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er 238 // geruisloos doorheen kwam. Dat is precies hoe deze er kwam. 239 const files = [ 240 ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js')) 241 .map((f) => `src/services/guardianship/${f}`), 242 'src/routes/guardian.js', 243 ]; 231 244 // Only top-level declarations name a scope; an indented `const base = ...` is 232 245 // a local and would otherwise take the blame for its enclosing function. A -
test/messages.test.js
rf85b2c3 r952baf3 2 2 // in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft 3 3 // 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. 4 8 // 5 9 // Run: npm test (= node --test) … … 42 46 const msgs = AP.getMessages('me', 50); 43 47 44 test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => { 45 const sent = msgs.find((m) => m.type === 'sent'); 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'); 46 53 assert.ok(sent, 'sent-item ontbreekt'); 47 54 assert.equal(sent.outboxId, 'out1'); 48 55 assert.equal(sent.to_handle, '@dana@r.test'); 49 assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan'); 56 // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips 57 // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad. 58 assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']); 59 assert.equal(thread.post.slug, 'mijn-post'); 50 60 }); 51 61 … … 58 68 59 69 test('privé-reply draagt visibility voor de badge en heeft post-context', () => { 60 const reply = msgs.find((m) => m.type === 'reply'); 70 const thread = msgs.find((m) => m.type === 'thread'); 71 const reply = thread.messages.find((m) => m.type === 'reply'); 61 72 assert.equal(reply.visibility, 'direct'); 62 73 assert.equal(reply.post_slug, 'mijn-post'); 63 74 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' }); 64 77 }); 65 78
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)