diff --git a/pb_migrations/1791500000_notices.js b/pb_migrations/1791500000_notices.js
new file mode 100644
index 0000000..4c7cf5b
--- /dev/null
+++ b/pb_migrations/1791500000_notices.js
@@ -0,0 +1,73 @@
+///
+
+// A short message the owner broadcasts into the app, for breakage he cannot fix.
+//
+// **Why this exists.** AniList disabled their public GraphQL API with no notice
+// and the family's Anime tab stopped working. The app had nothing to say: the
+// cause was outside it, no retry would help, and the only honest message was
+// "this is broken, it is not your television, and it is not ours to fix today".
+// The same shape covers every outage we do not control — prehraj.to down, a
+// TorBox key expired, the backend being upgraded this evening — and most of
+// those the app cannot detect at all. The owner can. This is how he says so.
+//
+// **Why read is PUBLIC.** A notice has to survive the case where signing in is
+// itself what is broken, which is exactly when an explanation is worth most. A
+// rule of `@request.auth.id != ''` would hide the message from the only people
+// who need it. The cost is that anyone who finds this backend can read it, so:
+//
+// NOTHING SENSITIVE GOES IN A NOTICE. No hostnames, no account names, no
+// "the TorBox key expired" — that last one names a service the household pays
+// for and tells a reader what to go looking for. Write what a viewer needs
+// ("anime is not working today, it is an outage at the provider"), not what an
+// operator would want.
+//
+// Writing stays superuser-only. This is the owner's voice, and a compromised
+// user account must not be able to put words in front of the family.
+//
+// **Why `endsAt` as well as `active`.** The predictable failure is not a wrong
+// notice, it is a stale one: the outage ends, the message stays, and within a
+// week everybody has learned to ignore the banner. `active` is the switch the
+// owner flips; `endsAt` is the one he does not have to remember. The client
+// enforces both, so a forgotten row expires even if nobody touches it again.
+//
+// There is deliberately no severity, no title, no dismissal state and no
+// targeting. Every one of those is a field to get wrong in exchange for
+// something a single sentence already does, and dismissal is per-run in the
+// client precisely so it needs no storage here.
+
+migrate(
+ (app) => {
+ const c = new Collection({
+ type: "base",
+ name: "notices",
+ listRule: "",
+ viewRule: "",
+ createRule: null,
+ updateRule: null,
+ deleteRule: null,
+ fields: [
+ {
+ name: "message",
+ type: "text",
+ required: true,
+ max: 400,
+ },
+ {
+ name: "active",
+ type: "bool",
+ required: false,
+ },
+ {
+ name: "endsAt",
+ type: "date",
+ required: false,
+ },
+ ],
+ });
+ app.save(c);
+ },
+ (app) => {
+ const c = app.findCollectionByNameOrId("notices");
+ app.delete(c);
+ },
+);
diff --git a/pb_public/status.html b/pb_public/status.html
index d3204a3..160a036 100644
--- a/pb_public/status.html
+++ b/pb_public/status.html
@@ -598,6 +598,63 @@ function viewDevices(rows){
signup and no invite code behind this; `users.createRule` is null, so only a
superuser can do it, which is exactly who is looking at this screen. */
+
+/* A message the family sees in the app, for breakage we cannot fix.
+ *
+ * Lives here rather than only in the PocketBase admin UI because the moment it
+ * is needed is the moment something is already going wrong, and a form that
+ * takes one sentence and a button beats remembering a collection name and a
+ * rule syntax under pressure.
+ *
+ * The warning is not decoration. The collection is PUBLICLY readable so a notice
+ * still appears when signing in is itself what is broken, which means anything
+ * typed here is world-readable to whoever finds the backend. */
+function viewNotices(ns){
+ var rows = (ns || []).map(function(n){
+ var ends = n.endsAt ? String(n.endsAt).substring(0, 16) : "—";
+ return "
" + esc(n.message) + "
" +
+ "
" + (n.active ? 'viditelné'
+ : 'skryté') + "
" +
+ "
" + esc(ends) + "
" +
+ '
";
+ }).join("");
+
+ return '
Oznámení v aplikaci
' +
+ '
Uvidí ho každý v aplikaci, i nepřihlášený. ' +
+ 'Nepiš sem nic citlivého — jména služeb, adresy ani klíče.
' +
+ '
' +
+ '
' +
+ ' ' +
+ (rows ? '
Text
Stav
Do
' +
+ rows + '
' : '
zatím žádné
') +
+ '';
+}
+
+async function saveNotice(){
+ var msg = (el("nMsg").value || "").trim();
+ var out = el("nMsgOut");
+ if (!msg) { out.textContent = "napiš text"; return; }
+ var body = { message: msg, active: true };
+ var ends = el("nEnds").value;
+ if (ends) body.endsAt = new Date(ends).toISOString().replace("T", " ").substring(0, 19) + "Z";
+ try {
+ await api("/api/collections/notices/records", { method: "POST", body: body });
+ out.textContent = "zveřejněno";
+ await load();
+ } catch (e) { out.textContent = "nepovedlo se: " + e.message; }
+}
+
+async function setNoticeActive(id, to){
+ try {
+ await api("/api/collections/notices/records/" + id,
+ { method: "PATCH", body: { active: to === "1" } });
+ await load();
+ } catch (e) { alert("nepovedlo se: " + e.message); }
+}
+
function viewAdmin(users, tpls){
var rows = (users || []).map(function(u){
return "
" + esc(u.email || u.username || u.id) + "
" +
@@ -969,6 +1026,10 @@ function wire(){
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
});
if (el("createBtn")) el("createBtn").onclick = createAccount;
+ if (el("nSave")) el("nSave").onclick = saveNotice;
+ Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){
+ b.onclick = function(){ setNoticeActive(b.getAttribute("data-notice"), b.getAttribute("data-to")); };
+ });
if (el("tSave")) el("tSave").onclick = saveTemplate;
if (el("tGroup")) el("tGroup").onchange = function(){
// Re-render from the selection. Unsaved edits in the fields are dropped, which
@@ -1005,7 +1066,10 @@ async function load(){
if (prefs.view === "admin") {
var us = await records("users", { sort: "-created", perPage: 200 });
var tps = await allTemplates();
- el("main").innerHTML = viewAdmin(us.items || [], tps) + viewTemplate(tps);
+ var ns = await records("notices", { sort: "-created", perPage: 20 })
+ .catch(function(){ return { items: [] }; });
+ el("main").innerHTML = viewNotices(ns.items || []) +
+ viewAdmin(us.items || [], tps) + viewTemplate(tps);
wire();
return;
}