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.
565 lines
38 KiB
JavaScript
565 lines
38 KiB
JavaScript
'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 <passwort></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();
|