bleeter/web-backend/public/app.js

566 lines
38 KiB
JavaScript
Raw Normal View History

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:00:48 +00:00
'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();