bleeter/web-backend/public/app.js
Bjoern Flessing 20076b0aee Soziales Netz im Spiel: Feed, Werbung, Markt, Kalender und Gewerbe
Jeder Spieler waehlt sich ein Handle mit @, Unternehmen und Behoerden bekommen
eigene Profile, fuer die sie Mitarbeiter freigeben.

Verwaltet wird ueber dieselbe Anmeldung wie das Webhosting: wer am IC-Computer
als Anbieter angemeldet ist, richtet Unternehmensprofile ein. Damit gibt es
genau eine Stelle, an der Verwaltungsrechte haengen - ein zweites Rechtesystem
daneben waere eine zweite Stelle, an der man jemanden zu entziehen vergisst.

Bewusst getrennt: wer fuer ein Unternehmen schreiben darf, kann sich nicht
selbst zum Verwalter machen.

Behoben gegenueber dem Ausgangsstand:

  - Die Resource startete nicht. Eine harte Abhaengigkeit zeigte auf eine
    Resource, die es nicht gibt - das verhindert den Start vollstaendig.
  - Das Schema war gegenueber dem Code stehengeblieben: eine Tabelle fehlte
    ganz, sechs Spalten fehlten. Jede Registrierung eines Handles scheiterte
    deshalb mit einem SQL-Fehler. Zwei Migrationen ziehen das nach.
  - Die Identitaet kommt jetzt von ESX statt von einem Charaktersystem, das
    hier nicht laeuft. Ohne das findet das Mailsystem die Postfaecher nicht.
  - Das Fenster hatte keinen Schliessknopf, nur ESC. Im Computerfenster ist
    diese Taste aber schon vergeben.
  - Bilder laden mit referrerpolicy="no-referrer": Bilderdienste sperren
    Hotlinks anhand der Herkunft, und die eines NUI kennen sie nicht.
  - Der Schluessel fuer den Bilder-Upload steht nicht mehr im Code, sondern in
    der server.cfg (set bleeter_imgbb_key). Er gehoert nicht in ein oeffentlich
    einsehbares Repository.

Neu: Unternehmensprofile und Mitarbeiterfreigabe, ueber Netzwerkereignisse und
Konsolenbefehle. Dazu eine Oberflaeche im IC-Computer, die dieselbe Gestaltung
benutzt - die Stildatei wird dafuer mechanisch gekapselt, statt sie nachzubauen.

Enthaelt README.md mit Einrichtung, Rechten und den Fallstricken.
2026-08-09 12:29:24 +00:00

565 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ── State ─────────────────────────────────────────────────────────────────
const S = {
token: localStorage.getItem('bleeter_token') || null,
account: null,
profiles: [],
activeId: Number(localStorage.getItem('bleeter_profile')) || null,
canModerate: false,
view: 'home',
openComments: new Set(),
};
const PROFILE_TYPE_LABEL = {
private: 'Privat', small_business: 'Kleingewerbe', company: 'Unternehmen',
authority: 'Behörde', lifeinvader: 'Lifeinvader',
};
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
function activeProfile() { return S.profiles.find(p => p.id === S.activeId) || S.profiles[0] || null; }
function canPostHere(profile, feedType) {
if (!profile) return false;
const rights = { private: { home: 1 }, small_business: { advertising: 1 }, company: { advertising: 1 }, authority: { home: 1, advertising: 1 }, lifeinvader: {} };
return !!(rights[profile.profile_type] || {})[feedType];
}
// ── API ───────────────────────────────────────────────────────────────────
async function api(method, path, body, isForm) {
const headers = {};
if (S.token) headers['Authorization'] = 'Bearer ' + S.token;
let payload;
if (isForm) { payload = body; }
else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); }
const res = await fetch(path, { method, headers, body: payload });
if (res.status === 401) { logout(); throw new Error('unauthorized'); }
let data = null; try { data = await res.json(); } catch (e) {}
if (!res.ok) { const e = new Error((data && data.error) || 'error'); e.data = data; throw e; }
return data;
}
function withProfile(params) { const p = activeProfile(); return Object.assign({ profileId: p ? p.id : '' }, params || {}); }
function qs(obj) { return '?' + new URLSearchParams(obj).toString(); }
function apiGet(path, params) { return api('GET', path + qs(withProfile(params))); }
function apiSend(method, path, body) { return api(method, path, withProfile(body)); }
// ── Helpers ─────────────────────────────────────────────────────────────────
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function initials(name) { return String(name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0] || '').join('').toUpperCase() || '?'; }
function avatar(url, name, size) {
size = size || 'md';
if (url) return `<img class="avatar ${size}" src="${esc(url)}" alt="" onerror="this.replaceWith(h(\`<div class='avatar ${size}'>${esc(initials(name))}</div>\`))">`;
return `<div class="avatar ${size}">${esc(initials(name))}</div>`;
}
function badge(p) {
let out = '';
if (p.is_verified) out += ' <span class="verified" title="Verifiziert">✔</span>';
if (p.is_lifeinvader_staff) out += ' <span class="pill staff">Staff</span>';
return out;
}
function timeAgo(str) {
if (!str) return '';
const t = new Date(str.replace(' ', 'T')).getTime();
if (isNaN(t)) return str;
const s = Math.floor((Date.now() - t) / 1000);
if (s < 60) return 'gerade eben';
if (s < 3600) return Math.floor(s / 60) + ' Min.';
if (s < 86400) return Math.floor(s / 3600) + ' Std.';
if (s < 604800) return Math.floor(s / 86400) + ' T.';
return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' });
}
function toast(msg, type) {
const t = h(`<div class="toast ${type || ''}">${esc(msg)}</div>`);
document.getElementById('toast-layer').appendChild(t);
setTimeout(() => { t.style.opacity = '0'; t.style.transition = '0.3s'; setTimeout(() => t.remove(), 300); }, 3200);
}
const ERR_DE = {
invalid_login: 'Handle/Mail oder Passwort falsch.', empty_post: 'Dein Post ist leer.',
not_allowed_here: 'Dieses Profil darf hier nicht posten.', media_rejected: 'Bild-URL abgelehnt.',
handle_taken: 'Dieses Handle ist bereits vergeben.', reserved: 'Dieses Handle ist reserviert.',
already_registered: 'Es existiert bereits ein Privatprofil.', name_required: 'Name darf nicht leer sein.',
cannot_block: 'Dieses Profil kann nicht blockiert werden.', no_lifeinvader_rights: 'Keine Lifeinvader-Rechte.',
invalid_date: 'Ungültiges Datum.', invalid_time: 'Ungültige Uhrzeit.', title_required: 'Titel fehlt.',
not_owner: 'Nur eigene Einträge können bearbeitet werden.', upload_failed: 'Upload fehlgeschlagen.',
};
function errText(e) { return ERR_DE[e && e.message] || 'Es ist ein Fehler aufgetreten.'; }
function modal(title, bodyEl, onSubmit, submitLabel) {
const layer = document.getElementById('modal-layer');
const m = h(`<div class="modal"><h3>${esc(title)}</h3></div>`);
m.appendChild(bodyEl);
const actions = h(`<div class="modal-actions"><button class="btn btn-ghost" data-x="cancel">Abbrechen</button><button class="btn btn-primary" data-x="ok">${esc(submitLabel || 'Speichern')}</button></div>`);
m.appendChild(actions);
layer.innerHTML = ''; layer.appendChild(m); layer.classList.add('show');
const close = () => { layer.classList.remove('show'); layer.innerHTML = ''; };
actions.querySelector('[data-x=cancel]').onclick = close;
actions.querySelector('[data-x=ok]').onclick = async () => { try { const ok = await onSubmit(); if (ok !== false) close(); } catch (e) { toast(errText(e), 'error'); } };
layer.onclick = (e) => { if (e.target === layer) close(); };
return close;
}
// ── Auth ────────────────────────────────────────────────────────────────────
async function boot() {
if (!S.token) return renderLogin();
try {
const me = await api('GET', '/api/auth/me');
S.account = me.account; S.profiles = me.profiles || []; S.canModerate = !!me.can_moderate;
if (!S.activeId || !S.profiles.some(p => p.id === S.activeId)) S.activeId = S.profiles[0] ? S.profiles[0].id : null;
renderApp();
} catch (e) { renderLogin(); }
}
async function doLogin(login, password, errBox) {
try {
const r = await api('POST', '/api/auth/login', { login, password });
S.token = r.token; localStorage.setItem('bleeter_token', r.token);
S.account = r.account; S.profiles = r.profiles || [];
S.activeId = S.profiles[0] ? S.profiles[0].id : null;
await boot();
} catch (e) { errBox.textContent = errText(e); errBox.style.display = 'block'; }
}
function logout() {
S.token = null; S.account = null; S.profiles = [];
localStorage.removeItem('bleeter_token'); localStorage.removeItem('bleeter_profile');
renderLogin();
}
function setActive(id) { S.activeId = Number(id); localStorage.setItem('bleeter_profile', S.activeId); go(S.view); }
// ── Login view ────────────────────────────────────────────────────────────
function renderLogin() {
document.getElementById('app').innerHTML = '';
const wrap = h(`<div id="login-wrap"><div class="login-card">
<div class="brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter<small>Lifeinvader Network</small></div></div>
<h2>Anmelden</h2>
<p class="sub">Melde dich mit deinem Handle oder deiner IC-Mail und deinem ingame gesetzten Bleeter-Passwort an.</p>
<div class="login-error" id="login-error" style="display:none"></div>
<form id="login-form">
<label class="field"><span>Handle oder Mail</span><input id="l-login" autocomplete="username" placeholder="@handle oder mail@..." /></label>
<label class="field"><span>Passwort</span><input id="l-pass" type="password" autocomplete="current-password" placeholder="••••••••" /></label>
<button class="btn btn-primary" style="width:100%;justify-content:center;margin-top:6px">Anmelden</button>
</form>
<div class="login-hint">Noch kein Passwort? Öffne Bleeter im Spiel und setze mit <b>/bleeterweb &lt;passwort&gt;</b> dein Web-Passwort.</div>
</div></div>`);
document.getElementById('app').appendChild(wrap);
const errBox = wrap.querySelector('#login-error');
wrap.querySelector('#login-form').addEventListener('submit', (e) => {
e.preventDefault();
const login = wrap.querySelector('#l-login').value.trim().replace(/^@/, '');
const pass = wrap.querySelector('#l-pass').value;
if (!login || !pass) { errBox.textContent = 'Bitte alle Felder ausfüllen.'; errBox.style.display = 'block'; return; }
doLogin(login, pass, errBox);
});
}
// ── App shell ───────────────────────────────────────────────────────────────
const NAV = [
{ key: 'home', label: 'Feed', ico: '🏠' },
{ key: 'ads', label: 'Werbefeed', ico: '📣' },
{ key: 'people', label: 'Leute', ico: '🧭' },
{ key: 'social', label: 'Follower', ico: '👥' },
{ key: 'market', label: 'Marktplatz', ico: '🛒' },
{ key: 'calendar', label: 'Kalender', ico: '📅' },
{ key: 'business', label: 'Gewerbe', ico: '💼' },
{ key: 'profile', label: 'Mein Profil', ico: '👤' },
{ key: 'legal', label: 'Rechtliches', ico: '📘' },
];
function navItems() { const n = NAV.slice(); if (S.canModerate) n.push({ key: 'moderation', label: 'Moderation', ico: '🛡️' }); return n; }
function renderApp() {
const p = activeProfile();
const profOptions = S.profiles.map(pp => `<option value="${pp.id}" ${pp.id === S.activeId ? 'selected' : ''}>${esc(pp.display_name)} · @${esc(pp.handle)}</option>`).join('');
const shell = h(`<div id="shell">
<aside id="sidebar">
<div class="side-brand"><div class="brand-logo">🐦</div><div class="brand-name">Bleeter</div></div>
<nav id="nav">${navItems().map(n => `<button class="nav-item ${n.key === S.view ? 'active' : ''}" data-nav="${n.key}"><span class="ico">${n.ico}</span>${n.label}</button>`).join('')}</nav>
<div class="nav-spacer"></div>
<div class="profile-switch">
<div class="cur">${p ? avatar(p.avatar_url, p.display_name, 'md') : ''}<div class="meta">
<b>${p ? esc(p.display_name) : 'Kein Profil'}</b><span>${p ? '@' + esc(p.handle) + ' · ' + (PROFILE_TYPE_LABEL[p.profile_type] || '') : ''}</span></div></div>
${S.profiles.length > 1 ? `<select id="prof-select">${profOptions}</select>` : ''}
<button class="btn btn-ghost btn-sm logout" data-nav="__logout"><span>⏻</span> Abmelden</button>
</div>
</aside>
<main id="main"></main>
</div>`);
const mob = h(`<div id="mobile-nav">${navItems().slice(0, 5).map(n => `<button class="${n.key === S.view ? 'active' : ''}" data-nav="${n.key}">${n.ico}</button>`).join('')}</div>`);
const app = document.getElementById('app'); app.innerHTML = ''; app.appendChild(shell); app.appendChild(mob);
document.querySelectorAll('[data-nav]').forEach(b => b.addEventListener('click', () => {
const k = b.getAttribute('data-nav');
if (k === '__logout') return logout();
go(k);
}));
const sel = shell.querySelector('#prof-select'); if (sel) sel.addEventListener('change', e => setActive(e.target.value));
go(S.view);
}
function setActiveNav() {
document.querySelectorAll('[data-nav]').forEach(b => {
const k = b.getAttribute('data-nav'); if (k && k[0] !== '_') b.classList.toggle('active', k === S.view);
});
}
function main() { return document.getElementById('main'); }
function loading() { main().innerHTML = `<div class="empty"><div class="big">🐦</div>Lädt…</div>`; }
// ── Router ────────────────────────────────────────────────────────────────
async function go(view) {
S.view = view; setActiveNav(); loading();
try {
if (view === 'home' || view === 'ads') return viewFeed(view === 'ads' ? 'advertising' : 'home');
if (view === 'people') return viewPeople();
if (view === 'social') return viewSocial();
if (view === 'market') return viewMarket();
if (view === 'calendar') return viewCalendar();
if (view === 'business') return viewBusiness();
if (view === 'profile') return viewProfile();
if (view === 'legal') return viewLegal();
if (view === 'moderation') return viewModeration();
} catch (e) { if (e.message !== 'unauthorized') main().innerHTML = `<div class="empty"><div class="big">⚠️</div>${errText(e)}</div>`; }
}
// ── Feed / Werbefeed ─────────────────────────────────────────────────────────
function topbar(title, sub) { return `<div class="topbar"><div><h1>${esc(title)}</h1>${sub ? `<div class="sub">${esc(sub)}</div>` : ''}</div></div>`; }
async function viewFeed(feedType) {
const { posts } = await apiGet('/api/feed', { type: feedType });
const p = activeProfile();
const canPost = canPostHere(p, feedType);
const c = h(`<div>${topbar(feedType === 'advertising' ? 'Werbefeed' : 'Feed', feedType === 'advertising' ? 'Angebote & Ankündigungen von Gewerben und Behörden' : 'Was in Los Santos gerade passiert')}</div>`);
if (canPost) c.appendChild(composer(feedType));
else if (p) c.appendChild(h(`<div class="card muted" style="font-size:.86rem">Als <b>${esc(PROFILE_TYPE_LABEL[p.profile_type])}</b>-Profil kannst du hier nicht posten. ${feedType === 'home' ? 'Wechsle auf ein Behördenprofil.' : 'Wechsle auf ein Gewerbe- oder Behördenprofil.'}</div>`));
if (!posts.length) c.appendChild(h(`<div class="empty"><div class="big">🐦</div>Noch keine Bleets hier.</div>`));
posts.forEach(post => c.appendChild(postCard(post)));
main().innerHTML = ''; main().appendChild(c);
}
function composer(feedType) {
const p = activeProfile();
const el = h(`<div class="card composer">
<div style="display:flex;gap:12px">${avatar(p.avatar_url, p.display_name, 'md')}
<div style="flex:1"><textarea maxlength="2000" placeholder="Was gibt's Neues, @${esc(p.handle)}?"></textarea>
<input class="media-url" placeholder="Bild-URL (optional, https, .jpg/.png)" style="margin-top:8px;font-size:.85rem" />
</div></div>
<div class="row"><div class="tools"><span class="char-count">0 / 2000</span></div>
<button class="btn btn-primary" data-post>Bleeten</button></div></div>`);
const ta = el.querySelector('textarea'), cc = el.querySelector('.char-count');
ta.addEventListener('input', () => cc.textContent = `${ta.value.length} / 2000`);
el.querySelector('[data-post]').addEventListener('click', async (ev) => {
const body = ta.value.trim(), mediaUrl = el.querySelector('.media-url').value.trim();
if (!body && !mediaUrl) return toast('Dein Post ist leer.', 'error');
ev.target.disabled = true;
try { await apiSend('POST', '/api/posts', { feedType, body, mediaUrl }); toast('Gepostet.', 'success'); go(S.view); }
catch (e) { toast(errText(e), 'error'); ev.target.disabled = false; }
});
return el;
}
function postCard(post) {
const a = post.author;
const el = h(`<div class="card post" data-post-id="${post.id}">
${avatar(a.avatar_url, a.display_name, 'md')}
<div class="body">
<div class="head"><span class="name">${esc(a.display_name)}</span>${badge(a)}
<span class="handle">@${esc(a.handle)}</span><span class="time">· ${timeAgo(post.created_at)}</span></div>
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy" /></div>` : ''}
<div class="actions">
<button class="like ${post.liked_by_viewer ? 'liked' : ''}" data-act="like-post" data-id="${post.id}">${post.liked_by_viewer ? '❤️' : '🤍'} <span>${post.likes}</span></button>
<button data-act="toggle-comments" data-id="${post.id}">💬 <span>${post.comments}</span></button>
${post.can_delete ? `<button class="del" data-act="del-post" data-id="${post.id}">🗑️</button>` : ''}
</div>
<div class="comments-wrap" data-comments="${post.id}" style="display:${S.openComments.has(post.id) ? 'block' : 'none'}"></div>
</div></div>`);
if (S.openComments.has(post.id)) renderComments(el.querySelector(`[data-comments="${post.id}"]`), post);
return el;
}
function renderComments(box, post) {
box.innerHTML = '';
const list = h(`<div class="comments"></div>`);
(post.comments_list || []).forEach(cm => {
const cmEl = h(`<div class="comment">${avatar('', cm.author, 'sm')}<div class="c-body">
<div class="chead"><b>@${esc(cm.author)}</b> · ${timeAgo(cm.created_at)}</div>
<div>${esc(cm.body)}</div>
<div class="actions" style="margin-top:6px;font-size:.8rem">
<button class="like ${cm.liked_by_viewer ? 'liked' : ''}" data-act="like-comment" data-id="${cm.id}" data-post="${post.id}">${cm.liked_by_viewer ? '❤️' : '🤍'} <span>${cm.likes}</span></button>
${cm.can_delete ? `<button class="del" data-act="del-comment" data-id="${cm.id}" data-post="${post.id}">🗑️</button>` : ''}
</div></div></div>`);
list.appendChild(cmEl);
});
box.appendChild(list);
const form = h(`<form class="comment-form"><input maxlength="500" placeholder="Kommentieren…" /><button class="btn btn-primary btn-sm">→</button></form>`);
form.addEventListener('submit', async (e) => {
e.preventDefault(); const inp = form.querySelector('input'); const body = inp.value.trim(); if (!body) return;
try { const r = await apiSend('POST', `/api/posts/${post.id}/comments`, { body }); post.comments_list = r.comments; post.comments = r.comments.length; inp.value = ''; renderComments(box, post); refreshCount(post); }
catch (err) { toast(errText(err), 'error'); }
});
box.appendChild(form);
}
function refreshCount(post) {
const btn = document.querySelector(`[data-post-id="${post.id}"] [data-act="toggle-comments"] span`);
if (btn) btn.textContent = post.comments;
}
// Delegated actions for feed-like interactions
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-act]'); if (!btn) return;
const act = btn.getAttribute('data-act'); const id = Number(btn.getAttribute('data-id'));
try {
if (act === 'like-post') { await apiSend('POST', `/api/posts/${id}/like`); go(S.view); }
else if (act === 'like-comment') { await apiSend('POST', `/api/comments/${id}/like`); go(S.view); }
else if (act === 'del-post') { if (confirm('Diesen Post löschen?')) { await apiSend('DELETE', `/api/posts/${id}`); toast('Gelöscht.', 'success'); go(S.view); } }
else if (act === 'del-comment') { if (confirm('Kommentar löschen?')) { await apiSend('DELETE', `/api/comments/${id}`); go(S.view); } }
else if (act === 'toggle-comments') { S.openComments.has(id) ? S.openComments.delete(id) : S.openComments.add(id); go(S.view); }
else if (act === 'follow') { await apiSend('POST', `/api/profiles/${id}/follow`); go(S.view); }
else if (act === 'unfollow') { await apiSend('POST', `/api/profiles/${id}/unfollow`); go(S.view); }
else if (act === 'block') { if (confirm('Profil blockieren?')) { await apiSend('POST', `/api/profiles/${id}/block`); toast('Blockiert.', 'success'); go(S.view); } }
else if (act === 'unblock') { await apiSend('POST', `/api/profiles/${id}/unblock`); go(S.view); }
else if (act === 'del-market') { if (confirm('Inserat löschen?')) { await apiSend('DELETE', `/api/marketplace/${id}`); go(S.view); } }
else if (act === 'del-event') { if (confirm('Termin löschen?')) { await apiSend('DELETE', `/api/events/${id}`); go(S.view); } }
else if (act === 'mod') { await modAction(btn); }
} catch (err) { if (err.message !== 'unauthorized') toast(errText(err), 'error'); }
});
// ── Leute (Directory) ───────────────────────────────────────────────────────
async function viewPeople() {
const [{ profiles }, social] = await Promise.all([apiGet('/api/directory'), apiGet('/api/social')]);
const followingIds = new Set(social.following.map(x => x.id));
const me = activeProfile();
const c = h(`<div>${topbar('Leute', 'Profile entdecken und folgen')}</div>`);
const grid = h(`<div class="grid cols-2"></div>`);
profiles.filter(p => !me || p.id !== me.id).forEach(p => {
const following = followingIds.has(p.id);
grid.appendChild(h(`<div class="card person">
${avatar(p.avatar_url, p.display_name, 'md')}
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''} · ${p.followers_count || 0} Follower</span></div>
<button class="btn btn-sm ${following ? '' : 'btn-primary'}" data-act="${following ? 'unfollow' : 'follow'}" data-id="${p.id}">${following ? 'Entfolgen' : 'Folgen'}</button>
</div>`));
});
if (!profiles.length) grid.appendChild(h(`<div class="empty">Keine Profile.</div>`));
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
}
// ── Follower / Social ────────────────────────────────────────────────────────
let socialTab = 'following';
async function viewSocial() {
const social = await apiGet('/api/social');
const tabs = [['following', 'Ich folge', social.following], ['followers', 'Follower', social.followers], ['blocked', 'Blockiert', social.blocked]];
const c = h(`<div>${topbar('Follower', 'Dein Netzwerk')}
<div class="tabs">${tabs.map(([k, l, arr]) => `<button class="tab ${k === socialTab ? 'active' : ''}" data-stab="${k}">${l} (${arr.length})</button>`).join('')}</div></div>`);
const listWrap = h(`<div></div>`);
const render = () => {
listWrap.innerHTML = '';
const arr = (tabs.find(t => t[0] === socialTab) || [, , []])[2];
if (!arr.length) { listWrap.appendChild(h(`<div class="empty">Niemand hier.</div>`)); return; }
const grid = h(`<div class="grid cols-2"></div>`);
arr.forEach(p => {
let action = '';
if (socialTab === 'following') action = `<button class="btn btn-sm" data-act="unfollow" data-id="${p.id}">Entfolgen</button>`;
else if (socialTab === 'blocked') action = `<button class="btn btn-sm" data-act="unblock" data-id="${p.id}">Freigeben</button>`;
else if (p.can_be_blocked) action = `<button class="btn btn-sm btn-danger" data-act="block" data-id="${p.id}">Blockieren</button>`;
grid.appendChild(h(`<div class="card person">${avatar(p.avatar_url, p.display_name, 'md')}
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}</span></div>${action}</div>`));
});
listWrap.appendChild(grid);
};
c.appendChild(listWrap); render();
c.querySelectorAll('[data-stab]').forEach(b => b.addEventListener('click', () => { socialTab = b.getAttribute('data-stab'); c.querySelectorAll('[data-stab]').forEach(x => x.classList.toggle('active', x === b)); render(); }));
main().innerHTML = ''; main().appendChild(c);
}
// ── Marktplatz ────────────────────────────────────────────────────────────
async function viewMarket() {
const { items } = await apiGet('/api/marketplace');
const c = h(`<div><div class="topbar"><div><h1>Marktplatz</h1><div class="sub">Angebote der Community</div></div>
<button class="btn btn-primary" id="new-market"> Inserat</button></div></div>`);
c.querySelector('#new-market').addEventListener('click', marketModal);
if (!items.length) c.appendChild(h(`<div class="empty"><div class="big">🛒</div>Noch keine Inserate.</div>`));
const grid = h(`<div class="grid cols-3"></div>`);
items.forEach(it => {
grid.appendChild(h(`<div class="card market-card">
${it.media_url ? `<div class="media"><img src="${esc(it.media_url)}" loading="lazy"></div>` : ''}
<b>${esc(it.title)}</b>
${it.price_label ? `<div class="price">${esc(it.price_label)}</div>` : ''}
<div class="muted" style="font-size:.86rem;margin:6px 0;white-space:pre-wrap">${esc(it.body || '')}</div>
<div class="muted" style="font-size:.78rem">@${esc(it.author.handle)}${it.author.email ? ' · ' + esc(it.author.email) : ''}</div>
${it.can_delete ? `<button class="btn btn-sm btn-danger" style="margin-top:10px" data-act="del-market" data-id="${it.id}">Löschen</button>` : ''}
</div>`));
});
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
}
function marketModal() {
const body = h(`<div>
<label class="field"><span>Titel</span><input id="m-title" maxlength="120"></label>
<label class="field"><span>Beschreibung</span><textarea id="m-desc" maxlength="2000"></textarea></label>
<label class="field"><span>Preis (optional)</span><input id="m-price" maxlength="80" placeholder="z.B. 5.000 $"></label>
<label class="field"><span>Bild-URL (optional)</span><input id="m-media" placeholder="https://...jpg"></label></div>`);
modal('Neues Inserat', body, async () => {
await apiSend('POST', '/api/marketplace', {
title: body.querySelector('#m-title').value, description: body.querySelector('#m-desc').value,
priceLabel: body.querySelector('#m-price').value, mediaUrl: body.querySelector('#m-media').value,
});
toast('Inserat erstellt.', 'success'); go('market');
}, 'Erstellen');
}
// ── Kalender ────────────────────────────────────────────────────────────────
async function viewCalendar() {
const { days } = await apiGet('/api/calendar');
const p = activeProfile();
const canCreate = p && BUSINESS_TYPES.includes(p.profile_type);
const c = h(`<div><div class="topbar"><div><h1>Kalender</h1><div class="sub">Veranstaltungen der nächsten 4 Wochen</div></div>
${canCreate ? '<button class="btn btn-primary" id="new-event"> Termin</button>' : ''}</div></div>`);
if (canCreate) c.querySelector('#new-event').addEventListener('click', eventModal);
const withEvents = days.filter(d => d.events.length);
if (!withEvents.length) c.appendChild(h(`<div class="empty"><div class="big">📅</div>Keine anstehenden Termine.</div>`));
withEvents.forEach(d => {
const day = h(`<div class="card cal-day"><h4>${esc(d.title)}</h4></div>`);
d.events.forEach(ev => day.appendChild(h(`<div class="event"><span class="time">${esc(ev.time)}</span>
<div style="flex:1"><b>${esc(ev.title)}</b>${ev.location ? `<div class="muted" style="font-size:.82rem">📍 ${esc(ev.location)}</div>` : ''}<div class="dim" style="font-size:.78rem">@${esc(ev.author)}</div></div>
${ev.can_delete ? `<button class="btn btn-sm btn-danger" data-act="del-event" data-id="${ev.id}">✕</button>` : ''}</div>`)));
c.appendChild(day);
});
main().innerHTML = ''; main().appendChild(c);
}
function eventModal() {
const today = new Date().toISOString().slice(0, 10);
const body = h(`<div>
<label class="field"><span>Titel</span><input id="e-title" maxlength="50"></label>
<div class="grid cols-2"><label class="field"><span>Datum</span><input id="e-date" type="date" value="${today}"></label>
<label class="field"><span>Uhrzeit</span><input id="e-time" type="time" value="20:00"></label></div>
<label class="field"><span>Ort (optional)</span><input id="e-loc" maxlength="50"></label></div>`);
modal('Neuer Termin', body, async () => {
await apiSend('POST', '/api/events', {
title: body.querySelector('#e-title').value, date: body.querySelector('#e-date').value,
time: body.querySelector('#e-time').value, location: body.querySelector('#e-loc').value,
});
toast('Termin erstellt.', 'success'); go('calendar');
}, 'Erstellen');
}
// ── Gewerbe ─────────────────────────────────────────────────────────────────
async function viewBusiness() {
const { businesses } = await apiGet('/api/businesses');
const p = activeProfile();
const canToggle = p && BUSINESS_TYPES.includes(p.profile_type) && p.can_edit_profile;
const mine = canToggle ? businesses.find(b => b.handle === p.handle) : null;
const c = h(`<div>${topbar('Gewerbe', 'Geöffnete und geschlossene Betriebe')}</div>`);
if (canToggle) {
const cur = mine ? mine.status : 'closed';
const toggle = h(`<div class="card" style="display:flex;align-items:center;justify-content:space-between">
<div><b>Dein Betrieb: @${esc(p.handle)}</b><div class="muted" style="font-size:.84rem">Aktueller Status: <span class="pill ${cur}">${cur === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div></div>
<div style="display:flex;gap:8px"><button class="btn btn-sm ${cur === 'open' ? 'btn-primary' : ''}" data-bstatus="open">Öffnen</button>
<button class="btn btn-sm ${cur === 'closed' ? 'btn-primary' : ''}" data-bstatus="closed">Schließen</button></div></div>`);
toggle.querySelectorAll('[data-bstatus]').forEach(b => b.addEventListener('click', async () => {
try { await apiSend('POST', '/api/business/status', { status: b.getAttribute('data-bstatus') }); toast('Status aktualisiert.', 'success'); go('business'); }
catch (e) { toast(errText(e), 'error'); }
}));
c.appendChild(toggle);
}
const grid = h(`<div class="grid cols-2"></div>`);
businesses.forEach(b => grid.appendChild(h(`<div class="card person">${avatar(b.avatar_url, b.display_name, 'md')}
<div class="meta"><b>${esc(b.display_name)}${b.is_verified ? ' <span class="verified">✔</span>' : ''}</b><span>@${esc(b.handle)}</span></div>
<span class="pill ${b.status}">${b.status === 'open' ? 'Geöffnet' : 'Geschlossen'}</span></div>`)));
if (!businesses.length) grid.appendChild(h(`<div class="empty">Keine Gewerbe gelistet.</div>`));
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
}
// ── Mein Profil ─────────────────────────────────────────────────────────────
async function viewProfile() {
const p = activeProfile();
if (!p) { return renderRegister(); }
const data = await apiGet('/api/profiles/' + encodeURIComponent(p.handle));
const prof = data.profile;
const c = h(`<div>${topbar('Mein Profil', '')}
<div class="card profile-head pad-lg">
<div class="banner">${prof.banner_url ? `<img src="${esc(prof.banner_url)}">` : ''}</div>
<div class="ptop">${avatar(prof.avatar_url, prof.display_name, 'lg')}
<div class="pmeta"><h2>${esc(prof.display_name)}${badge(prof)}</h2><div class="muted">@${esc(prof.handle)} · ${PROFILE_TYPE_LABEL[prof.profile_type] || ''}</div></div>
${p.can_edit_profile ? '<button class="btn btn-sm" id="edit-profile">Bearbeiten</button>' : ''}</div>
${prof.bio ? `<div style="margin:14px 4px;line-height:1.6">${esc(prof.bio)}</div>` : ''}
<div class="stat-row"><div class="s"><b>${prof.followers_count || 0}</b><span>Follower</span></div>
<div class="s"><b>${prof.following_count || 0}</b><span>Folge ich</span></div>
<div class="s"><b>${data.posts.length}</b><span>Bleets</span></div></div>
</div></div>`);
if (p.can_edit_profile) c.querySelector('#edit-profile').addEventListener('click', () => editProfileModal(prof));
if (!data.posts.length) c.appendChild(h(`<div class="empty">Noch keine Bleets.</div>`));
data.posts.forEach(post => {
c.appendChild(h(`<div class="card post"><div class="body" style="margin-left:0">
<div class="head"><span class="handle">${prof.feed_type === 'advertising' ? '📣 Werbung' : ''}</span><span class="time">${timeAgo(post.created_at)}</span></div>
${post.body ? `<div class="text">${esc(post.body)}</div>` : ''}
${post.media_url ? `<div class="media"><img src="${esc(post.media_url)}" loading="lazy"></div>` : ''}
<div class="actions"><span>❤️ ${post.likes}</span><span>💬 ${post.comments}</span></div></div></div>`));
});
main().innerHTML = ''; main().appendChild(c);
}
function editProfileModal(prof) {
const bioMax = prof.profile_type === 'private' ? 200 : 500;
const body = h(`<div>
<label class="field"><span>Anzeigename</span><input id="p-name" maxlength="80" value="${esc(prof.display_name)}"></label>
<label class="field"><span>Bio (max ${bioMax})</span><textarea id="p-bio" maxlength="${bioMax}">${esc(prof.bio || '')}</textarea></label>
<label class="field"><span>Avatar-URL</span><input id="p-avatar" value="${esc(prof.avatar_url || '')}" placeholder="https://...jpg"></label>
<label class="field"><span>Banner-URL</span><input id="p-banner" value="${esc(prof.banner_url || '')}" placeholder="https://...jpg"></label></div>`);
modal('Profil bearbeiten', body, async () => {
await apiSend('PATCH', '/api/profile', {
displayName: body.querySelector('#p-name').value, bio: body.querySelector('#p-bio').value,
avatarUrl: body.querySelector('#p-avatar').value, bannerUrl: body.querySelector('#p-banner').value,
});
toast('Profil gespeichert.', 'success'); await boot(); go('profile');
});
}
function renderRegister() {
const c = h(`<div>${topbar('Willkommen bei Bleeter', '')}
<div class="card pad-lg"><p class="muted" style="margin-bottom:16px">Du hast noch kein Bleeter-Profil. Lege jetzt dein privates Profil an.</p>
<label class="field"><span>Handle (@)</span><input id="r-handle" maxlength="32" placeholder="deinname"></label>
<label class="field"><span>Anzeigename</span><input id="r-name" maxlength="80"></label>
<button class="btn btn-primary" id="r-go">Profil erstellen</button></div></div>`);
c.querySelector('#r-go').addEventListener('click', async () => {
try {
const r = await api('POST', '/api/register', { handle: c.querySelector('#r-handle').value.trim().replace(/^@/, ''), displayName: c.querySelector('#r-name').value.trim() });
S.profiles = r.profiles; S.activeId = S.profiles[0].id; toast('Profil erstellt!', 'success'); renderApp();
} catch (e) { toast(errText(e), 'error'); }
});
main().innerHTML = ''; main().appendChild(c);
}
// ── Rechtliches ─────────────────────────────────────────────────────────────
function viewLegal() {
const c = h(`<div>${topbar('Rechtliches', '')}
<div class="card pad-lg legal">
<h3>Über Bleeter</h3>
<p>Bleeter ist das soziale Netzwerk von Lifeinvader in Los Santos. Alle Inhalte sind fiktiv und Teil des Rollenspiels auf NaturalBornPlayers.</p>
<h3>Nutzungsregeln</h3>
<p>Es gelten die Serverregeln von NaturalBornPlayers. Beleidigungen, reale Werbung, OOC-Inhalte sowie das Teilen realer personenbezogener Daten sind untersagt.</p>
<p>Behörden- und Lifeinvader-Profile können nicht blockiert werden. Verstöße können von Lifeinvader-Mitarbeitern moderiert (Verifizierung, Sperrung) werden.</p>
<h3>Inhalte & Haftung</h3>
<p>Für Inhalte ist der jeweilige Verfasser (IC) verantwortlich. Bilder werden über einen externen Dienst gehostet.</p>
<h3>Impressum (IC)</h3>
<p>Lifeinvader Bleeter · Los Santos · vertreten durch die Lifeinvader-Redaktion.</p>
</div></div>`);
main().innerHTML = ''; main().appendChild(c);
}
// ── Moderation (Lifeinvader) ──────────────────────────────────────────────────
async function viewModeration() {
const { profiles } = await apiGet('/api/directory');
const c = h(`<div>${topbar('Moderation', 'Lifeinvader-Werkzeuge')}
<div class="card muted" style="font-size:.85rem">Verifizieren, Staff markieren oder Profile sperren. Aktionen werden protokolliert.</div></div>`);
const grid = h(`<div class="grid cols-2"></div>`);
profiles.forEach(p => grid.appendChild(h(`<div class="card person" style="flex-wrap:wrap">
${avatar(p.avatar_url, p.display_name, 'md')}
<div class="meta"><b>${esc(p.display_name)}${badge(p)}</b><span>@${esc(p.handle)} · ${PROFILE_TYPE_LABEL[p.profile_type] || ''}${p.is_locked ? ' · <span style="color:var(--danger)">gesperrt</span>' : ''}</span></div>
<div style="display:flex;gap:6px;width:100%;margin-top:6px">
<button class="btn btn-sm" data-act="mod" data-modaction="verify" data-id="${p.id}">${p.is_verified ? 'Unverif.' : 'Verifizieren'}</button>
<button class="btn btn-sm" data-act="mod" data-modaction="staff" data-id="${p.id}">${p.is_lifeinvader_staff ? 'Staff -' : 'Staff +'}</button>
<button class="btn btn-sm btn-danger" data-act="mod" data-modaction="lock" data-id="${p.id}">${p.is_locked ? 'Entsperren' : 'Sperren'}</button>
</div></div>`)));
c.appendChild(grid); main().innerHTML = ''; main().appendChild(c);
}
async function modAction(btn) {
const action = btn.getAttribute('data-modaction'); const id = Number(btn.getAttribute('data-id'));
await apiSend('POST', '/api/moderation/profile', { action, targetId: id });
toast('Aktion ausgeführt.', 'success'); go('moderation');
}
// ── Start ────────────────────────────────────────────────────────────────────
boot();