/* =====================================================================
Der Benachrichtigungs-Knopf — auf JEDER Seite, in der Kopfleiste.
Wunsch Filipe (05.09.2026), mit dem Bild eines "Benachrichtigungen
aus"-Knopfes: *"ich will auch sowas und dass es perfekt funktioniert
auf der seite fuer jeden."*
---------------------------------------------------------------------
WARUM IN DER KOPFLEISTE UND NICHT AUF EINER EINSTELLUNGSSEITE
Dasselbe Argument wie bei der Suche, die hier schon so gebaut ist:
Was man nur auf einer Extraseite findet, benutzt niemand. Der Knopf
sagt jederzeit, woran man ist -- Glocke an oder durchgestrichen.
---------------------------------------------------------------------
DER KNOPF SAGT IMMER DIE WAHRHEIT, auch wenn sie unbequem ist
Es gibt mehr Zustaende als "an" und "aus", und jeder braucht einen
anderen Satz. Ein Knopf, der bei einem abgelehnten Recht einfach
nichts tut, ist der sichere Weg zu "das funktioniert nicht":
geht nicht Der Browser kann kein Push (aeltere Fassungen).
iPhone Safari kann es NUR nach "Zum Home-Bildschirm".
Im Reiter gibt es die Berechtigung ueberhaupt nicht.
verboten Schon einmal abgelehnt. Das laesst sich per
JavaScript NICHT zuruecknehmen -- man muss es in den
Browsereinstellungen tun. Also wird genau das gesagt.
aus / an der Normalfall.
===================================================================== */
(() => {
'use strict';
/* Nur im Workspace, und nicht auf der Anmeldeseite -- dort ist
niemand angemeldet, den man benachrichtigen koennte. */
if (!location.pathname.startsWith('/workspace/')) return;
if (/\/workspace\/(index\.html)?$/.test(location.pathname)) return;
const el = (tag, klasse, text) => {
const k = document.createElement(tag);
if (klasse) k.className = klasse;
if (text !== undefined) k.textContent = text;
return k;
};
const kannPush = 'serviceWorker' in navigator && 'PushManager' in window
&& 'Notification' in window;
/* Laeuft die Seite als installierte App? Auf dem iPhone ist das die
Bedingung dafuer, dass es ueberhaupt geht. */
const alsApp = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone === true;
const istApple = /iPad|iPhone|iPod/.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
let anmeldung = null;
let knopf = null;
const b64uZuBytes = (s) => {
const roh = atob(String(s).replace(/-/g, '+').replace(/_/g, '/')
+ '='.repeat((4 - String(s).length % 4) % 4));
return Uint8Array.from(roh, (c) => c.charCodeAt(0));
};
const bytesZuB64u = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/** Ein Name, an dem man sein Geraet in der Liste wiedererkennt. */
function geraetName() {
const u = navigator.userAgent;
const os = /Windows/.test(u) ? 'Windows'
: /Android/.test(u) ? 'Android'
: istApple ? (/iPad/.test(u) ? 'iPad' : 'iPhone')
: /Mac/.test(u) ? 'Mac' : /Linux/.test(u) ? 'Linux' : 'Gerät';
const browser = /Edg\//.test(u) ? 'Edge'
: /Chrome\//.test(u) ? 'Chrome'
: /Firefox\//.test(u) ? 'Firefox'
: /Safari\//.test(u) ? 'Safari' : 'Browser';
return `${os} · ${browser}${alsApp ? ' (App)' : ''}`;
}
/** Was ist gerade der Fall? */
function lage() {
if (!kannPush) return 'geht-nicht';
if (istApple && !alsApp) return 'iphone';
if (Notification.permission === 'denied') return 'verboten';
return anmeldung ? 'an' : 'aus';
}
const TEXTE = {
'geht-nicht': { text: 'Hier nicht möglich', titel: 'Dieser Browser kann keine Benachrichtigungen.' },
iphone: {
text: 'Erst zum Home-Bildschirm',
titel: 'Auf dem iPhone gehen Benachrichtigungen nur, wenn die Seite '
+ 'zum Home-Bildschirm hinzugefügt wurde: Teilen-Knopf → „Zum Home-Bildschirm“. '
+ 'Danach hier noch einmal antippen.',
},
verboten: {
text: 'Vom Browser blockiert',
titel: 'Du hast Benachrichtigungen für diese Seite einmal abgelehnt. Das lässt sich '
+ 'nur in den Browsereinstellungen wieder erlauben — bei Chrome über das Symbol '
+ 'links in der Adresszeile.',
},
aus: { text: 'Benachrichtigungen aus', titel: 'Antippen, um Erinnerungen zu bekommen.' },
an: { text: 'Benachrichtigungen an', titel: 'Antippen, um sie auf diesem Gerät wieder abzuschalten.' },
};
function zeichnen() {
if (!knopf) return;
const l = lage();
const t = TEXTE[l];
knopf.dataset.lage = l;
knopf.title = t.titel;
knopf.setAttribute('aria-label', t.titel);
knopf.querySelector('.glocke__text').textContent = t.text;
/* aria-pressed sagt einem Vorleseprogramm, dass es ein Schalter
ist und wie er gerade steht. */
knopf.setAttribute('aria-pressed', l === 'an' ? 'true' : 'false');
knopf.disabled = l === 'geht-nicht';
}
async function anmelden() {
const recht = await Notification.requestPermission();
if (recht !== 'granted') { zeichnen(); return; }
const reg = await navigator.serviceWorker.register('/workspace/sw.js', { scope: '/workspace/' });
await navigator.serviceWorker.ready;
const a = await fetch('/workspace/api/push/schluessel', { credentials: 'same-origin' });
const { schluessel } = await a.json();
const abo = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64uZuBytes(schluessel),
});
const roh = abo.toJSON();
await fetch('/workspace/api/push/anmelden', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpunkt: abo.endpoint,
p256dh: roh.keys?.p256dh || bytesZuB64u(abo.getKey('p256dh')),
auth: roh.keys?.auth || bytesZuB64u(abo.getKey('auth')),
geraet: geraetName(),
}),
});
anmeldung = abo;
zeichnen();
/* Sofort eine Probe. Das ist der Unterschied zwischen "sollte
gehen" und "geht": Wer den Knopf drueckt, will WISSEN, dass es
ankommt -- und nicht bis morgen warten. */
await fetch('/workspace/api/push/probe', {
method: 'POST', credentials: 'same-origin',
}).catch(() => {});
}
async function abmelden() {
try {
if (anmeldung) {
await fetch('/workspace/api/push/abmelden', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpunkt: anmeldung.endpoint }),
});
await anmeldung.unsubscribe();
}
} catch { /* der Servereintrag ist weg, das genuegt */ }
anmeldung = null;
zeichnen();
}
async function bauen() {
/* WO DIE GLOCKE HINGEHÖRT (07.09.2026, Wunsch Filipe: "der
benachrichtigungs button soll auch von der leiste in diese kachel
gesetzt werden").
Gibt es auf der Seite einen ausgewiesenen Platz, kommt sie
dorthin -- auf der Startseite in die Begrüßungskachel, neben die
Uhr. Das ist der richtige Ort: Benachrichtigungen sind eine
Einstellung zum eigenen Tag, keine Navigation.
Auf allen anderen Seiten gibt es diesen Platz nicht, und dort
bleibt die Leiste. Deshalb ein Rückfall und keine Umstellung:
Eine Glocke, die auf zwölf Seiten verschwindet, weil sie auf
einer umgezogen ist, wäre keine Verbesserung. Und weil die
Kopfleiste am 06.09. schon einmal an einem sechsten Element
zerbrochen ist, wird sie hier nebenbei um eines leichter. */
const platz = document.getElementById('glocke-platz');
const kopf = platz || document.querySelector('.kopfleiste__rechts');
if (!kopf || document.getElementById('glocke')) return;
knopf = el('button', 'glocke');
knopf.type = 'button';
knopf.id = 'glocke';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'glocke__zeichen');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = ''
/* "kloeppel" ohne Umlaut: Ein ö in einem Klassennamen ist erlaubt,
aber es überlebt nicht jede Datei, die zwischendurch in einer
anderen Kodierung gespeichert wird -- und im ganzen Haus wird
ohnehin "oe"/"ue" geschrieben. */
+ ''
+ '';
knopf.append(svg);
knopf.append(el('span', 'glocke__text', '…'));
/* VOR dem Abmelden-Knopf, nicht dahinter: Abmelden ist die letzte
Handlung auf einer Seite und bleibt ganz rechts. Im eigenen Platz
gibt es nichts, wovor sie stehen müsste. */
const abmeldenKnopf = platz ? null : kopf.querySelector('.abmelden');
if (abmeldenKnopf) kopf.insertBefore(knopf, abmeldenKnopf);
else kopf.append(knopf);
if (platz) knopf.classList.add('glocke--kachel');
knopf.addEventListener('click', async () => {
const l = lage();
if (l === 'geht-nicht') return;
/* Bei 'iphone' und 'verboten' nichts tun ausser erklaeren -- der
Titel steht schon dran, und ein Klick, der nichts bewirkt und
nichts sagt, wirkt kaputt. */
if (l === 'iphone' || l === 'verboten') { window.alert(TEXTE[l].titel); return; }
knopf.disabled = true;
try {
if (l === 'an') await abmelden(); else await anmelden();
} catch (f) {
console.warn('[glocke]', f);
zeichnen();
}
knopf.disabled = false;
});
/* Steht hier schon eine Anmeldung? */
if (kannPush && Notification.permission === 'granted') {
try {
const reg = await navigator.serviceWorker.getRegistration('/workspace/');
anmeldung = reg ? await reg.pushManager.getSubscription() : null;
} catch { anmeldung = null; }
}
zeichnen();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bauen);
} else bauen();
})();