pc-live/nui/js/apps/bleeter.js

1421 lines
53 KiB
JavaScript
Raw Permalink Normal View History

IC-Computer: Schreibtisch, Fenster und Apps Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen. Andere Resources haengen sich als App ein. Auf ESX umgestellt: - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt. Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die Resource waere nie hochgekommen. - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen. - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in der config.lua. Mail ueberarbeitet: - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute koennen dasselbe Firmenpostfach gleichzeitig offen haben. - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine Eigenschaft der Nachricht, sondern der Person. - Signaturen gehoeren ebenfalls zum Postfach. - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter niemand mehr aendern oder absagen. Neue Apps: Webhosting, Bleeter und dessen Verwaltung. Behoben: - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und kehrt nie zurueck. Ersetzt durch eigene Dialoge. - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht, schreibt ins Leere und das Fenster bleibt leer. Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis zum Schreibtischsymbol.
2026-08-09 12:02:11 +00:00
/**
* pc-live | Bleeter
*
* Eigene Oberfläche, kein iframe. Der frühere Weg Bleeters Fenster im PC
* einbetten und Nachrichten hin- und zurückreichen ging nicht auf:
* SetNuiFocus gilt global für den Client, und zwei Resourcen, die ihn
* beanspruchen, sperren die Maus aus.
*
* Jetzt spricht der PC direkt mit bleeter:server:*. Bleeter selbst bleibt
* unberührt und öffnet weiterhin sein eigenes Fenster über /bleeter.
*
* Die Gestaltung ist nicht nachgebaut, sondern übernommen: css/bleeter-embedded.css
* wird mechanisch aus bleeter/html/style.css erzeugt, jeder Selektor auf
* `.bl-root` gekapselt. Ohne die Kapselung würden Bleeters Regeln für `*`,
* `html` und `body` den gesamten PC einfärben.
*
* Deshalb tragen die Bausteine hier Bleeters eigene Klassennamen .post-card,
* .compose, .avatar, .nav-button. Ändert Bleeter sein Aussehen, reicht es, die
* Datei neu zu erzeugen.
*
* Inhalte kommen von Spielern und werden über textContent gesetzt, nie über
* innerHTML.
*/
const BleeterApp = (() => {
const WIN_ID = 'app-bleeter';
const el = ICWebRender.el;
let state = null; // vollständiger Zustand vom Server
let view = 'home'; // 'home' | 'ads' | 'profile'
let winEl = null;
let loading = true;
let regError = ''; // Fehlermeldung der Registrierung
const openComments = new Set(); // Beiträge mit ausgeklappten Kommentaren
let viewProfileId = null; // fremdes Profil, das gerade offen ist
/* Der Server schickt alles über ein Ereignis. Drei Sorten:
Antworten der Verwaltung gehören der anderen App
Unteraktionen wie registerError betreffen nur eine Maske
alles andere der vollständige Zustand
Ohne diese Trennung überschreibt eine Fehlermeldung den ganzen Zustand. */
const MANAGEMENT_ACTIONS = new Set([
'listBusinessProfiles', 'createBusinessProfile',
'listProfileMembers', 'listCandidates', 'setProfileMember',
]);
const SUB_ACTIONS = new Set(['registerError', 'handleCheck']);
/* ── Transport ────────────────────────────────────────── */
function send(op, payload) {
fetchNui('bleeter', payload === undefined ? { op } : { op, payload });
}
/** Von desktop.js, wenn action === 'bleeter_data'. */
function onData(message) {
if (!message) return;
const d = message.data;
if (d && d.action && MANAGEMENT_ACTIONS.has(d.action)) {
if (typeof BleeterAdminApp !== 'undefined') BleeterAdminApp.onResponse(d);
return;
}
if (d && d.action && SUB_ACTIONS.has(d.action)) {
if (d.action === 'registerError') {
loading = false;
regError = {
handle_taken: 'Dieses Handle ist bereits vergeben.',
reserved: 'Dieses Handle ist reserviert.',
}[d.reason] || 'Registrierung fehlgeschlagen.';
if (isOpen()) render();
}
return;
}
loading = false;
regError = '';
if (message.ok === false) {
state = { error: message.reason || 'unbekannt' };
} else {
state = message.data || null;
}
if (isOpen()) render();
}
function onNotify(payload) {
if (payload && payload.message) Desktop.showNotification('🐦 ' + payload.message);
}
/* ── Fenster ──────────────────────────────────────────── */
function isOpen() {
return !!(winEl && winEl.isConnected)
|| !!document.querySelector('[data-wid="' + WIN_ID + '"]');
}
function open() {
injectStyles();
const win = WindowManager.create({
id: WIN_ID, title: 'Bleeter', icon: '🐦', width: 1000, height: 680,
content: `
<div class="bl-root mode-desktop">
<header class="topbar" id="bl-topbar"></header>
<div class="app-grid">
<nav class="side-nav" id="bl-side"></nav>
<section class="content" id="bl-main"></section>
<aside class="context" id="bl-context"></aside>
</div>
</div>`,
});
if (!win) return;
winEl = win;
loading = true;
render();
send('requestBootstrap');
}
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('bl-main');
if (m) m.replaceChildren(node);
}
function info(icon, text, extra) {
const d = el('div', 'bl-empty');
d.appendChild(el('div', 'bl-empty-icon', icon));
d.appendChild(el('div', null, text));
if (extra) d.appendChild(extra);
return d;
}
/* ── Bildadressen ─────────────────────────────────────── */
/* Der häufigste Stolperstein: Leute kopieren die Adresse der *Seite*, auf
der ein Bild liegt, statt die des Bildes. https://imgur.com/a/BCvnigB ist
ein Album, kein Bild der Server lehnt es ab, und im Profil bliebe ein
Link stehen, der nie lädt.
Einzelbildseiten lassen sich umrechnen. Alben nicht: die Album-Kennung
ist nicht die Bildkennung, dafür bräuchte man die imgur-Schnittstelle. */
const IMAGE_HINT =
'Es muss die Adresse des Bildes sein, nicht die der Seite sie endet auf '
+ '.jpg oder .png. Rechtsklick aufs Bild → „Bildadresse kopieren".';
/* Bilderdienste geben gern fertige Schnipsel zum Einbetten heraus BBCode,
HTML, Markdown. Darin steckt die richtige Adresse schon drin, sie ist nur
von Beiwerk umgeben. Also holen wir sie heraus, statt den Block abzulehnen. */
function extractImageUrl(raw) {
const text = String(raw || '').trim();
const bb = /\[img\]\s*(https?:\/\/[^\s\]]+)\s*\[\/img\]/i.exec(text);
if (bb) return bb[1];
const html = /<img[^>]+src=["']([^"']+)["']/i.exec(text);
if (html) return html[1];
const md = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i.exec(text);
if (md) return md[1];
// Mehrere Adressen (etwa Seite + Bild): die mit Bildendung gewinnt.
const urls = text.match(/https?:\/\/[^\s"'<>\]]+/gi) || [];
const image = urls.find(u => /\.(jpe?g|png)(\?.*)?$/i.test(u));
if (image) return image;
return urls.length === 1 ? urls[0] : text;
}
function checkImageUrl(raw) {
const url = extractImageUrl(raw);
if (!url) return { ok: true, url: '' }; // leer ist erlaubt
if (!/^https:\/\//i.test(url)) {
return { ok: false, error: 'Die Adresse muss mit https:// beginnen.' };
}
// imgur-Album: nicht auflösbar
if (/^https:\/\/(www\.)?imgur\.com\/(a|gallery)\//i.test(url)) {
return { ok: false, error:
'Das ist ein imgur-Album, kein einzelnes Bild. Öffne das Bild und '
+ 'kopiere seine Adresse (Rechtsklick → „Bildadresse kopieren").' };
}
// imgur-Einzelbildseite → direkte Adresse
const single = /^https:\/\/(www\.)?imgur\.com\/([A-Za-z0-9]{5,12})$/.exec(url);
if (single) return { ok: true, url: 'https://i.imgur.com/' + single[2] + '.png' };
if (!/\.(jpe?g|png)(\?.*)?$/i.test(url)) {
return { ok: false, error: IMAGE_HINT };
}
return { ok: true, url };
}
/* ── Datei hochladen ──────────────────────────────────── */
/* Bleeter lädt Bilder zu ImgBB hoch (Zugang steht in bleeter/shared/config.lua).
Das ist der verlässlichere Weg als fremde Links: der Hoster kann das
Verlinken nicht sperren, weil das Bild dort selbst liegt. */
const uploadTargets = new Map(); // Kennung → Eingabefeld
function uploadButton(field, key) {
const btn = el('label', 'upload-button', 'Datei hochladen');
const input = el('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/jpeg,image/png');
input.style.display = 'none';
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
Desktop.showNotification('⚠ Die Datei ist größer als 2 MB.');
input.value = '';
return;
}
const reader = new FileReader();
reader.addEventListener('load', () => {
const result = String(reader.result || '');
const base64 = result.includes(',') ? result.split(',').pop() : result;
uploadTargets.set(key, field);
btn.textContent = 'Lädt hoch…';
send('uploadMedia', {
target: key, name: file.name, mime: file.type,
size: file.size, data: base64,
});
});
reader.readAsDataURL(file);
input.value = '';
});
btn.addEventListener('click', () => input.click());
const wrap = el('span', 'bl-upload');
wrap.append(btn, input);
return wrap;
}
/** Von desktop.js, wenn action === 'bleeter_uploaded'. */
function onUploaded(payload) {
const field = payload && uploadTargets.get(payload.target);
uploadTargets.delete(payload && payload.target);
// Beschriftung zurücksetzen, egal wie es ausging.
document.querySelectorAll('.bl-root .upload-button').forEach(b => {
if (b.textContent === 'Lädt hoch…') b.textContent = 'Datei hochladen';
});
if (!payload || !payload.ok) {
const texts = {
file_too_large: 'Die Datei ist größer als 2 MB.',
unsupported_extension: 'Nur JPG und PNG.',
upload_failed: 'Der Bilderdienst hat den Upload abgelehnt.',
missing_imgbb_key: 'Für Uploads fehlt der Zugang zum Bilderdienst.',
};
return Desktop.showNotification(
'⚠ ' + (texts[payload && payload.reason] || 'Upload fehlgeschlagen.'));
}
if (field) field.value = payload.url;
Desktop.showNotification('✔ Bild hochgeladen.');
}
/* Empfohlene Maße. Steht direkt am Eingabefeld, damit man nicht raten muss. */
const SIZE_HINTS = {
avatar: 'Empfohlen: quadratisch, etwa 400 × 400 px. Wird rund beschnitten.',
banner: 'Empfohlen: 1500 × 500 px. Wird auf die Fensterbreite beschnitten.',
post: 'Empfohlen: höchstens 1200 px breit, JPG oder PNG. '
+ 'Wenn ein Bild leer bleibt, sperrt der Hoster vermutlich das Verlinken '
+ 'imgur tut das zeitweise. Zuverlässig sind ibb.co und Discord-Anhänge.',
};
/* ── Bausteine ────────────────────────────────────────── */
/* Avatar. Ohne Bild ein farbiger Kreis mit dem Anfangsbuchstaben aus dem
Handle abgeleitet, damit dieselbe Person immer dieselbe Farbe hat. */
function avatar(profile, size) {
const px = size || 52;
const handle = (profile && profile.handle) || '?';
if (profile && profile.avatar_url) {
const img = el('img', 'avatar');
img.setAttribute('src', String(profile.avatar_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('loading', 'lazy');
img.style.width = px + 'px';
img.style.height = px + 'px';
img.addEventListener('error', () => {
const fallback = initialAvatar(handle, px);
if (img.parentNode) img.parentNode.replaceChild(fallback, img);
});
return img;
}
return initialAvatar(handle, px);
}
function initialAvatar(handle, px) {
let hash = 0;
for (let i = 0; i < handle.length; i++) hash = (hash * 31 + handle.charCodeAt(i)) >>> 0;
const hue = hash % 360;
const d = el('div', 'avatar bl-avatar-letter', handle.charAt(0).toUpperCase());
d.style.width = px + 'px';
d.style.height = px + 'px';
d.style.fontSize = Math.round(px * 0.42) + 'px';
d.style.background = `hsl(${hue} 42% 26%)`;
d.style.color = `hsl(${hue} 70% 78%)`;
return d;
}
/* Abzeichen wie im Original: .verified und .staff-badge. */
function verifiedMark(profile) {
if (profile && profile.is_lifeinvader_staff) {
const b = el('span', 'staff-badge', '★');
b.setAttribute('title', 'Lifeinvader Mitarbeiter');
return b;
}
if (profile && profile.is_verified) {
const b = el('span', 'verified', '✓');
b.setAttribute('title', 'Verifiziert');
return b;
}
return null;
}
/* ── Zeichnen ─────────────────────────────────────────── */
function render() {
renderChrome();
if (loading) return setMain(info('⏳', 'Lade Bleeter…'));
if (!state) return setMain(info('⚠', 'Bleeter ist nicht erreichbar.'));
if (state.error) return setMain(renderError(state.error));
if (state.needs_registration) return setMain(renderRegistration());
const pages = {
profile: renderProfile,
followers: renderFollowers,
market: renderMarket,
calendar: renderCalendar,
business: renderBusinesses,
};
if (pages[view]) return setMain(pages[view]());
setMain(renderFeed(view === 'ads' ? 'advertising' : 'home'));
}
function renderError(reason) {
const texts = {
missing_char: 'Kein Charakter geladen.',
no_mail: 'Du brauchst zuerst eine IC-Mailadresse. Richte sie in der Mail-App ein.',
missing_profile: 'Kein Profil vorhanden.',
};
return info('⚠', texts[reason] || ('Bleeter meldet: ' + reason));
}
const activeProfile = () =>
((state && state.profiles) || []).find(p => p.id === state.activeProfileId) || null;
/* Alle Profile, die man ansehen darf kommt als profileDirectory mit. */
const directoryProfile = (id) =>
((state && state.profileDirectory) || []).find(p => p.id === id) || null;
const isFollowing = (id) =>
(((state && state.social) || {}).following || []).some(p => p.id === id);
/* Fremdes Profil öffnen. Das eigene führt zur gewohnten Profilseite. */
function openProfile(id) {
const me = activeProfile();
viewProfileId = (me && me.id === id) ? null : id;
view = 'profile';
render();
}
/* Name und Bild eines Autors, anklickbar. */
function authorLink(profile, node) {
const btn = el('button', 'author-link');
btn.appendChild(node);
if (profile && profile.id) {
btn.addEventListener('click', () => openProfile(profile.id));
}
return btn;
}
/* ── Kopfzeile, Navigationsschiene, Randspalte ────────── */
/* Aufbau und Klassennamen wie im echten Bleeter: topbar, side-nav mit
nav-button, content, context. */
function renderChrome() {
const bar = root('bl-topbar');
if (bar) {
bar.replaceChildren();
const brand = el('div', 'brand');
brand.appendChild(el('span', 'bl-wordmark', 'Bleeter'));
bar.appendChild(brand);
bar.appendChild(el('div')); // Platzhalterspalte wie im Original
const me = activeProfile();
const top = el('div', 'top-profile');
if (me) top.appendChild(avatar(me, 50));
bar.appendChild(top);
}
renderSide();
renderContext();
}
function renderSide() {
const side = root('bl-side');
if (!side) return;
side.replaceChildren();
const ready = state && !state.error && !state.needs_registration;
[['home', '🏠', 'Feed'], ['ads', '📣', 'Werbung'], ['followers', '👥', 'Follower'],
['market', '🛒', 'Markt'], ['calendar', '📅', 'Kalender'],
['business', '💼', 'Gewerbe'], ['profile', '👤', 'Profil'],
['reload', '↻', 'Neu laden']]
.forEach(([id, icon, label]) => {
const b = el('button', 'nav-button' + (view === id ? ' active' : ''));
b.appendChild(el('span', 'nav-icon', icon));
b.appendChild(el('span', 'nav-label', label));
if (id === 'reload') {
b.addEventListener('click', () => {
loading = true; render(); send('requestBootstrap');
});
} else {
b.disabled = !ready;
if (ready) b.addEventListener('click', () => {
view = id;
viewProfileId = null; // Navigation führt immer zum eigenen Bereich
render();
});
}
side.appendChild(b);
});
}
/* Randspalte: aktives Profil und Profilwechsel. Wer für eine Firma
schreibt, sieht hier, unter welchem Namen er gerade auftritt. */
function renderContext() {
const ctx = root('bl-context');
if (!ctx) return;
ctx.replaceChildren();
const me = activeProfile();
if (!me) return;
const card = el('div', 'post-card bl-ctx-card');
const head = el('div', 'post-head');
head.appendChild(avatar(me, 52));
const who = el('div');
const name = el('div', 'author-name');
name.appendChild(el('span', null, me.display_name || me.handle));
const mark = verifiedMark(me);
if (mark) name.appendChild(mark);
who.appendChild(name);
who.appendChild(el('div', 'handle', '@' + me.handle));
head.appendChild(who);
card.appendChild(head);
if ((state.profiles || []).length > 1) {
const wrap = el('div', 'bl-ctx-switch');
wrap.appendChild(el('div', 'hint', 'Auftreten als'));
const sel = el('select', 'market-sort');
(state.profiles || []).forEach(p => {
const o = el('option', null, '@' + p.handle);
o.value = String(p.id);
if (p.id === me.id) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
loading = true;
render();
send('setActiveProfile', { profileId: Number(sel.value) });
});
wrap.appendChild(sel);
card.appendChild(wrap);
}
ctx.appendChild(card);
}
/* ── Registrierung ────────────────────────────────────── */
function renderRegistration() {
const wrap = el('div', 'bl-narrow');
const card = el('div', 'post-card bl-welcome');
card.appendChild(el('div', 'bl-welcome-mark', '🐦'));
card.appendChild(el('div', 'bl-title', 'Willkommen bei Bleeter'));
card.appendChild(el('div', 'bl-sub',
'Such dir dein Handle aus damit trittst du auf Bleeter auf. '
+ 'Es lässt sich später nicht mehr ändern.'));
if (state.reason === 'no_mail') {
card.appendChild(el('div', 'bl-banner',
'Dafür brauchst du zuerst eine IC-Mailadresse. Richte sie in der Mail-App '
+ 'ein und komm dann zurück.'));
wrap.appendChild(card);
return wrap;
}
const handleField = el('div', 'bl-field');
handleField.appendChild(el('label', 'bl-label', 'Handle'));
const inline = el('div', 'bl-inline');
inline.appendChild(el('span', 'bl-at', '@'));
const handle = el('input', 'event-input');
handle.value = state.suggested_handle || '';
handle.setAttribute('placeholder', 'deinname');
inline.appendChild(handle);
handleField.appendChild(inline);
card.appendChild(handleField);
const nameField = el('div', 'bl-field');
nameField.appendChild(el('label', 'bl-label', 'Anzeigename'));
const display = el('input', 'event-input');
display.value = state.display_name || '';
nameField.appendChild(display);
card.appendChild(nameField);
const err = el('div', 'bl-error');
err.textContent = regError;
card.appendChild(err);
const go = el('button', 'primary-button', 'Account anlegen');
go.addEventListener('click', () => {
const value = (handle.value || '').trim().toLowerCase();
if (!/^[a-z0-9._-]{2,32}$/.test(value)) {
err.textContent = '2 bis 32 Zeichen, nur az, 09, Punkt, Unterstrich, Bindestrich.';
return;
}
err.textContent = '';
loading = true;
render();
send('registerAccount', { handle: value, displayName: display.value });
});
card.appendChild(go);
card.appendChild(el('div', 'bl-hint', 'Deine Mailadresse: ' + (state.mail || '—')));
wrap.appendChild(card);
return wrap;
}
/* ── Feed ─────────────────────────────────────────────── */
function renderFeed(feedType) {
const wrap = el('div');
const head = el('div', 'bl-pagehead');
head.appendChild(el('div', 'bl-title',
feedType === 'advertising' ? 'Werbefeed' : 'Feed'));
head.appendChild(el('div', 'bl-sub', feedType === 'advertising'
? 'Anzeigen von Gewerben und Behörden.'
: 'Was auf Bleeter geschrieben wird.'));
wrap.appendChild(head);
const me = activeProfile();
if (me && me.can_post !== false) wrap.appendChild(renderComposer(feedType, me));
const posts = (state.posts || []).filter(p => (p.feed_type || 'home') === feedType);
if (!posts.length) {
wrap.appendChild(info('🐦', 'Hier ist noch nichts.'));
return wrap;
}
const list = el('div', 'bl-feed');
posts.forEach(p => list.appendChild(renderPost(p)));
wrap.appendChild(list);
return wrap;
}
function renderComposer(feedType, me) {
const card = el('div', 'compose');
card.appendChild(avatar(me, 52));
const right = el('div');
const ta = el('textarea');
ta.setAttribute('maxlength', '500');
ta.setAttribute('placeholder', 'Was gibt es Neues in Los Santos?');
right.appendChild(ta);
const actions = el('div', 'compose-actions');
const uploadRow = el('div', 'upload-row');
const media = el('input', 'media-url-input');
media.setAttribute('placeholder', 'Bildadresse oder Einbettungscode einfügen');
media.setAttribute('title', SIZE_HINTS.post);
uploadRow.appendChild(media);
uploadRow.appendChild(uploadButton(media, 'compose-' + feedType));
const counter = el('span', 'hint', '500');
ta.addEventListener('input', () => {
counter.textContent = String(500 - ta.value.length);
});
uploadRow.appendChild(counter);
actions.appendChild(uploadRow);
const problem = el('div', 'bl-error');
right.appendChild(problem);
right.appendChild(el('div', 'hint', SIZE_HINTS.post));
const go = el('button', 'primary-button', 'Posten');
go.addEventListener('click', () => {
const body = ta.value.trim();
// Vorher prüfen statt den Server ablehnen zu lassen: die Meldung steht
// dann direkt am Feld, und der geschriebene Text bleibt erhalten.
const image = checkImageUrl(media.value);
if (!image.ok) { problem.textContent = image.error; return; }
problem.textContent = '';
if (!body && !image.url) return;
ta.value = '';
media.value = '';
counter.textContent = '500';
send('createPost', { body, feedType, mediaUrl: image.url });
});
actions.appendChild(go);
right.appendChild(actions);
card.appendChild(right);
return card;
}
function renderPost(p) {
const card = el('article', 'post-card');
const author = p.author || {};
const head = el('header', 'post-head');
head.appendChild(authorLink(author, avatar(author, 52)));
const who = el('div');
const name = el('div', 'author-name');
name.appendChild(authorLink(author,
el('span', null, author.display_name || ('@' + author.handle))));
const mark = verifiedMark(author);
if (mark) name.appendChild(mark);
who.appendChild(name);
who.appendChild(el('div', 'handle', '@' + (author.handle || '?')));
head.appendChild(who);
head.appendChild(el('div', 'post-time', p.created_at || ''));
card.appendChild(head);
if (p.body) card.appendChild(el('div', 'post-body', p.body));
if (p.media_url) {
const img = el('img', 'post-image');
img.setAttribute('src', String(p.media_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('loading', 'lazy');
// Nicht still entfernen: sonst rätselt der Verfasser, warum sein Bild
// fehlt. Meist ist es die Adresse einer Seite statt die des Bildes.
img.addEventListener('error', () => {
// Die Adresse mit anzeigen: sonst rätselt man, was überhaupt versucht
// wurde. Meist ist es eine Seitenadresse statt der des Bildes oder
// der Rechner kommt an den Bildhoster nicht heran.
const note = el('div', 'bl-image-broken');
note.appendChild(el('div', null, '🖼 Bild konnte nicht geladen werden'));
note.appendChild(el('div', 'bl-image-url', String(p.media_url)));
if (img.parentNode) img.parentNode.replaceChild(note, img);
});
card.appendChild(img);
}
const actions = el('footer', 'post-actions');
const like = el('button', 'action-link',
(p.liked_by_viewer ? '♥ ' : '♡ ') + (p.likes || 0));
like.addEventListener('click', () => send('togglePostLike', { postId: p.id }));
actions.appendChild(like);
const commentBtn = el('button', 'action-link', 'Kommentare ' + (p.comments || 0));
commentBtn.addEventListener('click', () => {
if (openComments.has(p.id)) openComments.delete(p.id);
else openComments.add(p.id);
render();
});
actions.appendChild(commentBtn);
if (p.can_delete) {
const del = el('button', 'action-link danger-text', 'Löschen');
del.style.marginLeft = 'auto';
del.addEventListener('click', () => {
ICWebRender.confirmBox('Beitrag löschen', 'Diesen Beitrag löschen?',
() => send('deleteOwnPost', { postId: p.id }),
{ danger: true, okLabel: 'Löschen' });
});
actions.appendChild(del);
}
card.appendChild(actions);
if (openComments.has(p.id)) card.appendChild(renderComments(p));
return card;
}
/* Kommentare unter einem Beitrag. Aufbau wie im Original: comments-panel
mit comment-form. */
function renderComments(p) {
const panel = el('div', 'comments-panel');
(p.comments_list || []).forEach(c => {
const row = el('div', 'bl-comment');
// author ist hier der Handle als Text, kein Objekt anders als beim
// Beitrag. Beides abfangen, damit es nicht wieder an der Form scheitert.
const handle = typeof c.author === 'string'
? c.author
: ((c.author && c.author.handle) || c.author_handle || '');
const head = el('div', 'author-name');
const link = el('button', 'author-link', '@' + (handle || '?'));
if (c.author_profile_id) {
link.addEventListener('click', () => openProfile(c.author_profile_id));
}
head.appendChild(link);
row.appendChild(head);
row.appendChild(el('div', 'bl-comment-body', c.body || ''));
const meta = el('div', 'comment-actions');
meta.appendChild(el('span', 'post-time', c.created_at || ''));
if (c.can_delete) {
const del = el('button', 'action-link danger-text', 'Löschen');
del.addEventListener('click', () =>
send('deleteOwnComment', { commentId: c.id }));
meta.appendChild(del);
}
row.appendChild(meta);
panel.appendChild(row);
});
const me = activeProfile();
if (me && me.can_post !== false) {
const form = el('div', 'comment-form');
const input = el('input');
input.setAttribute('placeholder', 'Antworten…');
input.setAttribute('maxlength', '1000');
const go = el('button', 'primary-button', 'Senden');
const submit = () => {
const body = input.value.trim();
if (!body) return;
input.value = '';
send('createComment', { postId: p.id, body });
};
go.addEventListener('click', submit);
input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
form.append(input, go);
panel.appendChild(form);
}
return panel;
}
/* ── Seitenkopf ───────────────────────────────────────── */
function pageHead(title, sub) {
const head = el('div', 'bl-pagehead');
head.appendChild(el('div', 'bl-title', title));
if (sub) head.appendChild(el('div', 'bl-sub', sub));
return head;
}
/* Zeile mit Profil und Knopf für Follower, Gewerbe, Vorschläge. */
function profileRow(profile, action) {
const row = el('div', 'post-card bl-row');
const head = el('div', 'post-head');
head.appendChild(authorLink(profile, avatar(profile, 46)));
const who = el('div');
const name = el('div', 'author-name');
name.appendChild(authorLink(profile,
el('span', null, profile.display_name || profile.handle)));
const mark = verifiedMark(profile);
if (mark) name.appendChild(mark);
who.appendChild(name);
who.appendChild(el('div', 'handle', '@' + profile.handle));
head.appendChild(who);
if (action) head.appendChild(action);
row.appendChild(head);
return row;
}
/* ── Follower ─────────────────────────────────────────── */
function renderFollowers() {
const wrap = el('div');
wrap.appendChild(pageHead('Follower',
'Wer dir folgt und wem du folgst.'));
const social = state.social || {};
const section = (title, list, follows) => {
wrap.appendChild(el('div', 'bl-section', title + ' (' + (list || []).length + ')'));
if (!list || !list.length) {
wrap.appendChild(info('👥', 'Hier ist noch niemand.'));
return;
}
const box = el('div', 'bl-feed');
list.forEach(p => {
const btn = el('button', follows ? 'ghost-button' : 'primary-button',
follows ? 'Entfolgen' : 'Folgen');
btn.addEventListener('click', () => {
send(follows ? 'unfollowProfile' : 'followProfile', { profileId: p.id });
});
box.appendChild(profileRow(p, btn));
});
wrap.appendChild(box);
};
section('Ich folge', social.following, true);
section('Folgen mir', social.followers, false);
return wrap;
}
/* ── Gewerbe ──────────────────────────────────────────── */
function renderBusinesses() {
const wrap = el('div');
wrap.appendChild(pageHead('Gewerbe',
'Unternehmen und Behörden auf Bleeter.'));
const list = state.businesses || [];
if (!list.length) {
wrap.appendChild(info('💼', 'Noch keine Gewerbe eingetragen.'));
return wrap;
}
const box = el('div', 'bl-feed');
list.forEach(b => {
const status = el('span', 'bl-badge' + (b.status === 'open' ? ' bl-badge-open' : ''),
b.status === 'open' ? 'geöffnet' : 'geschlossen');
box.appendChild(profileRow(b, status));
});
wrap.appendChild(box);
return wrap;
}
/* ── Marktplatz ───────────────────────────────────────── */
function renderMarket() {
const wrap = el('div');
wrap.appendChild(pageHead('Marktplatz',
'Angebote und Gesuche aus Los Santos.'));
const me = activeProfile();
if (me && me.can_post !== false) {
const form = el('div', 'compose bl-market-form');
form.appendChild(avatar(me, 52));
const right = el('div');
const title = el('input', 'event-input');
title.setAttribute('placeholder', 'Was bietest du an?');
title.setAttribute('maxlength', '120');
right.appendChild(title);
const desc = el('textarea');
desc.setAttribute('placeholder', 'Beschreibung');
desc.setAttribute('maxlength', '2000');
right.appendChild(desc);
const row = el('div', 'compose-actions');
const price = el('input', 'event-input bl-price');
price.setAttribute('placeholder', 'Preis, z. B. 2500 $');
price.setAttribute('maxlength', '80');
row.appendChild(price);
const go = el('button', 'primary-button', 'Einstellen');
go.addEventListener('click', () => {
if (!title.value.trim()) return;
send('createMarketplaceEntry', {
title: title.value, description: desc.value, priceLabel: price.value,
});
title.value = ''; desc.value = ''; price.value = '';
});
row.appendChild(go);
right.appendChild(row);
form.appendChild(right);
wrap.appendChild(form);
}
const items = state.marketplace || [];
if (!items.length) {
wrap.appendChild(info('🛒', 'Der Marktplatz ist leer.'));
return wrap;
}
const box = el('div', 'bl-feed');
items.forEach(item => {
const card = el('article', 'post-card');
const head = el('header', 'post-head');
head.appendChild(avatar(item.author || {}, 46));
const who = el('div');
who.appendChild(el('div', 'author-name', item.title || ''));
who.appendChild(el('div', 'handle', '@' + ((item.author || {}).handle || '?')));
head.appendChild(who);
if (item.price_label) head.appendChild(el('div', 'bl-price-tag', item.price_label));
card.appendChild(head);
if (item.body) card.appendChild(el('div', 'post-body', item.body));
if (item.media_url) {
const img = el('img', 'post-image');
img.setAttribute('src', String(item.media_url));
// Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand
// der Herkunft, und die eines NUI kennen sie nicht.
img.setAttribute('referrerpolicy', 'no-referrer');
img.addEventListener('error', () => img.remove());
card.appendChild(img);
}
const actions = el('footer', 'post-actions');
actions.appendChild(el('span', 'post-time', item.created_at || ''));
if (item.can_delete) {
const del = el('button', 'action-link danger-text', 'Löschen');
del.style.marginLeft = 'auto';
del.addEventListener('click', () => {
ICWebRender.confirmBox('Anzeige löschen', 'Diese Anzeige löschen?',
() => send('deleteMarketplaceEntry', { entryId: item.id }),
{ danger: true, okLabel: 'Löschen' });
});
actions.appendChild(del);
}
card.appendChild(actions);
box.appendChild(card);
});
wrap.appendChild(box);
return wrap;
}
/* ── Kalender ─────────────────────────────────────────── */
function renderCalendar() {
const wrap = el('div');
wrap.appendChild(pageHead('Kalender',
'Termine der nächsten vier Wochen.'));
const me = activeProfile();
const mayCreate = me && ['small_business', 'company', 'authority']
.includes(me.profile_type);
if (mayCreate) {
const form = el('div', 'post-card bl-form');
form.appendChild(el('div', 'bl-section', 'Termin eintragen'));
const mk = (placeholder, cls) => {
const i = el('input', 'event-input' + (cls ? ' ' + cls : ''));
i.setAttribute('placeholder', placeholder);
return i;
};
const date = mk('2026-08-15', 'bl-narrow-input');
const time = mk('19:30', 'bl-narrow-input');
const title = mk('Wovon handelt der Termin?');
const place = mk('Ort');
const row1 = el('div', 'bl-inline');
row1.append(date, time);
form.append(row1, title, place);
const go = el('button', 'primary-button', 'Eintragen');
go.addEventListener('click', () => {
if (!date.value || !time.value || !title.value.trim()) return;
send('createEvent', {
date: date.value, time: time.value,
title: title.value, location: place.value,
});
title.value = ''; place.value = '';
});
form.appendChild(go);
wrap.appendChild(form);
}
const days = (state.calendarDays || []).filter(d => (d.events || []).length);
if (!days.length) {
wrap.appendChild(info('📅', 'Keine Termine in den nächsten vier Wochen.'));
return wrap;
}
const box = el('div', 'bl-feed');
days.forEach(day => {
const card = el('article', 'post-card bl-day');
card.appendChild(el('div', 'bl-day-title', day.title || day.date));
(day.events || []).forEach(e => {
const row = el('div', 'bl-event');
row.appendChild(el('span', 'bl-event-time', e.time || ''));
const mid = el('div', 'bl-event-mid');
mid.appendChild(el('div', 'bl-event-title', e.title || ''));
const meta = [];
if (e.location) meta.push(e.location);
if (e.author) meta.push('@' + e.author);
if (meta.length) mid.appendChild(el('div', 'handle', meta.join(' · ')));
row.appendChild(mid);
if (e.can_delete) {
const del = el('button', 'action-link danger-text', '✕');
del.addEventListener('click', () => send('deleteEvent', { eventId: e.id }));
row.appendChild(del);
}
card.appendChild(row);
});
box.appendChild(card);
});
wrap.appendChild(box);
return wrap;
}
/* ── Profil ───────────────────────────────────────────── */
function zurueckKnopf() {
const back = el('button', 'ghost-button', '← Zurück');
back.addEventListener('click', () => { viewProfileId = null; render(); });
return back;
}
/* Fremdes Profil. Bearbeiten gibt es hier nicht nur ansehen, folgen und
die Beiträge dieser Person. */
function renderForeignProfile(p) {
const wrap = el('div');
const kopf = el('div', 'bl-foreign-head');
kopf.appendChild(zurueckKnopf());
wrap.appendChild(kopf);
const card = el('div', 'post-card bl-profile');
const banner = el('div', 'bl-banner-strip');
if (p.banner_url) {
const img = el('img', 'bl-banner-img');
img.setAttribute('src', String(p.banner_url));
img.setAttribute('referrerpolicy', 'no-referrer');
img.addEventListener('error', () => img.remove());
banner.appendChild(img);
}
card.appendChild(banner);
const head = el('div', 'bl-profile-head');
head.appendChild(avatar(p, 76));
const who = el('div', 'bl-profile-who');
const line = el('div', 'bl-profile-name');
line.appendChild(el('span', null, p.display_name || p.handle));
const mark = verifiedMark(p);
if (mark) line.appendChild(mark);
who.appendChild(line);
who.appendChild(el('div', 'handle', '@' + p.handle));
const tags = el('div', 'bl-tags');
tags.appendChild(el('span', 'bl-badge', p.profile_type || ''));
if (p.location) tags.appendChild(el('span', 'bl-badge', '📍 ' + p.location));
who.appendChild(tags);
head.appendChild(who);
const ich = activeProfile();
if (ich && ich.id !== p.id) {
const folgt = isFollowing(p.id);
const btn = el('button', folgt ? 'ghost-button' : 'primary-button',
folgt ? 'Entfolgen' : 'Folgen');
btn.style.marginLeft = 'auto';
btn.addEventListener('click', () => {
send(folgt ? 'unfollowProfile' : 'followProfile', { profileId: p.id });
});
head.appendChild(btn);
}
card.appendChild(head);
if (p.bio) card.appendChild(el('div', 'bl-profile-bio', p.bio));
const eigene = (state.posts || []).filter(x => x.author && x.author.id === p.id);
const stats = el('div', 'bl-stats');
const stat = (n, label) => {
const box = el('div', 'bl-stat');
box.appendChild(el('span', 'bl-stat-num', String(n)));
box.appendChild(el('span', 'bl-stat-label', label));
return box;
};
stats.appendChild(stat(p.followers_count || 0, 'Follower'));
stats.appendChild(stat(p.following_count || 0, 'gefolgt'));
stats.appendChild(stat(eigene.length, 'Beiträge'));
card.appendChild(stats);
if (p.email_contact) {
const kontakt = el('div', 'bl-profile-bio');
kontakt.appendChild(el('span', 'handle', '✉ ' + p.email_contact));
card.appendChild(kontakt);
}
wrap.appendChild(card);
wrap.appendChild(el('div', 'bl-section', 'Beiträge (' + eigene.length + ')'));
if (!eigene.length) {
wrap.appendChild(info('🐦', 'Diese Person hat hier noch nichts geschrieben.'));
} else {
const list = el('div', 'bl-feed');
eigene.forEach(x => list.appendChild(renderPost(x)));
wrap.appendChild(list);
}
return wrap;
}
function renderProfile() {
const me = activeProfile();
/* Fremdes Profil: nur ansehen und folgen, nicht bearbeiten. */
if (viewProfileId) {
const other = directoryProfile(viewProfileId);
if (!other) return info('👤', 'Dieses Profil ist nicht abrufbar.', zurueckKnopf());
return renderForeignProfile(other);
}
if (!me) return info('👤', 'Kein Profil ausgewählt.');
const wrap = el('div');
/* Kopfkarte mit Banner */
const card = el('div', 'post-card bl-profile');
const banner = el('div', 'bl-banner-strip');
if (me.banner_url) {
const img = el('img', 'bl-banner-img');
img.setAttribute('src', String(me.banner_url));
// Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand
// der Herkunft, und die eines NUI kennen sie nicht.
img.setAttribute('referrerpolicy', 'no-referrer');
img.addEventListener('error', () => img.remove());
banner.appendChild(img);
}
card.appendChild(banner);
const head = el('div', 'bl-profile-head');
head.appendChild(avatar(me, 76));
const who = el('div', 'bl-profile-who');
const line = el('div', 'bl-profile-name');
line.appendChild(el('span', null, me.display_name || me.handle));
const mark = verifiedMark(me);
if (mark) line.appendChild(mark);
who.appendChild(line);
who.appendChild(el('div', 'handle', '@' + me.handle));
const tags = el('div', 'bl-tags');
tags.appendChild(el('span', 'bl-badge', me.profile_type || ''));
if (me.can_post === false) {
tags.appendChild(el('span', 'bl-badge bl-badge-red', 'darf nicht schreiben'));
}
who.appendChild(tags);
head.appendChild(who);
card.appendChild(head);
if (me.bio) card.appendChild(el('div', 'bl-profile-bio', me.bio));
const social = state.social || {};
const stats = el('div', 'bl-stats');
const stat = (n, label) => {
const s = el('div', 'bl-stat');
s.appendChild(el('span', 'bl-stat-num', String(n)));
s.appendChild(el('span', 'bl-stat-label', label));
return s;
};
stats.appendChild(stat(me.followers_count ?? social.followers_count ?? 0, 'Follower'));
stats.appendChild(stat(me.following_count ?? social.following_count ?? 0, 'gefolgt'));
stats.appendChild(stat((state.posts || []).filter(p => p.author && p.author.id === me.id).length,
'Beiträge'));
card.appendChild(stats);
wrap.appendChild(card);
/* Bearbeiten */
if (me.can_edit_profile !== false) {
const form = el('div', 'post-card bl-form');
form.appendChild(el('div', 'bl-section', 'Profil bearbeiten'));
const mkArea = (label, value) => {
const f = el('div', 'bl-field');
f.appendChild(el('label', 'bl-label', label));
const input = el('textarea', 'event-input');
input.setAttribute('rows', '3');
input.setAttribute('maxlength', '500');
input.value = value || '';
f.appendChild(input);
form.appendChild(f);
return input;
};
const mk = (label, value, placeholder) => {
const f = el('div', 'bl-field');
f.appendChild(el('label', 'bl-label', label));
const input = el('input', 'event-input');
input.value = value || '';
if (placeholder) input.setAttribute('placeholder', placeholder);
f.appendChild(input);
form.appendChild(f);
return input;
};
const bio = mkArea('Über mich', me.bio);
const display = mk('Anzeigename', me.display_name);
const location = mk('Ort', me.location, 'Los Santos');
const avatarIn = mk('Profilbild', me.avatar_url, 'Adresse einfügen oder Datei wählen');
form.appendChild(uploadButton(avatarIn, 'profile-avatar'));
form.appendChild(el('div', 'hint', SIZE_HINTS.avatar));
const bannerIn = mk('Titelbild', me.banner_url, 'Adresse einfügen oder Datei wählen');
form.appendChild(uploadButton(bannerIn, 'profile-banner'));
form.appendChild(el('div', 'hint', SIZE_HINTS.banner));
const problem = el('div', 'bl-error');
form.appendChild(problem);
const save = el('button', 'primary-button', 'Speichern');
save.addEventListener('click', () => {
// Der Server speichert Profilbilder ungeprüft. Ohne diese Prüfung
// stünde eine Adresse in der Datenbank, die nie ein Bild liefert
// und niemand erführe, warum nichts erscheint.
const av = checkImageUrl(avatarIn.value);
if (!av.ok) { problem.textContent = 'Profilbild: ' + av.error; return; }
const bn = checkImageUrl(bannerIn.value);
if (!bn.ok) { problem.textContent = 'Titelbild: ' + bn.error; return; }
problem.textContent = '';
avatarIn.value = av.url;
bannerIn.value = bn.url;
send('updateProfile', {
profileId: me.id,
displayName: display.value,
bio: bio.value,
location: location.value,
avatarUrl: av.url,
bannerUrl: bn.url,
});
Desktop.showNotification('✔ Profil gespeichert.');
});
form.appendChild(save);
wrap.appendChild(form);
}
const own = (state.posts || []).filter(p => p.author && p.author.id === me.id);
wrap.appendChild(el('div', 'bl-section', 'Eigene Beiträge (' + own.length + ')'));
if (!own.length) {
wrap.appendChild(info('🐦', 'Noch nichts geschrieben.'));
} else {
const list = el('div', 'bl-feed');
own.forEach(p => list.appendChild(renderPost(p)));
wrap.appendChild(list);
}
return wrap;
}
/* Anpassungen fürs PC-Fenster
Die Gestaltung kommt aus css/bleeter-embedded.css der mechanisch
gekapselten Fassung von bleeter/html/style.css. Hier steht nur, was das
Original nicht wissen kann: dass es in einem Fenster steckt statt in
einem Tablet-Rahmen. */
let stylesDone = false;
function injectStyles() {
if (stylesDone) return;
stylesDone = true;
const s = document.createElement('style');
s.textContent = `
/* Beim Maximieren soll der Inhalt nicht mitwachsen.
Im Original hat .post-image width:100% das Bild folgt also der Spalte und
wird beim Vergrößern des Fensters mitgezogen und hochskaliert. Gewollt ist:
das Bild behält seine Größe und schrumpft nur, wenn der Platz nicht reicht.
Dazu bekommt die Spalte eine Höchstbreite, sonst wandert der Text quer über
den ganzen Bildschirm und wird unlesbar. */
.bl-root .app-grid{max-width:1180px;margin:0 auto;width:100%}
.bl-root .content{max-width:640px}
.bl-root .post-image,.bl-root .market-image,.bl-root .bl-post-image{
width:auto;max-width:100%;height:auto;max-height:420px;object-fit:contain;
background:#101a29}
/* Kein Tablet-Rahmen: der Inhalt füllt das Fenster. */
.bl-root{display:flex;flex-direction:column;flex:1;min-height:0;height:100%;
overflow:hidden;background:var(--bg);color:var(--text);
font-family:Inter,Arial,Helvetica,sans-serif}
/* Die Kopfzeile des Originals hat fünf Spalten für Suche und Schließen-Knopf,
die es hier nicht gibt. */
.bl-root .topbar{height:64px;grid-template-columns:minmax(0,1fr) auto auto;
padding:0 26px;gap:14px}
.bl-root .bl-wordmark{font-weight:900;font-size:1.05rem;color:var(--green);
letter-spacing:.02em}
.bl-root .brand{height:auto;display:flex;align-items:center}
.bl-root .top-profile{width:auto;height:auto;border:0;background:transparent}
.bl-root .app-grid{height:calc(100% - 64px);grid-template-columns:74px minmax(0,1fr) 240px;
gap:16px;padding:0 26px 20px}
.bl-root .content{padding:16px 0 8px;display:flex;flex-direction:column;gap:14px}
.bl-root .context{padding-top:16px}
.bl-root .nav-button{width:60px;height:52px}
/* Avatar ohne Bild: farbiger Kreis mit dem Anfangsbuchstaben. Die Farbe wird
aus dem Handle abgeleitet, damit dieselbe Person immer dieselbe hat. */
.bl-root .bl-avatar-letter{display:grid;place-items:center;font-weight:900;line-height:1}
.bl-root .bl-feed{display:flex;flex-direction:column;gap:14px}
.bl-root .bl-narrow{max-width:420px;margin:0 auto}
.bl-root .bl-title{font-size:1.15rem;font-weight:900}
.bl-root .bl-sub{font-size:.76rem;color:var(--muted);margin-top:4px;line-height:1.5}
.bl-root .bl-pagehead{margin-bottom:2px}
.bl-root .bl-section{font-size:.66rem;text-transform:uppercase;letter-spacing:.13em;
color:var(--muted);margin-top:6px}
.bl-root .bl-hint{font-size:.7rem;color:var(--muted);line-height:1.5}
.bl-root .bl-error{font-size:.74rem;color:var(--red);min-height:1em}
.bl-root .bl-banner{background:rgba(242,184,102,.1);border:1px solid rgba(242,184,102,.32);
border-radius:8px;padding:11px 13px;font-size:.76rem;color:var(--gold);line-height:1.5}
.bl-root .bl-empty{display:flex;flex-direction:column;align-items:center;gap:10px;
padding:44px 0;color:var(--muted);font-size:.82rem}
.bl-root .bl-empty-icon{font-size:2rem;opacity:.7}
.bl-root .bl-welcome{padding:24px;display:flex;flex-direction:column;gap:12px;
text-align:center}
.bl-root .bl-welcome-mark{font-size:2.2rem}
.bl-root .bl-welcome .bl-field,.bl-root .bl-welcome .bl-error{text-align:left}
.bl-root .bl-field{display:flex;flex-direction:column;gap:5px}
.bl-root .bl-label{font-size:.64rem;text-transform:uppercase;letter-spacing:.1em;
color:var(--muted)}
.bl-root .bl-inline{display:flex;align-items:center;gap:8px}
.bl-root .bl-at{font-size:1rem;font-weight:900;color:var(--green)}
.bl-root .event-input{width:100%;box-sizing:border-box;background:var(--panel-2);
font-family:inherit;padding:9px 12px;min-height:38px}
.bl-root .bl-form{padding:16px;display:flex;flex-direction:column;gap:11px}
.bl-root .bl-profile{padding-bottom:16px}
.bl-root .bl-banner-strip{height:84px;background:linear-gradient(120deg,#0d203a,#123a2c);
overflow:hidden}
.bl-root .bl-banner-img{width:100%;height:100%;object-fit:cover;display:block}
.bl-root .bl-profile-head{display:flex;align-items:flex-end;gap:14px;padding:0 18px;
margin-top:-30px}
.bl-root .bl-profile-who{padding-bottom:4px;min-width:0}
.bl-root .bl-profile-name{display:flex;align-items:center;gap:7px;font-size:1rem;
font-weight:900}
.bl-root .bl-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
.bl-root .bl-badge{font-size:.6rem;padding:2px 8px;border-radius:999px;
border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.bl-root .bl-badge-red{color:var(--red);border-color:rgba(255,79,95,.4)}
.bl-root .bl-profile-bio{padding:12px 18px 0;font-size:.82rem;line-height:1.5;
color:#c8d2e4;white-space:pre-wrap}
.bl-root .bl-stats{display:flex;gap:22px;padding:14px 18px 0}
.bl-root .bl-stat{display:flex;align-items:baseline;gap:5px}
.bl-root .bl-stat-num{font-weight:900;font-size:.9rem}
.bl-root .bl-stat-label{font-size:.72rem;color:var(--muted)}
/* Neue Seiten: Follower, Gewerbe, Markt, Kalender, Kommentare. */
.bl-root .bl-row .post-head{padding:12px 16px}
.bl-root .bl-row .post-head > div:last-child{margin-left:auto}
.bl-root .bl-badge-open{color:var(--green);border-color:rgba(25,216,137,.4)}
.bl-root .bl-market-form textarea{min-height:56px;margin-top:8px}
.bl-root .bl-market-form .event-input{width:100%;box-sizing:border-box;
background:var(--panel-2);font-family:inherit;padding:8px 11px;min-height:36px}
.bl-root .bl-price{max-width:190px}
.bl-root .bl-price-tag{margin-left:auto;font-weight:900;color:var(--green);
font-size:.88rem;white-space:nowrap}
.bl-root .bl-day{padding:14px 16px}
.bl-root .bl-day-title{font-weight:900;font-size:.86rem;margin-bottom:9px}
.bl-root .bl-event{display:flex;align-items:center;gap:12px;padding:7px 0;
border-top:1px solid var(--line)}
.bl-root .bl-event:first-of-type{border-top:0}
.bl-root .bl-event-time{font-weight:900;color:var(--green);font-size:.8rem;
min-width:44px}
.bl-root .bl-event-mid{flex:1;min-width:0}
.bl-root .bl-event-title{font-size:.82rem;font-weight:700}
.bl-root .bl-narrow-input{max-width:130px}
.bl-root .bl-foreign-head{margin-bottom:10px}
.bl-root .author-link{border:0;background:transparent;padding:0;color:inherit;
font:inherit;text-align:left;cursor:pointer}
.bl-root .author-link:hover{color:var(--green)}
.bl-root .bl-upload{display:inline-flex}
.bl-root .upload-button{display:inline-block;border-radius:999px;padding:7px 14px;
font-size:.72rem;font-weight:900;cursor:pointer;white-space:nowrap;
border:1px solid var(--line);background:var(--panel-2);color:var(--text)}
.bl-root .upload-button:hover{border-color:var(--green);color:var(--green)}
.bl-root .bl-image-broken{padding:22px;text-align:center;color:var(--muted);
border-top:1px solid var(--line);border-bottom:1px solid var(--line);
font-size:.76rem;background:rgba(0,0,0,.15)}
.bl-root .bl-image-url{margin-top:5px;font-size:.66rem;color:var(--muted);
opacity:.7;word-break:break-all}
.bl-root .bl-comment{padding:8px 0;border-top:1px solid var(--line)}
.bl-root .bl-comment:first-child{border-top:0}
.bl-root .bl-comment-body{font-size:.82rem;line-height:1.45;margin:3px 0;
white-space:pre-wrap}
.bl-root .media-url-input{flex:1;min-width:0;background:var(--panel-2);
border:1px solid var(--line);border-radius:8px;padding:6px 10px;
color:var(--text);font-family:inherit;font-size:.76rem}
.bl-root .bl-ctx-card{padding-bottom:14px}
.bl-root .bl-ctx-switch{padding:0 16px}
.bl-root .bl-ctx-switch .market-sort{width:100%;margin-top:6px}
`;
document.head.appendChild(s);
}
return { open, onData, onNotify, onUploaded };
})();