1490 lines
64 KiB
JavaScript
1490 lines
64 KiB
JavaScript
|
|
/**
|
|||
|
|
* pc-live | Webhosting (ic-web)
|
|||
|
|
*
|
|||
|
|
* Drei Teile in dieser Datei:
|
|||
|
|
* ICWebNet – Transport zum Server (Anfrage-Id → Promise)
|
|||
|
|
* ICWebRender – Blockrenderer, wird auch vom Browser benutzt
|
|||
|
|
* WebhostingApp – die App: Anmeldung, Seiteneditor, Zugänge, Verwaltung
|
|||
|
|
*
|
|||
|
|
* Die gesamte Verwaltung läuft über Anmeldungen. Der Anbieter meldet sich mit
|
|||
|
|
* admin@liveinvader.ls an, legt Domänen an und richtet je Domäne einen Zugang
|
|||
|
|
* ein. Ein Zugang ist zugleich ein Postfach.
|
|||
|
|
*
|
|||
|
|
* WICHTIG: Inhalte kommen von Spielern. Sie werden ausschliesslich über
|
|||
|
|
* textContent und setAttribute gesetzt, niemals über innerHTML. Ein
|
|||
|
|
* eingeschleustes <script> wäre hier kein Darstellungsfehler, sondern
|
|||
|
|
* Codeausführung im PC-Interface.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
Transport
|
|||
|
|
═══════════════════════════════════════════════════════════════════════════ */
|
|||
|
|
const ICWebNet = (() => {
|
|||
|
|
const waiting = new Map();
|
|||
|
|
let seq = 0;
|
|||
|
|
|
|||
|
|
function call(op, ...args) {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const reqId = 'w' + (++seq);
|
|||
|
|
// null im Array würde beim Weg durch Lua eine Lücke reissen.
|
|||
|
|
const safeArgs = args.map(a => (a === undefined || a === null) ? '' : a);
|
|||
|
|
|
|||
|
|
const timer = setTimeout(() => {
|
|||
|
|
if (waiting.delete(reqId)) {
|
|||
|
|
resolve({ ok: false, error: 'Keine Antwort vom Server.' });
|
|||
|
|
}
|
|||
|
|
}, 10000);
|
|||
|
|
|
|||
|
|
waiting.set(reqId, (payload) => {
|
|||
|
|
clearTimeout(timer);
|
|||
|
|
resolve(payload || { ok: false, error: 'Leere Antwort.' });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
fetchNui('icweb', { reqId, op, args: safeArgs });
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onResponse(data) {
|
|||
|
|
if (!data) return;
|
|||
|
|
const fn = waiting.get(data.reqId);
|
|||
|
|
if (!fn) return; // abgelaufen oder doppelt
|
|||
|
|
waiting.delete(data.reqId);
|
|||
|
|
fn(data.payload);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { call, onResponse };
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
Blockrenderer
|
|||
|
|
═══════════════════════════════════════════════════════════════════════════ */
|
|||
|
|
const ICWebRender = (() => {
|
|||
|
|
/* Nur ic://-Ziele und einfache Seitennamen sind erlaubt. Alles andere
|
|||
|
|
(javascript:, data:, http:) wird als Text angezeigt statt verlinkt. */
|
|||
|
|
function safeTarget(raw) {
|
|||
|
|
const t = String(raw || '').trim();
|
|||
|
|
if (/^ic:\/\/[a-z0-9.\-\/]+$/i.test(t)) return t;
|
|||
|
|
if (/^[a-z0-9\-]+$/i.test(t)) return t; // Seitenname auf derselben Domäne
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function el(tag, className, text) {
|
|||
|
|
const n = document.createElement(tag);
|
|||
|
|
if (className) n.className = className;
|
|||
|
|
if (text !== undefined) n.textContent = text;
|
|||
|
|
return n;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderBlock(b, ctx) {
|
|||
|
|
switch (b.type) {
|
|||
|
|
case 'heading': {
|
|||
|
|
const lvl = [1, 2, 3].includes(Number(b.level)) ? Number(b.level) : 2;
|
|||
|
|
return el('h' + lvl, 'icweb-h icweb-h' + lvl, b.text || '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'text':
|
|||
|
|
return el('p', 'icweb-p', b.text || '');
|
|||
|
|
|
|||
|
|
case 'image': {
|
|||
|
|
const fig = el('figure', 'icweb-fig');
|
|||
|
|
const img = el('img', 'icweb-img');
|
|||
|
|
// src nur über setAttribute; der Server hat https + Hostliste geprüft.
|
|||
|
|
img.setAttribute('src', String(b.url || ''));
|
|||
|
|
// Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand
|
|||
|
|
// der Herkunft, und die eines NUI kennen sie nicht.
|
|||
|
|
img.setAttribute('referrerpolicy', 'no-referrer');
|
|||
|
|
img.setAttribute('alt', String(b.alt || ''));
|
|||
|
|
img.setAttribute('loading', 'lazy');
|
|||
|
|
img.addEventListener('error', () => {
|
|||
|
|
img.remove();
|
|||
|
|
fig.appendChild(el('div', 'icweb-img-broken', '🖼 Bild nicht erreichbar'));
|
|||
|
|
});
|
|||
|
|
fig.appendChild(img);
|
|||
|
|
if (b.caption) fig.appendChild(el('figcaption', 'icweb-cap', b.caption));
|
|||
|
|
return fig;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'list': {
|
|||
|
|
const ul = el('ul', 'icweb-ul');
|
|||
|
|
(b.items || []).forEach(i => ul.appendChild(el('li', null, String(i))));
|
|||
|
|
return ul;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'button': {
|
|||
|
|
const target = safeTarget(b.target);
|
|||
|
|
if (!target) return el('p', 'icweb-p', String(b.label || ''));
|
|||
|
|
|
|||
|
|
const a = el('a', 'icweb-btn', b.label || '');
|
|||
|
|
a.setAttribute('role', 'button');
|
|||
|
|
a.addEventListener('click', () => {
|
|||
|
|
const url = target.startsWith('ic://')
|
|||
|
|
? target
|
|||
|
|
: 'ic://' + (ctx && ctx.domain ? ctx.domain + '/' + target : target);
|
|||
|
|
if (typeof BrowserApp !== 'undefined') BrowserApp.navigate(url);
|
|||
|
|
});
|
|||
|
|
return a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'contact': {
|
|||
|
|
const box = el('div', 'icweb-contact');
|
|||
|
|
[['✉', b.email], ['☎', b.phone], ['📍', b.address]].forEach(([icon, value]) => {
|
|||
|
|
if (!value) return;
|
|||
|
|
const row = el('div', 'icweb-contact-row');
|
|||
|
|
row.appendChild(el('span', 'icweb-contact-icon', icon));
|
|||
|
|
row.appendChild(el('span', null, String(value)));
|
|||
|
|
box.appendChild(row);
|
|||
|
|
});
|
|||
|
|
return box.childNodes.length ? box : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'divider':
|
|||
|
|
return el('hr', 'icweb-hr');
|
|||
|
|
}
|
|||
|
|
return null; // unbekannter Typ: nichts anzeigen
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Baut eine ganze Seite. Gibt ein DocumentFragment zurück. */
|
|||
|
|
function renderPage(site) {
|
|||
|
|
const frag = document.createDocumentFragment();
|
|||
|
|
const wrap = el('div', 'icweb-site icweb-theme-' + (site.theme || 'clean'));
|
|||
|
|
|
|||
|
|
const head = el('div', 'icweb-sitehead');
|
|||
|
|
head.appendChild(el('div', 'icweb-sitetitle', site.title || site.domain));
|
|||
|
|
head.appendChild(el('div', 'icweb-sitedomain', 'ic://' + site.domain));
|
|||
|
|
wrap.appendChild(head);
|
|||
|
|
|
|||
|
|
if (site.nav && site.nav.length > 1) {
|
|||
|
|
const nav = el('nav', 'icweb-nav');
|
|||
|
|
site.nav.forEach(p => {
|
|||
|
|
const a = el('a', 'icweb-navlink' + (p.slug === site.page.slug ? ' active' : ''),
|
|||
|
|
p.title || p.slug);
|
|||
|
|
a.addEventListener('click', () => {
|
|||
|
|
if (typeof BrowserApp !== 'undefined') BrowserApp.navigate('ic://' + site.domain + '/' + p.slug);
|
|||
|
|
});
|
|||
|
|
nav.appendChild(a);
|
|||
|
|
});
|
|||
|
|
wrap.appendChild(nav);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const body = el('div', 'icweb-body');
|
|||
|
|
(site.page.blocks || []).forEach(b => {
|
|||
|
|
const node = renderBlock(b, { domain: site.domain });
|
|||
|
|
if (node) body.appendChild(node);
|
|||
|
|
});
|
|||
|
|
if (!body.childNodes.length) {
|
|||
|
|
body.appendChild(el('p', 'icweb-p icweb-muted', 'Diese Seite hat noch keinen Inhalt.'));
|
|||
|
|
}
|
|||
|
|
wrap.appendChild(body);
|
|||
|
|
|
|||
|
|
const foot = el('div', 'icweb-foot');
|
|||
|
|
const rep = el('a', 'icweb-report', '⚑ Seite melden');
|
|||
|
|
rep.addEventListener('click', () => openReport(site.domain, site.page.slug));
|
|||
|
|
foot.appendChild(rep);
|
|||
|
|
wrap.appendChild(foot);
|
|||
|
|
|
|||
|
|
frag.appendChild(wrap);
|
|||
|
|
return frag;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Melden ─────────────────────────────────────────────── */
|
|||
|
|
function openReport(domain, slug) {
|
|||
|
|
modal('Seite melden', (box, close) => {
|
|||
|
|
box.appendChild(el('div', 'icweb-modal-sub',
|
|||
|
|
'ic://' + domain + ' — was stimmt mit dieser Seite nicht?'));
|
|||
|
|
|
|||
|
|
const ta = el('textarea', 'icweb-input');
|
|||
|
|
ta.setAttribute('rows', '4');
|
|||
|
|
ta.setAttribute('placeholder', 'Kurze Begründung');
|
|||
|
|
box.appendChild(ta);
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
|
|||
|
|
cancel.addEventListener('click', close);
|
|||
|
|
const send = el('button', 'icweb-btn-primary', 'Melden');
|
|||
|
|
send.addEventListener('click', async () => {
|
|||
|
|
send.disabled = true;
|
|||
|
|
const res = await ICWebNet.call('report', domain, slug || 'home', ta.value);
|
|||
|
|
close();
|
|||
|
|
Desktop.showNotification(res.ok ? '⚑ Meldung eingegangen. Danke.'
|
|||
|
|
: '⚠ ' + (res.error || 'Meldung fehlgeschlagen.'));
|
|||
|
|
});
|
|||
|
|
row.append(cancel, send);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
setTimeout(() => ta.focus(), 0);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Kleiner Dialogbaukasten, den auch die App benutzt. */
|
|||
|
|
function modal(title, build, wide) {
|
|||
|
|
const back = el('div', 'icweb-modal-back');
|
|||
|
|
const box = el('div', 'icweb-modal' + (wide ? ' wide' : ''));
|
|||
|
|
box.appendChild(el('div', 'icweb-modal-title', title));
|
|||
|
|
|
|||
|
|
const close = () => back.remove();
|
|||
|
|
build(box, close);
|
|||
|
|
|
|||
|
|
back.appendChild(box);
|
|||
|
|
back.addEventListener('click', (e) => { if (e.target === back) close(); });
|
|||
|
|
document.getElementById('app').appendChild(back);
|
|||
|
|
return close;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Ersatz für prompt() und confirm() ──────────────────
|
|||
|
|
In FiveMs CEF gibt es keinen Handler für die eingebauten JS-Dialoge:
|
|||
|
|
prompt() und confirm() öffnen nichts und lassen das NUI stehen. Deshalb
|
|||
|
|
alles über den eigenen Dialog. */
|
|||
|
|
|
|||
|
|
/** Texteingabe. cb(wert) bei OK, gar nicht bei Abbruch. */
|
|||
|
|
function promptText(title, opts, cb) {
|
|||
|
|
opts = opts || {};
|
|||
|
|
modal(title, (box, close) => {
|
|||
|
|
if (opts.hint) box.appendChild(el('div', 'icweb-modal-sub', opts.hint));
|
|||
|
|
|
|||
|
|
const input = el('input', 'icweb-input');
|
|||
|
|
input.setAttribute('type', opts.password ? 'password' : 'text');
|
|||
|
|
if (opts.placeholder) input.setAttribute('placeholder', opts.placeholder);
|
|||
|
|
input.value = opts.value || '';
|
|||
|
|
box.appendChild(input);
|
|||
|
|
|
|||
|
|
const err = el('div', 'wh-login-error');
|
|||
|
|
box.appendChild(err);
|
|||
|
|
|
|||
|
|
const submit = () => {
|
|||
|
|
const value = input.value.trim();
|
|||
|
|
if (!value) { err.textContent = 'Bitte etwas eingeben.'; return; }
|
|||
|
|
close();
|
|||
|
|
cb(value);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
|
|||
|
|
cancel.addEventListener('click', close);
|
|||
|
|
const go = el('button', 'icweb-btn-primary', opts.okLabel || 'OK');
|
|||
|
|
go.addEventListener('click', submit);
|
|||
|
|
row.append(cancel, go);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
|
|||
|
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
|
|||
|
|
setTimeout(() => input.focus(), 0);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Rückfrage. cb() nur bei Bestätigung. */
|
|||
|
|
function confirmBox(title, text, cb, opts) {
|
|||
|
|
opts = opts || {};
|
|||
|
|
modal(title, (box, close) => {
|
|||
|
|
// Mehrzeilige Texte behalten ihre Umbrüche.
|
|||
|
|
const p = el('div', 'icweb-modal-sub', text || '');
|
|||
|
|
p.style.whiteSpace = 'pre-wrap';
|
|||
|
|
box.appendChild(p);
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
|
|||
|
|
cancel.addEventListener('click', close);
|
|||
|
|
const go = el('button', opts.danger ? 'icweb-btn-danger' : 'icweb-btn-primary',
|
|||
|
|
opts.okLabel || 'Ja');
|
|||
|
|
go.addEventListener('click', () => { close(); cb(); });
|
|||
|
|
row.append(cancel, go);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { renderPage, renderBlock, openReport, el, modal, promptText, confirmBox };
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
/* ═══════════════════════════════════════════════════════════════════════════
|
|||
|
|
Die App
|
|||
|
|
═══════════════════════════════════════════════════════════════════════════ */
|
|||
|
|
const WebhostingApp = (() => {
|
|||
|
|
const WIN_ID = 'app-webhosting';
|
|||
|
|
const el = ICWebRender.el;
|
|||
|
|
const modal = ICWebRender.modal;
|
|||
|
|
|
|||
|
|
/* Muss zum Schema in ic-web/shared/blocks.lua passen. Weicht es ab, fällt
|
|||
|
|
das Feld beim Speichern serverseitig weg – der Server entscheidet. */
|
|||
|
|
const BLOCK_TYPES = {
|
|||
|
|
heading: { label: 'Überschrift', icon: 'H',
|
|||
|
|
fields: [ { key: 'text', type: 'text', label: 'Text' },
|
|||
|
|
{ key: 'level', type: 'select', label: 'Größe',
|
|||
|
|
options: [[1, 'Groß'], [2, 'Mittel'], [3, 'Klein']], default: 2 } ] },
|
|||
|
|
text: { label: 'Textabsatz', icon: '¶',
|
|||
|
|
fields: [ { key: 'text', type: 'area', label: 'Text', rows: 5 } ] },
|
|||
|
|
image: { label: 'Bild', icon: '🖼',
|
|||
|
|
fields: [ { key: 'url', type: 'text', label: 'Bildadresse (https)' },
|
|||
|
|
{ key: 'alt', type: 'text', label: 'Bildbeschreibung' },
|
|||
|
|
{ key: 'caption', type: 'text', label: 'Bildunterschrift' } ] },
|
|||
|
|
list: { label: 'Aufzählung', icon: '•',
|
|||
|
|
fields: [ { key: 'items', type: 'lines', label: 'Ein Eintrag je Zeile', rows: 5 } ] },
|
|||
|
|
button: { label: 'Schaltfläche', icon: '▭',
|
|||
|
|
fields: [ { key: 'label', type: 'text', label: 'Beschriftung' },
|
|||
|
|
{ key: 'target', type: 'text', label: 'Ziel (Seitenname oder ic://…)' } ] },
|
|||
|
|
contact: { label: 'Kontaktbox', icon: '☎',
|
|||
|
|
fields: [ { key: 'email', type: 'text', label: 'E-Mail' },
|
|||
|
|
{ key: 'phone', type: 'text', label: 'Telefon' },
|
|||
|
|
{ key: 'address', type: 'text', label: 'Anschrift' } ] },
|
|||
|
|
divider: { label: 'Trennlinie', icon: '—', fields: [] },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let info = null; // { session, themes, tlds, imageHosts, provider, selfService }
|
|||
|
|
let current = null; // geladene Domäne im Editor
|
|||
|
|
let currentSlug = 'home';
|
|||
|
|
let view = 'domains';
|
|||
|
|
let winEl = null; // das Fensterelement von WindowManager.create
|
|||
|
|
|
|||
|
|
const session = () => (info && info.session) || null;
|
|||
|
|
const isProvider = () => { const s = session(); return s && s.role === 'superadmin'; };
|
|||
|
|
const mayManage = () => { const s = session(); return s && (s.role === 'superadmin' || s.role === 'admin'); };
|
|||
|
|
|
|||
|
|
/* ── Fenster ──────────────────────────────────────────── */
|
|||
|
|
async function open() {
|
|||
|
|
const win = WindowManager.create({
|
|||
|
|
id: WIN_ID, title: 'Webhosting', icon: '🌍', width: 1020, height: 680,
|
|||
|
|
content: `
|
|||
|
|
<div class="wh-root">
|
|||
|
|
<div class="wh-side" id="wh-side"></div>
|
|||
|
|
<div class="wh-main" id="wh-main"></div>
|
|||
|
|
</div>`,
|
|||
|
|
});
|
|||
|
|
if (!win) return;
|
|||
|
|
|
|||
|
|
winEl = win;
|
|||
|
|
await refreshSession();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refreshSession() {
|
|||
|
|
setMain(loading('Verbinde…'));
|
|||
|
|
const res = await ICWebNet.call('session');
|
|||
|
|
info = res.ok ? res.data : null;
|
|||
|
|
|
|||
|
|
renderSide();
|
|||
|
|
if (!info) return setMain(errorBox('Der Dienst ist nicht erreichbar.'));
|
|||
|
|
if (!session()) return showLogin();
|
|||
|
|
if (session().mustChange) return showForcedPassword();
|
|||
|
|
view = 'domains';
|
|||
|
|
renderSide();
|
|||
|
|
showDomains();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Der WindowManager vergibt keine id auf dem Fenster, sondern data-wid.
|
|||
|
|
// Deshalb wird im zurueckgegebenen Element gesucht, nicht im Dokument.
|
|||
|
|
function root(id) {
|
|||
|
|
if (!winEl || !winEl.isConnected) {
|
|||
|
|
winEl = document.querySelector('[data-wid="' + WIN_ID + '"]');
|
|||
|
|
}
|
|||
|
|
return winEl ? winEl.querySelector('#' + id) : null;
|
|||
|
|
}
|
|||
|
|
function setMain(node) {
|
|||
|
|
const m = root('wh-main');
|
|||
|
|
if (m) m.replaceChildren(node);
|
|||
|
|
}
|
|||
|
|
function loading(txt) {
|
|||
|
|
const d = el('div', 'wh-empty');
|
|||
|
|
d.appendChild(el('div', 'wh-empty-icon', '⏳'));
|
|||
|
|
d.appendChild(el('div', null, txt));
|
|||
|
|
return d;
|
|||
|
|
}
|
|||
|
|
function errorBox(txt) {
|
|||
|
|
const d = el('div', 'wh-empty');
|
|||
|
|
d.appendChild(el('div', 'wh-empty-icon', '⚠'));
|
|||
|
|
d.appendChild(el('div', null, txt));
|
|||
|
|
return d;
|
|||
|
|
}
|
|||
|
|
function notify(res, okText) {
|
|||
|
|
Desktop.showNotification(res.ok ? '✔ ' + okText : '⚠ ' + (res.error || 'Fehlgeschlagen.'));
|
|||
|
|
return res.ok;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Anmeldung ────────────────────────────────────────── */
|
|||
|
|
function showLogin() {
|
|||
|
|
const wrap = el('div', 'wh-login');
|
|||
|
|
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-logo', '🌍'));
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-title', (info && info.provider) || 'IC Webhosting'));
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-sub',
|
|||
|
|
'Melde dich mit deinem Zugang an. Zugänge vergibt die Administration.'));
|
|||
|
|
|
|||
|
|
const form = el('div', 'wh-form wh-login-form');
|
|||
|
|
const user = field(form, 'Benutzername', 'text', '', 'name@domäne.ls');
|
|||
|
|
const pass = field(form, 'Passwort', 'password', '');
|
|||
|
|
|
|||
|
|
const err = el('div', 'wh-login-error');
|
|||
|
|
form.appendChild(err);
|
|||
|
|
|
|||
|
|
const go = el('button', 'icweb-btn-primary', 'Anmelden');
|
|||
|
|
const submit = async () => {
|
|||
|
|
err.textContent = '';
|
|||
|
|
if (!user.value || !pass.value) {
|
|||
|
|
err.textContent = 'Bitte Benutzername und Passwort eingeben.';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
go.disabled = true;
|
|||
|
|
go.textContent = 'Prüfe…';
|
|||
|
|
const res = await ICWebNet.call('login', user.value, pass.value);
|
|||
|
|
go.disabled = false;
|
|||
|
|
go.textContent = 'Anmelden';
|
|||
|
|
pass.value = '';
|
|||
|
|
|
|||
|
|
if (!res.ok) { err.textContent = res.error || 'Anmeldung fehlgeschlagen.'; return; }
|
|||
|
|
await refreshSession();
|
|||
|
|
};
|
|||
|
|
go.addEventListener('click', submit);
|
|||
|
|
[user, pass].forEach(i =>
|
|||
|
|
i.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }));
|
|||
|
|
form.appendChild(go);
|
|||
|
|
|
|||
|
|
wrap.appendChild(form);
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-foot',
|
|||
|
|
'Mit der Anmeldung wird auch das Postfach dieses Zugangs geöffnet. '
|
|||
|
|
+ 'Mehrere Personen können denselben Zugang benutzen.'));
|
|||
|
|
|
|||
|
|
setMain(wrap);
|
|||
|
|
setTimeout(() => user.focus(), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showForcedPassword() {
|
|||
|
|
const wrap = el('div', 'wh-login');
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-logo', '🔑'));
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-title', 'Passwort ändern'));
|
|||
|
|
wrap.appendChild(el('div', 'wh-login-sub',
|
|||
|
|
'Dieser Zugang benutzt noch das vergebene Startpasswort. Bitte vergib ein eigenes.'));
|
|||
|
|
|
|||
|
|
const form = el('div', 'wh-form wh-login-form');
|
|||
|
|
const p1 = field(form, 'Neues Passwort', 'password', '');
|
|||
|
|
const p2 = field(form, 'Wiederholen', 'password', '');
|
|||
|
|
const err = el('div', 'wh-login-error');
|
|||
|
|
form.appendChild(err);
|
|||
|
|
|
|||
|
|
const go = el('button', 'icweb-btn-primary', 'Passwort setzen');
|
|||
|
|
go.addEventListener('click', async () => {
|
|||
|
|
err.textContent = '';
|
|||
|
|
if (p1.value !== p2.value) { err.textContent = 'Die Eingaben stimmen nicht überein.'; return; }
|
|||
|
|
const res = await ICWebNet.call('changePassword', session().id, p1.value);
|
|||
|
|
if (!res.ok) { err.textContent = res.error || 'Fehlgeschlagen.'; return; }
|
|||
|
|
Desktop.showNotification('✔ Passwort geändert.');
|
|||
|
|
await refreshSession();
|
|||
|
|
});
|
|||
|
|
form.appendChild(go);
|
|||
|
|
|
|||
|
|
const skip = el('a', 'wh-login-skip', 'Später ändern');
|
|||
|
|
skip.addEventListener('click', () => { info.session.mustChange = false; renderSide(); showDomains(); });
|
|||
|
|
form.appendChild(skip);
|
|||
|
|
|
|||
|
|
wrap.appendChild(form);
|
|||
|
|
setMain(wrap);
|
|||
|
|
setTimeout(() => p1.focus(), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Seitenleiste ─────────────────────────────────────── */
|
|||
|
|
function renderSide() {
|
|||
|
|
const side = root('wh-side');
|
|||
|
|
if (!side) return;
|
|||
|
|
side.replaceChildren();
|
|||
|
|
|
|||
|
|
side.appendChild(el('div', 'wh-side-title', 'Webhosting'));
|
|||
|
|
|
|||
|
|
if (!session()) {
|
|||
|
|
side.appendChild(el('div', 'wh-side-note', 'Nicht angemeldet'));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const entries = [{ id: 'domains', icon: '🌍', label: isProvider() ? 'Alle Seiten' : 'Meine Seite' }];
|
|||
|
|
if (mayManage()) entries.push({ id: 'accounts', icon: '👥', label: 'Zugänge' });
|
|||
|
|
if (isProvider()) {
|
|||
|
|
entries.push({ id: 'admin', icon: '🛠', label: 'Verwaltung' });
|
|||
|
|
entries.push({ id: 'reports', icon: '⚑', label: 'Meldungen' });
|
|||
|
|
}
|
|||
|
|
entries.push({ id: 'password', icon: '🔑', label: 'Mein Passwort' });
|
|||
|
|
|
|||
|
|
entries.forEach(e => {
|
|||
|
|
const b = el('div', 'wh-nav' + (view === e.id ? ' active' : ''));
|
|||
|
|
b.appendChild(el('span', 'wh-nav-icon', e.icon));
|
|||
|
|
b.appendChild(el('span', null, e.label));
|
|||
|
|
b.addEventListener('click', () => {
|
|||
|
|
view = e.id;
|
|||
|
|
renderSide();
|
|||
|
|
({ domains: showDomains, accounts: showAccounts, admin: showAdmin,
|
|||
|
|
reports: showReports, password: showOwnPassword })[e.id]();
|
|||
|
|
});
|
|||
|
|
side.appendChild(b);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const foot = el('div', 'wh-side-user');
|
|||
|
|
foot.appendChild(el('div', 'wh-side-username', session().username));
|
|||
|
|
foot.appendChild(el('div', 'wh-side-role',
|
|||
|
|
{ superadmin: 'Anbieter', admin: 'Domänenadmin', editor: 'Redakteur' }[session().role]
|
|||
|
|
|| session().role));
|
|||
|
|
const out = el('a', 'wh-side-logout', 'Abmelden');
|
|||
|
|
out.addEventListener('click', async () => {
|
|||
|
|
await ICWebNet.call('logout');
|
|||
|
|
Desktop.showNotification('Abgemeldet. Das Postfach ist wieder geschlossen.');
|
|||
|
|
await refreshSession();
|
|||
|
|
});
|
|||
|
|
foot.appendChild(out);
|
|||
|
|
side.appendChild(foot);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Domänen ──────────────────────────────────────────── */
|
|||
|
|
async function showDomains() {
|
|||
|
|
setMain(loading('Lade…'));
|
|||
|
|
const res = await ICWebNet.call('listMine');
|
|||
|
|
const domains = (res.ok && Array.isArray(res.data)) ? res.data : [];
|
|||
|
|
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
wrap.appendChild(header(isProvider() ? 'Alle Seiten' : 'Meine Seite',
|
|||
|
|
isProvider() ? 'Jede Domäne auf dem Server. Du darfst überall bearbeiten.'
|
|||
|
|
: 'Die Seiten deiner Domäne.'));
|
|||
|
|
|
|||
|
|
if (!domains.length) {
|
|||
|
|
const e = el('div', 'wh-empty');
|
|||
|
|
e.appendChild(el('div', 'wh-empty-icon', '🌍'));
|
|||
|
|
e.appendChild(el('div', null, isProvider()
|
|||
|
|
? 'Noch keine Domäne angelegt.'
|
|||
|
|
: 'Zu diesem Zugang gehört keine Domäne.'));
|
|||
|
|
if (isProvider()) {
|
|||
|
|
const b = el('button', 'icweb-btn-primary', 'Domäne anlegen');
|
|||
|
|
b.addEventListener('click', () => { view = 'admin'; renderSide(); showAdmin(); });
|
|||
|
|
e.appendChild(b);
|
|||
|
|
}
|
|||
|
|
wrap.appendChild(e);
|
|||
|
|
return setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const grid = el('div', 'wh-cards');
|
|||
|
|
domains.forEach(d => {
|
|||
|
|
const card = el('div', 'wh-card');
|
|||
|
|
card.appendChild(el('div', 'wh-card-domain', d.domain));
|
|||
|
|
card.appendChild(el('div', 'wh-card-title', d.title || ''));
|
|||
|
|
|
|||
|
|
const tags = el('div', 'wh-tags');
|
|||
|
|
tags.appendChild(el('span', 'wh-tag ' + (d.published ? 'ok' : 'warn'),
|
|||
|
|
d.published ? 'veröffentlicht' : 'nicht veröffentlicht'));
|
|||
|
|
if (d.blocked) tags.appendChild(el('span', 'wh-tag bad', 'gesperrt'));
|
|||
|
|
card.appendChild(tags);
|
|||
|
|
|
|||
|
|
const actions = el('div', 'wh-card-actions');
|
|||
|
|
const edit = el('button', 'icweb-btn-primary', 'Bearbeiten');
|
|||
|
|
edit.addEventListener('click', () => openDomain(d.domain));
|
|||
|
|
const visit = el('button', 'icweb-btn-ghost', 'Ansehen');
|
|||
|
|
visit.addEventListener('click', () => BrowserApp.open('ic://' + d.domain));
|
|||
|
|
actions.append(edit, visit);
|
|||
|
|
card.appendChild(actions);
|
|||
|
|
grid.appendChild(card);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
wrap.appendChild(grid);
|
|||
|
|
setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function openDomain(domain, tab) {
|
|||
|
|
view = 'domains';
|
|||
|
|
renderSide();
|
|||
|
|
setMain(loading('Lade ' + domain + '…'));
|
|||
|
|
|
|||
|
|
const res = await ICWebNet.call('getEditable', domain);
|
|||
|
|
if (!res.ok) return setMain(errorBox(res.error || 'Nicht verfügbar.'));
|
|||
|
|
|
|||
|
|
current = res.data;
|
|||
|
|
currentSlug = (current.pages[0] && current.pages[0].slug) || 'home';
|
|||
|
|
renderDomain(tab || 'pages');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderDomain(tab) {
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
|
|||
|
|
const head = el('div', 'wh-domainhead');
|
|||
|
|
const left = el('div');
|
|||
|
|
left.appendChild(el('div', 'wh-card-domain', current.domain));
|
|||
|
|
left.appendChild(el('div', 'wh-card-title', current.title || ''));
|
|||
|
|
head.appendChild(left);
|
|||
|
|
|
|||
|
|
const tags = el('div', 'wh-tags');
|
|||
|
|
tags.appendChild(el('span', 'wh-tag ' + (current.published ? 'ok' : 'warn'),
|
|||
|
|
current.published ? 'veröffentlicht' : 'Entwurf'));
|
|||
|
|
if (current.blocked) tags.appendChild(el('span', 'wh-tag bad', 'gesperrt'));
|
|||
|
|
head.appendChild(tags);
|
|||
|
|
wrap.appendChild(head);
|
|||
|
|
|
|||
|
|
if (current.blocked) {
|
|||
|
|
wrap.appendChild(el('div', 'wh-banner',
|
|||
|
|
'Diese Seite wurde gesperrt. Änderungen sind nicht möglich – wende dich an die Administration.'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tabs = el('div', 'wh-tabs');
|
|||
|
|
const list = [['pages', 'Seiten'], ['settings', 'Einstellungen']];
|
|||
|
|
list.forEach(([id, label]) => {
|
|||
|
|
const t = el('div', 'wh-tab' + (tab === id ? ' active' : ''), label);
|
|||
|
|
t.addEventListener('click', () => renderDomain(id));
|
|||
|
|
tabs.appendChild(t);
|
|||
|
|
});
|
|||
|
|
const back = el('div', 'wh-tab-back', '← Übersicht');
|
|||
|
|
back.addEventListener('click', () => showDomains());
|
|||
|
|
tabs.appendChild(back);
|
|||
|
|
wrap.appendChild(tabs);
|
|||
|
|
|
|||
|
|
const body = el('div', 'wh-tabbody');
|
|||
|
|
wrap.appendChild(body);
|
|||
|
|
setMain(wrap);
|
|||
|
|
|
|||
|
|
if (tab === 'pages') renderPagesTab(body);
|
|||
|
|
if (tab === 'settings') renderSettingsTab(body);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Reiter: Seiten ───────────────────────────────────── */
|
|||
|
|
function renderPagesTab(body) {
|
|||
|
|
body.replaceChildren();
|
|||
|
|
const cols = el('div', 'wh-split');
|
|||
|
|
|
|||
|
|
const list = el('div', 'wh-pagelist');
|
|||
|
|
current.pages.forEach(p => {
|
|||
|
|
const item = el('div', 'wh-pageitem' + (p.slug === currentSlug ? ' active' : ''));
|
|||
|
|
item.appendChild(el('div', 'wh-pageitem-title', p.title || p.slug));
|
|||
|
|
item.appendChild(el('div', 'wh-pageitem-slug', '/' + p.slug));
|
|||
|
|
item.addEventListener('click', () => { currentSlug = p.slug; renderPagesTab(body); });
|
|||
|
|
list.appendChild(item);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const add = el('button', 'icweb-btn-ghost', '+ Neue Seite');
|
|||
|
|
add.addEventListener('click', () => {
|
|||
|
|
ICWebRender.promptText('Neue Seite', {
|
|||
|
|
hint: 'Nur Buchstaben, Ziffern und Bindestrich. Erreichbar unter ic://'
|
|||
|
|
+ current.domain + '/name',
|
|||
|
|
placeholder: 'impressum',
|
|||
|
|
okLabel: 'Anlegen',
|
|||
|
|
}, (slug) => {
|
|||
|
|
const clean = slug.toLowerCase().replace(/\s+/g, '');
|
|||
|
|
if (current.pages.some(p => p.slug === clean)) {
|
|||
|
|
return Desktop.showNotification('⚠ Diese Seite gibt es schon.');
|
|||
|
|
}
|
|||
|
|
current.pages.push({ slug: clean, title: slug, blocks: [] });
|
|||
|
|
currentSlug = clean;
|
|||
|
|
renderPagesTab(body);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
list.appendChild(add);
|
|||
|
|
cols.appendChild(list);
|
|||
|
|
|
|||
|
|
const page = current.pages.find(p => p.slug === currentSlug);
|
|||
|
|
const edit = el('div', 'wh-editor');
|
|||
|
|
|
|||
|
|
if (!page) {
|
|||
|
|
edit.appendChild(el('div', 'wh-hint', 'Keine Seite ausgewählt.'));
|
|||
|
|
cols.appendChild(edit);
|
|||
|
|
return body.appendChild(cols);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const titleRow = el('div', 'wh-field');
|
|||
|
|
titleRow.appendChild(el('label', 'wh-label', 'Seitentitel'));
|
|||
|
|
const titleIn = el('input', 'icweb-input');
|
|||
|
|
titleIn.value = page.title || '';
|
|||
|
|
titleIn.addEventListener('input', () => { page.title = titleIn.value; });
|
|||
|
|
titleRow.appendChild(titleIn);
|
|||
|
|
edit.appendChild(titleRow);
|
|||
|
|
|
|||
|
|
const blocksBox = el('div', 'wh-blocks');
|
|||
|
|
edit.appendChild(blocksBox);
|
|||
|
|
drawBlocks(blocksBox, page);
|
|||
|
|
|
|||
|
|
const addBar = el('div', 'wh-addbar');
|
|||
|
|
addBar.appendChild(el('span', 'wh-addbar-label', 'Block hinzufügen:'));
|
|||
|
|
Object.entries(BLOCK_TYPES).forEach(([type, def]) => {
|
|||
|
|
const b = el('button', 'wh-addbtn');
|
|||
|
|
b.appendChild(el('span', 'wh-addbtn-icon', def.icon));
|
|||
|
|
b.appendChild(el('span', null, def.label));
|
|||
|
|
b.addEventListener('click', () => {
|
|||
|
|
page.blocks.push(newBlock(type));
|
|||
|
|
drawBlocks(blocksBox, page);
|
|||
|
|
});
|
|||
|
|
addBar.appendChild(b);
|
|||
|
|
});
|
|||
|
|
edit.appendChild(addBar);
|
|||
|
|
|
|||
|
|
const actions = el('div', 'wh-actions');
|
|||
|
|
const save = el('button', 'icweb-btn-primary', 'Seite speichern');
|
|||
|
|
save.disabled = current.blocked;
|
|||
|
|
save.addEventListener('click', async () => {
|
|||
|
|
save.disabled = true;
|
|||
|
|
const res = await ICWebNet.call('savePage', current.domain, page.slug,
|
|||
|
|
page.title, toWire(page.blocks));
|
|||
|
|
save.disabled = current.blocked;
|
|||
|
|
notify(res, 'Seite gespeichert.');
|
|||
|
|
});
|
|||
|
|
actions.appendChild(save);
|
|||
|
|
|
|||
|
|
const preview = el('button', 'icweb-btn-ghost', 'Vorschau');
|
|||
|
|
preview.addEventListener('click', () => showPreview(page));
|
|||
|
|
actions.appendChild(preview);
|
|||
|
|
|
|||
|
|
if (page.slug !== 'home') {
|
|||
|
|
const del = el('button', 'icweb-btn-danger', 'Seite löschen');
|
|||
|
|
del.addEventListener('click', () => {
|
|||
|
|
ICWebRender.confirmBox('Seite löschen',
|
|||
|
|
'Seite "' + page.slug + '" wirklich löschen?', async () => {
|
|||
|
|
const res = await ICWebNet.call('deletePage', current.domain, page.slug);
|
|||
|
|
if (notify(res, 'Seite gelöscht.')) openDomain(current.domain, 'pages');
|
|||
|
|
}, { danger: true, okLabel: 'Löschen' });
|
|||
|
|
});
|
|||
|
|
actions.appendChild(del);
|
|||
|
|
}
|
|||
|
|
edit.appendChild(actions);
|
|||
|
|
|
|||
|
|
cols.appendChild(edit);
|
|||
|
|
body.appendChild(cols);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function newBlock(type) {
|
|||
|
|
const b = { type };
|
|||
|
|
(BLOCK_TYPES[type].fields || []).forEach(f => {
|
|||
|
|
b[f.key] = f.default !== undefined ? f.default : '';
|
|||
|
|
});
|
|||
|
|
return b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Blocks für die Übertragung aufbereiten (Zeilen → Array). */
|
|||
|
|
function toWire(blocks) {
|
|||
|
|
return blocks.map(b => {
|
|||
|
|
const out = { type: b.type };
|
|||
|
|
(BLOCK_TYPES[b.type].fields || []).forEach(f => {
|
|||
|
|
if (f.type === 'lines') {
|
|||
|
|
const raw = Array.isArray(b[f.key]) ? b[f.key].join('\n') : String(b[f.key] || '');
|
|||
|
|
out[f.key] = raw.split('\n').map(s => s.trim()).filter(s => s.length);
|
|||
|
|
} else if (f.type === 'select') {
|
|||
|
|
out[f.key] = Number(b[f.key]);
|
|||
|
|
} else {
|
|||
|
|
out[f.key] = String(b[f.key] || '');
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return out;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function drawBlocks(box, page) {
|
|||
|
|
box.replaceChildren();
|
|||
|
|
|
|||
|
|
if (!page.blocks.length) {
|
|||
|
|
return box.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Noch keine Inhalte. Füge unten einen Block hinzu.'));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
page.blocks.forEach((b, idx) => {
|
|||
|
|
const def = BLOCK_TYPES[b.type];
|
|||
|
|
if (!def) return;
|
|||
|
|
|
|||
|
|
const card = el('div', 'wh-block');
|
|||
|
|
const head = el('div', 'wh-block-head');
|
|||
|
|
head.appendChild(el('span', 'wh-block-icon', def.icon));
|
|||
|
|
head.appendChild(el('span', 'wh-block-label', def.label));
|
|||
|
|
|
|||
|
|
const tools = el('div', 'wh-block-tools');
|
|||
|
|
const up = el('button', 'wh-icon-btn', '▲');
|
|||
|
|
up.disabled = idx === 0;
|
|||
|
|
up.addEventListener('click', () => {
|
|||
|
|
[page.blocks[idx - 1], page.blocks[idx]] = [page.blocks[idx], page.blocks[idx - 1]];
|
|||
|
|
drawBlocks(box, page);
|
|||
|
|
});
|
|||
|
|
const down = el('button', 'wh-icon-btn', '▼');
|
|||
|
|
down.disabled = idx === page.blocks.length - 1;
|
|||
|
|
down.addEventListener('click', () => {
|
|||
|
|
[page.blocks[idx + 1], page.blocks[idx]] = [page.blocks[idx], page.blocks[idx + 1]];
|
|||
|
|
drawBlocks(box, page);
|
|||
|
|
});
|
|||
|
|
const rm = el('button', 'wh-icon-btn danger', '✕');
|
|||
|
|
rm.addEventListener('click', () => {
|
|||
|
|
page.blocks.splice(idx, 1);
|
|||
|
|
drawBlocks(box, page);
|
|||
|
|
});
|
|||
|
|
tools.append(up, down, rm);
|
|||
|
|
head.appendChild(tools);
|
|||
|
|
card.appendChild(head);
|
|||
|
|
|
|||
|
|
def.fields.forEach(f => {
|
|||
|
|
const row = el('div', 'wh-field');
|
|||
|
|
row.appendChild(el('label', 'wh-label', f.label));
|
|||
|
|
|
|||
|
|
let input;
|
|||
|
|
if (f.type === 'area' || f.type === 'lines') {
|
|||
|
|
input = el('textarea', 'icweb-input');
|
|||
|
|
input.setAttribute('rows', String(f.rows || 4));
|
|||
|
|
input.value = Array.isArray(b[f.key]) ? b[f.key].join('\n') : (b[f.key] || '');
|
|||
|
|
} else if (f.type === 'select') {
|
|||
|
|
input = el('select', 'icweb-input');
|
|||
|
|
f.options.forEach(([v, label]) => {
|
|||
|
|
const o = el('option', null, label);
|
|||
|
|
o.value = String(v);
|
|||
|
|
input.appendChild(o);
|
|||
|
|
});
|
|||
|
|
input.value = String(b[f.key] ?? f.default);
|
|||
|
|
} else {
|
|||
|
|
input = el('input', 'icweb-input');
|
|||
|
|
input.value = b[f.key] || '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
input.addEventListener('input', () => { b[f.key] = input.value; });
|
|||
|
|
input.addEventListener('change', () => { b[f.key] = input.value; });
|
|||
|
|
row.appendChild(input);
|
|||
|
|
|
|||
|
|
if (f.key === 'url') {
|
|||
|
|
row.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Erlaubte Bildquellen: ' + ((info && info.imageHosts) || []).join(', ')));
|
|||
|
|
}
|
|||
|
|
card.appendChild(row);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
box.appendChild(card);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showPreview(page) {
|
|||
|
|
modal('Vorschau', (box, close) => {
|
|||
|
|
const view = el('div', 'wh-preview');
|
|||
|
|
view.appendChild(ICWebRender.renderPage({
|
|||
|
|
domain: current.domain, title: current.title, theme: current.theme,
|
|||
|
|
nav: current.pages.map(p => ({ slug: p.slug, title: p.title })),
|
|||
|
|
page: { slug: page.slug, title: page.title, blocks: toWire(page.blocks) },
|
|||
|
|
}));
|
|||
|
|
box.appendChild(view);
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const b = el('button', 'icweb-btn-primary', 'Schließen');
|
|||
|
|
b.addEventListener('click', close);
|
|||
|
|
row.appendChild(b);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
}, true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Reiter: Einstellungen ────────────────────────────── */
|
|||
|
|
function renderSettingsTab(body) {
|
|||
|
|
body.replaceChildren();
|
|||
|
|
|
|||
|
|
const form = el('div', 'wh-form');
|
|||
|
|
const title = field(form, 'Anzeigename', 'text', current.title || '');
|
|||
|
|
|
|||
|
|
const themeRow = el('div', 'wh-field');
|
|||
|
|
themeRow.appendChild(el('label', 'wh-label', 'Gestaltung'));
|
|||
|
|
const theme = el('select', 'icweb-input');
|
|||
|
|
((info && info.themes) || ['clean']).forEach(t => {
|
|||
|
|
const o = el('option', null, t);
|
|||
|
|
o.value = t;
|
|||
|
|
theme.appendChild(o);
|
|||
|
|
});
|
|||
|
|
theme.value = current.theme;
|
|||
|
|
themeRow.appendChild(theme);
|
|||
|
|
form.appendChild(themeRow);
|
|||
|
|
|
|||
|
|
const pubRow = el('div', 'wh-field wh-check');
|
|||
|
|
const pub = el('input');
|
|||
|
|
pub.setAttribute('type', 'checkbox');
|
|||
|
|
pub.checked = current.published;
|
|||
|
|
pubRow.appendChild(pub);
|
|||
|
|
pubRow.appendChild(el('label', 'wh-label', 'Veröffentlicht (für alle sichtbar)'));
|
|||
|
|
form.appendChild(pubRow);
|
|||
|
|
|
|||
|
|
const save = el('button', 'icweb-btn-primary', 'Speichern');
|
|||
|
|
save.disabled = current.blocked;
|
|||
|
|
save.addEventListener('click', async () => {
|
|||
|
|
const res = await ICWebNet.call('updateSite', current.domain, {
|
|||
|
|
title: title.value, theme: theme.value, published: pub.checked,
|
|||
|
|
});
|
|||
|
|
if (notify(res, 'Einstellungen gespeichert.')) openDomain(current.domain, 'settings');
|
|||
|
|
});
|
|||
|
|
form.appendChild(save);
|
|||
|
|
form.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Solange die Seite nicht veröffentlicht ist, sehen sie nur angemeldete Bearbeiter.'));
|
|||
|
|
|
|||
|
|
body.appendChild(form);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Zugänge ──────────────────────────────────────────── */
|
|||
|
|
let accountDomain = null;
|
|||
|
|
|
|||
|
|
async function showAccounts() {
|
|||
|
|
setMain(loading('Lade Zugänge…'));
|
|||
|
|
|
|||
|
|
// Der Anbieter wählt die Domäne, alle anderen haben genau ihre.
|
|||
|
|
if (!isProvider()) accountDomain = session().domain;
|
|||
|
|
if (isProvider() && !accountDomain) {
|
|||
|
|
const res = await ICWebNet.call('adminListSites');
|
|||
|
|
const sites = (res.ok && res.data) || [];
|
|||
|
|
if (!sites.length) return setMain(errorBox('Noch keine Domäne angelegt.'));
|
|||
|
|
accountDomain = sites[0].domain;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [accs, sitesRes] = await Promise.all([
|
|||
|
|
ICWebNet.call('listAccounts', accountDomain),
|
|||
|
|
isProvider() ? ICWebNet.call('adminListSites') : Promise.resolve({ ok: false }),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
wrap.appendChild(header('Zugänge',
|
|||
|
|
'Ein Zugang ist Benutzername, Passwort und Postfach in einem. '
|
|||
|
|
+ 'Domänenadmins dürfen Zugänge anlegen, Redakteure nur Seiten pflegen.'));
|
|||
|
|
|
|||
|
|
if (isProvider() && sitesRes.ok) {
|
|||
|
|
const row = el('div', 'wh-field');
|
|||
|
|
row.appendChild(el('label', 'wh-label', 'Domäne'));
|
|||
|
|
const sel = el('select', 'icweb-input');
|
|||
|
|
sitesRes.data.forEach(s => {
|
|||
|
|
const o = el('option', null, s.domain);
|
|||
|
|
o.value = s.domain;
|
|||
|
|
sel.appendChild(o);
|
|||
|
|
});
|
|||
|
|
sel.value = accountDomain;
|
|||
|
|
sel.addEventListener('change', () => { accountDomain = sel.value; showAccounts(); });
|
|||
|
|
row.appendChild(sel);
|
|||
|
|
wrap.appendChild(row);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!accs.ok) {
|
|||
|
|
wrap.appendChild(el('div', 'wh-hint', accs.error || 'Keine Berechtigung.'));
|
|||
|
|
return setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const table = el('div', 'wh-table');
|
|||
|
|
if (!accs.data.length) table.appendChild(el('div', 'wh-hint', 'Noch keine Zugänge.'));
|
|||
|
|
|
|||
|
|
accs.data.forEach(a => {
|
|||
|
|
const row = el('div', 'wh-row');
|
|||
|
|
const infoCol = el('div');
|
|||
|
|
infoCol.appendChild(el('div', 'wh-row-title', a.username));
|
|||
|
|
infoCol.appendChild(el('div', 'wh-row-sub',
|
|||
|
|
(a.display_name || '—') + ' · letzte Anmeldung: ' + (a.last_login || 'nie')));
|
|||
|
|
row.appendChild(infoCol);
|
|||
|
|
|
|||
|
|
const right = el('div', 'wh-row-right');
|
|||
|
|
right.appendChild(el('span', 'wh-tag',
|
|||
|
|
{ superadmin: 'Anbieter', admin: 'Admin', editor: 'Redakteur' }[a.role] || a.role));
|
|||
|
|
if (!a.active) right.appendChild(el('span', 'wh-tag bad', 'gesperrt'));
|
|||
|
|
if (a.must_change) right.appendChild(el('span', 'wh-tag warn', 'Startpasswort'));
|
|||
|
|
|
|||
|
|
const own = session().username === a.username;
|
|||
|
|
|
|||
|
|
const pw = el('button', 'icweb-btn-ghost', 'Passwort');
|
|||
|
|
pw.addEventListener('click', () => askPassword(a));
|
|||
|
|
right.appendChild(pw);
|
|||
|
|
|
|||
|
|
if (!own && a.role !== 'superadmin') {
|
|||
|
|
const role = el('button', 'icweb-btn-ghost',
|
|||
|
|
a.role === 'admin' ? '→ Redakteur' : '→ Admin');
|
|||
|
|
role.addEventListener('click', async () => {
|
|||
|
|
const res = await ICWebNet.call('setAccountRole', a.id,
|
|||
|
|
a.role === 'admin' ? 'editor' : 'admin');
|
|||
|
|
if (notify(res, 'Rolle geändert.')) showAccounts();
|
|||
|
|
});
|
|||
|
|
right.appendChild(role);
|
|||
|
|
|
|||
|
|
const lock = el('button', 'icweb-btn-ghost', a.active ? 'Sperren' : 'Freigeben');
|
|||
|
|
lock.addEventListener('click', async () => {
|
|||
|
|
const res = await ICWebNet.call('setAccountActive', a.id, !a.active);
|
|||
|
|
if (notify(res, a.active ? 'Gesperrt.' : 'Freigegeben.')) showAccounts();
|
|||
|
|
});
|
|||
|
|
right.appendChild(lock);
|
|||
|
|
|
|||
|
|
const del = el('button', 'icweb-btn-danger', 'Löschen');
|
|||
|
|
del.addEventListener('click', () => {
|
|||
|
|
ICWebRender.confirmBox('Zugang löschen',
|
|||
|
|
'Zugang ' + a.username + ' löschen?\n'
|
|||
|
|
+ 'Das Postfach mit den Nachrichten bleibt bestehen.', async () => {
|
|||
|
|
const res = await ICWebNet.call('deleteAccount', a.id);
|
|||
|
|
if (notify(res, 'Zugang gelöscht.')) showAccounts();
|
|||
|
|
}, { danger: true, okLabel: 'Löschen' });
|
|||
|
|
});
|
|||
|
|
right.appendChild(del);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
row.appendChild(right);
|
|||
|
|
table.appendChild(row);
|
|||
|
|
});
|
|||
|
|
wrap.appendChild(table);
|
|||
|
|
|
|||
|
|
/* Anlegen */
|
|||
|
|
wrap.appendChild(el('div', 'wh-subhead', 'Zugang anlegen'));
|
|||
|
|
const form = el('div', 'wh-form wh-form-inline');
|
|||
|
|
|
|||
|
|
const nameRow = el('div', 'wh-field');
|
|||
|
|
nameRow.appendChild(el('label', 'wh-label', 'Benutzername'));
|
|||
|
|
const nameWrap = el('div', 'wh-inline');
|
|||
|
|
const nameIn = el('input', 'icweb-input');
|
|||
|
|
nameIn.setAttribute('placeholder', 'presse');
|
|||
|
|
nameWrap.appendChild(nameIn);
|
|||
|
|
nameWrap.appendChild(el('span', 'wh-inline-suffix', '@' + accountDomain));
|
|||
|
|
nameRow.appendChild(nameWrap);
|
|||
|
|
form.appendChild(nameRow);
|
|||
|
|
|
|||
|
|
const display = field(form, 'Anzeigename', 'text', '', 'z. B. Redaktion');
|
|||
|
|
const pass = field(form, 'Startpasswort', 'text', '',
|
|||
|
|
'mindestens 6 Zeichen – wird weitergegeben');
|
|||
|
|
|
|||
|
|
const roleRow = el('div', 'wh-field');
|
|||
|
|
roleRow.appendChild(el('label', 'wh-label', 'Rolle'));
|
|||
|
|
const role = el('select', 'icweb-input');
|
|||
|
|
[['editor', 'Redakteur – darf Seiten pflegen'],
|
|||
|
|
['admin', 'Domänenadmin – darf zusätzlich Zugänge verwalten']].forEach(([v, l]) => {
|
|||
|
|
const o = el('option', null, l);
|
|||
|
|
o.value = v;
|
|||
|
|
role.appendChild(o);
|
|||
|
|
});
|
|||
|
|
roleRow.appendChild(role);
|
|||
|
|
form.appendChild(roleRow);
|
|||
|
|
|
|||
|
|
const create = el('button', 'icweb-btn-primary', 'Zugang anlegen');
|
|||
|
|
create.addEventListener('click', async () => {
|
|||
|
|
const username = (nameIn.value || '').trim().toLowerCase() + '@' + accountDomain;
|
|||
|
|
const res = await ICWebNet.call('createAccount', accountDomain, username,
|
|||
|
|
pass.value, role.value, display.value);
|
|||
|
|
if (!notify(res, 'Zugang angelegt.')) return;
|
|||
|
|
showCredentials('Zugang angelegt', username, pass.value);
|
|||
|
|
showAccounts();
|
|||
|
|
});
|
|||
|
|
form.appendChild(create);
|
|||
|
|
form.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Mit dem Zugang entsteht auch das Postfach ' + (nameIn.value || 'name') + '@' + accountDomain
|
|||
|
|
+ '. Wer die Daten kennt, kann sich anmelden – auch mehrere gleichzeitig.'));
|
|||
|
|
|
|||
|
|
wrap.appendChild(form);
|
|||
|
|
setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function askPassword(account) {
|
|||
|
|
modal('Passwort setzen: ' + account.username, (box, close) => {
|
|||
|
|
const form = el('div', 'wh-form');
|
|||
|
|
const p = field(form, 'Neues Passwort', 'text', '', 'mindestens 6 Zeichen');
|
|||
|
|
box.appendChild(form);
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
|
|||
|
|
cancel.addEventListener('click', close);
|
|||
|
|
const go = el('button', 'icweb-btn-primary', 'Setzen');
|
|||
|
|
go.addEventListener('click', async () => {
|
|||
|
|
const res = await ICWebNet.call('setAccountPassword', account.id, p.value);
|
|||
|
|
if (notify(res, 'Passwort gesetzt.')) { close(); showAccounts(); }
|
|||
|
|
});
|
|||
|
|
row.append(cancel, go);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
setTimeout(() => p.focus(), 0);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showCredentials(title, username, password) {
|
|||
|
|
modal(title, (box, close) => {
|
|||
|
|
box.appendChild(el('div', 'icweb-modal-sub',
|
|||
|
|
'Diese Daten werden nur jetzt angezeigt. Notiere sie und gib sie weiter.'));
|
|||
|
|
|
|||
|
|
const grid = el('div', 'wh-cred');
|
|||
|
|
grid.appendChild(el('div', 'wh-cred-label', 'Benutzername'));
|
|||
|
|
grid.appendChild(el('div', 'wh-cred-value', username));
|
|||
|
|
grid.appendChild(el('div', 'wh-cred-label', 'Passwort'));
|
|||
|
|
grid.appendChild(el('div', 'wh-cred-value', password));
|
|||
|
|
box.appendChild(grid);
|
|||
|
|
|
|||
|
|
box.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Beim ersten Anmelden wird zum Ändern des Passworts aufgefordert.'));
|
|||
|
|
|
|||
|
|
const row = el('div', 'icweb-modal-actions');
|
|||
|
|
const b = el('button', 'icweb-btn-primary', 'Verstanden');
|
|||
|
|
b.addEventListener('click', close);
|
|||
|
|
row.appendChild(b);
|
|||
|
|
box.appendChild(row);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Mein Passwort ────────────────────────────────────── */
|
|||
|
|
function showOwnPassword() {
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
wrap.appendChild(header('Mein Passwort',
|
|||
|
|
'Gilt für ' + session().username + '. Wer das Passwort kennt, hat auch Zugriff '
|
|||
|
|
+ 'auf das Postfach dieses Zugangs.'));
|
|||
|
|
|
|||
|
|
const form = el('div', 'wh-form');
|
|||
|
|
const p1 = field(form, 'Neues Passwort', 'password', '');
|
|||
|
|
const p2 = field(form, 'Wiederholen', 'password', '');
|
|||
|
|
const err = el('div', 'wh-login-error');
|
|||
|
|
form.appendChild(err);
|
|||
|
|
|
|||
|
|
const go = el('button', 'icweb-btn-primary', 'Passwort ändern');
|
|||
|
|
go.addEventListener('click', async () => {
|
|||
|
|
err.textContent = '';
|
|||
|
|
if (p1.value !== p2.value) { err.textContent = 'Die Eingaben stimmen nicht überein.'; return; }
|
|||
|
|
const res = await ICWebNet.call('changePassword', session().id, p1.value);
|
|||
|
|
if (!res.ok) { err.textContent = res.error || 'Fehlgeschlagen.'; return; }
|
|||
|
|
p1.value = ''; p2.value = '';
|
|||
|
|
Desktop.showNotification('✔ Passwort geändert.');
|
|||
|
|
});
|
|||
|
|
form.appendChild(go);
|
|||
|
|
wrap.appendChild(form);
|
|||
|
|
setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Verwaltung (nur Anbieter) ────────────────────────── */
|
|||
|
|
async function showAdmin() {
|
|||
|
|
setMain(loading('Lade Verwaltung…'));
|
|||
|
|
|
|||
|
|
const [sites, jobs] = await Promise.all([
|
|||
|
|
ICWebNet.call('adminListSites'),
|
|||
|
|
ICWebNet.call('adminListJobs'),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
wrap.appendChild(header('Verwaltung',
|
|||
|
|
'Domänen anlegen, übertragen, sperren und löschen. '
|
|||
|
|
+ 'Mit jeder Domäne entsteht ein Zugang für ihren Admin.'));
|
|||
|
|
|
|||
|
|
if (!sites.ok) {
|
|||
|
|
wrap.appendChild(el('div', 'wh-hint', sites.error || 'Keine Berechtigung.'));
|
|||
|
|
return setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Anlegen */
|
|||
|
|
const box = el('div', 'wh-form wh-form-inline');
|
|||
|
|
box.appendChild(el('div', 'wh-subhead', 'Domäne anlegen'));
|
|||
|
|
const dom = field(box, 'Domäne (name.endung)', 'text', '', 'z. B. weazel-news.ls');
|
|||
|
|
const title = field(box, 'Anzeigename', 'text', '', 'z. B. Weazel News');
|
|||
|
|
|
|||
|
|
const ownerRow = el('div', 'wh-field');
|
|||
|
|
ownerRow.appendChild(el('label', 'wh-label', 'Besitzer (Job)'));
|
|||
|
|
const owner = el('select', 'icweb-input');
|
|||
|
|
((jobs.ok && jobs.data) || []).forEach(j => {
|
|||
|
|
const o = el('option', null, j.label + ' · ' + j.name);
|
|||
|
|
o.value = j.name;
|
|||
|
|
owner.appendChild(o);
|
|||
|
|
});
|
|||
|
|
ownerRow.appendChild(owner);
|
|||
|
|
box.appendChild(ownerRow);
|
|||
|
|
|
|||
|
|
const adminUser = field(box, 'Zugang für den Domänenadmin', 'text', 'admin', 'admin');
|
|||
|
|
const adminPass = field(box, 'Startpasswort (leer = wird erzeugt)', 'text', '');
|
|||
|
|
|
|||
|
|
const create = el('button', 'icweb-btn-primary', 'Anlegen');
|
|||
|
|
create.addEventListener('click', async () => {
|
|||
|
|
create.disabled = true;
|
|||
|
|
const res = await ICWebNet.call('adminRegister', dom.value, title.value,
|
|||
|
|
owner.value, adminUser.value, adminPass.value);
|
|||
|
|
create.disabled = false;
|
|||
|
|
if (!notify(res, 'Domäne angelegt.')) return;
|
|||
|
|
|
|||
|
|
const c = res.data && res.data.credentials;
|
|||
|
|
if (c) showCredentials('Domäne ' + res.data.domain + ' angelegt', c.username, c.password);
|
|||
|
|
showAdmin();
|
|||
|
|
});
|
|||
|
|
box.appendChild(create);
|
|||
|
|
box.appendChild(el('div', 'wh-hint',
|
|||
|
|
'Die Domäne wird zugleich als Mail-Domäne angelegt. '
|
|||
|
|
+ 'Der Zugang des Domänenadmins ist sein Postfach.'));
|
|||
|
|
wrap.appendChild(box);
|
|||
|
|
|
|||
|
|
/* Liste */
|
|||
|
|
wrap.appendChild(el('div', 'wh-subhead', 'Domänen (' + sites.data.length + ')'));
|
|||
|
|
const table = el('div', 'wh-table');
|
|||
|
|
if (!sites.data.length) table.appendChild(el('div', 'wh-hint', 'Noch keine Domäne registriert.'));
|
|||
|
|
|
|||
|
|
sites.data.forEach(s => {
|
|||
|
|
const row = el('div', 'wh-row');
|
|||
|
|
const col = el('div');
|
|||
|
|
col.appendChild(el('div', 'wh-row-title', s.domain));
|
|||
|
|
col.appendChild(el('div', 'wh-row-sub',
|
|||
|
|
(s.title || '—') + ' · ' + s.owner_id + ' · '
|
|||
|
|
+ s.pages + ' Seiten, ' + s.accounts + ' Zugänge'));
|
|||
|
|
row.appendChild(col);
|
|||
|
|
|
|||
|
|
const right = el('div', 'wh-row-right');
|
|||
|
|
right.appendChild(el('span', 'wh-tag ' + (s.published ? 'ok' : 'warn'),
|
|||
|
|
s.published ? 'live' : 'Entwurf'));
|
|||
|
|
if (s.blocked) right.appendChild(el('span', 'wh-tag bad', 'gesperrt'));
|
|||
|
|
|
|||
|
|
const openBtn = el('button', 'icweb-btn-ghost', 'Bearbeiten');
|
|||
|
|
openBtn.addEventListener('click', () => openDomain(s.domain));
|
|||
|
|
right.appendChild(openBtn);
|
|||
|
|
|
|||
|
|
const accBtn = el('button', 'icweb-btn-ghost', 'Zugänge');
|
|||
|
|
accBtn.addEventListener('click', () => {
|
|||
|
|
accountDomain = s.domain; view = 'accounts'; renderSide(); showAccounts();
|
|||
|
|
});
|
|||
|
|
right.appendChild(accBtn);
|
|||
|
|
|
|||
|
|
const blockBtn = el('button', 'icweb-btn-ghost', s.blocked ? 'Freigeben' : 'Sperren');
|
|||
|
|
blockBtn.addEventListener('click', async () => {
|
|||
|
|
const res = await ICWebNet.call('adminSetBlocked', s.domain, !s.blocked);
|
|||
|
|
if (notify(res, s.blocked ? 'Freigegeben.' : 'Gesperrt.')) showAdmin();
|
|||
|
|
});
|
|||
|
|
right.appendChild(blockBtn);
|
|||
|
|
|
|||
|
|
const ownBtn = el('button', 'icweb-btn-ghost', 'Besitzer');
|
|||
|
|
ownBtn.addEventListener('click', () => {
|
|||
|
|
ICWebRender.promptText('Besitzer ändern', {
|
|||
|
|
hint: 'Jobname, dem ' + s.domain + ' künftig gehört.',
|
|||
|
|
value: s.owner_id, okLabel: 'Übertragen',
|
|||
|
|
}, async (job) => {
|
|||
|
|
const res = await ICWebNet.call('adminSetOwner', s.domain, job);
|
|||
|
|
if (notify(res, 'Besitzer geändert.')) showAdmin();
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
right.appendChild(ownBtn);
|
|||
|
|
|
|||
|
|
const delBtn = el('button', 'icweb-btn-danger', 'Löschen');
|
|||
|
|
delBtn.addEventListener('click', () => {
|
|||
|
|
ICWebRender.confirmBox('Domäne löschen',
|
|||
|
|
'Domäne ' + s.domain + ' mit allen Seiten und Zugängen löschen?\n'
|
|||
|
|
+ 'Die Postfächer und ihre Nachrichten bleiben bestehen.', async () => {
|
|||
|
|
const res = await ICWebNet.call('adminDeleteSite', s.domain);
|
|||
|
|
if (notify(res, 'Domäne gelöscht.')) showAdmin();
|
|||
|
|
}, { danger: true, okLabel: 'Löschen' });
|
|||
|
|
});
|
|||
|
|
right.appendChild(delBtn);
|
|||
|
|
|
|||
|
|
row.appendChild(right);
|
|||
|
|
table.appendChild(row);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
wrap.appendChild(table);
|
|||
|
|
setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Meldungen ────────────────────────────────────────── */
|
|||
|
|
async function showReports() {
|
|||
|
|
setMain(loading('Lade Meldungen…'));
|
|||
|
|
const res = await ICWebNet.call('adminListReports');
|
|||
|
|
|
|||
|
|
const wrap = el('div', 'wh-page');
|
|||
|
|
wrap.appendChild(header('Meldungen',
|
|||
|
|
'Gemeldete Seiten. Bilder werden extern verlinkt – Meldungen sind der Weg, '
|
|||
|
|
+ 'unpassende Inhalte zu finden.'));
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
wrap.appendChild(el('div', 'wh-hint', res.error || 'Keine Berechtigung.'));
|
|||
|
|
return setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const table = el('div', 'wh-table');
|
|||
|
|
if (!res.data.length) table.appendChild(el('div', 'wh-hint', 'Keine offenen Meldungen.'));
|
|||
|
|
|
|||
|
|
res.data.forEach(r => {
|
|||
|
|
const row = el('div', 'wh-row');
|
|||
|
|
const col = el('div');
|
|||
|
|
col.appendChild(el('div', 'wh-row-title',
|
|||
|
|
r.domain + (r.page_slug ? '/' + r.page_slug : '')));
|
|||
|
|
col.appendChild(el('div', 'wh-row-sub', r.reason || '(ohne Begründung)'));
|
|||
|
|
row.appendChild(col);
|
|||
|
|
|
|||
|
|
const right = el('div', 'wh-row-right');
|
|||
|
|
const look = el('button', 'icweb-btn-ghost', 'Ansehen');
|
|||
|
|
look.addEventListener('click', () =>
|
|||
|
|
BrowserApp.open('ic://' + r.domain + (r.page_slug ? '/' + r.page_slug : '')));
|
|||
|
|
right.appendChild(look);
|
|||
|
|
|
|||
|
|
const block = el('button', 'icweb-btn-ghost', 'Sperren');
|
|||
|
|
block.addEventListener('click', async () => {
|
|||
|
|
if (notify(await ICWebNet.call('adminSetBlocked', r.domain, true), 'Gesperrt.')) showReports();
|
|||
|
|
});
|
|||
|
|
right.appendChild(block);
|
|||
|
|
|
|||
|
|
const done = el('button', 'icweb-btn-primary', 'Erledigt');
|
|||
|
|
done.addEventListener('click', async () => {
|
|||
|
|
if (notify(await ICWebNet.call('adminSetReportStatus', r.id, 'reviewed'),
|
|||
|
|
'Meldung abgeschlossen.')) showReports();
|
|||
|
|
});
|
|||
|
|
right.appendChild(done);
|
|||
|
|
|
|||
|
|
const dismiss = el('button', 'icweb-btn-ghost', 'Verwerfen');
|
|||
|
|
dismiss.addEventListener('click', async () => {
|
|||
|
|
if (notify(await ICWebNet.call('adminSetReportStatus', r.id, 'dismissed'),
|
|||
|
|
'Meldung verworfen.')) showReports();
|
|||
|
|
});
|
|||
|
|
right.appendChild(dismiss);
|
|||
|
|
|
|||
|
|
row.appendChild(right);
|
|||
|
|
table.appendChild(row);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
wrap.appendChild(table);
|
|||
|
|
setMain(wrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Kleinkram ────────────────────────────────────────── */
|
|||
|
|
function header(title, sub) {
|
|||
|
|
const h = el('div', 'wh-head');
|
|||
|
|
h.appendChild(el('div', 'wh-head-title', title));
|
|||
|
|
if (sub) h.appendChild(el('div', 'wh-head-sub', sub));
|
|||
|
|
return h;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function field(parent, label, type, value, placeholder) {
|
|||
|
|
const row = el('div', 'wh-field');
|
|||
|
|
row.appendChild(el('label', 'wh-label', label));
|
|||
|
|
const input = el('input', 'icweb-input');
|
|||
|
|
input.setAttribute('type', type || 'text');
|
|||
|
|
if (placeholder) input.setAttribute('placeholder', placeholder);
|
|||
|
|
input.value = value || '';
|
|||
|
|
row.appendChild(input);
|
|||
|
|
parent.appendChild(row);
|
|||
|
|
return input;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── Stile ────────────────────────────────────────────── */
|
|||
|
|
let stylesDone = false;
|
|||
|
|
function injectStyles() {
|
|||
|
|
if (stylesDone) return;
|
|||
|
|
stylesDone = true;
|
|||
|
|
|
|||
|
|
const s = document.createElement('style');
|
|||
|
|
s.textContent = `
|
|||
|
|
.wh-root{display:flex;flex:1;min-height:0;height:100%;overflow:hidden}
|
|||
|
|
.wh-side{width:200px;flex-shrink:0;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:12px 8px;overflow-y:auto;display:flex;flex-direction:column}
|
|||
|
|
.wh-side-title{font-size:.68rem;text-transform:uppercase;letter-spacing:.12em;color:var(--text-dim);padding:0 8px 10px}
|
|||
|
|
.wh-nav{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:var(--radius);cursor:pointer;font-size:.8rem;color:var(--text-muted)}
|
|||
|
|
.wh-nav:hover{background:rgba(255,255,255,.04);color:var(--text)}
|
|||
|
|
.wh-nav.active{background:rgba(255,255,255,.07);color:var(--accent)}
|
|||
|
|
.wh-nav-icon{width:16px;text-align:center}
|
|||
|
|
.wh-side-note{margin-top:14px;padding:6px 10px;font-size:.63rem;color:var(--text-dim);border-top:1px solid var(--border)}
|
|||
|
|
.wh-side-user{margin-top:auto;padding:10px 10px 4px;border-top:1px solid var(--border)}
|
|||
|
|
.wh-side-username{font-size:.7rem;color:var(--text);word-break:break-all}
|
|||
|
|
.wh-side-role{font-size:.62rem;color:var(--text-dim);margin-top:2px}
|
|||
|
|
.wh-side-logout{display:inline-block;margin-top:7px;font-size:.68rem;color:var(--text-muted);cursor:pointer}
|
|||
|
|
.wh-side-logout:hover{color:#ff8a80}
|
|||
|
|
.wh-main{flex:1;min-width:0;overflow-y:auto;padding:18px 22px}
|
|||
|
|
.wh-page{display:flex;flex-direction:column;gap:14px}
|
|||
|
|
.wh-head-title{font-size:1.15rem;font-weight:700;color:var(--text)}
|
|||
|
|
.wh-head-sub{font-size:.75rem;color:var(--text-muted);margin-top:3px;max-width:75ch;line-height:1.5}
|
|||
|
|
.wh-subhead{font-size:.68rem;text-transform:uppercase;letter-spacing:.11em;color:var(--text-dim);margin-top:14px;padding-bottom:6px;border-bottom:1px solid var(--border)}
|
|||
|
|
.wh-empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:50px 0;color:var(--text-muted);font-size:.82rem}
|
|||
|
|
.wh-empty-icon{font-size:2rem}
|
|||
|
|
.wh-hint{font-size:.72rem;color:var(--text-muted);line-height:1.55;max-width:78ch}
|
|||
|
|
.wh-banner{background:rgba(229,57,53,.1);border:1px solid rgba(229,57,53,.35);border-radius:var(--radius);padding:10px 12px;font-size:.76rem;color:#ff8a80}
|
|||
|
|
|
|||
|
|
.wh-login{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:6px;text-align:center}
|
|||
|
|
.wh-login-logo{font-size:2.4rem}
|
|||
|
|
.wh-login-title{font-size:1.3rem;font-weight:700;color:var(--accent)}
|
|||
|
|
.wh-login-sub{font-size:.76rem;color:var(--text-muted);max-width:46ch;line-height:1.5}
|
|||
|
|
.wh-login-form{width:320px;margin-top:14px;text-align:left}
|
|||
|
|
.wh-login-error{font-size:.72rem;color:#ff5252;min-height:1em}
|
|||
|
|
.wh-login-skip{font-size:.7rem;color:var(--text-dim);cursor:pointer;text-align:center;margin-top:4px}
|
|||
|
|
.wh-login-skip:hover{color:var(--text)}
|
|||
|
|
.wh-login-foot{margin-top:18px;font-size:.66rem;color:var(--text-dim);max-width:52ch;line-height:1.55}
|
|||
|
|
|
|||
|
|
.wh-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px}
|
|||
|
|
.wh-card{background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;display:flex;flex-direction:column;gap:7px}
|
|||
|
|
.wh-card-domain{font-weight:700;color:var(--accent);font-size:.92rem}
|
|||
|
|
.wh-card-title{font-size:.76rem;color:var(--text-muted)}
|
|||
|
|
.wh-card-actions{display:flex;gap:6px;margin-top:4px}
|
|||
|
|
.wh-tags{display:flex;gap:5px;flex-wrap:wrap}
|
|||
|
|
.wh-tag{font-size:.61rem;padding:2px 7px;border-radius:9px;border:1px solid var(--border);color:var(--text-muted);white-space:nowrap}
|
|||
|
|
.wh-tag.ok{color:#4caf50;border-color:rgba(76,175,80,.35)}
|
|||
|
|
.wh-tag.warn{color:#ffb74d;border-color:rgba(255,183,77,.35)}
|
|||
|
|
.wh-tag.bad{color:#ff5252;border-color:rgba(255,82,82,.4)}
|
|||
|
|
|
|||
|
|
.wh-domainhead{display:flex;justify-content:space-between;align-items:flex-start}
|
|||
|
|
.wh-tabs{display:flex;gap:2px;border-bottom:1px solid var(--border)}
|
|||
|
|
.wh-tab{padding:8px 14px;font-size:.78rem;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent}
|
|||
|
|
.wh-tab:hover{color:var(--text)}
|
|||
|
|
.wh-tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
|||
|
|
.wh-tab-back{margin-left:auto;padding:8px 4px;font-size:.72rem;color:var(--text-dim);cursor:pointer}
|
|||
|
|
.wh-tab-back:hover{color:var(--text)}
|
|||
|
|
.wh-tabbody{padding-top:6px;display:flex;flex-direction:column;gap:12px}
|
|||
|
|
|
|||
|
|
.wh-split{display:flex;gap:14px;align-items:flex-start}
|
|||
|
|
.wh-pagelist{width:180px;flex-shrink:0;display:flex;flex-direction:column;gap:4px}
|
|||
|
|
.wh-pageitem{padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;background:var(--bg-sidebar)}
|
|||
|
|
.wh-pageitem:hover{border-color:var(--accent)}
|
|||
|
|
.wh-pageitem.active{border-color:var(--accent);background:rgba(255,255,255,.05)}
|
|||
|
|
.wh-pageitem-title{font-size:.78rem;color:var(--text);font-weight:600}
|
|||
|
|
.wh-pageitem-slug{font-size:.64rem;color:var(--text-dim)}
|
|||
|
|
.wh-editor{flex:1;min-width:0;display:flex;flex-direction:column;gap:10px}
|
|||
|
|
.wh-blocks{display:flex;flex-direction:column;gap:8px}
|
|||
|
|
.wh-block{border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-sidebar);padding:10px 12px;display:flex;flex-direction:column;gap:8px}
|
|||
|
|
.wh-block-head{display:flex;align-items:center;gap:8px}
|
|||
|
|
.wh-block-icon{width:18px;text-align:center;color:var(--accent)}
|
|||
|
|
.wh-block-label{font-size:.73rem;font-weight:600;color:var(--text-muted);flex:1}
|
|||
|
|
.wh-block-tools{display:flex;gap:3px}
|
|||
|
|
.wh-icon-btn{background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:4px;width:22px;height:22px;font-size:.62rem;cursor:pointer}
|
|||
|
|
.wh-icon-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
|
|||
|
|
.wh-icon-btn:disabled{opacity:.3;cursor:default}
|
|||
|
|
.wh-icon-btn.danger:hover{border-color:#ff5252;color:#ff5252}
|
|||
|
|
|
|||
|
|
.wh-addbar{display:flex;flex-wrap:wrap;gap:5px;align-items:center;padding-top:4px;border-top:1px dashed var(--border)}
|
|||
|
|
.wh-addbar-label{font-size:.68rem;color:var(--text-dim);margin-right:4px}
|
|||
|
|
.wh-addbtn{display:flex;align-items:center;gap:5px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--radius);padding:5px 9px;font-size:.7rem;cursor:pointer}
|
|||
|
|
.wh-addbtn:hover{border-color:var(--accent);color:var(--accent)}
|
|||
|
|
.wh-addbtn-icon{color:var(--accent)}
|
|||
|
|
.wh-actions{display:flex;gap:6px;padding-top:6px}
|
|||
|
|
|
|||
|
|
.wh-form{display:flex;flex-direction:column;gap:10px;max-width:520px}
|
|||
|
|
.wh-form-inline{max-width:none;background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
|
|||
|
|
.wh-field{display:flex;flex-direction:column;gap:4px}
|
|||
|
|
.wh-check{flex-direction:row;align-items:center;gap:8px}
|
|||
|
|
.wh-label{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim)}
|
|||
|
|
.wh-inline{display:flex;align-items:center;gap:6px}
|
|||
|
|
.wh-inline-suffix{font-size:.78rem;color:var(--text-dim);white-space:nowrap}
|
|||
|
|
.icweb-input{background:var(--bg-window,#15171c);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);padding:7px 9px;font-size:.79rem;font-family:inherit;width:100%;box-sizing:border-box}
|
|||
|
|
.icweb-input:focus{outline:none;border-color:var(--accent)}
|
|||
|
|
|
|||
|
|
.wh-table{display:flex;flex-direction:column;gap:5px}
|
|||
|
|
.wh-row{display:flex;justify-content:space-between;align-items:center;gap:12px;background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:10px 12px}
|
|||
|
|
.wh-row-title{font-size:.82rem;font-weight:600;color:var(--text)}
|
|||
|
|
.wh-row-sub{font-size:.68rem;color:var(--text-dim);margin-top:2px}
|
|||
|
|
.wh-row-right{display:flex;align-items:center;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end}
|
|||
|
|
|
|||
|
|
.wh-cred{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
|
|||
|
|
.wh-cred-label{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim)}
|
|||
|
|
.wh-cred-value{font-family:monospace;font-size:.86rem;color:var(--accent);word-break:break-all}
|
|||
|
|
|
|||
|
|
.icweb-btn-primary,.icweb-btn-ghost,.icweb-btn-danger{border-radius:var(--radius);padding:6px 12px;font-size:.74rem;font-family:inherit;cursor:pointer;border:1px solid var(--border);background:transparent;color:var(--text-muted)}
|
|||
|
|
.icweb-btn-primary{background:var(--accent);border-color:var(--accent);color:#08131c;font-weight:600}
|
|||
|
|
.icweb-btn-primary:disabled{opacity:.45;cursor:default}
|
|||
|
|
.icweb-btn-ghost:hover{border-color:var(--accent);color:var(--accent)}
|
|||
|
|
.icweb-btn-danger{color:#ff5252;border-color:rgba(255,82,82,.35)}
|
|||
|
|
.icweb-btn-danger:hover{background:rgba(255,82,82,.1)}
|
|||
|
|
|
|||
|
|
.icweb-modal-back{position:absolute;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:9500}
|
|||
|
|
.icweb-modal{background:var(--bg-window,#15171c);border:1px solid var(--border);border-radius:8px;padding:18px;width:430px;max-width:88%;display:flex;flex-direction:column;gap:10px;box-shadow:var(--shadow)}
|
|||
|
|
.icweb-modal.wide{width:740px;max-height:80%;overflow-y:auto}
|
|||
|
|
.icweb-modal-title{font-size:.95rem;font-weight:700;color:var(--text)}
|
|||
|
|
.icweb-modal-sub{font-size:.73rem;color:var(--text-muted);line-height:1.5}
|
|||
|
|
.icweb-modal-actions{display:flex;justify-content:flex-end;gap:6px}
|
|||
|
|
.wh-preview{border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
|
|||
|
|
|
|||
|
|
/* ── Gerenderte IC-Seiten ────────────────────────────── */
|
|||
|
|
.icweb-site{padding:22px 26px;line-height:1.6;font-size:.82rem;color:var(--text)}
|
|||
|
|
.icweb-sitehead{border-bottom:1px solid var(--border);padding-bottom:12px;margin-bottom:16px}
|
|||
|
|
.icweb-sitetitle{font-size:1.4rem;font-weight:700;color:var(--accent)}
|
|||
|
|
.icweb-sitedomain{font-size:.68rem;color:var(--text-dim);margin-top:2px}
|
|||
|
|
.icweb-nav{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:16px}
|
|||
|
|
.icweb-navlink{padding:5px 11px;border:1px solid var(--border);border-radius:14px;font-size:.72rem;color:var(--text-muted);cursor:pointer}
|
|||
|
|
.icweb-navlink:hover{border-color:var(--accent);color:var(--accent)}
|
|||
|
|
.icweb-navlink.active{background:var(--accent);border-color:var(--accent);color:#08131c;font-weight:600}
|
|||
|
|
.icweb-body{display:flex;flex-direction:column;gap:12px}
|
|||
|
|
.icweb-h{margin:6px 0 0;color:var(--text);line-height:1.3}
|
|||
|
|
.icweb-h1{font-size:1.45rem;font-weight:700}
|
|||
|
|
.icweb-h2{font-size:1.15rem;font-weight:700}
|
|||
|
|
.icweb-h3{font-size:.98rem;font-weight:600}
|
|||
|
|
.icweb-p{margin:0;white-space:pre-wrap;color:var(--text-muted)}
|
|||
|
|
.icweb-muted{color:var(--text-dim);font-style:italic}
|
|||
|
|
.icweb-fig{margin:0}
|
|||
|
|
.icweb-img{max-width:100%;border-radius:var(--radius);display:block}
|
|||
|
|
.icweb-img-broken{padding:24px;text-align:center;color:var(--text-dim);border:1px dashed var(--border);border-radius:var(--radius);font-size:.74rem}
|
|||
|
|
.icweb-cap{font-size:.68rem;color:var(--text-dim);margin-top:5px}
|
|||
|
|
.icweb-ul{margin:0;padding-left:20px;color:var(--text-muted)}
|
|||
|
|
.icweb-ul li{margin:3px 0}
|
|||
|
|
.icweb-btn{align-self:flex-start;background:var(--accent);color:#08131c;font-weight:600;padding:7px 15px;border-radius:var(--radius);font-size:.76rem;cursor:pointer}
|
|||
|
|
.icweb-contact{background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;display:flex;flex-direction:column;gap:6px}
|
|||
|
|
.icweb-contact-row{display:flex;gap:9px;align-items:center;font-size:.78rem;color:var(--text-muted)}
|
|||
|
|
.icweb-contact-icon{width:16px;text-align:center;color:var(--accent)}
|
|||
|
|
.icweb-hr{border:none;border-top:1px solid var(--border);margin:6px 0}
|
|||
|
|
.icweb-foot{margin-top:22px;padding-top:10px;border-top:1px solid var(--border);display:flex;justify-content:flex-end}
|
|||
|
|
.icweb-report{font-size:.66rem;color:var(--text-dim);cursor:pointer}
|
|||
|
|
.icweb-report:hover{color:#ff8a80}
|
|||
|
|
|
|||
|
|
/* Themes: nur Akzentfarbe und Kanten, keine eigenen Layouts */
|
|||
|
|
.icweb-theme-dark{background:#0c0d10}
|
|||
|
|
.icweb-theme-gov .icweb-sitetitle{color:#7ab8ff}
|
|||
|
|
.icweb-theme-gov .icweb-navlink.active,.icweb-theme-gov .icweb-btn{background:#7ab8ff;border-color:#7ab8ff}
|
|||
|
|
.icweb-theme-business .icweb-sitetitle{color:#d4a656}
|
|||
|
|
.icweb-theme-business .icweb-navlink.active,.icweb-theme-business .icweb-btn{background:#d4a656;border-color:#d4a656}
|
|||
|
|
`;
|
|||
|
|
document.head.appendChild(s);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stile sofort einspielen, nicht erst beim Oeffnen: der Mailclient benutzt
|
|||
|
|
// denselben Dialogbaukasten (Postfach hinzufuegen), und der soll auch dann
|
|||
|
|
// aussehen wie vorgesehen, wenn die Webhosting-App nie geoeffnet wurde.
|
|||
|
|
injectStyles();
|
|||
|
|
|
|||
|
|
return { open, openDomain };
|
|||
|
|
})();
|