Changes in / [952baf3:f85b2c3] in Klonkt
- Files:
-
- 48 deleted
- 65 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
deploy/HOSTING.md (deleted)
-
deploy/MULTI-INSTANCE.md (modified) (1 diff)
-
docs/EXPORT-FORMAT.md (deleted)
-
docs/shaer-c2s-api.md (modified) (1 diff)
-
package-lock.json (modified) (20 diffs)
-
package.json (modified) (2 diffs)
-
scripts/backfill-reactions.mjs (deleted)
-
scripts/export-archive.mjs (deleted)
-
scripts/import-archive.mjs (deleted)
-
scripts/klonkt-refresh-updater.sh (modified) (2 diffs)
-
scripts/recover-from-cache.mjs (deleted)
-
src/assets/css/guardian.css (modified) (1 diff)
-
src/assets/js/guardian.js (modified) (10 diffs)
-
src/assets/js/mod/admin-audio.js (deleted)
-
src/assets/js/mod/admin-epk.js (deleted)
-
src/assets/js/mod/admin-help.js (deleted)
-
src/assets/js/mod/admin-media.js (deleted)
-
src/assets/js/mod/admin-paid.js (deleted)
-
src/assets/js/mod/admin-playlists.js (deleted)
-
src/assets/js/mod/admin-push.js (deleted)
-
src/assets/js/mod/admin-settings.js (deleted)
-
src/assets/js/mod/admin-site-edit.js (deleted)
-
src/assets/js/mod/admin-videos.js (deleted)
-
src/assets/js/mod/auth-register.js (deleted)
-
src/assets/js/mod/authorize-interaction.js (deleted)
-
src/assets/js/mod/chrome.js (deleted)
-
src/assets/js/mod/download.js (deleted)
-
src/assets/js/mod/lib.js (deleted)
-
src/assets/js/mod/messages.js (deleted)
-
src/assets/js/mod/news.js (deleted)
-
src/assets/js/mod/paid-gate.js (deleted)
-
src/assets/js/mod/paid-passkey.js (deleted)
-
src/assets/js/mod/playlist-editor.js (deleted)
-
src/assets/js/mod/post-edit.js (deleted)
-
src/assets/js/mod/post.js (deleted)
-
src/assets/js/mod/track-editor.js (deleted)
-
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 (deleted)
-
src/services/ArchiveImportService.js (deleted)
-
src/services/ArchiveRecoveryService.js (deleted)
-
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 (deleted)
-
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 (deleted)
-
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 (deleted)
-
test/archive-import.test.js (deleted)
-
test/archive-recovery.test.js (deleted)
-
test/authorized-fetch.test.js (modified) (1 diff)
-
test/co-location.test.js (modified) (1 diff)
-
test/feed-wait.test.js (deleted)
-
test/forwarded-activities.test.js (deleted)
-
test/guardian-follow-queues.test.js (deleted)
-
test/guardian-gate-panel.test.js (deleted)
-
test/guardian-gate-push.test.js (deleted)
-
test/guardian-help-state.test.js (deleted)
-
test/interaction-reactions.test.js (deleted)
-
test/messages-conversations.test.js (deleted)
-
test/messages.test.js (modified) (3 diffs)
-
test/reactions-characterization.test.js (deleted)
-
test/seen-notes.test.js (deleted)
-
test/signature-verification.test.js (deleted)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
r952baf3 rf85b2c3 5 5 6 6 ## [Unreleased] 7 8 ## [1.7.0] · 2026-08-079 10 ### Hinzugefügt11 - **Nachrichten ist eine einzige Gesprächsansicht.** Nachrichten, Gespräche und12 Gesendet waren drei getrennte Filter, wodurch ein Austausch auseinanderfiel:13 was du gesendet hast, stand unter Gesendet, was zurückkam unter einem der14 beiden anderen, und um einem Verlauf zu folgen, musstest du hin und her15 wechseln. Es ist jetzt eine Gesprächsansicht, in der Gesendetes und16 Empfangenes im selben Verlauf stehen, das Älteste oben, mit deinen eigenen17 Beiträgen markiert. Vier Filter bleiben: Alle, Gespräche, Aktivität und18 Moderation.19 - **Ein Gespräch sagt, worum es geht.** Hängt ein Verlauf an einem deiner20 Beiträge, verlinkt die Kopfzeile darauf. Ohne diesen Zusammenhang lässt sich21 eine Antwort in einer Liste nicht einordnen. Verläufe ohne Beitrag laufen22 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 dargestellt25 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 Text27 an, während alle anderen das Bild sahen.28 - **Die Guardian-App zeigt Beiträge deines Wards vollständig.** Dieselbe Lücke29 steckte in der Guardian-Ansicht: ein Beitrag deines Wards kam ohne Medien,30 Zitatkarte oder Emoji an — genau der Beitrag, den ein Guardian beurteilen31 können muss. Eine Inhaltswarnung bleibt dort wie bisher eingeklappt.32 - **Aus einem Gespräch heraus antworten.** Unter einem Verlauf steht ein eigenes33 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 ### Behoben38 - **Menschen auf datenschutzstrengen Servern können dir wieder folgen.** Manche39 Server geben den öffentlichen Schlüssel eines Kontos nur an eine signierte40 Anfrage heraus. Klonkt fragte ohne Signatur, wurde abgewiesen und konnte die41 gerade eingegangene Folge-Anfrage deshalb nicht prüfen — sie wurde abgelehnt,42 und der andere Server versuchte es tagelang erneut. Klonkt signiert diese43 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 einen46 Beitrag in einem Gespräch, an dem du beteiligt warst, leitete dessen Server die47 Antwort an dich weiter — und sie wurde abgewiesen, weil ein weiterleitender48 Server mit seinem eigenen Schlüssel signiert und nicht mit dem des Autors.49 Verläufe waren auf deiner Seite dadurch stillschweigend unvollständig. Eine50 solche Antwort wird jetzt an der Quelle geprüft statt abgewiesen: der Beitrag51 wird bei dem Server geholt, der ihn beherbergt, und nur das von dort wird52 gespeichert. Eine weitergeleitete Löschung wird weiterhin abgewiesen, denn ein53 gelöschter Beitrag lässt sich nicht prüfen.54 - **Ein Like oder Boost sieht überall gleich aus.** Derselbe Beitrag konnte in55 der Zeitung als geliked und auf der Interact-Seite als nicht geliked56 erscheinen, weil beide eine eigene Buchführung hatten. Es gibt jetzt nur noch57 eine, also stimmen die Schaltflächen überein — auch für alles, worauf du vor58 dieser Version schon reagiert hast; das wird beim Aktualisieren der Seite59 automatisch übernommen.60 - **Ein Like aus einer App bleibt jetzt bestehen.** Ein Like aus Shaer wurde61 zwar gespeichert, aber die App bekam das nie zurück, sodass das Herz beim62 nächsten Laden wieder aussprang — und weil die App das Like nie sah, konnte sie63 es nur erneut senden und nie zurücknehmen. Das Zurücknehmen aus einer App64 funktioniert jetzt.65 66 ### Sicherheit67 68 - **Aktualisierte Komponenten schließen sieben Sicherheitshinweise.** Der69 schwerwiegendste steckte in der Bibliothek, die von außen eintreffende70 Beiträge säubert: ein sorgfältig gebauter Beitrag konnte ein Skript daran71 vorbeischmuggeln, und ein Skript, das auf deiner Seite läuft, kann in deinem72 Namen handeln. Gleichzeitig ging der Mailversand drei Hauptversionen weiter73 und schloss damit eine Reihe von Lücken — darunter eine, bei der ein74 präparierter Name Befehle in das Gespräch mit dem Mailserver einschleusen75 konnte, und eine, bei der eine Nachricht bei einer anderen Domain landen76 konnte als der adressierten. An Aussehen und Verhalten von Klonkt ändert das77 nichts. Ein Hinweis bleibt bewusst offen: er betrifft eine Art, Bezeichner zu78 erzeugen, die Klonkt nicht verwendet.79 7 80 8 ## [1.6.0] · 2026-07-31 -
CHANGELOG.md
r952baf3 rf85b2c3 5 5 6 6 ## [Unreleased] 7 8 ## [1.7.0] · 2026-08-079 10 ### Added11 - **Messages is one conversation view.** Messages, Conversations and Sent were12 three separate filters, so a single exchange fell apart: what you sent sat13 under Sent, what came back under one of the other two, and you had to switch14 filters to follow a thread. They are now one Conversations view in which sent15 and received sit in the same thread, oldest at the top, with your own16 contributions marked. Four filters remain: All, Conversations, Activity and17 Moderation.18 - **A conversation says what it is about.** When a thread hangs off one of your19 posts, its header links to that post. Without it a reply in a list is20 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, quote22 cards and link previews now render the same way they do in News, including on23 the messages you sent yourself — a photo you attached used to arrive as bare24 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 the26 guardian view: a post from your ward arrived without its media, quote card or27 emoji, which is exactly the post a guardian needs to be able to judge. A28 content warning still stays collapsed there, as before.29 - **Reply from inside a conversation.** A thread has its own reply editor, the30 rich one with formatting, media and a language picker. Waving stays a separate31 button next to it: a wave is a nudge, not an answer.32 33 ### Fixed34 - **People on privacy-strict servers can follow you again.** Some servers only35 hand out an account's public key to a signed request. Klonkt asked without36 signing, got turned away, and could therefore not check the follow request that37 had just arrived — so it was refused, and the other server kept retrying for38 days. Klonkt now signs that lookup, and those follows go through. This also39 affected everything else fetched from such a server: profiles, posts and40 replies.41 - **Replies from other people now arrive in threads.** When someone replied to a42 post in a conversation you were part of, their server forwarded that reply to43 you — and it was turned away, because the forwarding server signs with its own44 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 is46 fetched from the server that hosts it, and only what comes back from there is47 stored. A forwarded delete is still refused, because a deleted post cannot be48 checked.49 - **A like or boost looks the same everywhere.** The same post could show as50 liked in News and as not liked on the interact page, because both kept their51 own record. There is one record now, so the buttons agree — including for52 everything you reacted to before this release, which is carried over53 automatically when the site updates.54 - **A like from an app now sticks.** Liking a post from Shaer was stored, but55 the app never got that back, so the heart popped off again on the next reload56 — and because the app never saw the like, it could only offer "like" again and57 never undo it. Un-liking from an app now works.58 59 ### Security60 61 - **Updated components close seven security advisories.** The heaviest one sat62 in the library that cleans up posts arriving from elsewhere: a carefully built63 post could slip a script past it, and a script running on your page can act as64 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 prepared66 name could smuggle commands into the conversation with the mail server, and67 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 knowingly69 left open: it concerns a way of generating identifiers that Klonkt does not70 use.71 7 72 8 ## [1.6.0] · 2026-07-31 -
CHANGELOG.nl.md
r952baf3 rf85b2c3 5 5 6 6 ## [Unreleased] 7 8 ## [1.7.0] · 2026-08-079 10 ### Toegevoegd11 - **Berichten is één gesprekkenweergave.** Berichten, Gesprekken en Verzonden12 waren drie losse filters, waardoor één uitwisseling uit elkaar viel: wat jij13 stuurde stond onder Verzonden, wat terugkwam onder een van de andere twee, en14 om een draad te volgen moest je heen en weer klikken. Het is nu één15 Gesprekken-weergave waarin verzonden en ontvangen in dezelfde draad staan,16 oudste bovenaan, met jouw eigen bijdragen gemarkeerd. Er blijven vier filters17 over: Alles, Gesprekken, Activiteit en Moderatie.18 - **Een gesprek vertelt waar het over gaat.** Hangt een draad aan een van je19 posts, dan staat er een link naar die post in de kop. Zonder die context is20 een antwoord in een lijst niet te plaatsen. Draden die niet over een post gaan21 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 de24 Krant, ook bij de berichten die je zelf stuurde — een foto die jij meestuurde25 kwam op je eigen scherm als kale tekst binnen terwijl de ander wel een plaatje26 zag.27 - **De Guardian-app toont posts van je ward compleet.** Hetzelfde gat zat in de28 guardian-weergave: een post van je ward kwam binnen zonder media, quote-kaart29 of emoji, terwijl dat juist de post is die je als guardian wilt kunnen30 beoordelen. Een content warning blijft daar zoals eerder dichtgeklapt.31 - **Antwoorden vanuit een gesprek.** Onder een draad staat een eigen32 antwoordvenster, de rijke editor met opmaak, media en een taalkeuze. Zwaaien33 blijft een aparte knop ernaast: een zwaai is een seintje, geen antwoord.34 35 ### Opgelost36 - **Mensen op privacy-strenge servers kunnen je weer volgen.** Sommige servers37 geven de publieke sleutel van een account alleen aan een ondertekend verzoek.38 Klonkt vroeg zonder handtekening, werd geweigerd, en kon daardoor het39 volgverzoek dat net binnenkwam niet controleren — dus werd dat afgewezen, en40 bleef de andere server het dagenlang opnieuw proberen. Klonkt ondertekent die41 navraag nu, en die volgers komen erdoor. Dit raakte ook al het andere dat bij42 zo'n server werd opgehaald: profielen, posts en reacties.43 - **Reacties van anderen komen nu in de draad aan.** Reageerde iemand op een44 post in een gesprek waar jij bij zat, dan stuurde hun server die reactie naar45 je door — en werd hij geweigerd, omdat een doorsturende server met zijn eigen46 sleutel ondertekent en niet met die van de schrijver. Threads waren daardoor47 aan jouw kant stilletjes onvolledig. Zo'n reactie wordt nu bij de bron48 gecontroleerd in plaats van afgewezen: de post wordt opgehaald bij de server49 die hem herbergt, en alleen wat daarvandaan komt wordt bewaard. Een50 doorgestuurde verwijdering wordt nog steeds geweigerd, want een verwijderde51 post valt niet te controleren.52 - **Een like of boost ziet er overal hetzelfde uit.** Dezelfde post kon in de53 Krant als geliket verschijnen en op de interact-pagina als niet-geliket, omdat54 allebei een eigen administratie bijhielden. Er is er nu één, dus de knoppen55 zijn het eens — ook voor alles wat je vóór deze versie al had gereageerd; dat56 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 werd58 wel opgeslagen, maar de app kreeg dat nooit terug, dus het hartje sprong bij de59 eerste herlaadbeurt weer uit — en omdat de app de like nooit zag, kon hij hem60 alleen opnieuw geven en nooit intrekken. Un-liken vanuit een app werkt nu.61 62 ### Beveiliging63 64 - **Bijgewerkte onderdelen sluiten zeven beveiligingsadviezen.** Het zwaarste65 zat in de bibliotheek die berichten van elders opschoont: een zorgvuldig66 opgebouwd bericht kon daar een script langs krijgen, en een script dat op jouw67 pagina draait kan doen alsof het jou is. Tegelijk ging de mailafhandeling drie68 hoofdversies vooruit, wat een reeks gaten rond verzenden dichtte — waaronder69 een waarbij een geprepareerde naam commando's het gesprek met de mailserver in70 kon smokkelen, en een waarbij een bericht bij een ander domein kon belanden71 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 te73 maken die Klonkt niet gebruikt.74 7 75 8 ## [1.6.0] · 2026-07-31 -
deploy/MULTI-INSTANCE.md
r952baf3 rf85b2c3 195 195 Then remove its block from the web server config and reload it. 196 196 197 ## Hosting for other people198 199 Everything here is about the machine. The moment an instance belongs to someone200 who is not you, there is a second set of questions — what you are holding, what201 you owe them, and how they leave. See [HOSTING.md](HOSTING.md).202 203 197 ## Everyday commands 204 198 -
docs/shaer-c2s-api.md
r952baf3 rf85b2c3 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 read84 85 The inbox read can hold the answer until there is something new, so a client does86 not have to poll. It is the **same call and the same response** — no separate87 endpoint and no separate "there is news" shape, because a second shape is a88 second description of a post that can drift from the first.89 90 ```91 GET /ap/users/:slug/inbox?since=<cursor>&wait=2592 ```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 why106 this costs nothing while nothing happens. Sending the whole timeline back every107 `wait` seconds just to say "still nothing" would be a poor trade for saving one108 round trip.109 110 A server that has not run the feed-state migration yet cannot tell change from111 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 state120 immediately rather than an error, so a broken reconnect loop degrades to121 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 can124 never hold a connection open.125 126 This works while your app is in the foreground, and only there. iOS freezes127 network activity the moment an app is backgrounded, and a held-open connection128 does not survive it.129 130 **There is no background channel to fall back on for a native app.** Klonkt's131 web push uses VAPID, which a self-hosted instance can generate and send entirely132 on its own — but only to a browser or an installed PWA. A native app receives133 push through APNs (or FCM), signed with the *application's* key, which a134 self-hosted Klonkt does not have and should not have: whoever holds it can push135 to every user of that app. Closing that gap needs a relay run by the app's136 publisher, and that is a decision about money and control, not a protocol137 detail.138 139 So for a self-hosted setup this is not a stopgap until push arrives. For now it140 is the freshness channel.141 82 | `GET /ap/users/:slug/followers` | see below | 142 83 | `GET /ap/users/:slug/following` | see below | -
package-lock.json
r952baf3 rf85b2c3 1 1 { 2 2 "name": "klonkt", 3 "version": "1. 7.0",3 "version": "1.6.0-dev", 4 4 "lockfileVersion": 3, 5 5 "requires": true, … … 7 7 "": { 8 8 "name": "klonkt", 9 "version": "1. 7.0",9 "version": "1.6.0-dev", 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": "^ 9.0.5",30 "nodemailer": "^6.9.15", 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. 6",683 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20. 6.tgz",684 "integrity": "sha512- p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",682 "version": "1.20.5", 683 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", 684 "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", 685 685 "license": "MIT", 686 686 "dependencies": { … … 704 704 }, 705 705 "node_modules/brace-expansion": { 706 "version": "2.1. 4",707 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1. 4.tgz",708 "integrity": "sha512- hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",706 "version": "2.1.0", 707 "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", 708 "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", 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 },942 936 "node_modules/debug": { 943 937 "version": "2.6.9", … … 1212 1206 }, 1213 1207 "node_modules/es-object-atoms": { 1214 "version": "1.1. 2",1215 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1. 2.tgz",1216 "integrity": "sha512- HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",1208 "version": "1.1.1", 1209 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 1210 "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 1217 1211 "license": "MIT", 1218 1212 "dependencies": { … … 1260 1254 }, 1261 1255 "node_modules/express": { 1262 "version": "4.22. 2",1263 "resolved": "https://registry.npmjs.org/express/-/express-4.22. 2.tgz",1264 "integrity": "sha512- IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",1256 "version": "4.22.1", 1257 "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", 1258 "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", 1265 1259 "license": "MIT", 1266 1260 "dependencies": { 1267 1261 "accepts": "~1.3.8", 1268 1262 "array-flatten": "1.1.1", 1269 "body-parser": "~1.20. 5",1263 "body-parser": "~1.20.3", 1270 1264 "content-disposition": "~0.5.4", 1271 1265 "content-type": "~1.0.4", … … 1286 1280 "path-to-regexp": "~0.1.12", 1287 1281 "proxy-addr": "~2.0.7", 1288 "qs": "~6.1 5.1",1282 "qs": "~6.14.0", 1289 1283 "range-parser": "~1.2.1", 1290 1284 "safe-buffer": "5.2.1", … … 1341 1335 "type": "opencollective", 1342 1336 "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" 1343 1352 } 1344 1353 }, … … 1534 1543 }, 1535 1544 "node_modules/hasown": { 1536 "version": "2.0. 4",1537 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0. 4.tgz",1538 "integrity": "sha512- T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",1545 "version": "2.0.3", 1546 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", 1547 "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", 1539 1548 "license": "MIT", 1540 1549 "dependencies": { … … 1774 1783 } 1775 1784 }, 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 7",1962 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1 7.tgz",1963 "integrity": "sha512- xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",1961 "version": "3.3.11", 1962 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1963 "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1964 1964 "funding": [ 1965 1965 { … … 2010 2010 }, 2011 2011 "node_modules/nodemailer": { 2012 "version": " 9.0.5",2013 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer- 9.0.5.tgz",2014 "integrity": "sha512- wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",2012 "version": "6.10.1", 2013 "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", 2014 "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", 2015 2015 "license": "MIT-0", 2016 2016 "engines": { … … 2156 2156 }, 2157 2157 "node_modules/postcss": { 2158 "version": "8.5. 26",2159 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5. 26.tgz",2160 "integrity": "sha512- u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",2158 "version": "8.5.12", 2159 "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", 2160 "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", 2161 2161 "funding": [ 2162 2162 { … … 2175 2175 "license": "MIT", 2176 2176 "dependencies": { 2177 "nanoid": "^3.3.1 7",2177 "nanoid": "^3.3.11", 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. 3",2287 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15. 3.tgz",2288 "integrity": "sha512- O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",2286 "version": "6.15.1", 2287 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", 2288 "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", 2289 2289 "license": "BSD-3-Clause", 2290 2290 "dependencies": { 2291 "es-define-property": "^1.0.1", 2292 "side-channel": "^1.1.1" 2291 "side-channel": "^1.1.0" 2293 2292 }, 2294 2293 "engines": { … … 2409 2408 }, 2410 2409 "node_modules/sanitize-html": { 2411 "version": "2.17. 5",2412 "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17. 5.tgz",2413 "integrity": "sha512- ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",2410 "version": "2.17.3", 2411 "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", 2412 "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", 2414 2413 "license": "MIT", 2415 2414 "dependencies": { … … 2418 2417 "htmlparser2": "^10.1.0", 2419 2418 "is-plain-object": "^5.0.0", 2420 "launder": "^1.7.1",2421 2419 "parse-srcset": "^1.0.2", 2422 2420 "postcss": "^8.3.11" … … 2493 2491 }, 2494 2492 "node_modules/side-channel": { 2495 "version": "1.1. 1",2496 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1. 1.tgz",2497 "integrity": "sha512- 6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",2493 "version": "1.1.0", 2494 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", 2495 "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 2498 2496 "license": "MIT", 2499 2497 "dependencies": { 2500 2498 "es-errors": "^1.3.0", 2501 "object-inspect": "^1.13. 4",2502 "side-channel-list": "^1.0. 1",2499 "object-inspect": "^1.13.3", 2500 "side-channel-list": "^1.0.0", 2503 2501 "side-channel-map": "^1.0.1", 2504 2502 "side-channel-weakmap": "^1.0.2" -
package.json
r952baf3 rf85b2c3 1 1 { 2 2 "name": "klonkt", 3 "version": "1. 7.0",3 "version": "1.6.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": "^ 9.0.5",35 "nodemailer": "^6.9.15", 36 36 "qrcode": "^1.5.4", 37 37 "sanitize-html": "^2.17.3", -
scripts/klonkt-refresh-updater.sh
r952baf3 rf85b2c3 30 30 D="${KLONKT_DIR}" 31 31 B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true) 32 # Deepen a shallow checkout ONCE. \`fetch --depth 1\` keeps only the newest commit 33 # and \`checkout -f\` throws away the tree that was there, so after an update the 34 # previous version existed nowhere on the machine: a bad release could not be 35 # undone without the network, and only if you knew which commit to ask for. 36 # One deepening buys back the history; every later fetch keeps it. 37 if [ "\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then 38 echo "deepening the checkout once so updates can be rolled back..." 39 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --unshallow origin ${BRANCH} || true 40 fi 41 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch origin ${BRANCH} 32 runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH} 42 33 runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD 43 34 A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD) … … 63 54 else 64 55 echo "Klonkt updated (\$A) + restarted \$N instance(s)." 65 fi66 # History alone is not a rollback; at three in the morning you also need the67 # command. Print it while the previous commit is still known.68 if [ -n "\$B" ]; then69 echo70 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@*')"74 56 fi 75 57 EOF -
src/assets/css/guardian.css
r952baf3 rf85b2c3 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 de172 drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna173 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 de179 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 niet184 kunt terugdraaien. */185 .g-gate-warn { font-weight: 600; }186 .g-gate-wait { font-weight: 600; }187 188 /* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm,189 het moet alleen onmogelijk te missen zijn wie er al op af is. */190 .g-help-state { margin-top: .5rem; }191 .g-help-pick { font-size: .9em; }192 .g-help-age { opacity: .6; } /* oud, maar niet weg */193 .g-help-done { font-weight: 600; }194 .g-confirm { margin-top: .5rem; }195 196 /* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */197 .g-help-archive { margin-top: .5rem; opacity: .75; } -
src/assets/js/guardian.js
r952baf3 rf85b2c3 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 lijst56 * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die57 * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan58 * hoort hij hier langs te komen.59 *60 * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als61 * 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; }65 51 66 52 function helpCard(h) { … … 86 72 card.appendChild(a); 87 73 } 88 card.appendChild(helpState(h));89 74 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 het102 * is afgesloten, en de twee knoppen.103 *104 * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is105 * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude106 * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er107 * 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' en126 // '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 een140 // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten141 // 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; });164 75 } 165 76 … … 167 78 var list = document.getElementById('help-list'); 168 79 list.textContent = ''; 169 var alle = S.help || []; 170 // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet 171 // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af 172 // is, en neemt een gesloten vraag de ruimte in van een open vraag. 173 var open = alle.filter(helpOpen); 174 var klaar = alle.filter(function (h) { return !helpOpen(h); }); 175 176 open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); }); 177 if (open.length > HELP_TOP) { 178 list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || ''))); 179 } 180 181 // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan -- 182 // er wordt in dit systeem niets herschreven -- maar hij hoort achter een 183 // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso 184 // in het paneel van dat kind. 185 if (klaar.length) { 186 var doos = el('div', 'g-help-archive'); 187 doos.hidden = true; 188 klaar.forEach(function (h) { doos.appendChild(helpCard(h)); }); 189 var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length)); 190 knop.addEventListener('click', function () { 191 doos.hidden = !doos.hidden; 192 knop.textContent = doos.hidden 193 ? (T.help_archive || '{n} handled').replace('{n}', klaar.length) 194 : (T.help_archive_hide || 'hide'); 195 }); 196 list.appendChild(knop); 197 list.appendChild(doos); 198 } 199 80 var help = S.help || []; 81 help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); }); 82 if (help.length > HELP_TOP) { 83 list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || ''))); 84 } 200 85 var badge = document.getElementById('help-count'); 201 badge.textContent = open.length; badge.hidden = open.length === 0; 202 // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is: 203 // er wacht dan immers niets. 204 show('help-empty', open.length === 0); 86 badge.textContent = help.length; badge.hidden = help.length === 0; 87 show('help-empty', help.length === 0); 205 88 } 206 89 … … 468 351 } 469 352 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, welke477 * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te478 * verzetten valt.479 *480 * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan481 * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is482 * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar483 * 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 op493 // een andere server wordt die kolom daar bijgehouden.494 var stand = g.value === null || g.value === undefined495 ? (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 530 353 function wardPanel(w) { 531 354 var uri = w.other_uri; … … 535 358 var set = el('div', 'g-panel-sec'); 536 359 set.appendChild(el('h3', null, T.settings_title || 'Settings')); 537 // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse 538 // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat 539 // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord 540 // is op "wat mag dit kind". 541 (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); }); 542 // Terugval voor een server die de catalogus nog niet stuurt: dan de twee 543 // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit. 544 if (!(w.gates || []).length) { 545 var setRow = el('div', 'row'); 546 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off)); 547 if (w.embeds !== false) { 548 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off)); 549 } 550 set.appendChild(setRow); 551 (w.proposals || []).forEach(function (p) { 552 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews'); 553 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}') 554 .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off')) 555 .replace('{status}', T['prop_st_' + p.status] || p.status))); 556 }); 557 } 360 var setRow = el('div', 'row'); 361 setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off)); 362 // The heavier sibling (5.6): seeing that a video exists is one decision, 363 // letting a third party's player run inside the app is another. Hidden 364 // only when previews are known-OFF: for a ward on another server the 365 // value is unknown, and unknown is not off. Hiding it there meant a 366 // guardian elsewhere could never even propose playback, which is how a 367 // whole proposal round went into the wrong gate. The ward's server 368 // enforces play-needs-previews at serve time regardless. 369 if (w.embeds !== false) { 370 setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off)); 371 } 372 set.appendChild(setRow); 373 // What this guardian proposed and how it stands (5.6). This used to be a 374 // button caption that vanished on refresh, so a running decision was 375 // invisible: you could not tell "waiting", "done" and "expired" apart. 376 (w.proposals || []).forEach(function (p) { 377 var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews'); 378 var line = (T.prop_line || 'Proposal {what} {value}: {status}') 379 .replace('{what}', what) 380 .replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off')) 381 .replace('{status}', T['prop_st_' + p.status] || p.status); 382 set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line)); 383 }); 558 384 panel.appendChild(set); 559 385 … … 594 420 T.panel_follow_empty || '', followCard); 595 421 596 // Hier juist de VOLLEDIGE geschiedenis van dit kind -- dat is waar dit597 // paneel voor is. Wel wat nog wacht bovenaan, want dat is waar je naar zoekt598 // als je het opent.599 422 sectionInto(panel, T.panel_help || 'Calls for help', 600 (S.help || []).filter(function (h) { return h.actor_uri === uri; }) 601 .sort(function (a, b) { return (helpOpen(b) ? 1 : 0) - (helpOpen(a) ? 1 : 0); }), 423 (S.help || []).filter(function (h) { return h.actor_uri === uri; }), 602 424 T.panel_help_empty || '', helpCard); 603 425 … … 631 453 actRow.appendChild(rel); 632 454 act.appendChild(actRow); 633 // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET. 634 // De rest van het paneel is lezen -- instellingen, wie er nog meer op let, 635 // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs 636 // vijf secties te scrollen. 637 panel.insertBefore(act, panel.firstChild); 455 panel.appendChild(act); 638 456 return panel; 639 457 } … … 649 467 row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle))); 650 468 // Counts on the row: whatever is waiting must be visible with the panel shut. 651 // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een 652 // afgehandelde vraag hoorde de boei niet te laten staan. 653 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length; 469 var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length; 654 470 var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length; 655 471 if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp)); … … 737 553 card.appendChild(head); 738 554 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 || '';743 555 if (p.cw) { 744 // De content warning blijft van de PWA zelf: note-body versluiert alleen745 // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.746 556 var d = document.createElement('details'); 747 557 var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum); 748 var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);558 var inner = el('div'); inner.innerHTML = p.content || ''; d.appendChild(inner); 749 559 body.appendChild(d); 750 560 } else { 751 body.innerHTML = html; // server-sanitized HTML (same as Berichten)561 body.innerHTML = p.content || ''; // server-sanitized HTML (same as Berichten) 752 562 } 753 563 card.appendChild(body); … … 890 700 891 701 renderAll(); pushState(); loadFeed(); loadFollowReqs(); 892 893 // De push die de melding brengt is meteen het teken dat de staat veranderd is. 894 // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het 895 // werk, en het werkt ook als de app dicht is. 896 if ('serviceWorker' in navigator) { 897 navigator.serviceWorker.addEventListener('message', function (e) { 898 if (e.data && e.data.klonkt === 'push') refresh(); 899 }); 900 } 901 // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en 902 // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand 903 // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen 904 // meteen een keer, want dan is de kans op nieuws het grootst. 905 setInterval(function () { if (!document.hidden) refresh(); }, 45000); 906 document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); }); 702 setInterval(refresh, 45000); // live-ish while open 907 703 } catch (e) { 908 704 fatal((e && e.message) || String(e)); -
src/config/database.js
r952baf3 rf85b2c3 106 106 PRIMARY KEY (guardian_slug, id) 107 107 )`); 108 // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt109 // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het110 // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het111 // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als112 // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets113 // 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');117 108 ensureColumn('sites', 'profile_photo', 'TEXT'); 118 109 ensureColumn('audio_tracks', 'cover_url', 'TEXT'); … … 478 469 UNIQUE(slug, actor_uri) 479 470 ); 480 -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend481 -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en482 -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP483 -- 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 dit485 -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het486 -- 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_TIMESTAMP490 );491 CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);492 471 CREATE TABLE IF NOT EXISTS ap_timeline ( 493 472 id TEXT NOT NULL, -- the remote note's AP id … … 735 714 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes 736 715 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 kind739 // heeft er een kopie van, en die komt binnen als bericht van een ander. Een740 // kolom op de mention zou alleen over onszelf gaan.741 //742 // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om743 // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de744 // ander het "geclaimd" had, wel.745 //746 // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige747 // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --748 // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets749 // 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 | handled754 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)');759 716 ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre) 760 717 ensureColumn('ap_outbox', 'wave', 'INTEGER'); // FEP-633c shaer:wave (guardian -> ward nudge) … … 802 759 ensureColumn('ap_followers', 'handle', 'TEXT'); // @user@host 803 760 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" werd811 * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van812 * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken813 * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem814 * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit815 * ap_timeline.liked (shaer-9e9).816 *817 * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat818 * 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 reden822 * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen823 * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de824 * 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 schrijft828 * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je829 * 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 | deleted839 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 de844 -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een845 -- 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} BEGIN855 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 UPDATE859 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 tijd882 // volmaken", en dat is traag maar niet stuk.883 console.error('❌ feed-state triggers:', e.message);884 }885 761 } 886 762 -
src/middleware/render.js
r952baf3 rf85b2c3 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 op252 // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.253 // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een254 // pad.255 pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',256 251 socialDescr: data.socialDescr || '', 257 252 socialImage: data.socialImage || '', … … 291 286 ? _site.accent : '#e8b04b'; 292 287 const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt'; 293 // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell294 // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er295 // altijd bij, want die komt bij elke navigatie opnieuw binnen.296 const _navJs = ('chrome ' + (locals.pageJs || '')).trim();297 288 const triggerJson = JSON.stringify({ 298 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette , js: _navJs},289 pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette }, 299 290 pcmsPostSwap: data.post ? { 300 291 title: data.post.title, -
src/routes/activitypub.js
r952baf3 rf85b2c3 253 253 } 254 254 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me)); 255 queueRoute('follows', (id , slug, me) => Guardianship.followsCollection(id, slug, me));255 queueRoute('follows', (id) => Guardianship.followsCollection(id)); 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', async(req, res) => {270 router.get('/ap/users/:slug/inbox', (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 het276 // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee277 // gedraagt de route zich exact zoals altijd.278 //279 // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan280 // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van281 // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede282 // 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 wachten287 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 collectie292 // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn293 // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost294 // 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 instance298 // 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. Bij300 // 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 }306 274 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's 307 275 // world outside the fediverse is the guardians' call. The gate is applied … … 317 285 const playbackAllowed = embedsAllowed 318 286 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard); 319 const rows = AP.getTimeline(auth.site.slug, 60); 320 // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de 321 // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op 322 // ap_timeline. Per rij vragen zou hier een N+1 opleveren. 323 const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id)); 324 const posts = rows.map((t) => ({ 287 const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({ 325 288 id: `${t.id}#create`, 326 289 type: 'Create', … … 370 333 // Whether THIS account already liked/boosted the note, so the app's 371 334 // detail-view buttons show the current state (and can toggle/undo). 372 'shaer:liked': !! (reacties.get(t.id) || {}).liked,373 'shaer:boosted': !! (reacties.get(t.id) || {}).boosted,335 'shaer:liked': !!t.liked, 336 'shaer:boosted': !!t.boosted, 374 337 // An external (non-fediverse) embed, thumbnail-only and never an iframe. 375 338 // Omitted entirely when the gate is closed (see above). … … 476 439 'shaer:externalLinks': playbackAllowed, 477 440 }, 478 // Het merk van wat hierin zit. Geef hem terug als `since` om op het479 // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt480 // wat je in handen hebt en niet iets dat er ondertussen bij kwam.481 'shaer:cursor': AP.feedCursor(auth.site.slug),482 441 totalItems: items.length, 483 442 orderedItems: items, 484 443 }); 485 return undefined;486 444 }); 487 445 -
src/routes/admin-audio.js
r952baf3 rf85b2c3 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',115 113 pageTitleKey: 'admin.t_audio', 116 114 bodyClass: 'on-admin', -
src/routes/admin-epk.js
r952baf3 rf85b2c3 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',36 35 pageTitleKey: 'admin.t_epk', 37 36 bodyClass: 'on-admin', -
src/routes/admin-media.js
r952baf3 rf85b2c3 81 81 .sort((a, b) => b._mtime - a._mtime); // newest first 82 82 renderPage(req, res, 'pages/admin-media', { 83 pageJs: 'admin-media',84 83 pageTitleKey: 'admin.t_media', 85 84 bodyClass: 'on-admin', … … 123 122 if (!site) return res.status(404).send('Site required'); 124 123 renderPage(req, res, 'pages/admin-videos', { 125 pageJs: 'admin-videos',126 124 pageTitleKey: 'admin.t_media', 127 125 bodyClass: 'on-admin', -
src/routes/admin-paid.js
r952baf3 rf85b2c3 36 36 if (!gate(req, res)) return; 37 37 renderPage(req, res, 'pages/admin-paid', { 38 pageJs: 'admin-paid',39 38 pageTitleKey: 'apaid.t', 40 39 bodyClass: 'on-admin', -
src/routes/admin-playlists.js
r952baf3 rf85b2c3 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',66 64 pageTitleKey: 'admin.t_playlists', 67 65 playlists, -
src/routes/admin-push.js
r952baf3 rf85b2c3 12 12 router.get('/', requireSiteManager, async (req, res) => { 13 13 renderPage(req, res, 'pages/admin-push', { 14 pageJs: 'admin-push',15 14 pageTitleKey: 'push.t', 16 15 bodyClass: 'on-admin', -
src/routes/admin-settings.js
r952baf3 rf85b2c3 66 66 router.get('/', requireGod, (req, res) => { 67 67 renderPage(req, res, 'pages/admin-settings', { 68 pageJs: 'admin-settings',69 68 pageTitleKey: 'admin.t_settings', 70 69 bodyClass: 'on-admin', -
src/routes/admin-sites.js
r952baf3 rf85b2c3 200 200 router.get('/new', requireGod, (req, res) => { 201 201 renderPage(req, res, 'pages/admin-site-edit', { 202 pageJs: 'admin-site-edit',203 202 pageTitleKey: 'admin.t_newsite', 204 203 bodyClass: 'on-admin', … … 284 283 285 284 renderPage(req, res, 'pages/admin-site-edit', { 286 pageJs: 'admin-site-edit',287 285 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title }, 288 286 bodyClass: 'on-admin', -
src/routes/admin.js
r952baf3 rf85b2c3 87 87 router.get('/handleiding', requireAuth, (req, res) => { 88 88 renderPage(req, res, 'pages/admin-help', { 89 pageJs: 'admin-help',90 89 pageTitleKey: 'admin.t_manual', 91 90 bodyClass: 'on-admin', -
src/routes/auth.js
r952baf3 rf85b2c3 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',118 117 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special', 119 118 error: null, username: '', email: '', siteName: '', next, … … 125 124 const next = safeNext(req.body.next) || ''; 126 125 const renderErr = (error) => renderPage(req, res, 'pages/auth-register', { 127 pageJs: 'auth-register',128 126 pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special', 129 127 error, username: username || '', email: email || '', siteName: siteName || '', next, … … 207 205 } else { 208 206 // Production without SMTP: NEVER log the token. Refer to the CLI break-glass. 209 console.log(`[password-reset] requested for ${user.email} (no SMTP — use'npm run reset-admin')`);207 console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`); 210 208 } 211 209 } -
src/routes/download.js
r952baf3 rf85b2c3 85 85 const fan = req.session && req.session.user; 86 86 renderPage(req, res, 'pages/download', { 87 pageJs: 'download',88 87 pageTitle: track.title + ' — download', 89 88 bodyClass: 'on-download', -
src/routes/guardian.js
r952baf3 rf85b2c3 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', 60 // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en 61 // de drempel erbij. De namen volgen de catalogus in gated.js. 62 'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows', 63 'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover', 64 'gate_unknown', 'gate_threshold', 'gate_threshold_unknown', 65 'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose', 66 // Oppikken en afhandelen van een hulpvraag (shaer-lgo). 67 'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note', 68 'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days', 69 'help_archive', 'help_archive_hide']; 59 'panel_guards_far']; 70 60 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)])); 71 61 s.wave = i18nT(L, 'guardian.wave'); … … 81 71 emoji_json, actor_emoji_json, media_json, quote_json, embed_json 82 72 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50` 83 ).all(site.slug); 84 // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is 85 // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies 86 // het scherm dat een guardian in een haast openslaat. 87 const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri)); 88 const helpItems = help.map((h) => ({ 73 ).all(site.slug).map((h) => ({ 89 74 ...h, 90 // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat91 // niet is, is de gevaarlijke fout -- niet andersom.92 state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },93 75 // The dashboard is built in the browser, so it gets the body finished: the 94 76 // same partial de Krant and Berichten use. A 🛟 often carries a screenshot … … 121 103 status: Guardianship.gated.sentStatus(p, Date.now()), 122 104 })), 123 // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en124 // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf125 // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.126 gates: wardGates(site.slug, w.other_uri),127 105 })), 128 106 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems, … … 135 113 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value, 136 114 })), 137 help : helpItems,115 help, 138 116 strings: uiStrings(L), 139 117 }; … … 214 192 const site = siteForUser(req); 215 193 if (!site) return res.status(404).json({ error: 'no_site' }); 216 const L = resolveLang(req);217 194 ensureWardConnections(site); 218 195 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri)); … … 232 209 cw: p.cw || null, 233 210 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 en235 // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en236 // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag237 // een lege regel waar een foto stond. `content` blijft ernaast staan voor238 // een client die nog uit de cache draait.239 body_html: renderNoteBody(p, L),240 211 })); 241 212 res.json({ items, following: wardUris.size }); … … 348 319 if (!r) return res.status(502).json({ error: 'delivery' }); 349 320 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 er354 // iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het355 // 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 opnieuw359 // gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van360 // 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 van374 // 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 soort386 // stilte.387 res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });388 321 }); 389 322 … … 598 531 /** The playback gate of a ward we host (5.6): the heavier sibling. */ 599 532 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 ward604 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal605 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat606 * 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 625 533 function wardGateSetting(uri, column) { 626 534 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); -
src/routes/paid.js
r952baf3 rf85b2c3 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',79 78 pageTitleKey: 'ppk.t', bodyClass: 'on-post', 80 79 optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post, -
src/routes/posts.js
r952baf3 rf85b2c3 256 256 257 257 renderPage(req, res, 'pages/post-edit', { 258 // post-edit neemt de playlist-editor op.259 pageJs: 'post-edit playlist-editor',260 258 post: { 261 259 id: uuid(), … … 797 795 } 798 796 renderPage(req, res, 'pages/authorize-interaction', { 799 pageJs: 'authorize-interaction',800 797 pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites 801 798 bodyClass: 'on-special', … … 809 806 liked: !!req.query.liked, 810 807 boosted: !!req.query.boosted, 811 reacted: (site && uri) ? ActivityPubService.get Reaction(site.slug, uri) : { liked: false, boosted: false },808 reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false }, 812 809 siteTitle: site ? site.title : '', 813 810 }); … … 842 839 let on = false; 843 840 if (site && uri) { 844 on = !ActivityPubService.get Reaction(site.slug, uri).liked;841 on = !ActivityPubService.getMyReactions(site.slug, uri).liked; 845 842 ActivityPubService.resolveRemoteNote(uri) 846 843 .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri)) 847 844 .catch((e) => console.warn('[AP] remote like failed:', e.message)); 848 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag. 849 ActivityPubService.setReaction(site.slug, uri, 'like', on); 845 ActivityPubService.setMyReaction(site.slug, uri, 'like', on); 850 846 } 851 847 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 860 856 let on = false; 861 857 if (site && uri) { 862 on = !ActivityPubService.get Reaction(site.slug, uri).boosted;858 on = !ActivityPubService.getMyReactions(site.slug, uri).boosted; 863 859 ActivityPubService.resolveRemoteNote(uri) 864 860 .then((note) => { … … 866 862 const id = note.object_uri || uri; 867 863 return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri)) 868 // De note gaat mee: een boost zet niet alleen een vlag maar trekt de 869 // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in 870 // de Cirkel verschijnt. 871 .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null })); 864 // Boost → store the post in the timeline (even if you don't follow the author) so it 865 // surfaces in the Cirkel; unboost → just clear the flag. 866 .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id)); 872 867 }) 873 868 .catch((e) => console.warn('[AP] remote boost failed:', e.message)); 874 // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via 875 // setReaction en niet via setMyReaction: ook dit korte moment mag geen 876 // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de 877 // note, zodat de post ook in je tijdlijn belandt. 878 ActivityPubService.setReaction(site.slug, uri, 'boost', on); 869 ActivityPubService.setMyReaction(site.slug, uri, 'boost', on); 879 870 } 880 871 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 939 930 : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']); 940 931 renderPage(req, res, 'pages/messages', { 941 pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages',items, seenAt,932 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt, 942 933 hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers, 943 934 success: req.query.success || null, error: req.query.error || null, … … 974 965 } catch { /* fall through */ } 975 966 res.redirect(back + '?error=quickreply'); 976 });977 978 // Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt979 // bepaalt de draad zelf (zie groupConversations → replyTo):980 // - hangt de draad aan een post van jou, dan is dit een gewone reply op het981 // 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` de984 // platte versie die de editor er altijd bij levert (en die het no-JS-formulier985 // 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 gemanipuleerd1008 // 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');1024 967 }); 1025 968 … … 1196 1139 } 1197 1140 renderPage(req, res, 'pages/news', { 1198 pageJs: 'news',1199 1141 pageTitle: 'News', bodyClass: 'on-special', 1200 1142 timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase, … … 1287 1229 let on = false; 1288 1230 if (site && note) { 1289 on = !ActivityPubService.get Reaction(site.slug, note).liked;1231 on = !ActivityPubService.getTimelineReaction(site.slug, note).liked; 1290 1232 try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ } 1291 ActivityPubService.setReaction(site.slug, note, 'like', on);1233 if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note); 1292 1234 } 1293 1235 if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on }); … … 1301 1243 let on = false; 1302 1244 if (site && note) { 1303 on = !ActivityPubService.get Reaction(site.slug, note).boosted;1245 on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted; 1304 1246 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 state1306 1247 if (on) { 1248 ActivityPubService.markBoosted(site.slug, note); // instant UI state 1307 1249 // Fire-and-forget: re-resolve the note so the cached row is refreshed 1308 1250 // (cover/content) — boosting again heals a stale copy from EVERY boost 1309 1251 // path, not just the interact page. 1310 1252 ActivityPubService.resolveRemoteNote(note) 1311 .then((n) => { if (n) ActivityPubService. setReaction(site.slug, note, 'boost', true, { note: n }); })1253 .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); }) 1312 1254 .catch(() => { /* best-effort */ }); 1255 } else { 1256 ActivityPubService.unmarkBoosted(site.slug, note); 1313 1257 } 1314 1258 } … … 1394 1338 const { newerPost, olderPost } = postNeighbors(site, post); 1395 1339 return renderPage(req, res, 'pages/paid-gate', { 1396 pageJs: 'paid-gate',1397 1340 pageTitle: post.title || 'Voor supporters', 1398 1341 bodyClass: 'on-special', … … 1511 1454 1512 1455 renderPage(req, res, 'pages/post', { 1513 pageJs: 'post',1514 1456 post, 1515 1457 poll: ActivityPubService.ownPollView(post), … … 1563 1505 const kind = req.body.kind === 'boost' ? 'boost' : 'like'; 1564 1506 if (parent && parent.post_id === post.id && parent.object_uri) { 1565 // Toggle: react, or retract it (Undo Announce / Undo Like) if already on. 1566 // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit 1567 // de kolom en de knop uit de tussentabel, dan draait een divergentie de 1568 // richting om en stuur je een Undo voor iets dat nooit is verstuurd. 1569 const ik = ActivityPubService.getReaction(site.slug, parent.object_uri); 1570 const on = kind === 'boost' ? !ik.boosted : !ik.liked; 1571 ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri) 1572 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1573 // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net 1574 // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig 1575 // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na 1576 // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen. 1577 ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on); 1578 if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on); 1579 else ActivityPubService.setInteractionLiked(parent.id, on); 1507 if (kind === 'boost') { 1508 // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted. 1509 const on = !parent.acted_boost; 1510 ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri) 1511 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1512 ActivityPubService.setInteractionBoosted(parent.id, on); 1513 } else { 1514 // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked. 1515 const on = !parent.acted_like; 1516 ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri) 1517 .catch((e) => console.warn('[AP] reaction failed:', e.message)); 1518 ActivityPubService.setInteractionLiked(parent.id, on); 1519 } 1580 1520 } 1581 1521 res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`); -
src/server.js
r952baf3 rf85b2c3 65 65 import apRoutes from './routes/activitypub.js'; 66 66 import oauthRoutes from './routes/oauth.js'; 67 import { apWants, startDeliveryWorker, selfHealTimeline , migrateReactions} from './services/ActivityPubService.js';67 import { apWants, startDeliveryWorker, selfHealTimeline } 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, onder200 // de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code201 // die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.202 migrateReactions();203 199 selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update 204 200 … … 546 542 try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ } 547 543 const title = d.title || 'Klonkt'; 548 e.waitUntil(Promise.all([ 549 self.registration.showNotification(title, { 550 body: d.body || '', 551 icon: '/favicon.svg', 552 badge: '/favicon.svg', 553 tag: d.type ? ('klonkt-' + d.type) : undefined, // collapse same-type bursts 554 data: { url: d.url || '/' }, 555 }), 556 // Wek ook een pagina die al openstaat. De push IS het teken dat er iets 557 // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog 558 // eens doen -- en die tweede zou alleen werken zolang de app open is, 559 // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen. 560 self.clients.matchAll({ type: 'window', includeUncontrolled: true }) 561 .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); }) 562 .catch(() => { /* geen open venster: niets te wekken */ }), 563 ])); 544 e.waitUntil(self.registration.showNotification(title, { 545 body: d.body || '', 546 icon: '/favicon.svg', 547 badge: '/favicon.svg', 548 tag: d.type ? ('klonkt-' + d.type) : undefined, // collapse same-type bursts 549 data: { url: d.url || '/' }, 550 })); 564 551 }); 565 552 self.addEventListener('notificationclick', e => { -
src/services/ActivityPubService.js
r952baf3 rf85b2c3 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-body630 * 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 = list638 .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 644 629 export function getMessages(slug, limit, offset) { 645 630 const off = Math.max(0, offset || 0); … … 653 638 for (const m of listOutbox(slug).slice(0, need)) { 654 639 items.push({ 655 type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors, 656 in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content, 657 editable: m.editable, language: m.language, created_at: m.created_at, 658 // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander: 659 // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest 660 // media uit media_json met een `type`, terwijl ap_outbox ze als 661 // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam 662 // een foto die JIJ meestuurde als kale tekst binnen. 663 media_json: outboxMediaJson(m.attachments), 640 type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to, 641 content: m.content, editable: m.editable, language: m.language, created_at: m.created_at, 664 642 }); 665 643 } 666 644 } catch { /* ignore */ } 667 // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox668 // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets669 // 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 */ }681 645 items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications) 682 646 const out = []; … … 692 656 out.push(it); 693 657 } 694 // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot 695 // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap 696 // telt een draad als één item voor de paginering, wat klopt: je scrolt door 697 // gesprekken, niet door losse zinnen. 698 return groupConversations(out).slice(off, off + lim); 699 } 700 701 // De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie 702 // aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van 703 // de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen 704 // moest dus tussen chips heen en weer, terwijl het één draad is. 705 const CONV_TYPES = new Set(['reply', 'mention', 'sent']); 706 707 /** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt: 708 * 709 * 1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join 710 * op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is 711 * dezelfde sleutel, en daarom staan ze nu in dezelfde draad. 712 * 2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands 713 * eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad 714 * loopt per tegenpartij. 715 * 716 * De post wint van de persoon: twee mensen die onder dezelfde post reageren 717 * voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek 718 * is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid 719 * door. 720 */ 721 export function threadKey(it) { 722 if (!it || !CONV_TYPES.has(it.type)) return null; 723 if (it.post_slug) return `post:${it.post_slug}`; 724 let who = it.handle || it.to_handle || ''; 725 // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de 726 // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de 727 // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen 728 // draad -- precies het geval waarin het meest onlogisch is dat het los blijft. 729 if (!who && it.to_actors) { 730 try { 731 const first = JSON.parse(it.to_actors)[0]; 732 if (first) who = deriveHandle(first); 733 } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ } 734 } 735 const norm = String(who || '').trim().toLowerCase().replace(/^@/, ''); 736 return norm ? `actor:${norm}` : null; 737 } 738 739 /** Vouw losse berichten samen tot draden, met alles wat geen gesprek is 740 * ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend 741 * (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn 742 * nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen 743 * de draad draait het om: een gesprek leest naar beneden, oud naar nieuw. 744 */ 745 export function groupConversations(items) { 746 const threads = new Map(); 747 const out = []; 748 for (const it of items || []) { 749 const key = threadKey(it); 750 if (!key) { out.push(it); continue; } 751 let t = threads.get(key); 752 if (!t) { 753 // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de 754 // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later. 755 t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at }; 756 threads.set(key, t); 757 out.push(t); 758 } 759 t.messages.push(it); 760 // De context bij de draad: gaat het over een post, dan hoort de link 761 // erbij, anders is een los antwoord in een lijst niet te plaatsen. 762 // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te 763 // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen 764 // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden, 765 // dan kan het ontvangen antwoord eronder de echte titel niet meer 766 // invullen. De terugval op de slug hoort in de weergave, niet in de data. 767 if (it.post_slug) { 768 if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null }; 769 else if (!t.post.title && it.post_title) t.post.title = it.post_title; 770 } 771 } 772 for (const t of threads.values()) { 773 t.messages.sort((a, b) => _msgTs(a) - _msgTs(b)); 774 t.count = t.messages.length; 775 // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij. 776 const seen = new Set(); 777 for (const m of t.messages) { 778 if (m.type === 'sent') continue; 779 const h = m.handle || m.name; 780 if (!h || seen.has(h)) continue; 781 seen.add(h); 782 t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url }); 783 } 784 // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als 785 // onbeantwoord bericht leest. 786 t.mine = t.messages.some((m) => m.type === 'sent'); 787 // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten 788 // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE 789 // ontvangen bericht erin (dat is de parent van de thread) -- anders is het 790 // een direct bericht aan de tegenpartij. 791 const inkomend = t.messages.filter((m) => m.type !== 'sent'); 792 const laatste = inkomend[inkomend.length - 1]; 793 t.replyTo = { 794 interactionId: (laatste && laatste.interactionId) || null, 795 postSlug: (t.post && t.post.slug) || null, 796 actorUri: (laatste && (laatste.actorUri || laatste.url)) 797 || (t.messages.find((m) => m.to_actor) || {}).to_actor 798 || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(), 799 }; 800 } 801 return out; 658 return out.slice(off, off + lim); 802 659 } 803 660 … … 1186 1043 const siteIcon = (site && site.profile_photo) || null; 1187 1044 1188 // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit1189 // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een1190 // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de1191 // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het1192 // éé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 1200 1045 const nodes = []; 1201 1046 for (const r of rows) { 1202 1047 if (r.kind !== 'reply') continue; 1203 const ik = mijnReactie(r.object_uri);1204 1048 nodes.push({ 1205 1049 noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id, … … 1208 1052 actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at, 1209 1053 emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, // FEP-9098 (thread render) 1210 acted_boost: ik.boosted, acted_like: ik.liked,1054 acted_boost: !!r.acted_boost, acted_like: !!r.acted_like, 1211 1055 children: [], 1212 1056 }); … … 1297 1141 } 1298 1142 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; 1143 export async function fetchActor(url) { 1319 1144 try { 1320 1145 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } }); 1321 if (r.ok) { 1322 const len = Number(r.headers.get('content-length') || 0); 1323 if (len > 2_000_000) return null; // refuse oversized actor docs 1324 doc = await r.json(); 1325 } 1326 } catch { /* val door naar de ondertekende poging */ } 1327 // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document 1328 // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die 1329 // telt als mislukt. 1330 if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc; 1331 if (!opts.asSlug) return doc; 1332 const signed = await signedGetJson(opts.asSlug, url).catch(() => null); 1333 return (signed && signed.id) ? signed : doc; 1146 if (!r.ok) return null; 1147 const len = Number(r.headers.get('content-length') || 0); 1148 if (len > 2_000_000) return null; // refuse oversized actor docs 1149 return await r.json(); 1150 } catch { return null; } 1334 1151 } 1335 1152 … … 1408 1225 } 1409 1226 1410 /** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is1411 * (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 1420 1227 // Best-effort verification of an incoming signed request. Returns the sender's 1421 1228 // actor doc if the signature checks out, else null. (Not gating yet — MVP.) … … 1423 1230 // federating servers with drifting clocks; an operator can widen it via env. 1424 1231 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000; 1425 export async function verifyRequest(req , asSlug = null) {1232 export async function verifyRequest(req) { 1426 1233 const sigH = req.headers['signature']; 1427 1234 if (!sigH) return null; 1428 1235 const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); 1429 1236 if (!p.keyId || !p.signature) return null; 1430 // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized 1431 // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we 1432 // als een willekeurige lokale actor: elke Klonkt-actor is een geldige 1433 // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is. 1434 const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() }); 1237 const actor = await fetchActor(p.keyId.split('#')[0]); 1435 1238 const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem; 1436 1239 if (!pem) return null; … … 1692 1495 } 1693 1496 1694 /**1695 * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).1696 *1697 * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke1698 * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN1699 * 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 niet1701 * binnen, zonder dat iemand een fout zag.1702 *1703 * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt1704 * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de1705 * 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 te1710 * 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 dit1712 * anker wijst een doorsturer je naar een host die hij zelf beheert, waar1713 * attributedTo alles kan beweren.1714 * 3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders1715 * levert een doorsturer een echt id met verdraaide inhoud.1716 * 4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de1717 * geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.1718 */1719 /** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een1720 * gecachete post in de tijdlijn, of een reactie die al in een thread van ons1721 * staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop1722 * 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 -- dit1744 * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri1745 * 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 een1749 * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een1750 * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor1751 * 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 doorloopt1760 // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat1761 // 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 bezorging1774 // dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de1775 // 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 eruit1786 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 a1794 // rejection count could not be told apart from a narrowing that closed too far1795 // — and that is exactly the measurement shaer-drf is waiting for. Bounded by1796 // 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); // ankereis1807 } catch { return skipped('unparsable id', objId); }1808 // Alleen dereferencen als het object beweert een antwoord te zijn op iets van1809 // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door1810 // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk1811 // 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 kost1813 // niets aan legitiem verkeer waarvan we de ouder kennen.1814 const parent = typeof o === 'object' && o1815 ? (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 een1820 // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde1821 // 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 — and1825 // 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 in1827 // secure mode could never be dereferenced at all. Same fix verifyRequest got1828 // 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.attributedTo1834 : (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 1847 1497 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 1848 1498 export async function handleInbox(req, slugParam, preVerified = null) { … … 1858 1508 // keeps everything below identical, including the actor-versus-signer check, 1859 1509 // which is exactly the check that must not be skipped for being local. 1860 const verified = preVerified || await verifyRequest(req , slugParam).catch(() => null);1510 const verified = preVerified || await verifyRequest(req).catch(() => null); 1861 1511 1862 1512 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very … … 1868 1518 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move']; 1869 1519 if (GATED.includes(type)) { 1870 // Een geldige handtekening van iemand anders dan de auteur is doorsturen, 1871 // geen vervalsing. Haal het object dan bij de bron op in plaats van het af 1872 // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder. 1873 let forwarded = null; 1874 if (verified && claimedActor && verified.id !== claimedActor) { 1875 forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null); 1876 if (forwarded) { 1877 act.object = forwarded; // de OPGEHAALDE inhoud, niet de bezorgde 1878 console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id); 1879 } 1880 } 1881 if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) { 1882 // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid" 1883 // heetten: geen handtekening meegestuurd, wel een handtekening maar niet 1884 // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is), 1885 // of geldig ondertekend door iemand anders. 1886 const reden = verified ? '(signer mismatch)' 1887 : (req.headers && req.headers.signature) ? '(signature present, unverifiable)' 1888 : '(no signature)'; 1889 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden); 1520 if (!verified || !claimedActor || verified.id !== claimedActor) { 1521 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)'); 1890 1522 return 401; 1891 1523 } … … 2177 1809 } 2178 1810 } 2179 // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n2180 // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar2181 // 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 de2183 // 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 schrijver2185 // 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 }2189 1811 // Mentioned in a post that is NOT a reply to our content (a reply to us already returned 2190 1812 // above): store a mention notification for each of our actors named in the Mention tags. 2191 1813 // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is 2192 1814 // someone else's actor, not ours. 2193 // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten2194 // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met2195 // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de2196 // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward2197 // 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 }2206 1815 if (actorUri && !isLocalActor && o.id) { 2207 1816 const slugs = localMentionSlugs(o.tag, base); … … 2888 2497 const kind = type === 'Announce' ? 'boost' : 'like'; 2889 2498 await sendInteraction(site, kind, objUri, authorUri); 2890 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer. 2891 // De note gaat mee zodat een boost de post je tijdlijn in trekt. 2892 try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); } 2893 catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ } 2894 // Een Like uit een app moet ook in ap_timeline.liked landen, want dat 2895 // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd 2896 // de reactie wel opgeslagen (setMyReaction, de webroute leest die), 2897 // maar kreeg de app altijd liked:false terug: het hartje sprong bij de 2898 // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood 2899 // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like. 2900 // Anders dan bij een boost geen upsert: een like hoort een post niet 2901 // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit 2902 // terecht een no-op. 2499 setMyReaction(site.slug, targetUri, kind, true); 2500 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } } 2903 2501 return { status: 202, url: objUri }; 2904 2502 } … … 2949 2547 const objUri = (note && note.object_uri) || innerTarget; 2950 2548 await sendInteraction(site, kind, objUri, note && note.actor_uri); 2951 try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }2952 catch { /* non-fatal */}2549 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false); 2550 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } } 2953 2551 return { status: 202, url: objUri }; 2954 2552 } … … 3315 2913 } 3316 2914 export function listOutbox(siteSlug) { 3317 // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel 3318 // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post 3319 // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit 3320 // elkaar in "Verzonden" en "Gesprekken". 3321 return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 2915 return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC') 3322 2916 .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; }); 3323 2917 } … … 3457 3051 return { ins: _insTl, list: _listTl, del: _delTl }; 3458 3052 } 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 } 3053 export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); } 3482 3054 3483 3055 /** … … 3515 3087 } 3516 3088 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 is3524 * 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, terwijl3527 * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een3528 * andere server niet te vertrouwen is.3529 *3530 * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd3531 * 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 en3545 * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens3546 * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige3547 * 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_state3552 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 met3558 // een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen3559 // 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 emitter3567 * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je3568 * 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 tik3570 * 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 op3587 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 3599 3089 export function getDirectMessages(slug, limit) { 3600 3090 try { … … 3753 3243 if (!_cirkelPosts) _cirkelPosts = db.prepare(` 3754 3244 SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url, 3755 t.content, t.url, t.published, t.media_json, t.nsfw, t.cw, 3756 (rb.target_uri IS NOT NULL) AS boosted 3245 t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw 3757 3246 FROM ap_timeline t 3758 3247 LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri 3759 -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De 3760 -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus 3761 -- deze join kan geen rijen verdubbelen. 3762 LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost' 3763 WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL) 3248 WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1) 3764 3249 ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC 3765 3250 LIMIT ? OFFSET ?`); … … 3770 3255 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 []; } 3771 3256 } 3772 // AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd" 3773 // staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door 3774 // setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de 3775 // helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12). 3776 // 3777 // ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de 3778 // Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen 3779 // kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg 3780 // zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee 3781 // terugdraaien een code-revert blijft in plaats van dataherstel. 3257 // Mark a timeline post as boosted so it shows in the Cirkel (mixed by date). 3782 3258 let _markBoost, _unmarkBoost, _boostedCount; 3783 3259 export function markBoosted(slug, noteId) { … … 3794 3270 try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ } 3795 3271 } 3796 /**3797 * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn3798 * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op3799 * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar3800 * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies3801 * dat leverde de shaer:liked-bug op (04aca12).3802 *3803 * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel3804 * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur3805 * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de3806 * boost nergens -- ook niet in de Cirkel.3807 *3808 * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld3809 * worden: de tussentabel op de URI die de client stuurde, de vlag op de3810 * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze3811 * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is3812 * werk voor fase 2, mét datamigratie.3813 */3814 // Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als3815 // selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een3816 // 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 bump3819 // 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 binnenkwam3828 * en de bookmarklet geeft window.location.href door, dus de permalink. Sinds3829 * canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen3830 * wees zouden zijn. De created_at reist mee: bij hersleutelen weten we3831 * wanneer je reageerde, bij aanvullen niet.3832 * 2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de3833 * Krant is gegeven staat alleen daar; zonder deze stap toont het als3834 * niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de3835 * 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 raken3848 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_uri3852 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 t3855 WHERE t.${kolom} = 13856 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r3857 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 slug3859 // hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een3860 // 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 i3864 JOIN posts p ON p.id = i.post_id3865 JOIN sites s ON s.id = p.site_id3866 WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''3867 AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r3868 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 rij3882 // 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 zijn3921 * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de3922 * 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 op3924 * 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 fase3927 * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen3928 * highlight. Vandaar hier, en niet later.3929 *3930 * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de3931 * 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 waarheid3945 * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die3946 * 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 zet3960 * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je3961 * 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_reactions3970 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 van3985 // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is3986 // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de3987 // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat3988 // 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 3999 3272 export function getTimelineReaction(slug, noteId) { 4000 3273 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 }; } … … 4030 3303 } 4031 3304 export function boostedCount(slug) { 4032 // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost 4033 // bevatten van iets dat er (nog) niet in staat. 4034 try { 4035 if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r 4036 JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri 4037 WHERE r.site_slug = ? AND r.kind = 'boost'`); 4038 return _boostedCount.get(slug).n; 4039 } catch { return 0; } 3305 try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; } 4040 3306 } 4041 3307 … … 4752 4018 await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem); 4753 4019 } 4754 console.log('[AP] MOVE announced:', site.slug, '→', target.id, ' to', inboxes.length, 'inbox(es)');4020 console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)'); 4755 4021 return { ok: true, target: target.id, inboxes: inboxes.length }; 4756 4022 } … … 4932 4198 const wardDoc = await fetchActor(wardUri).catch(() => null); 4933 4199 const fai = actorInfo(await fetchActor(follower).catch(() => null), follower); 4934 // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande 4935 // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde 4936 // hier als "deze ward wil deze ward volgen" met het doel weggegooid. 4937 // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het 4938 // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden. 4939 const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri; 4940 const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null; 4941 const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null; 4942 Guardianship.follows.recordReview(gslug, { 4943 id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, 4944 follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo), 4945 direction: uitgaand ? 'outgoing' : 'incoming', 4946 target: doel || null, targetHandle: dai ? dai.handle : null, 4947 }); 4200 Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) }); 4948 4201 const L = pushLang(gslug); 4949 4202 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` }); … … 5048 4301 try { 5049 4302 const rows = db.prepare(` 5050 SELECT i. id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,4303 SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility, 5051 4304 i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json, 5052 4305 p.slug AS post_slug, p.title AS post_title … … 5057 4310 for (const r of rows) out.push({ 5058 4311 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 voor5060 // deliverReply, de uri het adres voor een direct bericht.5061 interactionId: r.interaction_id, actorUri: r.actor_uri,5062 4312 content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at, 5063 4313 // When the post was written, for display. created_at (when it reached us) … … 5293 4543 getOutboxRow: (id) => iStmts().getO.get(id), 5294 4544 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery, 5295 // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat5296 // een antwoord uit Berichten door precies één poort gaat.5297 sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),5298 htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),5299 4545 }); 5300 4546 /** … … 5333 4579 // Guardian PWA / Berichten push. The kid answers an incoming offer in its 5334 4580 // 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 een5337 // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.5338 4581 onEvent: (slug, ev) => { 5339 const p = guardianEventPush(slug, ev); 5340 if (p) pushEvent(slug, p); 4582 const L = pushLang(slug); 4583 const texts = { 4584 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], // I am the ward 4585 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward 4586 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'], 4587 // §3.2: a guardian ended the relation. The ward hears that someone who 4588 // was looking after them has gone; a co-guardian hears they are one fewer. 4589 guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'], 4590 coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'], 4591 }[ev.kind]; 4592 if (!texts) return; 4593 const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?'; 4594 const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian'; 4595 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url }); 5341 4596 }, 5342 4597 }); 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: de5348 * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke5349 * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push5350 * 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 ward5356 offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'], // I co-guard this ward5357 committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],5358 // §3.2: a guardian ended the relation. The ward hears that someone who5359 // 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 guardian5363 // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het5364 // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is5365 // geen drempel.5366 gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'], // jij moet antwoorden5367 gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'], // er is besloten5368 }[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 er5372 // 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 voor5381 // gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van5382 // 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 };5392 4598 5393 4599 // The notification duty of FEP-633c 3.6.2, wired once for every place a … … 5419 4625 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured, 5420 4626 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 5421 feedCursor, feedChangesSince, waitForFeedChange, 5422 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 4627 getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote, 5423 4628 listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote, 5424 4629 webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll, … … 5426 4631 gateOutgoingFollow, performApprovedFollow, 5427 4632 parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, 5428 autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,4633 autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline, 5429 4634 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 5430 4635 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker, -
src/services/guardianship/delivery.js
r952baf3 rf85b2c3 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, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark}) {43 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) { 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 de87 // 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 : '';91 85 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>'); 92 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het 93 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint. 94 const content = rich 95 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}` 96 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 86 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 97 87 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null; 98 88 // Attachments: same rules as deliverReply (own /media/ uploads only, … … 109 99 const row = getOutboxRow(id); 110 100 const note = buildReplyNote(base, site, row); 111 // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een112 // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde113 // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de114 // mede-guardians er staat aan over.115 if (helpMark && helpMark.noteUri) {116 note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;117 }118 101 const create = { 119 102 '@context': AP_CONTEXT, -
src/services/guardianship/follows.js
r952baf3 rf85b2c3 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, 84 direction, target_uri, target_handle, created_at) 85 VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`), 83 (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at) 84 VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`), 86 85 get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'), 87 86 bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"), … … 92 91 } 93 92 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 follower98 * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de99 * follower en staat het doel in het Follow-object -- die werd hiervoor100 * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.101 */102 93 export function recordReview(guardianSlug, r) { 103 const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming'; 104 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, 105 r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null); 94 rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null); 106 95 return rstmts().get.get(guardianSlug, r.id); 107 }108 109 /**110 * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al111 * 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 wordt115 // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als116 // "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);145 96 } 146 97 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); } … … 151 102 recordPending, getPending, listForWard, decide, remove, 152 103 recordReview, getReview, listReviews, removeReview, 153 listReviewsByDirection, reviewQueueItem,154 104 }; -
src/services/guardianship/gated.js
r952baf3 rf85b2c3 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 het66 * 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 data68 * te zijn en geen nieuw stuk scherm.69 *70 * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag71 * een guardian niet hoeven raden:72 *73 * setting een stand, aan of uit, terug te draaien74 * perRequest geen stand maar een stroom beslissingen (5.3 volgverzoeken)75 * handover draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt76 *77 * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat78 * 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 paneel84 // 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 regels90 * getoetst kunnen worden zonder database of scherm.91 *92 * @param settings {feature: true|false|null} -- null is ONBEKEND, niet uit93 * @param guardianCount aantal guardians, of null als we het niet weten94 * @param proposals [{feature, value, status}] lopende voorstellen95 * @param waiting {feature: aantal} wat er per gate op een besluit wacht96 */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 daar102 // 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 gok113 // 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 122 62 export function featureColumn(feature) { 123 63 return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null; … … 299 239 300 240 export default { 301 GATE_CATALOGUE, gateRows,302 241 tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS, 303 242 parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer, -
src/services/guardianship/index.js
r952baf3 rf85b2c3 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
r952baf3 rf85b2c3 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 ON my wards (§5.3), Fase 2 (shaer-jdb)8 * - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now 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';16 15 import * as handshake from './handshake.js'; 17 16 … … 36 35 } 37 36 38 /** 39 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2, 40 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar 41 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag. 42 * 43 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog) 44 * op deze: 45 * - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward 46 * - ap_pending_follows: een ward op deze instance 47 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan. 48 */ 49 export function followsCollection(id, slug, me) { 50 const items = follows.listReviewsByDirection(slug, 'incoming') 51 .map((r) => follows.reviewQueueItem(r, me)); 52 for (const w of relations.listWards(slug)) { 53 const wardSlug = slugOf(w.other_uri); 54 if (!wardSlug) continue; 55 for (const p of follows.listForWard(wardSlug)) { 56 items.push({ 57 id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri, 58 'shaer:direction': 'incoming', 'shaer:ward': w.other_uri, 59 'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined, 60 'shaer:quorum': p.quorum || 'any', published: p.created_at, 61 }); 62 } 63 } 64 return collection(id, items); 37 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */ 38 export function followsCollection(id) { 39 return collection(id, []); 65 40 } 66 41 67 /** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */ 68 function slugOf(uri) { 69 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 70 if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null; 71 return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null; 72 } 73 74 /** 75 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een 76 * guardian wederzijds uitsluit: je bent het een of het ander. 77 * 78 * ALS WARD wat IK wil volgen en waar mijn guardians nog over moeten 79 * ALS GUARDIAN wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb) 80 * 81 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor 82 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to 83 * follow" kon nooit iets tonen. 84 */ 42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */ 85 43 export function outgoingFollowsCollection(id, slug, me) { 86 const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)); 87 for (const r of follows.listReviewsByDirection(slug, 'outgoing')) { 88 items.push(follows.reviewQueueItem(r, me)); 89 } 90 return collection(id, items); 44 return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me))); 91 45 } 92 46 -
src/services/i18n.js
r952baf3 rf85b2c3 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.', ' msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',69 'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.', 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.', ' msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',1019 'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.', 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.', ' msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',1963 'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.', 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
r952baf3 rf85b2c3 573 573 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 574 574 575 <%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 576 <%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %> 575 <script> 576 (function() { 577 578 // ── Live filename display for custom file inputs ────────────── 579 document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => { 580 const nameEl = input.parentElement.querySelector('.ax-file-name'); 581 if (!nameEl) return; 582 input.addEventListener('change', () => { 583 if (input.files && input.files[0]) { 584 nameEl.textContent = input.files[0].name; 585 nameEl.removeAttribute('data-empty'); 586 } else { 587 nameEl.textContent = '<%= t('aaud.no_file') %>'; 588 nameEl.setAttribute('data-empty', ''); 589 } 590 }); 591 }); 592 593 // ── Inline track preview (routed through the global mini-player) ── 594 // Each .ax-track-play button is a thin wrapper around the global 595 // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ / 596 // .is-playing) is synced from the player's own audio element so it 597 // stays accurate even when the user uses the mini-player's controls. 598 (function setupTrackPreview() { 599 const buttons = document.querySelectorAll('.ax-track-play'); 600 if (!buttons.length) return; 601 602 function setIcon(btn, playing) { 603 const icon = btn.querySelector('.ax-track-play-icon'); 604 if (icon) icon.textContent = playing ? '⏸' : '▶'; 605 btn.classList.toggle('is-playing', playing); 606 btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>'); 607 } 608 609 // Resync ALL preview buttons against the current audio element state. 610 // Called on every play/pause/ended event so admins always see the right 611 // icon — including the case where they hit pause on the mini-player 612 // bar instead of the row's button. 613 function resyncAll() { 614 const audio = document.getElementById('audio-element'); 615 const player = window.pcmsAudioPlayer; 616 const playing = audio && !audio.paused && !audio.ended; 617 // audio.src is now a blob: URL (Spotify-style playback), so compare 618 // against the player's logical track URL, not the element src. 619 const cur = player && player.currentTrack(); 620 const curUrl = cur ? cur.url : ''; 621 buttons.forEach(b => { 622 const isThisOne = playing && curUrl === b.dataset.streamUrl; 623 setIcon(b, isThisOne); 624 }); 625 } 626 627 buttons.forEach(btn => { 628 btn.addEventListener('click', e => { 629 e.preventDefault(); 630 const player = window.pcmsAudioPlayer; 631 if (!player) { 632 console.warn('[admin-audio] miniplayer not available'); 633 return; 634 } 635 const url = btn.dataset.streamUrl; 636 if (!url) return; 637 638 // Build track metadata from the row's DOM so the mini-player shows 639 // useful info (title/artist/album/cover) without an extra API call. 640 const row = btn.closest('.ax-track'); 641 const titleEl = row && row.querySelector('[data-cell="title"]'); 642 const artistEl = row && row.querySelector('[data-cell="artist"]'); 643 const albumEl = row && row.querySelector('[data-cell="album"]'); 644 const coverImg = row && row.querySelector('img[data-cover-thumb]'); 645 const track = { 646 url, 647 title: titleEl ? titleEl.textContent.trim() : 'Track', 648 artist: artistEl ? artistEl.textContent.trim() : '', 649 album: albumEl ? albumEl.textContent.trim() : '', 650 cover: coverImg ? coverImg.src : '', 651 }; 652 653 // If this exact track is already current, toggle pause/play instead 654 // of restarting from zero. Compare logical URLs (audio.src is a blob:). 655 const cur = player.currentTrack(); 656 if (cur && cur.url === url) { 657 if (player.isPlaying()) player.pause(); 658 else player.play(); 659 return; 660 } 661 662 player.setQueue([track], 0); 663 }); 664 }); 665 666 // Hook the global audio element's events to keep the row buttons synced. 667 // We attach lazily after the player has built its DOM. The script in 668 // shell.ejs runs at page-load so #audio-element exists by the time 669 // this IIFE fires (script tag is below the body content). 670 const audio = document.getElementById('audio-element'); 671 if (audio) { 672 ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => { 673 audio.addEventListener(ev, resyncAll); 674 }); 675 // Initial state on page load (e.g. user navigated back while a track 676 // was already playing — buttons should reflect that). 677 resyncAll(); 678 } 679 })(); 680 681 682 // ── Bulk upload (drag-drop + sequential transcoding) ───────── 683 // Files dropped or picked are queued (not uploaded immediately) so the 684 // user can review the list, set shared metadata, then hit "Start upload". 685 // The loop POSTs one file at a time to /admin/audio/upload with 686 // Accept: application/json so the server returns structured per-file 687 // results instead of redirecting. 688 const dropzone = document.getElementById('audio-dropzone'); 689 const fileInput = document.getElementById('audio-files'); 690 const queueEl = document.getElementById('upload-queue'); 691 const actionsEl = document.getElementById('upload-actions'); 692 const startBtn = document.getElementById('start-upload-btn'); 693 const clearBtn = document.getElementById('clear-queue-btn'); 694 const artistInput= document.getElementById('batch-artist'); 695 const albumInput = document.getElementById('batch-album'); 696 const coverInput = document.getElementById('batch-cover'); 697 698 /** @type {Array<{file: File, el: HTMLElement, status: string}>} */ 699 const queue = []; 700 701 function fmtBytes(n) { 702 if (n < 1024) return n + ' B'; 703 if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB'; 704 return (n / 1024 / 1024).toFixed(1) + ' MB'; 705 } 706 707 function setItemStatus(item, status, label) { 708 const labels = { 709 queued: '<%= t('aaud.st_queued') %>', 710 uploading: '<%= t('aaud.st_uploading') %>', 711 transcoding: '<%= t('aaud.st_transcoding') %>', 712 done: '✓ <%= t('aaud.st_done') %>', 713 error: '✗ <%= t('aaud.st_error') %>', 714 }; 715 item.status = status; 716 const badge = item.el.querySelector('.ax-queue-status'); 717 badge.className = 'ax-queue-status ax-queue-status--' + status; 718 badge.textContent = label || labels[status] || status; 719 // Restyle the row 720 item.el.classList.remove( 721 'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error' 722 ); 723 if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active'); 724 else if (status === 'done') item.el.classList.add('ax-queue-item--done'); 725 else if (status === 'error') item.el.classList.add('ax-queue-item--error'); 726 } 727 728 function addFiles(files) { 729 let added = 0; 730 for (const file of files) { 731 if (!file.type.startsWith('audio/') && 732 !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) { 733 // Silently skip non-audio drops; keeps the UX uncluttered. 734 continue; 735 } 736 const li = document.createElement('li'); 737 li.className = 'ax-queue-item'; 738 li.innerHTML = 739 '<div class="ax-queue-name"></div>' + 740 '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>'; 741 // Use textContent to avoid HTML-injection if a filename contains markup. 742 li.querySelector('.ax-queue-name').textContent = file.name; 743 // Append size hint inline 744 const size = document.createElement('span'); 745 size.className = 'ax-queue-size'; 746 size.textContent = fmtBytes(file.size); 747 li.querySelector('.ax-queue-name').appendChild(size); 748 queueEl.appendChild(li); 749 queue.push({ file, el: li, status: 'queued' }); 750 added++; 751 } 752 if (added) { 753 queueEl.hidden = false; 754 actionsEl.hidden = false; 755 } 756 } 757 758 // ── Drop-zone events ───────────────────────────────────────── 759 // Page-level guard: a dropped file outside the zone would otherwise 760 // make the browser navigate to it (e.g. opening the audio inline), which 761 // discards typed metadata. We swallow drops anywhere unless the dropzone 762 // explicitly handles them. 763 ['dragover', 'drop'].forEach(ev => { 764 window.addEventListener(ev, e => { 765 // Allow drops INSIDE the dropzone — its own listener handles those. 766 if (dropzone.contains(e.target)) return; 767 e.preventDefault(); 768 }); 769 }); 770 771 ['dragenter', 'dragover'].forEach(ev => { 772 dropzone.addEventListener(ev, e => { 773 e.preventDefault(); 774 dropzone.classList.add('is-dragover'); 775 }); 776 }); 777 ['dragleave', 'drop'].forEach(ev => { 778 dropzone.addEventListener(ev, e => { 779 e.preventDefault(); 780 dropzone.classList.remove('is-dragover'); 781 }); 782 }); 783 dropzone.addEventListener('drop', e => { 784 if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files); 785 }); 786 fileInput.addEventListener('change', () => { 787 addFiles(fileInput.files); 788 // Reset so the same file can be picked again later if user wants 789 fileInput.value = ''; 790 }); 791 792 // ── Clear queue button ─────────────────────────────────────── 793 clearBtn.addEventListener('click', () => { 794 // Only remove items that aren't currently uploading (anything queued 795 // or already finished). An in-progress upload finishes, then its row 796 // would also disappear once we re-render — but we keep it simple and 797 // just refuse to clear during an active run. 798 if (startBtn.disabled) return; 799 queue.length = 0; 800 queueEl.innerHTML = ''; 801 queueEl.hidden = true; 802 actionsEl.hidden = true; 803 }); 804 805 // ── Sequential upload loop ─────────────────────────────────── 806 startBtn.addEventListener('click', async () => { 807 if (startBtn.disabled) return; 808 startBtn.disabled = true; 809 clearBtn.disabled = true; 810 dropzone.style.pointerEvents = 'none'; 811 dropzone.style.opacity = '0.5'; 812 813 const sharedArtist = artistInput.value.trim(); 814 const sharedAlbum = albumInput.value.trim(); 815 const sharedCover = coverInput.files && coverInput.files[0]; 816 817 // Process queued items one at a time. We iterate via index so that 818 // if more files get dropped during the run they ALSO get processed 819 // (queue.push above mutates the same array we're iterating). 820 for (let i = 0; i < queue.length; i++) { 821 const item = queue[i]; 822 if (item.status !== 'queued') continue; 823 try { 824 await uploadOne(item, sharedArtist, sharedAlbum, sharedCover); 825 } catch (err) { 826 console.error('upload failed for', item.file.name, err); 827 setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>')); 828 } 829 } 830 831 startBtn.disabled = false; 832 clearBtn.disabled = false; 833 dropzone.style.pointerEvents = ''; 834 dropzone.style.opacity = ''; 835 836 // Reload the page so the new tracks appear in the list below. 837 // Could also fetch them and inject, but a full reload is simpler and 838 // ensures position indexes / album-grouping are correct. 839 const anyDone = queue.some(q => q.status === 'done'); 840 if (anyDone) { 841 setTimeout(() => location.reload(), 700); 842 } 843 }); 844 845 async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) { 846 setItemStatus(item, 'uploading'); 847 848 const fd = new FormData(); 849 fd.append('audio', item.file); 850 if (sharedArtist) fd.append('artist', sharedArtist); 851 if (sharedAlbum) fd.append('album', sharedAlbum); 852 if (sharedCover) fd.append('cover', sharedCover); 853 // Title is intentionally omitted — server uses filename fallback. 854 855 // We can't reliably distinguish "still uploading bytes" from 856 // "uploading done, ffmpeg running" without progress events, but the 857 // status flips to "Converteren…" once the request is past upload phase. 858 // We approximate this by waiting until the response arrives — by then 859 // both phases are complete on the server side. For a smoother feel we 860 // briefly show "transcoding" near the end of the request lifecycle. 861 const transcodeHint = setTimeout(() => { 862 if (item.status === 'uploading') setItemStatus(item, 'transcoding'); 863 }, 1500); 864 865 try { 866 const res = await fetch('/admin/audio/upload', { 867 method: 'POST', 868 headers: { 'Accept': 'application/json' }, 869 body: fd, 870 credentials: 'same-origin', 871 }); 872 clearTimeout(transcodeHint); 873 874 // Server responds with JSON for our Accept header. If it didn't 875 // (e.g. session expired and got an HTML login page), surface that. 876 let data; 877 try { data = await res.json(); } 878 catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); } 879 880 if (!res.ok || !data.ok) { 881 throw new Error(data.error || ('HTTP ' + res.status)); 882 } 883 884 setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>')); 885 } catch (err) { 886 clearTimeout(transcodeHint); 887 throw err; 888 } 889 } 890 891 // ── Click-to-copy embed codes ───────────────────────────────── 892 document.querySelectorAll('[data-copy]').forEach(el => { 893 el.addEventListener('click', async () => { 894 const text = el.dataset.copy; 895 try { 896 await navigator.clipboard.writeText(text); 897 el.classList.add('is-copied'); 898 const original = el.textContent; 899 el.textContent = '✓ <%= t('aaud.copied') %>'; 900 setTimeout(() => { 901 el.classList.remove('is-copied'); 902 el.textContent = original; 903 }, 1200); 904 } catch (_) { /* fall through — selection still works */ } 905 }); 906 }); 907 908 // ── "+ Track zonder audio": maak een link-only stub + open de editor ── 909 const addLinkBtn = document.getElementById('add-link-track-btn'); 910 if (addLinkBtn) { 911 addLinkBtn.addEventListener('click', async () => { 912 addLinkBtn.disabled = true; 913 try { 914 const r = await fetch('/admin/audio/create-link', { 915 method: 'POST', credentials: 'same-origin', 916 headers: { 'Content-Type': 'application/json' }, 917 body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }), 918 }); 919 const j = await r.json(); 920 if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>'); 921 if (typeof window.openTrackEditor !== 'function') { location.reload(); return; } 922 window.openTrackEditor({ id: j.id, onSaved: () => location.reload() }); 923 } catch (err) { 924 alert('<%= t('aaud.create_failed') %>: ' + err.message); 925 } finally { 926 addLinkBtn.disabled = false; 927 } 928 }); 929 } 930 931 // ── Wire all "Edit" buttons to the track-editor modal ───────── 932 // After save we patch the row in-place rather than reloading, 933 // so the user keeps their scroll position on long lists. 934 document.querySelectorAll('[data-track-edit]').forEach(btn => { 935 btn.addEventListener('click', () => { 936 if (typeof window.openTrackEditor !== 'function') { 937 alert('<%= t('aaud.editor_not_loaded') %>'); 938 return; 939 } 940 const id = btn.dataset.id; 941 window.openTrackEditor({ 942 id, 943 onSaved: (track) => { 944 const row = document.querySelector('li[data-track-id="' + id + '"]'); 945 if (!row) return; 946 // Update visible cells 947 const titleEl = row.querySelector('[data-cell="title"]'); 948 const artistEl = row.querySelector('[data-cell="artist"]'); 949 const albumEl = row.querySelector('[data-cell="album"]'); 950 if (titleEl) titleEl.textContent = track.title || '<%= t('aaud.untitled') %>'; 951 if (artistEl) artistEl.textContent = track.artist || '—'; 952 if (albumEl) { 953 albumEl.textContent = track.album || ''; 954 if (track.album) albumEl.removeAttribute('hidden'); 955 else albumEl.setAttribute('hidden', ''); 956 } 957 // Update cover thumb (replace element if type changed) 958 const oldThumb = row.querySelector('[data-cover-thumb]'); 959 if (oldThumb) { 960 const parent = oldThumb.parentElement; 961 if (track.cover_url) { 962 const img = document.createElement('img'); 963 img.className = 'ax-track-cover'; 964 img.src = track.cover_url; 965 img.alt = ''; 966 img.dataset.coverThumb = ''; 967 parent.replaceChild(img, oldThumb); 968 } else { 969 const sp = document.createElement('span'); 970 sp.className = 'ax-track-cover ax-track-cover-empty'; 971 sp.textContent = '♫'; 972 sp.dataset.coverThumb = ''; 973 parent.replaceChild(sp, oldThumb); 974 } 975 } 976 }, 977 }); 978 }); 979 }); 980 981 // Download-voor-email toggle — AJAX (geen pagina-reload meer). 982 document.querySelectorAll('[data-track-dl]').forEach(btn => { 983 btn.addEventListener('click', async () => { 984 if (btn.disabled) return; 985 const want = btn.dataset.on === '1' ? 0 : 1; 986 btn.disabled = true; 987 try { 988 const res = await fetch('/admin/audio/api/' + btn.dataset.id, { 989 method: 'POST', 990 headers: { 'Content-Type': 'application/json' }, 991 body: JSON.stringify({ downloadable: !!want }), 992 }); 993 if (!res.ok) throw new Error('HTTP ' + res.status); 994 btn.dataset.on = String(want); 995 btn.style.color = want ? 'var(--accent,#6b8f71)' : ''; 996 btn.style.opacity = want ? '1' : '.5'; 997 btn.title = want 998 ? '<%= t('aaud.dl_on') %>' 999 : '<%= t('aaud.dl_off') %>'; 1000 } catch (e) { 1001 alert('<%= t('aaud.change_failed') %>: ' + (e.message || e)); 1002 } finally { 1003 btn.disabled = false; 1004 } 1005 }); 1006 }); 1007 })(); 1008 </script> -
src/views/pages/admin-epk.ejs
r952baf3 rf85b2c3 90 90 .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; } 91 91 </style> 92 <%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %> 92 <script> 93 (function () { 94 var box = document.querySelector('.epk-track-pick'); 95 if (!box) return; 96 var max = parseInt(box.dataset.max, 10) || 5; 97 function sync() { 98 var checks = box.querySelectorAll('input[type=checkbox]'); 99 var n = box.querySelectorAll('input[type=checkbox]:checked').length; 100 checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); }); 101 } 102 box.addEventListener('change', sync); 103 sync(); 104 })(); 105 </script> -
src/views/pages/admin-help.ejs
r952baf3 rf85b2c3 145 145 .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; } 146 146 </style> 147 <%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %> 147 <script> 148 (function () { 149 var q = document.getElementById('hb-q'); 150 var items = [].slice.call(document.querySelectorAll('.hb-item')); 151 var empty = document.getElementById('hb-empty'); 152 var count = document.getElementById('hb-count'); 153 if (!q) return; 154 // Bewaar de originele tekst per item (voor highlight-reset). 155 items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); }); 156 function run() { 157 var term = q.value.trim().toLowerCase(); 158 var shown = 0; 159 items.forEach(function (it) { 160 var hit = !term || it._txt.indexOf(term) >= 0; 161 it.classList.toggle('hb-hidden', !hit); 162 if (hit) shown++; 163 }); 164 empty.hidden = shown !== 0; 165 count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : ''; 166 } 167 q.addEventListener('input', run); 168 })(); 169 </script> -
src/views/pages/admin-media.ejs
r952baf3 rf85b2c3 41 41 </div> 42 42 43 <%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 44 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %> 43 <script> 44 (function () { 45 if (window.__mediaWired) return; window.__mediaWired = true; 46 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>; 47 document.addEventListener('click', function (e) { 48 var c = e.target.closest('[data-copy]'); 49 if (c) { 50 var u = location.origin + c.getAttribute('data-copy'); 51 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 52 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 53 else window.prompt('URL', u); 54 return; 55 } 56 var d = e.target.closest('[data-del]'); 57 if (d) { 58 if (!window.confirm(T.delC)) return; 59 fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 60 .then(function (r) { return r.json(); }) 61 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); }) 62 .catch(function () { window.alert('Error'); }); 63 return; 64 } 65 if (e.target.closest('#media-cleanup')) { 66 if (!window.confirm(T.cleanC)) return; 67 fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' }) 68 .then(function (r) { return r.json(); }) 69 .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); }) 70 .catch(function () { window.alert('Error'); }); 71 } 72 }); 73 })(); 74 </script> -
src/views/pages/admin-paid.ejs
r952baf3 rf85b2c3 72 72 </div> 73 73 74 <%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %> 74 <script> 75 (function () { 76 var field = document.getElementById('pd-redirect'); 77 var btn = document.getElementById('pd-copy'); 78 if (!field || !btn) return; 79 field.addEventListener('focus', function () { field.select(); }); 80 btn.addEventListener('click', function () { 81 field.select(); 82 var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); }; 83 if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); } 84 else { try { document.execCommand('copy'); done(); } catch (e) {} } 85 }); 86 })(); 87 </script> -
src/views/pages/admin-playlists.ejs
r952baf3 rf85b2c3 222 222 </style> 223 223 224 <%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 225 <%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %> 224 <script> 225 (function() { 226 const csrf = '<%= _csrf %>'; 227 228 document.getElementById('pl-new-btn')?.addEventListener('click', () => { 229 if (typeof window.openPlaylistEditor === 'function') { 230 window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() }); 231 } 232 }); 233 234 // Click-to-copy on shortcodes 235 document.querySelectorAll('[data-copy]').forEach(el => { 236 el.addEventListener('click', async () => { 237 try { 238 await navigator.clipboard.writeText(el.dataset.copy); 239 el.classList.add('is-copied'); 240 const original = el.textContent; 241 el.textContent = '✓ <%= t('apl.copied') %>'; 242 setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200); 243 } catch (_) { /* fall back to selection */ } 244 }); 245 }); 246 247 document.querySelectorAll('[data-pl-edit]').forEach(btn => { 248 btn.addEventListener('click', () => { 249 if (typeof window.openPlaylistEditor === 'function') { 250 window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() }); 251 } 252 }); 253 }); 254 255 document.querySelectorAll('[data-pl-delete]').forEach(btn => { 256 btn.addEventListener('click', async () => { 257 const id = btn.dataset.id; 258 const title = btn.dataset.title || id; 259 if (!confirm('<%= t('apl.delete_confirm') %>'.replace('{title}', title))) return; 260 try { 261 const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, { 262 method: 'POST', 263 headers: { 'X-CSRF-Token': csrf }, 264 credentials: 'same-origin', 265 }); 266 const j = await r.json(); 267 if (j.ok) location.reload(); 268 else alert('<%= t('apl.delete_failed') %>: ' + (j.error || '')); 269 } catch (err) { 270 alert('<%= t('apl.delete_failed') %>: ' + err.message); 271 } 272 }); 273 }); 274 275 // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor. 276 // openPlaylistEditor is defined synchronously by the included partial, so 277 // it's available by the time this IIFE runs. 278 (function deepLinkEdit() { 279 const params = new URLSearchParams(location.search); 280 const editId = params.get('edit'); 281 if (!editId) return; 282 if (typeof window.openPlaylistEditor !== 'function') return; 283 // Strip the query param immediately so reload after save doesn't re-open. 284 history.replaceState({}, '', location.pathname); 285 window.openPlaylistEditor({ 286 mode: 'edit', 287 id: editId, 288 onSaved: () => location.reload(), 289 }); 290 })(); 291 })(); 292 </script> -
src/views/pages/admin-push.ejs
r952baf3 rf85b2c3 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 <%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %> 60 <script> 61 (function () { 62 var cfg = JSON.parse(document.getElementById('np-data').textContent); 63 var elState = document.getElementById('np-state'); 64 var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test'); 65 var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved'); 66 var currentEndpoint = null; 67 68 var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent); 69 var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; 70 if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false; 71 72 if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { 73 document.getElementById('np-unsupported').hidden = false; 74 elState.textContent = cfg.i18n.unsupported; 75 return; 76 } 77 78 function b64ToU8(s) { 79 var pad = '='.repeat((4 - (s.length % 4)) % 4); 80 var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/')); 81 var out = new Uint8Array(raw.length); 82 for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); 83 return out; 84 } 85 function post(url, body) { 86 return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); 87 } 88 function deviceLabel() { 89 var ua = navigator.userAgent; 90 var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser'; 91 var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : ''; 92 return (browser + (os ? ' op ' + os : '')); 93 } 94 function readAlertBoxes() { 95 var out = {}; 96 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; }); 97 return out; 98 } 99 function setAlertBoxes(alerts) { 100 alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { 101 cb.checked = !!alerts[cb.getAttribute('data-alert')]; 102 }); 103 } 104 105 function render(sub) { 106 currentEndpoint = sub ? sub.endpoint : null; 107 elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off); 108 btnOn.hidden = !!sub || Notification.permission === 'denied'; 109 btnOff.hidden = !sub; 110 btnTest.hidden = !sub; 111 alertsBox.hidden = !sub; 112 } 113 114 navigator.serviceWorker.ready.then(function (reg) { 115 return reg.pushManager.getSubscription(); 116 }).then(function (sub) { 117 // Show this device's SAVED prefs when we know them, defaults otherwise. 118 setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts); 119 render(sub); 120 }).catch(function () { elState.textContent = cfg.i18n.unknown; }); 121 122 btnOn.addEventListener('click', function () { 123 btnOn.disabled = true; 124 Notification.requestPermission().then(function (perm) { 125 if (perm !== 'granted') { btnOn.disabled = false; render(null); return; } 126 navigator.serviceWorker.ready.then(function (reg) { 127 return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) }); 128 }).then(function (sub) { 129 return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() }) 130 .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); }); 131 }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; }); 132 }); 133 }); 134 135 btnOff.addEventListener('click', function () { 136 navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) { 137 if (!sub) { render(null); return; } 138 var ep = sub.endpoint; 139 sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); }) 140 .then(function () { location.reload(); }); 141 }); 142 }); 143 144 btnTest.addEventListener('click', function () { 145 btnTest.disabled = true; 146 post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); }); 147 }); 148 149 alertsBox.addEventListener('change', function () { 150 if (!currentEndpoint) return; 151 post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) { 152 if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); } 153 }); 154 }); 155 156 document.querySelectorAll('.np-remove').forEach(function (btn) { 157 btn.addEventListener('click', function () { 158 post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); }); 159 }); 160 }); 161 })(); 162 </script> 61 163 <% } %> -
src/views/pages/admin-settings.ejs
r952baf3 rf85b2c3 27 27 <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div> 28 28 </form> 29 <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %> 29 <script> 30 (function () { 31 var f = document.getElementById('mode-form'); 32 if (!f || f.__age18wired) return; f.__age18wired = true; 33 f.addEventListener('submit', function (e) { 34 var sel = f.querySelector('input[name=mode]:checked'); 35 if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') { 36 if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); 37 } 38 }); 39 })(); 40 </script> 30 41 </section> 31 42 -
src/views/pages/admin-site-edit.ejs
r952baf3 rf85b2c3 126 126 <%# Live preview: selecting an accent / theme / palette re-themes this page 127 127 instantly (reverts on reload; persists on save). %> 128 <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %> 128 <script> 129 (function(){ 130 if (window.__themePreviewWired) return; window.__themePreviewWired = true; 131 var html = document.documentElement; 132 function applyAccent(hex){ 133 if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return; 134 var sa = document.getElementById('pcms-site-accent'); 135 if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); } 136 sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}'; 137 } 138 document.addEventListener('change', function(e){ 139 var t = e.target; if(!t || !t.name) return; 140 if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); } 141 else if(t.name === 'accent'){ applyAccent(t.value); } 142 else if(t.name === 'theme_override'){ 143 if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); } 144 else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); } 145 } 146 }); 147 })(); 148 </script> 129 149 130 150 <fieldset> … … 255 275 </div> 256 276 257 277 <script> 278 (function() { 279 // Slug URL field: show the real host as a dimmed prefix (the slug itself is 280 // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>. 281 var sh = document.getElementById('slug-host'); 282 if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/'); 283 })(); 284 (function() { 285 // Profile-links repeater: add row from <template>, remove on click. 286 var rows = document.getElementById('profile-links-rows'); 287 var tpl = document.getElementById('profile-link-template'); 288 var add = document.getElementById('profile-link-add'); 289 if (!rows || !tpl || !add) return; 290 291 add.addEventListener('click', function() { 292 var clone = tpl.content.cloneNode(true); 293 rows.appendChild(clone); 294 }); 295 rows.addEventListener('click', function(e) { 296 if (e.target && e.target.classList.contains('pl-remove')) { 297 var row = e.target.closest('.profile-link-row'); 298 if (row) row.remove(); 299 } 300 }); 301 })(); 302 303 // P63 — Profile photo picker: upload via /admin/sites/upload-photo, 304 // then write the returned URL into the visible input. Live thumb preview. 305 (function() { 306 var picker = document.getElementById('photo-picker'); 307 if (!picker) return; 308 var thumb = document.getElementById('photo-thumb'); 309 var preview = document.getElementById('photo-preview'); 310 var urlEl = document.getElementById('photo-url'); 311 var trigger = document.getElementById('photo-upload-trigger'); 312 var clear = document.getElementById('photo-clear'); 313 var field = document.getElementById('photo-upload-field'); 314 var status = document.getElementById('photo-status'); 315 316 function showPreview(url) { 317 if (url) { 318 preview.src = url; 319 preview.hidden = false; 320 thumb.removeAttribute('data-empty'); 321 clear.hidden = false; 322 } else { 323 preview.src = ''; 324 preview.hidden = true; 325 thumb.setAttribute('data-empty', ''); 326 clear.hidden = true; 327 } 328 } 329 330 if (urlEl) { 331 urlEl.addEventListener('input', function() { 332 showPreview(urlEl.value.trim()); 333 }); 334 } 335 336 if (trigger && field) { 337 trigger.addEventListener('click', function() { field.click(); }); 338 field.addEventListener('change', async function() { 339 var file = field.files && field.files[0]; 340 if (!file) return; 341 status.classList.remove('is-error'); 342 status.textContent = 'Uploaden…'; 343 try { 344 var fd = new FormData(); 345 fd.append('photo', file); 346 var r = await fetch('/admin/sites/upload-photo', { 347 method: 'POST', 348 body: fd, 349 credentials: 'same-origin', 350 }); 351 var j = await r.json(); 352 if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')')); 353 urlEl.value = j.url; 354 showPreview(j.url); 355 status.textContent = 'Geüpload ✓'; 356 setTimeout(function() { status.textContent = ''; }, 2000); 357 } catch (e) { 358 status.classList.add('is-error'); 359 status.textContent = 'Mislukt: ' + e.message; 360 } finally { 361 field.value = ''; 362 } 363 }); 364 } 365 366 if (clear) { 367 clear.addEventListener('click', function() { 368 urlEl.value = ''; 369 showPreview(''); 370 status.textContent = ''; 371 // Note: doesn't delete the file from disk — saving the form with empty 372 // URL leaves the file orphaned on the server. Acceptable for now. 373 }); 374 } 375 })(); 376 </script> 258 377 259 378 <style> -
src/views/pages/admin-videos.ejs
r952baf3 rf85b2c3 40 40 </div> 41 41 42 <%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 43 <%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %> 42 <script> 43 (function () { 44 if (window.__videosWired) return; window.__videosWired = true; 45 var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>; 46 document.addEventListener('click', function (e) { 47 var c = e.target.closest('[data-copy]'); 48 if (c) { 49 var u = location.origin + c.getAttribute('data-copy'); 50 var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); }; 51 if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); }); 52 else window.prompt('URL', u); 53 return; 54 } 55 var d = e.target.closest('[data-del]'); 56 if (d) { 57 if (!window.confirm(T.delC)) return; 58 fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) }) 59 .then(function (r) { return r.json(); }) 60 .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } }) 61 .catch(function () {}); 62 } 63 }); 64 })(); 65 </script> -
src/views/pages/auth-register.ejs
r952baf3 rf85b2c3 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 <%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %> 43 <script> 44 (function () { 45 var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle'); 46 if (!u || !out) return; 47 function upd() { 48 var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username'; 49 out.textContent = '@' + v + '@' + location.host; 50 } 51 u.addEventListener('input', upd); 52 upd(); 53 })(); 54 </script> -
src/views/pages/authorize-interaction.ejs
r952baf3 rf85b2c3 12 12 <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p> 13 13 </div> 14 <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %> 14 <script> 15 (function () { 16 if (window.__fediBmWired) return; window.__fediBmWired = true; 17 var a = document.getElementById('fedi-bm-btn'); if (!a) return; 18 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 19 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 20 })(); 21 </script> 15 22 <% if (!manage.length) { %> 16 23 <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p> … … 49 56 <% }); %> 50 57 </ul> 51 58 <script> 59 (function () { 60 if (window.__fediEditWired) return; window.__fediEditWired = true; 61 document.addEventListener('click', function (e) { 62 var b = e.target.closest && e.target.closest('.fedi-edit-btn'); 63 if (!b) return; 64 var li = b.closest('.fedi-manage-item'); if (!li) return; 65 var form = li.querySelector('.fedi-edit-form'); if (!form) return; 66 var open = form.classList.toggle('is-open'); 67 b.classList.toggle('is-open', open); 68 b.setAttribute('aria-expanded', open ? 'true' : 'false'); 69 if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); } 70 }); 71 })(); 72 </script> 52 73 <% } %> 53 74 <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p> … … 310 331 </style> 311 332 312 333 <script> 334 /* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */ 335 (function(){ 336 if (window.__fediReactWired) return; window.__fediReactWired = true; 337 document.addEventListener('submit', function(e){ 338 var f = e.target.closest && e.target.closest('.fedi-react-form'); 339 if (!f) return; 340 e.preventDefault(); 341 var btn = f.querySelector('button'); if (!btn || btn.disabled) return; 342 btn.disabled = true; 343 var body = new URLSearchParams(); 344 new FormData(f).forEach(function(v, k){ body.append(k, v); }); 345 fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) 346 .then(function(r){ return r.ok ? r.json() : null; }) 347 .then(function(j){ 348 if (j) { 349 var on = !!j.on; 350 btn.classList.toggle('is-on', on); 351 var lbl = btn.querySelector('.fedi-bigact-label'); 352 if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent); 353 } 354 }) 355 .catch(function(){}) 356 .then(function(){ btn.disabled = false; }); 357 }); 358 })(); 359 </script> -
src/views/pages/download.ejs
r952baf3 rf85b2c3 26 26 <p class="dl-sub"><%= _ready_sub %></p> 27 27 <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p> 28 <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 28 <script> 29 // Auto-start de download (zelfde-origin attachment-link). 30 setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600); 31 </script> 29 32 <% } else { %> 30 33 <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1> … … 54 57 .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; } 55 58 </style> 56 <%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %> -
src/views/pages/messages.ejs
r952baf3 rf85b2c3 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 === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error))%></div><% } %>4 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %> 5 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error %></div><% } %> 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 in28 een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde29 draad staan. Activiteit en Moderatie blijven wat ze waren. %>30 27 <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> 31 29 <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button> 32 30 <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button> 33 31 <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> 34 33 </div> 35 34 <div class="msg-search"> … … 60 59 </div> 61 60 62 <%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js. 63 Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een 64 link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op 65 body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %> 61 <script> 62 (function () { 63 if (window.__msgWired) return; window.__msgWired = true; 64 65 // Filtering: kind chip AND free-text search, combined in JS (search over the 66 // sender and the rendered message text). Empty search + "all" = show all. 67 var list = document.querySelector('.msg-list'); 68 var noMatch = document.querySelector('.msg-nomatch'); 69 var q = document.getElementById('msg-q'); 70 var kind = 'all'; 71 var items = []; 72 function indexItem(li) { 73 // Index once: sender + message body + linked post title + poll text. 74 var body = li.querySelector('.msg-content'); 75 var post = li.querySelector('.msg-post'); 76 var poll = li.querySelector('.msg-poll'); 77 li._search = ((li.getAttribute('data-who') || '') + ' ' + 78 (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' + 79 (poll ? poll.textContent : '')).toLowerCase(); 80 } 81 // Re-collect + index; called on load and after each "Load more" append so new 82 // items join the filter/search (and inherit the active chip via apply()). 83 function reindex() { 84 items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : []; 85 items.forEach(function (li) { if (!li._search) indexItem(li); }); 86 } 87 reindex(); 88 function apply() { 89 if (!list) return; 90 var term = (q && q.value || '').trim().toLowerCase(); 91 var shown = 0; 92 items.forEach(function (li) { 93 var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) && 94 (!term || li._search.indexOf(term) !== -1); 95 li.style.display = ok ? '' : 'none'; 96 if (ok) shown++; 97 }); 98 if (noMatch) noMatch.hidden = shown !== 0; 99 } 100 document.addEventListener('click', function (e) { 101 var chip = e.target.closest('.msg-chip'); if (!chip) return; 102 kind = chip.getAttribute('data-show'); 103 document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); }); 104 apply(); 105 }); 106 if (q) q.addEventListener('input', apply); 107 // After a "Load more" append (htmx), index the new rows and re-apply the filter. 108 document.body.addEventListener('htmx:afterSettle', function (e) { 109 if (e.target && e.target.id === 'msg-list') { reindex(); apply(); } 110 }); 111 112 var a = document.getElementById('fedi-bm-btn'); 113 if (a) { 114 a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))"); 115 a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); }); 116 } 117 })(); 118 </script> 66 119 67 120 <style> … … 86 139 .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; } 87 140 88 89 141 .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start; 90 142 background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); … … 96 148 background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); } 97 149 .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 bij101 gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de102 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: een106 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 en109 geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens110 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 telefoon119 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 de126 naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest127 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 die147 .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de148 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 -- precies156 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 er166 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 }172 150 173 151 .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%; -
src/views/pages/news.ejs
r952baf3 rf85b2c3 41 41 .tl-paste-go { background: var(--accent); color: var(--paper); } 42 42 </style> 43 <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %> 43 <script> 44 (function () { 45 if (window.__tlPasteWired) return; window.__tlPasteWired = true; 46 function modal() { return document.getElementById('tl-paste'); } 47 function openM() { 48 var m = modal(); if (!m) return; 49 var inp = m.querySelector('.tl-paste-input'); 50 m.classList.add('is-open'); 51 if (inp) { 52 inp.value = ''; inp.focus(); 53 if (navigator.clipboard && navigator.clipboard.readText) { 54 navigator.clipboard.readText().then(function (t) { 55 t = (t || '').trim(); 56 if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); } 57 }).catch(function () {}); 58 } 59 } 60 } 61 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 62 function go() { 63 var m = modal(); if (!m) return; 64 var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim(); 65 if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; } 66 location.href = '/authorize_interaction?uri=' + encodeURIComponent(v); 67 } 68 document.addEventListener('click', function (e) { 69 if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; } 70 if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; } 71 if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; } 72 var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM(); 73 }); 74 document.addEventListener('keydown', function (e) { 75 var m = document.querySelector('.tl-paste.is-open'); if (!m) return; 76 if (e.key === 'Escape') closeM(); 77 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); } 78 }); 79 })(); 80 </script> 44 81 <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %> 45 82 <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %> … … 147 184 .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); } 148 185 </style> 149 <%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %> 186 187 <script> 188 (function(){ 189 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true; 190 var current = null, currentBtn = null; 191 function close(){ if (current) { current.remove(); current = null; currentBtn = null; } } 192 function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } } 193 function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; } 194 document.addEventListener('click', function(e){ 195 if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; } 196 if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; 197 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn'); 198 if (b) { 199 e.preventDefault(); 200 if (currentBtn === b) { close(); return; } 201 close(); 202 var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||''; 203 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">→</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> -
src/views/pages/paid-gate.ejs
r952baf3 rf85b2c3 31 31 32 32 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 33 <%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 33 <script> 34 (function () { 35 var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>"; 36 var slug = "<%= pgSlug %>"; 37 var hasPatron = <%= _hasPatron ? 'true' : 'false' %>; 38 var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" }; 39 var btn = document.getElementById('pg-unlock'); 40 var status = document.getElementById('pg-status'); 41 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 42 function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); } 43 44 // No WebAuthn here: an assertion is impossible. With a Patreon page there is 45 // already a "Word supporter" button, so hide the (dead) unlock button rather 46 // than turn it into a second "Word supporter". Without one, this IS the button. 47 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 48 if (hasPatron) { btn.style.display = 'none'; } 49 else { btn.textContent = I.join; btn.addEventListener('click', toLink); } 50 return; 51 } 52 53 btn.addEventListener('click', function () { 54 btn.disabled = true; 55 say(I.confirm); 56 fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug)) 57 .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); }) 58 .then(function (data) { 59 return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options }) 60 .then(function (response) { 61 return fetch(base + '/paid/unlock', { 62 method: 'POST', headers: { 'Content-Type': 'application/json' }, 63 body: JSON.stringify({ response: response, blob: data.blob }), 64 }); 65 }); 66 }) 67 .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); }) 68 .then(function (res) { 69 if (res.j && res.j.ok && res.j.redirect) { 70 // Reload the real post page via the one-shot unlock capability, so it 71 // renders through its normal template (layout, styles, audio). 72 location.href = res.j.redirect; 73 } else if (res.status === 403) { 74 toLink(); // no valid passkey yet (or lapsed tier): link via Patreon 75 } else { 76 btn.disabled = false; say(I.failed, true); 77 } 78 }) 79 .catch(function (e) { 80 if (e && e.link) { toLink(); return; } 81 if (e && e.name === 'NotAllowedError') { toLink(); return; } // cancelled / no passkey -> link 82 btn.disabled = false; say(I.error, true); 83 }); 84 }); 85 })(); 86 </script> 34 87 35 88 <style> … … 52 105 .pg-status.is-err { color: #c0392b; } 53 106 </style> 54 <%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %> -
src/views/pages/paid-passkey.ejs
r952baf3 rf85b2c3 12 12 13 13 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script> 14 <%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 14 <script> 15 (function () { 16 var options = <%- optionsJson %>; 17 var blob = "<%= regBlob %>"; 18 var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" }; 19 var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>"; 20 var btn = document.getElementById('pk-go'); 21 var status = document.getElementById('pk-status'); 22 function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); } 23 24 if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { 25 btn.disabled = true; 26 say(I.unsupported, true); 27 return; 28 } 29 btn.addEventListener('click', function () { 30 btn.disabled = true; 31 say(I.follow); 32 window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options }) 33 .then(function (response) { 34 return fetch('/paid/register', { 35 method: 'POST', headers: { 'Content-Type': 'application/json' }, 36 body: JSON.stringify({ response: response, blob: blob }), 37 }); 38 }) 39 .then(function (r) { return r.json(); }) 40 .then(function (j) { 41 if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); } 42 else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); } 43 }) 44 .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); }); 45 }); 46 })(); 47 </script> 15 48 16 49 <style> … … 25 58 .pk-status.is-err { color: #c0392b; } 26 59 </style> 27 <%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %> -
src/views/pages/post-edit.ejs
r952baf3 rf85b2c3 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 <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 274 <script> 275 (function () { 276 var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn'); 277 if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); } 278 })(); 279 </script> 275 280 <% } %> 276 281 <script> 282 (function () { 283 var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw'); 284 // Typing a warning text implies the post is sensitive → auto-tick NSFW. 285 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true; 286 cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; }); 287 } 288 })(); 289 </script> 277 290 <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen. 278 291 var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; } … … 322 335 </select> 323 336 </div> 324 337 <script> 338 (function () { 339 var box = document.getElementById('pe-poll-fields'); 340 var tog = document.getElementById('pe-poll-toggle'); 341 var opts = document.getElementById('pe-poll-opts'); 342 var add = document.getElementById('pe-poll-add'); 343 if (!box || !opts) return; 344 if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); } 345 var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || ''; 346 function rows() { return opts.querySelectorAll('.pe-poll-row'); } 347 // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8. 348 function refresh() { 349 var n = rows().length; 350 opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; }); 351 if (add) add.disabled = n >= 8; 352 } 353 function makeRow() { 354 var row = document.createElement('div'); row.className = 'pe-poll-row'; 355 var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH; 356 var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '×'; 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> 325 364 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 326 365 <label class="pe-checkbox"> … … 338 377 </label> 339 378 </div> 340 379 <script> 380 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); 381 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); 382 </script> 341 383 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %> 342 384 <label class="pe-checkbox" style="margin-top:8px"> … … 350 392 <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div> 351 393 </div> 352 394 <script> 395 (function () { 396 var cb = document.getElementById('pe-sched-toggle'); 397 var box = document.getElementById('pe-sched-fields'); 398 if (!cb || !box) return; 399 var inp = document.getElementById('pe-publish-at'); 400 var SITE_TZ = '<%= timezone || '' %>'; // configured site timezone; empty = browser local 401 var pad = function (n) { return String(n).padStart(2, '0'); }; 402 // Offset (ms) between a timezone and UTC at a given moment. 403 function tzOffset(date, tz) { 404 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); 405 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; }); 406 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime(); 407 } 408 // datetime-local "wall time" (in the site zone) → UTC Date. 409 function wallToUtc(wall) { 410 if (!SITE_TZ) return new Date(wall); 411 var guess = new Date(wall + ':00Z').getTime(); 412 return new Date(guess - tzOffset(new Date(guess), SITE_TZ)); 413 } 414 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone. 415 function utcToWall(iso) { 416 var d = new Date(iso); if (isNaN(d)) return ''; 417 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()); 418 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); 419 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; }); 420 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute; 421 } 422 // Prefill: stored UTC → wall time in the site zone. 423 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso); 424 // "Scheduled for" in human-readable time in the site zone. 425 var when = document.getElementById('pe-sched-when'); 426 if (when && when.dataset.iso) { 427 var dw = new Date(when.dataset.iso); 428 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined); 429 } 430 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; } 431 cb.addEventListener('change', sync); sync(); 432 // On save: wall time in the site zone → UTC-ISO via a hidden field. 433 var form = cb.closest('form'); 434 if (form) { 435 form.addEventListener('submit', function () { 436 if (inp) inp.removeAttribute('name'); 437 var old = form.querySelector('input[data-pa-utc]'); 438 if (old) old.remove(); 439 if (cb.checked && inp && inp.value) { 440 var d2 = wallToUtc(inp.value); 441 if (!isNaN(d2)) { 442 var h = document.createElement('input'); 443 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', ''); 444 h.value = d2.toISOString(); 445 form.appendChild(h); 446 } 447 } 448 }); 449 } 450 })(); 451 </script> 353 452 <% } %> 354 453 </div> … … 1182 1281 </style> 1183 1282 1184 1283 <script> 1284 (function() { 1285 1286 // ── Cover upload ──────────────────────────────────────────────── 1287 const coverField = document.getElementById('cover-upload-field'); 1288 const coverTrigger = document.getElementById('cover-upload-trigger'); 1289 const coverUrl = document.getElementById('cover-url-field'); 1290 const coverVideo = document.getElementById('cover-video-field'); 1291 const coverStatus = document.getElementById('cover-upload-status'); 1292 const coverWrap = document.getElementById('cover-preview-wrap'); 1293 const coverImg = document.getElementById('cover-preview-img'); 1294 1295 async function uploadImage(file) { 1296 const fd = new FormData(); 1297 fd.append('image', file); 1298 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd }); 1299 if (!res.ok) { 1300 const j = await res.json().catch(() => ({})); 1301 throw new Error(j.error || ('Upload failed (' + res.status + ')')); 1302 } 1303 return await res.json(); // {url, size, mime} 1304 } 1305 1306 // ── Image editor (rotate / crop / mirror) ────────── 1307 // Lazy-load Cropper.js (locally vendored) on first use. 1308 let _cropperReady = null; 1309 function ensureCropper() { 1310 if (window.Cropper) return Promise.resolve(); 1311 if (_cropperReady) return _cropperReady; 1312 _cropperReady = new Promise((resolve, reject) => { 1313 if (!document.querySelector('link[data-cropper-css]')) { 1314 const l = document.createElement('link'); 1315 l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', ''); 1316 document.head.appendChild(l); 1317 } 1318 const s = document.createElement('script'); 1319 s.src = '/assets/vendor/cropper.min.js'; 1320 s.onload = () => resolve(); 1321 s.onerror = () => reject(new Error('cropper load failed')); 1322 document.head.appendChild(s); 1323 }); 1324 return _cropperReady; 1325 } 1326 1327 // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip 1328 // the canvas editor, otherwise it'd be flattened to a single static frame. 1329 async function isAnimatedWebpFile(file) { 1330 if (!file || file.type !== 'image/webp') return false; 1331 try { 1332 const b = new Uint8Array(await file.slice(0, 40).arrayBuffer()); 1333 return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0; 1334 } catch (_) { return false; } 1335 } 1336 1337 // Opens the editor for a chosen file; resolves with an edited File, 1338 // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the 1339 // canvas editor (they would become static) — those upload directly. 1340 async function openImageEditor(file) { 1341 if (!file || !file.type || !file.type.startsWith('image/')) return file; 1342 if (file.type === 'image/gif') return file; // preserve animation 1343 if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation 1344 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly 1345 1346 return new Promise((resolve) => { 1347 const back = document.createElement('div'); 1348 back.className = 'imed-backdrop'; 1349 back.innerHTML = 1350 '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="<%= t('imed.title') %>">' + 1351 '<div class="imed-stage"><img alt=""></div>' + 1352 '<div class="imed-tools">' + 1353 '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' + 1354 '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' + 1355 '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' + 1356 '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' + 1357 '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">+</button>' + 1358 '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">-</button>' + 1359 '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' + 1360 '</div>' + 1361 '<div class="imed-actions">' + 1362 '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' + 1363 '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.apply') %></button>' + 1364 '</div>' + 1365 '</div>'; 1366 document.body.appendChild(back); 1367 const img = back.querySelector('img'); 1368 const url = URL.createObjectURL(file); 1369 let cropper = null, sx = 1, sy = 1; 1370 1371 function cleanup() { 1372 try { if (cropper) cropper.destroy(); } catch (_) {} 1373 URL.revokeObjectURL(url); 1374 back.remove(); 1375 document.removeEventListener('keydown', onKey); 1376 } 1377 function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } } 1378 document.addEventListener('keydown', onKey); 1379 1380 img.onload = () => { 1381 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true }); 1382 }; 1383 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original 1384 img.src = url; 1385 1386 back.addEventListener('click', (e) => { 1387 const btn = e.target.closest('[data-act]'); 1388 if (e.target === back) { cleanup(); resolve(null); return; } 1389 if (!btn || !cropper) return; 1390 const a = btn.getAttribute('data-act'); 1391 if (a === 'rl') cropper.rotate(-90); 1392 else if (a === 'rr') cropper.rotate(90); 1393 else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); } 1394 else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); } 1395 else if (a === 'zi') cropper.zoom(0.1); 1396 else if (a === 'zo') cropper.zoom(-0.1); 1397 else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); } 1398 else if (a === 'cancel') { cleanup(); resolve(null); } 1399 else if (a === 'apply') { 1400 const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' }); 1401 const png = (file.type === 'image/png' || file.type === 'image/webp'); 1402 const mime = png ? 'image/png' : 'image/jpeg'; 1403 const ext = png ? '.png' : '.jpg'; 1404 canvas.toBlob((blob) => { 1405 cleanup(); 1406 if (!blob) { resolve(file); return; } 1407 const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, ''); 1408 resolve(new File([blob], base + ext, { type: mime })); 1409 }, mime, 0.92); 1410 } 1411 }); 1412 }); 1413 } 1414 1415 function showCoverPreview(url) { 1416 if (!coverWrap || !coverImg) return; 1417 if (url) { 1418 coverImg.src = url; 1419 coverImg.hidden = false; 1420 coverWrap.removeAttribute('data-empty'); 1421 const emptyIcon = coverWrap.querySelector('.pe-cover-empty'); 1422 if (emptyIcon) emptyIcon.remove(); 1423 } else { 1424 coverImg.hidden = true; 1425 coverImg.src = ''; 1426 coverWrap.setAttribute('data-empty', ''); 1427 if (!coverWrap.querySelector('.pe-cover-empty')) { 1428 const span = document.createElement('span'); 1429 span.className = 'pe-cover-empty'; 1430 span.textContent = '🖼'; 1431 coverWrap.appendChild(span); 1432 } 1433 } 1434 } 1435 1436 if (coverTrigger && coverField) { 1437 coverTrigger.addEventListener('click', () => coverField.click()); 1438 } 1439 if (coverField) { 1440 coverField.addEventListener('change', async () => { 1441 if (!coverField.files[0]) return; 1442 const edited = await openImageEditor(coverField.files[0]); 1443 coverField.value = ''; 1444 if (!edited) return; // cancelled 1445 coverStatus.classList.remove('is-error'); 1446 coverStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1447 try { 1448 const j = await uploadImage(edited); 1449 coverUrl.value = j.url; 1450 if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover 1451 showCoverPreview(j.url); 1452 coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓'; 1453 setTimeout(() => { coverStatus.textContent = ''; }, 2000); 1454 } catch (e) { 1455 coverStatus.classList.add('is-error'); 1456 coverStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1457 } 1458 }); 1459 } 1460 // Live-update preview when user pastes a URL manually 1461 if (coverUrl) { 1462 coverUrl.addEventListener('input', () => { 1463 const v = coverUrl.value.trim(); 1464 if (v) showCoverPreview(v); else showCoverPreview(''); 1465 }); 1466 } 1467 1468 // ── WYSIWYG editor (P58) ──────────────────────────────────────── 1469 // Architecture: 1470 // - Visible <div contenteditable> (`#content-editor`) is what the user 1471 // types in; it shows real HTML (formatted, not raw markup). 1472 // - Hidden <input name="content"> (`#content-hidden`) is what submits. 1473 // On submit we serialize the editor's HTML into it, with shortcode 1474 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text. 1475 // - Initial content comes from a <script type="application/json"> tag 1476 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load 1477 // and walk text nodes to render shortcode tokens as chips. 1478 const contentField = document.getElementById('content-upload-field'); 1479 const contentBtn = document.getElementById('insert-image-btn'); 1480 const contentStatus = document.getElementById('content-upload-status'); 1481 const editor = document.getElementById('content-editor'); 1482 const hiddenField = document.getElementById('content-hidden'); 1483 const charCountEl = document.getElementById('char-count'); 1484 const initialEl = document.getElementById('initial-content'); 1485 const toolbar = document.getElementById('pe-toolbar'); 1486 const form = editor && editor.closest('form'); 1487 1488 if (!editor) return; 1489 1490 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would 1491 // immediately open the keyboard when the editor opens — not desired. 1492 try { 1493 const titleInput = form && form.querySelector('input[name="title"]'); 1494 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) { 1495 titleInput.focus({ preventScroll: true }); 1496 } 1497 } catch (_) {} 1498 1499 // ── Shortcode chip rendering / serialization ──────────────────── 1500 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]] 1501 // — but we DON'T want to chipify text the user is mid-typing inside an 1502 // HTML attribute; since chipify only walks text nodes (never attribute 1503 // values) that's already safe. 1504 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g; 1505 1506 const SC_ICONS = { 1507 track: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>', 1508 album: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>', 1509 playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>', 1510 embed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>', 1511 }; 1512 1513 function chipLabel(kind, value) { 1514 if (kind === 'track') { 1515 // UUIDs are noisy — show a 6-char prefix for visual hint 1516 const v = String(value || ''); 1517 return '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v); 1518 } 1519 if (kind === 'album') return '<%= t('pedit.chip_album') %> ' + value; 1520 if (kind === 'playlist') return '<%= t('pedit.chip_playlist') %> ' + value; 1521 if (kind === 'embed') { 1522 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, ''); 1523 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean); 1524 } 1525 return value; 1526 } 1527 1528 function makeChip(kind, value) { 1529 const span = document.createElement('span'); 1530 span.className = 'sc-chip'; 1531 span.contentEditable = 'false'; 1532 span.setAttribute('data-sc', kind + ':' + value); 1533 span.innerHTML = 1534 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' + 1535 '<span class="sc-chip-label"></span>'; 1536 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value); 1537 return span; 1538 } 1539 1540 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips. 1541 function chipifyShortcodes(root) { 1542 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); 1543 const targets = []; 1544 while (walker.nextNode()) { 1545 const n = walker.currentNode; 1546 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears) 1547 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue; 1548 if (SC_RE.test(n.nodeValue)) targets.push(n); 1549 SC_RE.lastIndex = 0; 1550 } 1551 for (const node of targets) { 1552 const txt = node.nodeValue; 1553 const frag = document.createDocumentFragment(); 1554 let last = 0; 1555 let m; 1556 SC_RE.lastIndex = 0; 1557 while ((m = SC_RE.exec(txt)) !== null) { 1558 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index))); 1559 frag.appendChild(makeChip(m[1], m[2].trim())); 1560 last = m.index + m[0].length; 1561 } 1562 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); 1563 node.parentNode.replaceChild(frag, node); 1564 } 1565 } 1566 1567 // Inverse of chipify: clone the editor, replace every chip with its text. 1568 function serializeChips(rootClone) { 1569 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]'); 1570 for (const c of chips) { 1571 const txt = '[[' + c.getAttribute('data-sc') + ']]'; 1572 c.replaceWith(document.createTextNode(txt)); 1573 } 1574 } 1575 1576 // ── Boot: load initial content as HTML, then render shortcodes as chips 1577 try { 1578 const initial = JSON.parse(initialEl.textContent || '""'); 1579 editor.innerHTML = initial || ''; 1580 chipifyShortcodes(editor); 1581 } catch (e) { 1582 console.error('[editor] could not parse initial content', e); 1583 editor.innerHTML = ''; 1584 } 1585 1586 // ── Char counter 1587 function updateCharCount() { 1588 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim(); 1589 if (charCountEl) charCountEl.textContent = String(text.length); 1590 } 1591 updateCharCount(); 1592 editor.addEventListener('input', updateCharCount); 1593 1594 // ── Toolbar wiring 1595 // Lock the scroll position around an edit command. execCommand/insert scrolls 1596 // the caret into view by default → the view "jumps" when clicking a formatting 1597 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …) 1598 // + the page and restore them — sync and over a few frames, because Chrome 1599 // sometimes scrolls a frame later. The user scrolls themselves. 1600 function scrollableAncestors(el) { 1601 const list = []; 1602 let node = el; 1603 while (node && node !== document.body && node !== document.documentElement) { 1604 const oy = getComputedStyle(node).overflowY; 1605 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node); 1606 node = node.parentElement; 1607 } 1608 return list; 1609 } 1610 function keepScroll(fn) { 1611 // In fullscreen the page is locked (body overflow:hidden) and the field may 1612 // scroll to the caret freely — no page jump possible, so nothing to fix. 1613 const frame = document.querySelector('.pe-editor-frame'); 1614 if (frame && frame.classList.contains('pe-fs')) { fn(); return; } 1615 const wx = window.scrollX, wy = window.scrollY; 1616 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; }); 1617 const restore = function () { 1618 window.scrollTo(wx, wy); 1619 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; }); 1620 }; 1621 fn(); 1622 restore(); 1623 requestAnimationFrame(restore); 1624 } 1625 function execCmd(cmd, arg) { 1626 keepScroll(function () { 1627 editor.focus({ preventScroll: true }); 1628 document.execCommand(cmd, false, arg); 1629 }); 1630 updateToolbarState(); 1631 updateCharCount(); 1632 } 1633 function wrapCode() { 1634 const sel = window.getSelection(); 1635 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; 1636 keepScroll(function () { 1637 const range = sel.getRangeAt(0); 1638 const code = document.createElement('code'); 1639 code.textContent = sel.toString(); 1640 range.deleteContents(); 1641 range.insertNode(code); 1642 // Move caret after the new node 1643 range.setStartAfter(code); 1644 range.collapse(true); 1645 sel.removeAllRanges(); 1646 sel.addRange(range); 1647 editor.focus({ preventScroll: true }); 1648 }); 1649 } 1650 function linkPrompt() { 1651 const url = window.prompt('<%= t('pedit.js_link_prompt') %>'); 1652 if (!url) return; 1653 execCmd('createLink', url); 1654 } 1655 // Is the current selection inside a <blockquote> within the editor? Return it. 1656 function blockquoteAncestor() { 1657 const sel = window.getSelection(); 1658 if (!sel || sel.rangeCount === 0) return null; 1659 let node = sel.anchorNode; 1660 while (node && node !== editor) { 1661 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node; 1662 node = node.parentNode; 1663 } 1664 return null; 1665 } 1666 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but 1667 // can never turn it OFF (browser quirk). If the caret is already in a quote → 1668 // unwrap it; otherwise apply blockquote. 1669 function toggleBlockquote() { 1670 keepScroll(function () { 1671 editor.focus({ preventScroll: true }); 1672 const bq = blockquoteAncestor(); 1673 if (bq) { 1674 const parent = bq.parentNode; 1675 // Extract content from the quote in place, then remove the empty wrapper. 1676 const ref = bq; 1677 let firstMoved = null; 1678 while (bq.firstChild) { 1679 const child = bq.firstChild; 1680 if (!firstMoved) firstMoved = child; 1681 parent.insertBefore(child, ref); 1682 } 1683 parent.removeChild(bq); 1684 // Restore the caret inside the unwrapped content. 1685 if (firstMoved) { 1686 const sel = window.getSelection(); 1687 const range = document.createRange(); 1688 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent); 1689 range.collapse(false); 1690 sel.removeAllRanges(); 1691 sel.addRange(range); 1692 } 1693 } else { 1694 document.execCommand('formatBlock', false, 'blockquote'); 1695 } 1696 }); 1697 updateToolbarState(); 1698 updateCharCount(); 1699 } 1700 1701 if (toolbar) { 1702 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection 1703 // from the editor field. Without this the selection is lost on tap 1704 // → execCommand operates on an empty selection (bold can no longer be toggled OFF) 1705 // and the browser scrolls the caret back into view (the "jump down"). preventDefault 1706 // on mousedown keeps focus in the editor; the click still fires normally. 1707 toolbar.addEventListener('mousedown', (e) => { 1708 if (e.target.closest('button')) e.preventDefault(); 1709 }); 1710 toolbar.addEventListener('click', (e) => { 1711 const btn = e.target.closest('button[data-cmd]'); 1712 if (!btn) return; 1713 e.preventDefault(); 1714 const cmd = btn.dataset.cmd; 1715 const arg = btn.dataset.arg || null; 1716 if (cmd === 'link-prompt') linkPrompt(); 1717 else if (cmd === 'code-wrap') wrapCode(); 1718 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote(); 1719 else execCmd(cmd, arg); 1720 }); 1721 } 1722 1723 // ── Full-screen writing mode: the writing field fills the whole page. 1724 const fsBtn = document.getElementById('pe-fullscreen-btn'); 1725 const editorFrame = document.querySelector('.pe-editor-frame'); 1726 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); 1727 1728 // On mobile the keyboard pushes the visible (visual) viewport up while 1729 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar 1730 // slides out of view. Keep the fullscreen frame aligned to the visual 1731 // viewport (top + height) so the toolbar stays visible at the top. 1732 function syncFsViewport() { 1733 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return; 1734 const vv = window.visualViewport; 1735 if (!vv) return; 1736 editorFrame.style.top = vv.offsetTop + 'px'; 1737 editorFrame.style.height = vv.height + 'px'; 1738 } 1739 function clearFsViewport() { 1740 if (!editorFrame) return; 1741 editorFrame.style.top = ''; 1742 editorFrame.style.height = ''; 1743 } 1744 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); } 1745 function applyFs(on) { 1746 if (!editorFrame) return; 1747 editorFrame.classList.toggle('pe-fs', on); 1748 document.body.classList.toggle('pe-fs-open', on); 1749 document.documentElement.classList.toggle('pe-fs-open', on); 1750 if (fsBtn) { 1751 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false'); 1752 fsBtn.title = on ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.tb_fullscreen') %>'; 1753 } 1754 if (window.visualViewport) { 1755 if (on) { 1756 window.visualViewport.addEventListener('resize', syncFsViewport); 1757 window.visualViewport.addEventListener('scroll', syncFsViewport); 1758 syncFsViewport(); 1759 } else { 1760 window.visualViewport.removeEventListener('resize', syncFsViewport); 1761 window.visualViewport.removeEventListener('scroll', syncFsViewport); 1762 clearFsViewport(); 1763 } 1764 } 1765 // On touch the field is NOT editable inline; only in fullscreen. 1766 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false'); 1767 if (on) { 1768 editor.focus({ preventScroll: true }); 1769 } else { 1770 if (isTouch) editor.blur(); 1771 // On close: scroll to the TOP of the content instead of staying 1772 // somewhere at the bottom (footer). 1773 requestAnimationFrame(function () { 1774 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {} 1775 }); 1776 } 1777 } 1778 // The fullscreen writing "page": opening pushes a history state so the browser 1779 // back button (and the Done button) closes it and returns you to the form — feels 1780 // like a separate page, but all form fields remain intact (same DOM). 1781 function openFs() { 1782 if (isFs()) return; 1783 try { history.pushState({ peFs: true }, ''); } catch (_) {} 1784 applyFs(true); 1785 } 1786 function closeFs() { 1787 if (!isFs()) return; 1788 if (history.state && history.state.peFs) history.back(); // → popstate closes it 1789 else applyFs(false); 1790 } 1791 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); } 1792 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); }); 1793 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen); 1794 var fsDoneBtn = document.getElementById('pe-fs-done'); 1795 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs); 1796 document.addEventListener('keydown', (e) => { 1797 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); } 1798 }); 1799 1800 // On mobile/tablet (touch): the content field is NOT editable inline — it is 1801 // not a text field there. One tap → fullscreen, where it becomes editable 1802 // (toggleFullscreen toggles contenteditable). This prevents inline typing. 1803 if (isTouch) { 1804 editor.setAttribute('contenteditable', 'false'); 1805 editor.classList.add('pe-tap-to-edit'); 1806 editor.addEventListener('click', function () { 1807 if (!isFs()) openFs(); 1808 }); 1809 } 1810 1811 // Reflect bold/italic/list state on the toolbar buttons 1812 function updateToolbarState() { 1813 if (!toolbar) return; 1814 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList']; 1815 for (const cmd of cmds) { 1816 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]'); 1817 if (!btn) continue; 1818 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {} 1819 } 1820 // Quote button: active when the caret is inside a <blockquote> (toggle feedback). 1821 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]'); 1822 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor()); 1823 } 1824 document.addEventListener('selectionchange', () => { 1825 if (document.activeElement === editor) updateToolbarState(); 1826 }); 1827 1828 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K 1829 editor.addEventListener('keydown', (e) => { 1830 const mod = e.ctrlKey || e.metaKey; 1831 if (!mod) return; 1832 const k = e.key.toLowerCase(); 1833 if (k === 'b') { e.preventDefault(); execCmd('bold'); } 1834 else if (k === 'i') { e.preventDefault(); execCmd('italic'); } 1835 else if (k === 'u') { e.preventDefault(); execCmd('underline'); } 1836 else if (k === 'k') { e.preventDefault(); linkPrompt(); } 1837 }); 1838 1839 // Paste: keep it simple — strip formatting unless user wants it. Default 1840 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline 1841 // styles from clipboard only when shift is held — otherwise plain text. 1842 editor.addEventListener('paste', (e) => { 1843 if (e.shiftKey) return; // user wants formatted paste 1844 const text = (e.clipboardData || window.clipboardData).getData('text/plain'); 1845 if (text == null) return; 1846 e.preventDefault(); 1847 document.execCommand('insertText', false, text); 1848 }); 1849 1850 // ── Image upload (button + drag-drop into the editor) 1851 async function uploadAndInsertImage(file) { 1852 const edited = await openImageEditor(file); 1853 if (!edited) return; // cancelled 1854 contentStatus.classList.remove('is-error'); 1855 contentStatus.textContent = '<%= t('pedit.js_uploading') %>'; 1856 try { 1857 const j = await uploadImage(edited); 1858 const img = '<img src="' + j.url + '" alt="">'; 1859 editor.focus({ preventScroll: true }); 1860 document.execCommand('insertHTML', false, img); 1861 contentStatus.textContent = '<%= t('pedit.js_inserted') %> ✓'; 1862 setTimeout(() => { contentStatus.textContent = ''; }, 2000); 1863 updateCharCount(); 1864 } catch (e) { 1865 contentStatus.classList.add('is-error'); 1866 contentStatus.textContent = '<%= t('pedit.js_failed') %>: ' + e.message; 1867 } 1868 } 1869 1870 if (contentBtn && contentField) { 1871 contentBtn.addEventListener('click', () => contentField.click()); 1872 contentField.addEventListener('change', () => { 1873 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]); 1874 contentField.value = ''; 1875 }); 1876 1877 editor.addEventListener('dragover', (e) => { 1878 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { 1879 e.preventDefault(); 1880 editor.classList.add('is-dragover'); 1881 } 1882 }); 1883 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover')); 1884 editor.addEventListener('drop', async (e) => { 1885 editor.classList.remove('is-dragover'); 1886 const files = e.dataTransfer && e.dataTransfer.files; 1887 if (!files || !files.length) return; 1888 e.preventDefault(); 1889 for (const f of files) { 1890 if (f.type.startsWith('image/')) await uploadAndInsertImage(f); 1891 } 1892 }); 1893 } 1894 1895 // ── Insert chip helpers (track / playlist) 1896 function insertChip(kind, value) { 1897 editor.focus({ preventScroll: true }); 1898 const chip = makeChip(kind, value); 1899 // Insert at caret using the Selection API (execCommand insertNode) 1900 const sel = window.getSelection(); 1901 if (sel && sel.rangeCount > 0) { 1902 const range = sel.getRangeAt(0); 1903 range.deleteContents(); 1904 range.insertNode(chip); 1905 // Insert a trailing space so the user can keep typing after the chip 1906 const space = document.createTextNode('\u00A0'); 1907 chip.after(space); 1908 range.setStartAfter(space); 1909 range.collapse(true); 1910 sel.removeAllRanges(); 1911 sel.addRange(range); 1912 } else { 1913 editor.appendChild(chip); 1914 editor.appendChild(document.createTextNode('\u00A0')); 1915 } 1916 updateCharCount(); 1917 } 1918 1919 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes 1920 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp). 1921 const embedBtn = document.getElementById('insert-embed-btn'); 1922 if (embedBtn) { 1923 embedBtn.addEventListener('click', () => { 1924 const raw = window.prompt('<%= t('pedit.js_embed_prompt') %>'); 1925 if (!raw) return; 1926 const url = raw.trim(); 1927 if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.js_embed_invalid') %>'); return; } 1928 insertChip('embed', url); 1929 }); 1930 } 1931 1932 // ── Track insert: opens the track-picker modal (P59) 1933 const trackBtn = document.getElementById('insert-track-btn'); 1934 const trackPicker = document.getElementById('track-picker'); 1935 if (trackBtn && trackPicker) { 1936 const tpList = document.getElementById('tp-list'); 1937 const tpEmpty = document.getElementById('tp-empty'); 1938 const tpSearch = document.getElementById('tp-search'); 1939 let tpCache = null; // cached track list (fetched once per page load) 1940 let tpLastFocus = null; // element to restore focus to on close 1941 1942 const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>'; 1943 1944 function fmtDur(sec) { 1945 sec = Math.max(0, Math.floor(sec || 0)); 1946 const m = Math.floor(sec / 60), s = sec % 60; 1947 return m + ':' + String(s).padStart(2, '0'); 1948 } 1949 function escAttr(s) { 1950 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ 1951 '&':'&','<':'<','>':'>','"':'"',"'":''' 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> 1185 2200 1186 2201 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small … … 1214 2229 <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %> 1215 2230 <% } %> 1216 <%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %> 2231 2232 <script> 2233 (function () { 2234 // Pin: checkbox toggles the hidden rank field (0 = not pinned), 2235 // ▲▼ shifts the position, with a readable description instead of a raw number. 2236 var toggle = document.getElementById('pin-toggle'); 2237 var rank = document.getElementById('pin-rank'); 2238 var pos = document.getElementById('pin-pos'); 2239 var label = document.getElementById('pin-label'); 2240 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top) 2241 var down = document.getElementById('pin-down'); 2242 if (!toggle || !rank || !pos) return; 2243 2244 function descr(n) { 2245 n = Number(n) || 0; 2246 if (n <= 1) return '<%= t('pedit.pin_top') %>'; 2247 return n + '<%= t('pedit.pin_nth_suffix') %>'; 2248 } 2249 function render() { 2250 var on = toggle.checked; 2251 pos.hidden = !on; 2252 if (on && Number(rank.value) < 1) rank.value = 1; 2253 if (!on) rank.value = 0; 2254 if (label) label.textContent = on ? descr(rank.value) : ''; 2255 if (up) up.disabled = Number(rank.value) <= 1; 2256 } 2257 toggle.addEventListener('change', render); 2258 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); }); 2259 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); }); 2260 render(); 2261 })(); 2262 2263 (function () { 2264 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible 2265 // obstacles by setting a dynamic bottom offset = the greater of: 2266 // 1) the height of the keyboard area NOT covered by the layout viewport 2267 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks 2268 // due to interactive-widget=resizes-content → offset ≈ 0); 2269 // 2) the height of the playing audio player (fixed, z-index 1000). 2270 // We stick with sticky (no fixed/top tricks → no bar floating in the middle). 2271 var bar = document.querySelector('.pe-actions'); 2272 if (!bar) return; 2273 var vv = window.visualViewport; 2274 function position() { 2275 var ap = document.querySelector('.audio-player'); 2276 var playing = document.body.classList.contains('has-audio-player') && 2277 ap && getComputedStyle(ap).display !== 'none'; 2278 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0; 2279 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0; 2280 var offset = Math.max(audioOffset, kbCovered); 2281 bar.style.bottom = offset ? offset + 'px' : ''; 2282 } 2283 position(); 2284 window.addEventListener('resize', position); 2285 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); } 2286 // has-audio-player is toggled via a body class → observe it. 2287 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {} 2288 })(); 2289 </script> -
src/views/pages/post.ejs
r952baf3 rf85b2c3 147 147 </section> 148 148 <% } %> 149 <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt 150 door de CSP geweigerd zodra je deze pagina via een link binnen de site opent 151 -- zie shaer-0i6. %> 149 <script> 150 (function(){ 151 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true; 152 var current = null, currentBtn = null; 153 function close(){ if (current) { current.remove(); current = null; currentBtn = null; } } 154 function go(raw, uri){ 155 var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 156 if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); } 157 } 158 function place(f, b){ 159 var r = b.getBoundingClientRect(); 160 f.style.top = (r.bottom + window.scrollY + 6) + 'px'; 161 f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px'; 162 } 163 document.addEventListener('click', function(e){ 164 if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; } 165 if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep 166 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn'); 167 if (b) { 168 e.preventDefault(); 169 if (currentBtn === b) { close(); return; } // toggle off 170 close(); 171 var f = document.createElement('form'); 172 f.className = 'fedi-remote-form'; 173 f.dataset.uri = b.getAttribute('data-fedi-uri') || ''; 174 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">' 175 + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">→</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> 152 196 153 197 -
src/views/partials/bottom-tab.ejs
r952baf3 rf85b2c3 302 302 </style> 303 303 304 <%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js). 305 Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx 306 binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %> 304 <script> 305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking 306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced 307 // during htmx navigation, so a once-captured reference goes stale 308 // (button did nothing afterwards). Guard against double-wiring. 309 (function() { 310 if (window.__btSearchWired) return; 311 window.__btSearchWired = true; 312 document.addEventListener('click', function(e) { 313 if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return; 314 var o = document.getElementById('search-overlay'); 315 if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } 316 }); 317 })(); 318 </script> -
src/views/partials/msg-item.ejs
r952baf3 rf85b2c3 1 <% if (n.type === 'thread') { %>2 <%3 var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;4 // Een draad is nieuw zodra EEN bericht erin nieuw is.5 var _tnew = !!_tseen && (n.messages || []).some(function (m) {6 return m.created_at && Date.parse(m.created_at) > _tseen;7 });8 var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });9 // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan10 // 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 het17 gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.18 19 Deze uitsparing is ook de knop die het gesprek in- en uitklapt20 (Barts aanwijzing): tikken op de tegenpartij is waar je van21 nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en22 geen span met een klikhandler, want dan werkt hij ook met het23 toetsenbord en leest een schermlezer de stand voor.24 25 Zonder JavaScript doet hij niets, en dan staat het gesprek26 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 deze34 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 editor46 onder elk gesprek maakt de lijst onleesbaar -- juist het probleem47 dat deze weergave moest oplossen. De route kiest het pad: een48 draad aan een post krijgt een reply, een draad aan een persoon een49 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 een70 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 { %>81 1 <% 82 2 var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0; … … 85 5 var _who = n.name || n.handle || ''; 86 6 var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct'); 87 // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie = 88 // reports, en al het overige is gesprek. De oude emmers 'msgs' en 89 // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden 90 // samenvielen -- en dat is geen cosmetische hernoeming: een 91 // gespreksregel die om wat voor reden dan ook geen draad werd (geen 92 // post, geen herkenbare tegenpartij) hield anders een data-kind 93 // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip 94 // behalve Alles onzichtbaar. 95 var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 7 // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies; 8 // Activity = likes/boosts/follows; Moderation = reports; Sent = your 9 // replies; Conversations = every other (public) reply. 10 var _kind = _t === 'sent' ? 'sent' 11 : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act' 96 12 : _t === 'report' ? 'mod' 13 : (_t === 'mention' || _priv0) ? 'msgs' 97 14 : 'conv'; 98 // Binnen een draad is dit geen zelfstandige regel: de chip filtert op99 // de draad, niet op de losse zinnen erin.100 var _sub = (typeof inThread !== 'undefined' && inThread);101 15 var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase(); 102 16 var _priv = (n.visibility === 'followers' || n.visibility === 'direct'); 103 17 %> 104 <li class=" <%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %>data-who="<%= String(_who).toLowerCase() %>">18 <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>"> 105 19 <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true"> 106 20 <% 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> … … 124 38 <% if (_t === 'sent') { %> 125 39 <span class="msg-who"><%= t('msg.you') %></span> 126 <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>40 <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %> 127 41 <% } else { %> 128 42 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a> 129 <% /* De naam blijft: binnen een draad moet je zien WIE wat zei. 130 De handle erachter niet -- die staat in de kop. */ %> 131 <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %> 43 <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %> 132 44 <% } %> 133 45 <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %> … … 138 50 </div> 139 51 140 <% var _hasWhat = !_sub || _priv141 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))142 || (_t === 'sent' && n.in_reply_to); %>143 <% if (_hasWhat) { %>144 52 <div class="msg-what"> 145 53 <% if (n.count && n.count > 1) { %> … … 152 60 <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %> 153 61 <% } 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 %>155 62 <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %> 156 63 <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %> … … 158 65 <% } else { %><%= t('notif.replied') %><% } %> 159 66 <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %> 160 <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>67 <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %> 161 68 <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %> 162 69 <% 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><% } %> 163 70 </div> 164 <% } %>165 71 166 72 <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div> … … 210 116 </div> 211 117 </li> 212 <% } %> -
src/views/partials/playlist-editor.ejs
r952baf3 rf85b2c3 280 280 </style> 281 281 282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 282 <script> 283 (function() { 284 // Idempotency: if window.openPlaylistEditor already defined (multiple 285 // partial includes on a single page), skip re-binding. 286 if (typeof window.openPlaylistEditor === 'function') return; 287 288 const CSRF = '<%= _csrf %>'; 289 290 function esc(s) { 291 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ 292 '&':'&','<':'<','>':'>','"':'"',"'":''' 293 }[c])); 294 } 295 function fmtDur(sec) { 296 if (!sec) return ''; 297 const m = Math.floor(sec / 60), s = sec % 60; 298 return `${m}:${String(s).padStart(2, '0')}`; 299 } 300 301 async function api(method, url, body) { 302 const opts = { 303 method, credentials: 'same-origin', 304 headers: { 'X-CSRF-Token': CSRF }, 305 }; 306 if (body !== undefined) { 307 opts.headers['Content-Type'] = 'application/json'; 308 opts.body = JSON.stringify(body); 309 } 310 const r = await fetch(url, opts); 311 return r.json(); 312 } 313 314 /** 315 * Public entry point: open the editor. 316 * opts: { mode: 'create'|'edit', id?, onSaved? } 317 * onSaved is called with { id, playlist } after a successful save. 318 */ 319 window.openPlaylistEditor = async function(opts) { 320 opts = opts || {}; 321 const mode = opts.mode === 'edit' ? 'edit' : 'create'; 322 const isEdit = mode === 'edit'; 323 324 // Load all audio tracks for the picker 325 let tracks = []; 326 try { 327 const j = await api('GET', '/admin/playlists/api/tracks'); 328 if (Array.isArray(j.tracks)) tracks = j.tracks; 329 } catch (e) { 330 alert('Tracks ophalen mislukt'); 331 return; 332 } 333 if (!tracks.length) { 334 alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.'); 335 return; 336 } 337 338 // Existing playlist data when editing 339 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] }; 340 if (isEdit && opts.id) { 341 try { 342 const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id)); 343 if (j.ok) initial = { ...initial, ...j.playlist }; 344 } catch (e) {} 345 } 346 347 // ── DOM ──────────────────────────────────────────────────────── 348 const backdrop = document.createElement('div'); 349 backdrop.className = 'pl-modal-backdrop'; 350 backdrop.innerHTML = ` 351 <div class="pl-modal" role="dialog" aria-label="Playlist editor"> 352 <div class="pl-modal-header"> 353 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3> 354 <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button> 355 </div> 356 <div class="pl-modal-body"> 357 <div class="pl-editor-cols"> 358 <div class="pl-editor-left"> 359 <div class="pl-meta-grid"> 360 <label class="pl-field pl-field-full"> 361 <span>Titel *</span> 362 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}"> 363 </label> 364 <label class="pl-field"> 365 <span>Artiest</span> 366 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}"> 367 </label> 368 <label class="pl-field"> 369 <span>Jaar</span> 370 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}"> 371 </label> 372 <label class="pl-field"> 373 <span>Type</span> 374 <select id="pli-kind"> 375 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option> 376 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option> 377 </select> 378 </label> 379 <div class="pl-field pl-field-full"> 380 <span>Cover</span> 381 <div class="pl-cover-row"> 382 <span class="pl-cover-thumb" id="pli-cover-thumb"> 383 ${initial.cover 384 ? `<img src="${esc(initial.cover)}" alt="">` 385 : `<span class="pl-cover-empty">🎨</span>`} 386 </span> 387 <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1"> 388 </div> 389 <div class="pl-cover-upload-row"> 390 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden> 391 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button> 392 <span class="pl-cover-status" id="pli-cover-status"></span> 393 </div> 394 </div> 395 </div> 396 <div class="pl-section-title"> 397 Tracks in playlist <span class="pl-track-count" id="pli-count">0</span> 398 <small>(sleep ⠿ om te ordenen)</small> 399 </div> 400 <div id="pli-selected" class="pl-selected-list"></div> 401 </div> 402 <div class="pl-editor-right"> 403 <div class="pl-section-title">Beschikbare tracks</div> 404 <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input"> 405 <div id="pli-available" class="pl-available-list"></div> 406 </div> 407 </div> 408 </div> 409 <div class="pl-modal-footer"> 410 <button type="button" class="btn" id="pli-cancel">Annuleren</button> 411 <button type="button" class="btn btn-primary" id="pli-save" disabled> 412 ${isEdit ? 'Opslaan' : 'Aanmaken'} 413 </button> 414 </div> 415 </div> 416 `; 417 document.body.appendChild(backdrop); 418 419 const $ = sel => backdrop.querySelector(sel); 420 const close = () => backdrop.remove(); 421 backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); }); 422 $('.pl-modal-close').addEventListener('click', close); 423 $('#pli-cancel').addEventListener('click', close); 424 425 // Index tracks by id for fast lookup 426 const trackById = new Map(tracks.map(t => [t.id, t])); 427 let selected = (initial.track_ids || []).filter(id => trackById.has(id)); 428 429 const titleEl = $('#pli-title'); 430 const artistEl = $('#pli-artist'); 431 const yearEl = $('#pli-year'); 432 const kindEl = $('#pli-kind'); 433 const coverEl = $('#pli-cover'); 434 const coverThumb = $('#pli-cover-thumb'); 435 const saveBtn = $('#pli-save'); 436 const selectedEl = $('#pli-selected'); 437 const availEl = $('#pli-available'); 438 const searchEl = $('#pli-search'); 439 const countEl = $('#pli-count'); 440 441 coverEl.addEventListener('input', () => { 442 const u = coverEl.value.trim(); 443 coverThumb.innerHTML = u 444 ? `<img src="${esc(u)}" alt="" data-fallback>` 445 : `<span class="pl-cover-empty">🎨</span>`; 446 }); 447 448 // ── Cover file upload (werkt in create ÉN edit) ─────────── 449 // We uploaden naar het generieke image-endpoint (/posts/upload-image, 450 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id 451 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het 452 // cover-veld en wordt bij 'Aanmaken'/'Opslaan' met de playlist meegestuurd. 453 const coverFileInput = $('#pli-cover-file'); 454 const coverPickBtn = $('#pli-cover-pick'); 455 const coverStatus = $('#pli-cover-status'); 456 if (coverFileInput && coverPickBtn) { 457 coverPickBtn.addEventListener('click', () => coverFileInput.click()); 458 coverFileInput.addEventListener('change', async (e) => { 459 const file = e.target.files && e.target.files[0]; 460 coverFileInput.value = ''; 461 if (!file) return; 462 if (!/^image\//.test(file.type)) { 463 coverStatus.textContent = 'Alleen afbeeldingen'; 464 coverStatus.className = 'pl-cover-status is-error'; 465 return; 466 } 467 coverStatus.textContent = 'Uploaden…'; 468 coverStatus.className = 'pl-cover-status'; 469 const fd = new FormData(); 470 fd.append('image', file); 471 try { 472 const r = await fetch('/posts/upload-image', 473 { method: 'POST', body: fd, credentials: 'same-origin' } 474 ); 475 const j = await r.json(); 476 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt'); 477 const url = j.url || ''; 478 coverEl.value = url; 479 coverThumb.innerHTML = url 480 ? `<img src="${esc(url)}" alt="">` 481 : `<span class="pl-cover-empty">🎨</span>`; 482 coverStatus.textContent = '✓ Geüpload'; 483 coverStatus.className = 'pl-cover-status is-ok'; 484 } catch (err) { 485 coverStatus.textContent = 'Mislukt: ' + err.message; 486 coverStatus.className = 'pl-cover-status is-error'; 487 } 488 }); 489 } 490 491 function updateSaveBtn() { 492 saveBtn.disabled = !titleEl.value.trim() || selected.length === 0; 493 } 494 495 function renderSelected() { 496 countEl.textContent = selected.length; 497 if (selected.length === 0) { 498 selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>'; 499 updateSaveBtn(); 500 return; 501 } 502 selectedEl.innerHTML = selected.map((id, i) => { 503 const t = trackById.get(id); 504 if (!t) return ''; 505 const cover = t.cover 506 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 507 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 508 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}"> 509 <span class="pl-row-handle" aria-label="Verslepen">⠿</span> 510 <span class="pl-row-num">${i + 1}</span> 511 ${cover} 512 <span class="pl-row-info"> 513 <span class="pl-row-title">${esc(t.title)}</span> 514 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''} 515 </span> 516 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button> 517 </div>`; 518 }).join(''); 519 520 selectedEl.querySelectorAll('.pl-row-x').forEach(b => { 521 b.addEventListener('click', () => { 522 selected = selected.filter(x => x !== b.dataset.id); 523 renderSelected(); 524 renderAvailable(); 525 }); 526 }); 527 bindDrag(); 528 updateSaveBtn(); 529 } 530 531 function renderAvailable() { 532 const q = searchEl.value.trim().toLowerCase(); 533 const matches = tracks.filter(t => { 534 if (!q) return true; 535 return (t.title || '').toLowerCase().includes(q) 536 || (t.artist || '').toLowerCase().includes(q); 537 }); 538 if (matches.length === 0) { 539 availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>'; 540 return; 541 } 542 availEl.innerHTML = matches.map(t => { 543 const isAdded = selected.includes(t.id); 544 const cls = ['pl-avail-row']; 545 if (isAdded) cls.push('is-added'); 546 if (!t.playable) cls.push('is-unplayable'); 547 const cover = t.cover 548 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 549 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 550 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}> 551 ${cover} 552 <span class="pl-row-info"> 553 <span class="pl-row-title">${esc(t.title)}</span> 554 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''} 555 </span> 556 <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span> 557 </div>`; 558 }).join(''); 559 560 availEl.querySelectorAll('.pl-avail-row').forEach(row => { 561 if (row.classList.contains('is-unplayable')) return; 562 row.addEventListener('click', () => { 563 const id = row.dataset.id; 564 if (selected.includes(id)) selected = selected.filter(x => x !== id); 565 else selected.push(id); 566 renderSelected(); 567 renderAvailable(); 568 }); 569 }); 570 } 571 572 // Pointer-based drag-to-reorder. Same pattern as v9's admin.js. 573 function bindDrag() { 574 selectedEl.querySelectorAll('.pl-row').forEach(row => { 575 const handle = row.querySelector('.pl-row-handle'); 576 if (!handle) return; 577 let dragging = false, originalIdx = -1; 578 579 handle.addEventListener('pointerdown', e => { 580 e.preventDefault(); 581 handle.setPointerCapture(e.pointerId); 582 dragging = true; 583 originalIdx = parseInt(row.dataset.pos, 10); 584 row.classList.add('is-dragging'); 585 }); 586 handle.addEventListener('pointermove', e => { 587 if (!dragging) return; 588 e.preventDefault(); 589 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 590 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 591 let targetIdx = -1, above = false; 592 for (let i = 0; i < rows.length; i++) { 593 const r = rows[i]; if (r === row) continue; 594 const rect = r.getBoundingClientRect(); 595 const mid = rect.top + rect.height / 2; 596 if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; } 597 if (e.clientY >= rect.top && e.clientY <= rect.bottom) { 598 targetIdx = i; above = e.clientY < mid; break; 599 } 600 } 601 if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below'); 602 }); 603 const finish = e => { 604 if (!dragging) return; 605 dragging = false; 606 try { handle.releasePointerCapture(e.pointerId); } catch (_) {} 607 row.classList.remove('is-dragging'); 608 const rows = Array.from(selectedEl.querySelectorAll('.pl-row')); 609 let targetIdx = -1, above = false; 610 for (let i = 0; i < rows.length; i++) { 611 if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; } 612 if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; } 613 } 614 rows.forEach(r => r.classList.remove('drop-above', 'drop-below')); 615 if (targetIdx === -1 || targetIdx === originalIdx) return; 616 const moved = selected[originalIdx]; 617 selected.splice(originalIdx, 1); 618 let newIdx = targetIdx; 619 if (originalIdx < targetIdx) newIdx--; 620 if (!above) newIdx++; 621 newIdx = Math.max(0, Math.min(selected.length, newIdx)); 622 selected.splice(newIdx, 0, moved); 623 renderSelected(); 624 }; 625 handle.addEventListener('pointerup', finish); 626 handle.addEventListener('pointercancel', () => { 627 dragging = false; 628 row.classList.remove('is-dragging'); 629 selectedEl.querySelectorAll('.drop-above, .drop-below') 630 .forEach(r => r.classList.remove('drop-above', 'drop-below')); 631 }); 632 }); 633 } 634 635 titleEl.addEventListener('input', updateSaveBtn); 636 searchEl.addEventListener('input', renderAvailable); 637 renderSelected(); 638 renderAvailable(); 639 640 saveBtn.addEventListener('click', async () => { 641 if (saveBtn.disabled) return; 642 const orig = saveBtn.textContent; 643 saveBtn.disabled = true; 644 saveBtn.textContent = 'Opslaan...'; 645 646 const payload = { 647 title: titleEl.value.trim(), 648 artist: artistEl.value.trim(), 649 year: parseInt(yearEl.value, 10) || 0, 650 cover: coverEl.value.trim(), 651 kind: kindEl.value === 'playlist' ? 'playlist' : 'album', 652 tracks: selected.slice(), 653 }; 654 655 try { 656 const url = isEdit 657 ? '/admin/playlists/api/' + encodeURIComponent(initial.id) 658 : '/admin/playlists/api'; 659 const j = await api('POST', url, payload); 660 if (!j.ok) { 661 alert('Opslaan mislukt: ' + (j.error || 'onbekend')); 662 saveBtn.disabled = false; 663 saveBtn.textContent = orig; 664 return; 665 } 666 const savedId = isEdit ? initial.id : j.id; 667 close(); 668 if (typeof opts.onSaved === 'function') { 669 opts.onSaved({ id: savedId, playlist: payload }); 670 } 671 } catch (err) { 672 alert('Opslaan mislukt: ' + err.message); 673 saveBtn.disabled = false; 674 saveBtn.textContent = orig; 675 } 676 }); 677 678 // ESC to close 679 document.addEventListener('keydown', function onEsc(e) { 680 if (e.key === 'Escape' && document.body.contains(backdrop)) { 681 close(); 682 document.removeEventListener('keydown', onEsc); 683 } 684 }); 685 }; 686 })(); 687 </script> -
src/views/partials/profile-header.ejs
r952baf3 rf85b2c3 160 160 </div> 161 161 <% } %> 162 <%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial 163 zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd 164 (shaer-bqr). %> 162 <script> 163 (function () { 164 if (window.__pcmsFediFollowWired) return; 165 window.__pcmsFediFollowWired = true; 166 function modal() { return document.querySelector('.pf-follow'); } 167 function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); } 168 function go() { 169 var m = modal(); if (!m) return; 170 var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : ''; 171 // Strip a full @user@host handle (and any scheme/path) down to the server host. 172 var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim(); 173 if (!server) { if (inp) inp.focus(); return; } 174 try { localStorage.setItem('pcmsFediServer', server); } catch (e) {} 175 var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || ''); 176 location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor); 177 } 178 document.addEventListener('click', function (e) { 179 var b = e.target.closest && e.target.closest('.profile-fedi-link'); 180 if (b) { e.preventDefault(); 181 var m = modal(); if (!m) return; 182 m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || ''); 183 var inp = m.querySelector('.pf-follow-input'); 184 try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {} 185 m.classList.add('is-open'); 186 setTimeout(function () { if (inp) inp.focus(); }, 30); 187 return; 188 } 189 if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; } 190 if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; } 191 var open = document.querySelector('.pf-follow.is-open'); 192 if (open && e.target === open) closeM(); /* click on backdrop */ 193 }); 194 document.addEventListener('keydown', function (e) { 195 var m = document.querySelector('.pf-follow.is-open'); if (!m) return; 196 if (e.key === 'Escape') { closeM(); } 197 else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); } 198 }); 199 })(); 200 201 /* Profile summary: click the avatar to open a modal with photo + details. */ 202 (function () { 203 if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true; 204 function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); } 205 document.addEventListener('click', function (e) { 206 if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; } 207 var open = document.querySelector('.pf-summary.is-open'); 208 if (open && e.target === open) { close(); return; } /* click on backdrop */ 209 var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]'); 210 if (!t) return; 211 e.preventDefault(); 212 var root = (t.closest && t.closest('#pcms-chrome')) || document; 213 var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary'); 214 if (modal) modal.classList.add('is-open'); 215 }); 216 document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); }); 217 })(); 218 </script> 165 219 <% } %> 166 220 -
src/views/partials/profile-sheet.ejs
r952baf3 rf85b2c3 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" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>107 <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span> 108 108 </button> 109 109 </li> … … 326 326 </style> 327 327 328 <%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %> 328 <script> 329 (function() { 330 const sheet = document.getElementById('profile-sheet'); 331 if (!sheet) return; 332 333 const backdrop = document.getElementById('profile-sheet-backdrop'); 334 const panel = sheet.querySelector('.profile-sheet-panel'); 335 const closeBtn = document.getElementById('profile-sheet-close'); 336 const dragZone = document.getElementById('profile-sheet-drag-zone'); 337 const themeBtn = document.getElementById('profile-sheet-theme'); 338 const themeLbl = document.getElementById('profile-sheet-theme-state'); 339 340 function openSheet() { 341 sheet.classList.add('is-open'); 342 sheet.setAttribute('aria-hidden', 'false'); 343 document.body.classList.add('profile-sheet-locked'); 344 syncTheme(); 345 } 346 function closeSheet() { 347 sheet.classList.remove('is-open'); 348 sheet.setAttribute('aria-hidden', 'true'); 349 document.body.classList.remove('profile-sheet-locked'); 350 panel.style.removeProperty('--pcms-drag-y'); 351 } 352 353 // Open: any element with [data-profile-sheet-toggle] 354 document.addEventListener('click', function(e) { 355 const trigger = e.target.closest('[data-profile-sheet-toggle]'); 356 if (trigger) { 357 e.preventDefault(); 358 openSheet(); 359 } 360 }); 361 362 // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item 363 if (backdrop) backdrop.addEventListener('click', closeSheet); 364 if (closeBtn) closeBtn.addEventListener('click', closeSheet); 365 document.addEventListener('keydown', (e) => { 366 if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet(); 367 }); 368 369 // Menu items that navigate: close synchronously before nav fires 370 sheet.querySelectorAll('[data-close-sheet]').forEach((el) => { 371 el.addEventListener('click', closeSheet); 372 }); 373 374 // Drag-down-to-close on touch. 375 // Uses --pcms-drag-y custom property rather than overwriting the panel's 376 // transform string. This composes correctly with any horizontal centering 377 // (currently none here, but the pattern matches audio-sheet for safety). 378 let startY = 0, lastY = 0, dragging = false; 379 function onDown(e) { 380 if (e.pointerType !== 'touch') return; 381 if (panel.scrollTop > 0) return; 382 startY = lastY = e.clientY; 383 dragging = true; 384 panel.classList.add('is-dragging'); 385 } 386 function onMove(e) { 387 if (!dragging) return; 388 lastY = e.clientY; 389 const dy = Math.max(0, lastY - startY); 390 panel.style.setProperty('--pcms-drag-y', dy + 'px'); 391 } 392 function onUp() { 393 if (!dragging) return; 394 dragging = false; 395 panel.classList.remove('is-dragging'); 396 const dy = lastY - startY; 397 if (dy > 80) closeSheet(); 398 else panel.style.removeProperty('--pcms-drag-y'); 399 } 400 if (window.PointerEvent && dragZone) { 401 dragZone.addEventListener('pointerdown', onDown); 402 document.addEventListener('pointermove', onMove); 403 document.addEventListener('pointerup', onUp); 404 document.addEventListener('pointercancel', onUp); 405 } 406 407 // Theme toggle inside sheet — syncs label 408 function syncTheme() { 409 if (!themeLbl) return; 410 const t = document.documentElement.getAttribute('data-theme') || 'dark'; 411 themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>'; 412 } 413 if (themeBtn) { 414 themeBtn.addEventListener('click', function() { 415 const cur = document.documentElement.getAttribute('data-theme') || 'dark'; 416 const next = cur === 'dark' ? 'light' : 'dark'; 417 document.documentElement.setAttribute('data-theme', next); 418 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 419 syncTheme(); 420 }); 421 } 422 })(); 423 </script> -
src/views/partials/topnav.ejs
r952baf3 rf85b2c3 176 176 177 177 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) --> 178 <%# De teksten staan op het element dat ze gebruikt, niet in het script: een 179 module is een statisch bestand en kan geen vertaling interpoleren. %> 180 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search" 181 data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>"> 178 <div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"> 182 179 <div class="container"> 183 180 <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search"> … … 194 191 </div> 195 192 196 <%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %> 193 <script> 194 (function() { 195 // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation 196 // → without this guard the script would stack EXTRA listeners on every navigation, 197 // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working"). 198 // Everything below uses event delegation on body/document, so it also works for 199 // buttons that appear after this run (OOB). 200 if (window.__pcmsChromeWired) return; 201 window.__pcmsChromeWired = true; 202 203 function toggleTheme() { 204 var cur = document.documentElement.getAttribute('data-theme') || 'dark'; 205 var next = cur === 'dark' ? 'light' : 'dark'; 206 document.documentElement.setAttribute('data-theme', next); 207 try { localStorage.setItem('pcms-theme', next); } catch (e) {} 208 } 209 function overlay() { return document.getElementById('search-overlay'); } 210 function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } } 211 function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } } 212 213 document.body.addEventListener('click', function(e) { 214 if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; } 215 if (e.target.closest('#search-toggle')) { openSearch(); return; } 216 if (e.target.closest('#search-close')) { closeSearch(); return; } 217 // Close open dropdowns (user menu + language picker) on click outside. 218 document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) { 219 if (!d.contains(e.target)) d.removeAttribute('open'); 220 }); 221 }); 222 223 document.addEventListener('keydown', function(e) { 224 if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); } 225 }); 226 227 // ── Live results while typing ─────────────────────────────────── 228 var _ST = { 229 posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>', 230 events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>', 231 all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>' 232 }; 233 function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[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> 197 274 198 275 <style> -
src/views/partials/track-editor.ejs
r952baf3 rf85b2c3 284 284 </style> 285 285 286 <%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %> 286 <script> 287 (function() { 288 289 function esc(s) { 290 return String(s == null ? '' : s) 291 .replace(/&/g, '&').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> -
src/views/shell.ejs
r952baf3 rf85b2c3 339 339 </head> 340 340 341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data- js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>"> 342 342 343 343 <% if (typeof isViewer !== 'undefined' && isViewer) { %> … … 449 449 } 450 450 451 // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest452 // 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 457 451 var next = d.bodyClass; 458 452 if (!next) return; … … 473 467 // de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere 474 468 // 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 de479 CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx480 binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat481 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 dus483 wél de goede nonce heeft.484 485 Een dynamische import vanuit een vertrouwd (genonced) script is precies waar486 '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. Alleen497 // kleine letters, cijfers en streepjes; nooit een punt of een schuine498 // 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-trigger508 // 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 elke510 // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.511 document.body.addEventListener('pcmsNav', load);512 469 })(); 513 470 </script> -
test/authorized-fetch.test.js
r952baf3 rf85b2c3 1 // Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn 2 // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND 3 // verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en 4 // wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu 5 // bleven daardoor vier accounts eindeloos hangen. 6 // 7 // De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of 8 // er wordt teruggevallen, niet om echte HTTP of crypto. 9 // 10 // Run: npm test 11 1 // FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch. 12 2 import { test } from 'node:test'; 13 3 import assert from 'node:assert/strict'; 14 4 15 5 process.env.DATABASE_PATH = ':memory:'; 16 process.env.PUBLIC_BASE_URL = 'https:// klonkt.test';6 process.env.PUBLIC_BASE_URL = 'https://test.example'; 17 7 18 8 const dbMod = await import('../src/config/database.js'); 19 9 const db = dbMod.default; 20 10 dbMod.initializeDatabase(); 21 const AP = await import('../src/services/ActivityPubService.js');11 const AP = (await import('../src/services/ActivityPubService.js')).default; 22 12 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'); 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); 26 18 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'); 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); 54 23 }); 55 56 test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => {57 // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid58 // 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 op70 // voordat er iets geverifieerd is, en die URL komt uit een header die iedereen71 // mag sturen. Tekenden we standaard, dan kan een vreemde ons een ondertekend72 // verzoek naar een adres van zijn keuze laten sturen, met onze identiteit73 // 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. Voor84 // 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 = ondertekend92 ? { id: KAAL, type: 'Person', publicKey: { id: `${KAAL}#k`, owner: KAAL, publicKeyPem: 'x' } }93 : { id: KAAL, type: 'Person' }; // kaal: geen sleutel94 return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/activity+json' } });95 }96 return stubOrig(url, opts);97 };98 verzoeken = [];99 const actor = await AP.fetchActor(KAAL, { asSlug: 'me' });100 globalThis.fetch = stubOrig;101 assert.ok(actor.publicKey && actor.publicKey.publicKeyPem, 'de ondertekende poging levert de sleutel');102 assert.deepEqual(verzoeken.map((v) => v.ondertekend), [false, true], 'eerst onbetekend, daarna pas ondertekend');103 });104 105 test('een onbekende actor blijft null, ondertekend of niet', async () => {106 assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null);107 });108 109 test.after(() => { globalThis.fetch = echteFetch; }); -
test/co-location.test.js
r952baf3 rf85b2c3 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 uit231 // 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)',234 229 }; 235 // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke 236 // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten 237 // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er 238 // geruisloos doorheen kwam. Dat is precies hoe deze er kwam. 239 const files = [ 240 ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js')) 241 .map((f) => `src/services/guardianship/${f}`), 242 'src/routes/guardian.js', 243 ]; 230 const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js']; 244 231 // Only top-level declarations name a scope; an indented `const base = ...` is 245 232 // a local and would otherwise take the blame for its enclosing function. A -
test/messages.test.js
r952baf3 rf85b2c3 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 eigen6 // verzonden berichten samen tot draden (zie messages-conversations.test.js voor7 // de groepeerlogica zelf); likes, boosts en follows blijven losse regels.8 4 // 9 5 // Run: npm test (= node --test) … … 46 42 const msgs = AP.getMessages('me', 50); 47 43 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'); 44 test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => { 45 const sent = msgs.find((m) => m.type === 'sent'); 53 46 assert.ok(sent, 'sent-item ontbreekt'); 54 47 assert.equal(sent.outboxId, 'out1'); 55 48 assert.equal(sent.to_handle, '@dana@r.test'); 56 // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips 57 // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad. 58 assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']); 59 assert.equal(thread.post.slug, 'mijn-post'); 49 assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan'); 60 50 }); 61 51 … … 68 58 69 59 test('privé-reply draagt visibility voor de badge en heeft post-context', () => { 70 const thread = msgs.find((m) => m.type === 'thread'); 71 const reply = thread.messages.find((m) => m.type === 'reply'); 60 const reply = msgs.find((m) => m.type === 'reply'); 72 61 assert.equal(reply.visibility, 'direct'); 73 62 assert.equal(reply.post_slug, 'mijn-post'); 74 63 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' });77 64 }); 78 65
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)