1421 lines
53 KiB
JavaScript
1421 lines
53 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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 a–z, 0–9, 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 };
|
|||
|
|
})();
|