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.
This commit is contained in:
Bjoern Flessing 2026-08-09 12:00:48 +00:00
commit 20076b0aee
41 changed files with 8652 additions and 0 deletions

16
web-backend/.env.example Normal file
View file

@ -0,0 +1,16 @@
PORT=
DB_HOST=
DB_PORT=
DB_USER=
DB_PASS=
DB_NAME=
JWT_SECRET=
INTERNAL_API_KEY=
IMGBB_KEY=
# Vorlage. Kopieren nach .env und ausfuellen:
# PORT Port des Backends
# DB_* Zugang zur Spieldatenbank
# JWT_SECRET beliebige lange Zufallszeichenkette
# INTERNAL_API_KEY gemeinsames Geheimnis zwischen Backend und Resource
# IMGBB_KEY API-Schluessel von imgbb.com fuer Bilduploads

38
web-backend/auth.js Normal file
View file

@ -0,0 +1,38 @@
const jwt = require('jsonwebtoken');
const db = require('./db');
require('dotenv').config();
const SECRET = process.env.JWT_SECRET;
function sign(account) {
return jwt.sign(
{ aid: account.id, cid: account.char_id },
SECRET,
{ expiresIn: '7d' }
);
}
// Middleware: prueft JWT, laedt Account frisch aus DB -> req.account
async function requireAuth(req, res, next) {
try {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'no_token' });
const payload = jwt.verify(token, SECRET);
const account = await db.q1(
'SELECT * FROM bleeter_accounts WHERE id = ? AND char_id = ?',
[payload.aid, payload.cid]
);
if (!account) return res.status(401).json({ error: 'account_gone' });
if (account.status && account.status !== 'active') {
return res.status(403).json({ error: 'account_disabled' });
}
req.account = account;
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token' });
}
}
module.exports = { sign, requireAuth };

40
web-backend/db.js Normal file
View file

@ -0,0 +1,40 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
charset: 'utf8mb4_general_ci',
dateStrings: true,
});
// Mehrere Zeilen
async function q(sql, params = []) {
const [rows] = await pool.query(sql, params);
return rows;
}
// Genau eine Zeile (oder null)
async function q1(sql, params = []) {
const rows = await q(sql, params);
return rows[0] || null;
}
// INSERT -> insertId
async function insert(sql, params = []) {
const [res] = await pool.query(sql, params);
return res.insertId;
}
// UPDATE/DELETE -> affectedRows
async function exec(sql, params = []) {
const [res] = await pool.query(sql, params);
return res.affectedRows;
}
module.exports = { pool, q, q1, insert, exec };

View file

@ -0,0 +1,98 @@
const db = require('../db');
const { decodeBool, canProfileBeBlocked } = require('./permissions');
// Portiert aus main.lua mapProfile()
function mapProfile(row) {
if (!row) return null;
const profile = {
id: row.id,
account_id: row.account_id,
profile_type: row.profile_type,
handle: row.handle,
display_name: row.display_name,
avatar_url: row.avatar_url || '',
banner_url: row.banner_url || '',
bio: row.bio || '',
location: row.location || '',
email_contact: row.email_contact || '',
phone_contact: row.phone_contact || '',
is_verified: decodeBool(row.is_verified),
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
is_active: row.is_active === undefined || row.is_active === null || decodeBool(row.is_active),
is_locked: decodeBool(row.is_locked),
can_post: decodeBool(row.can_post),
can_edit_profile: decodeBool(row.can_edit_profile),
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
};
if (row.followers_count !== undefined) profile.followers_count = Number(row.followers_count) || 0;
if (row.following_count !== undefined) profile.following_count = Number(row.following_count) || 0;
return profile;
}
// Alle Profile, auf die der Account zugreifen kann (eigene + Mitgliedschaften)
async function getAccessibleProfiles(account) {
const profiles = [];
const seen = new Set();
const ownRows = await db.q(
`SELECT p.*, 1 AS can_post, 1 AS can_edit_profile
FROM bleeter_profiles p
WHERE p.account_id = ? AND p.is_active = 1 AND p.is_locked = 0
ORDER BY FIELD(p.profile_type, 'private','small_business','company','authority','lifeinvader'), p.display_name`,
[account.id]
);
for (const row of ownRows) {
const profile = mapProfile(row);
profiles.push(profile);
seen.add(profile.id);
}
const memberRows = await db.q(
`SELECT p.*, m.can_post, m.can_edit_profile
FROM bleeter_profile_members m
JOIN bleeter_profiles p ON p.id = m.profile_id
WHERE m.char_id = ? AND p.is_active = 1 AND p.is_locked = 0
ORDER BY p.display_name`,
[account.char_id]
);
for (const row of memberRows) {
if (!seen.has(row.id)) {
const profile = mapProfile(row);
profiles.push(profile);
seen.add(profile.id);
}
}
return profiles;
}
// Das aktuell handelnde Profil (per profileId vom Client, sonst erstes).
// Gibt { profile, profiles } zurueck. profile=null wenn kein Profil vorhanden.
async function resolveActingProfile(account, profileId) {
const profiles = await getAccessibleProfiles(account);
let profile = null;
if (profileId) {
profile = profiles.find(p => Number(p.id) === Number(profileId)) || null;
}
if (!profile) profile = profiles[0] || null;
return { profile, profiles };
}
// Zielprofil fuer Follow/Block/Moderation laden.
// includeLocked=true fuer Moderationsaktionen.
async function findTargetProfile(payload, includeLocked = false) {
const id = Number(payload && (payload.targetId || payload.profileId));
const handle = payload && payload.handle ? String(payload.handle).toLowerCase() : null;
let row = null;
if (id) {
row = await db.q1('SELECT * FROM bleeter_profiles WHERE id = ?', [id]);
} else if (handle) {
row = await db.q1('SELECT * FROM bleeter_profiles WHERE handle = ?', [handle]);
}
if (!row) return null;
if (!decodeBool(row.is_active)) return null;
if (!includeLocked && decodeBool(row.is_locked)) return null;
return mapProfile(row);
}
module.exports = { mapProfile, getAccessibleProfiles, resolveActingProfile, findTargetProfile };

39
web-backend/lib/media.js Normal file
View file

@ -0,0 +1,39 @@
require('dotenv').config();
const ALLOWED_EXT = ['jpg', 'jpeg', 'png'];
function extFromUrl(url) {
const m = /\.([a-z0-9]+)(\?|$)/i.exec(String(url || ''));
return m ? m[1].toLowerCase() : null;
}
// Portiert aus adapters/media.lua IsAllowedExternalUrl
function isAllowedExternalUrl(url) {
url = String(url || '');
if (!/^https:\/\//i.test(url)) return { ok: false, reason: 'url_must_be_https' };
if (/^https:\/\/i\.ibb\.co\//i.test(url) || /^https:\/\/ibb\.co\//i.test(url)) return { ok: true };
const ext = extFromUrl(url);
if (ext && ALLOWED_EXT.includes(ext)) return { ok: true };
return { ok: false, reason: 'unsupported_image_url' };
}
// Upload eines Buffers zu imgbb -> { url }
async function uploadToImgbb(buffer, filename) {
const key = process.env.IMGBB_KEY;
if (!key) throw new Error('imgbb_key_missing');
const form = new FormData();
form.append('image', buffer.toString('base64'));
if (filename) form.append('name', filename.replace(/\.[^.]+$/, ''));
const resp = await fetch(`https://api.imgbb.com/1/upload?key=${encodeURIComponent(key)}`, {
method: 'POST',
body: form,
});
const data = await resp.json();
if (!data || !data.success || !data.data || !data.data.url) {
throw new Error('imgbb_upload_failed');
}
return { url: data.data.url };
}
module.exports = { isAllowedExternalUrl, uploadToImgbb, ALLOWED_EXT };

View file

@ -0,0 +1,39 @@
// Portiert aus bleeter/server/permissions.lua + main.lua
// Wer darf in welchem Feed posten (feed_type: 'home' | 'advertising')
const feedPostRights = {
private: { home: true },
small_business: { advertising: true },
company: { advertising: true },
authority: { home: true, advertising: true },
lifeinvader: {},
};
function canPost(profileType, feedType) {
const rights = feedPostRights[profileType || ''] || {};
return rights[feedType || ''] === true;
}
function decodeBool(v) {
return v === true || v === 1 || v === '1';
}
// Behoerden / Lifeinvader / Staff koennen nicht blockiert werden
function canProfileBeBlocked(profileType, isLifeinvaderStaff) {
return profileType !== 'authority'
&& profileType !== 'lifeinvader'
&& !decodeBool(isLifeinvaderStaff);
}
// Lifeinvader-Moderationsrechte eines Charakters (char_id) aus DB
async function hasLifeinvaderPermission(db, charId, permission) {
if (!charId) return false;
const row = await db.q1(
`SELECT allowed FROM bleeter_lifeinvader_permissions
WHERE char_id = ? AND permission = ? LIMIT 1`,
[charId, permission]
);
return !!(row && decodeBool(row.allowed));
}
module.exports = { feedPostRights, canPost, decodeBool, canProfileBeBlocked, hasLifeinvaderPermission };

281
web-backend/lib/queries.js Normal file
View file

@ -0,0 +1,281 @@
const db = require('../db');
const { decodeBool, canProfileBeBlocked } = require('./permissions');
// ── Kommentare eines Posts ────────────────────────────────────────────────
async function loadCommentsForPost(postId, viewerProfileId) {
const v = viewerProfileId || 0;
const rows = await db.q(
`SELECT c.id, c.body, c.created_at, c.author_profile_id,
p.handle AS author_handle,
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id) AS likes,
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='comment' AND l.target_id=c.id AND l.profile_id=?) AS liked_by_viewer
FROM bleeter_comments c
JOIN bleeter_profiles p ON p.id = c.author_profile_id
WHERE c.post_id = ? AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL
AND p.is_active = 1 AND p.is_locked = 0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY c.created_at ASC`,
[v, postId, v, v]
);
return rows.map(row => ({
id: row.id,
author_profile_id: row.author_profile_id,
author: row.author_handle,
body: row.body,
created_at: String(row.created_at || ''),
likes: Number(row.likes) || 0,
liked_by_viewer: decodeBool(row.liked_by_viewer),
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
}));
}
// ── Feed-Posts ────────────────────────────────────────────────────────────
async function loadPosts(viewerProfileId, feedType) {
const v = viewerProfileId || 0;
const params = [v, v, v];
let feedClause = '';
if (feedType === 'home' || feedType === 'advertising') {
feedClause = ' AND posts.feed_type = ?';
params.push(feedType);
}
const rows = await db.q(
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, posts.author_profile_id,
media.url AS media_url,
author.id AS author_id, author.handle AS author_handle, author.display_name AS author_name,
author.avatar_url AS author_avatar, author.is_verified AS author_verified,
author.is_lifeinvader_staff AS author_lifeinvader_staff,
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
EXISTS(SELECT 1 FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id AND l.profile_id=?) AS liked_by_viewer
FROM bleeter_posts posts
JOIN bleeter_profiles author ON author.id = posts.author_profile_id
LEFT JOIN bleeter_media media ON media.id = posts.media_id
WHERE posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
AND author.is_active = 1 AND author.is_locked = 0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=author.id)
OR (b.blocker_profile_id=author.id AND b.blocked_profile_id=?))
${feedClause}
ORDER BY posts.created_at DESC
LIMIT 80`,
params
);
const posts = [];
for (const row of rows) {
const comments = await loadCommentsForPost(row.id, viewerProfileId);
posts.push({
id: row.id,
feed_type: row.feed_type,
body: row.body || '',
media_url: row.media_url,
created_at: String(row.created_at || ''),
likes: Number(row.likes) || 0,
liked_by_viewer: decodeBool(row.liked_by_viewer),
can_delete: Number(row.author_profile_id) === Number(viewerProfileId),
comments: comments.length,
comments_list: comments,
author: {
id: row.author_id,
handle: row.author_handle,
display_name: row.author_name,
avatar_url: row.author_avatar || '',
is_verified: decodeBool(row.author_verified),
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
},
});
}
return posts;
}
// ── Profil-Verzeichnis (mit Follower-Zahlen) ──────────────────────────────
async function loadProfileDirectory(viewerProfileId, includeLocked) {
const v = viewerProfileId || 0;
const rows = await db.q(
`SELECT p.*, 0 AS can_post, 0 AS can_edit_profile,
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
FROM bleeter_profiles p
WHERE p.is_active = 1 AND (? = 1 OR p.is_locked = 0)
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY p.display_name
LIMIT 180`,
[includeLocked ? 1 : 0, v, v]
);
const { mapProfile } = require('./account');
return rows.map(mapProfile);
}
function mapSocialProfile(row) {
return {
id: row.id,
handle: row.handle,
display_name: row.display_name,
avatar_url: row.avatar_url || '',
profile_type: row.profile_type,
is_verified: decodeBool(row.is_verified),
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
can_be_blocked: canProfileBeBlocked(row.profile_type, row.is_lifeinvader_staff),
};
}
// ── Following / Followers / Blocked ───────────────────────────────────────
async function loadSocial(activeProfileId) {
const a = activeProfileId || 0;
const following = await db.q(
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.followed_profile_id
WHERE f.follower_profile_id=? AND p.is_active=1 AND p.is_locked=0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY p.display_name`,
[a, a, a]
);
const followers = await db.q(
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
FROM bleeter_follows f JOIN bleeter_profiles p ON p.id=f.follower_profile_id
WHERE f.followed_profile_id=? AND p.is_active=1 AND p.is_locked=0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY p.display_name`,
[a, a, a]
);
const blocked = await db.q(
`SELECT p.id,p.handle,p.display_name,p.avatar_url,p.profile_type,p.is_verified,p.is_lifeinvader_staff
FROM bleeter_blocks b JOIN bleeter_profiles p ON p.id=b.blocked_profile_id
WHERE b.blocker_profile_id=? ORDER BY p.display_name`,
[a]
);
return {
following: following.map(mapSocialProfile),
followers: followers.map(mapSocialProfile),
blocked: blocked.map(mapSocialProfile),
};
}
// ── Kalender (28 Tage) ────────────────────────────────────────────────────
const germanWeekdays = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
function dateKeyFromOffset(offset) {
const d = new Date();
d.setDate(d.getDate() + offset);
return d.toISOString().slice(0, 10);
}
function dateTitleFromKey(dateKey) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
if (!m) return dateKey;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12);
return `${germanWeekdays[d.getDay()]} ${m[3]}.${m[2]}.${m[1]}`;
}
async function loadCalendarDays(activeProfileId) {
const rows = await db.q(
`SELECT e.id, DATE_FORMAT(e.starts_at,'%Y-%m-%d') AS date_key,
DATE_FORMAT(e.starts_at,'%H:%i') AS time_text,
e.title, e.location, e.author_profile_id, p.handle AS author_handle
FROM bleeter_events e JOIN bleeter_profiles p ON p.id=e.author_profile_id
WHERE e.deleted_at IS NULL AND e.status='active'
AND e.starts_at >= CURDATE() AND e.starts_at < DATE_ADD(CURDATE(), INTERVAL 28 DAY)
AND p.is_active=1 AND p.is_locked=0
ORDER BY e.starts_at ASC, e.id ASC`
);
const byDate = {};
for (const row of rows) {
(byDate[row.date_key] = byDate[row.date_key] || []).push({
id: row.id,
time: row.time_text,
title: row.title,
location: row.location || '',
author: row.author_handle,
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
});
}
const days = [];
for (let offset = 0; offset < 28; offset++) {
const dateKey = dateKeyFromOffset(offset);
days.push({ offset, date: dateKey, title: dateTitleFromKey(dateKey), events: byDate[dateKey] || [] });
}
return days;
}
// ── Gewerbe (Open/Closed) ─────────────────────────────────────────────────
async function loadBusinesses(viewerProfileId) {
const v = viewerProfileId || 0;
const rows = await db.q(
`SELECT p.handle,p.display_name,p.avatar_url,p.banner_url,p.is_verified,p.is_lifeinvader_staff,
COALESCE(s.status,'closed') AS status
FROM bleeter_profiles p
LEFT JOIN bleeter_business_status s ON s.profile_id=p.id
WHERE p.profile_type IN ('small_business','company','authority')
AND p.is_active=1 AND p.is_locked=0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY FIELD(COALESCE(s.status,'closed'),'open','closed'), p.display_name
LIMIT 80`,
[v, v]
);
return rows.map(row => ({
handle: row.handle,
display_name: row.display_name,
avatar_url: row.avatar_url || '',
banner_url: row.banner_url || '',
status: row.status || 'closed',
is_verified: decodeBool(row.is_verified),
is_lifeinvader_staff: decodeBool(row.is_lifeinvader_staff),
}));
}
// ── Marktplatz ────────────────────────────────────────────────────────────
async function loadMarketplace(activeProfileId) {
const a = activeProfileId || 0;
const rows = await db.q(
`SELECT m.id,m.title,m.description,m.price_label,m.created_at,m.author_profile_id,
media.url AS media_url,
p.handle AS author_handle,p.display_name AS author_name,p.avatar_url AS author_avatar,
p.email_contact AS author_email,p.is_verified AS author_verified,
p.is_lifeinvader_staff AS author_lifeinvader_staff
FROM bleeter_marketplace m
JOIN bleeter_profiles p ON p.id=m.author_profile_id
LEFT JOIN bleeter_media media ON media.id=m.media_id
WHERE m.deleted_at IS NULL AND m.status='active' AND p.is_active=1 AND p.is_locked=0
AND NOT EXISTS (SELECT 1 FROM bleeter_blocks b
WHERE (b.blocker_profile_id=? AND b.blocked_profile_id=p.id)
OR (b.blocker_profile_id=p.id AND b.blocked_profile_id=?))
ORDER BY m.created_at DESC
LIMIT 100`,
[a, a]
);
return rows.map(row => {
const priceNumber = Number((String(row.price_label || '').match(/\d+/) || [0])[0]) || 0;
return {
id: row.id,
title: row.title,
body: row.description,
price: priceNumber,
price_label: row.price_label || '',
created_at: String(row.created_at || ''),
media_url: row.media_url,
can_delete: Number(row.author_profile_id) === Number(activeProfileId),
author: {
id: row.author_profile_id,
handle: row.author_handle,
display_name: row.author_name,
avatar_url: row.author_avatar || '',
email: row.author_email || '',
is_verified: decodeBool(row.author_verified),
is_lifeinvader_staff: decodeBool(row.author_lifeinvader_staff),
},
};
});
}
module.exports = {
loadCommentsForPost, loadPosts, loadProfileDirectory,
loadSocial, loadCalendarDays, loadBusinesses, loadMarketplace,
};

18
web-backend/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "zc-bleeter-web",
"version": "1.0.0",
"description": "Bleeter Web-Anwendung (Lifeinvader) Web-Backend fuer bleeter.naturalbornplayers.de",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.0"
}
}

565
web-backend/public/app.js Normal file
View file

@ -0,0 +1,565 @@
'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();

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bleeter · Lifeinvader</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app"></div>
<div id="toast-layer"></div>
<div id="modal-layer"></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,250 @@
:root {
--bg: #0f1117;
--bg-2: #141824;
--surface: rgba(26, 30, 44, 0.72);
--surface-solid: #1a1e2c;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.14);
--accent: #8b5cf6;
--accent-2: #a78bfa;
--accent-soft: rgba(139, 92, 246, 0.16);
--text: #e8eaf0;
--text-muted: #9aa0b0;
--text-dim: #6b7180;
--danger: #f87171;
--success: #34d399;
--radius: 16px;
--blur: blur(18px);
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: var(--font);
background:
radial-gradient(1100px 600px at 12% -8%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 500px at 100% 0%, rgba(99, 102, 241, 0.10), transparent 55%),
var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
input, textarea, select { font-family: inherit; }
img { max-width: 100%; }
/* ── Buttons ─────────────────────────────────────────── */
.btn {
border: 1px solid var(--border-strong);
background: rgba(255,255,255,0.04);
color: var(--text);
padding: 9px 16px;
border-radius: 999px;
font-weight: 600;
font-size: 0.9rem;
transition: 0.15s;
display: inline-flex; align-items: center; gap: 7px;
}
.btn:hover { background: rgba(255,255,255,0.09); }
.btn-primary { background: var(--accent); border-color: transparent; color: #fff; }
.btn-primary:hover { background: var(--accent-2); }
.btn-danger { color: var(--danger); border-color: rgba(248,113,113,0.35); }
.btn-danger:hover { background: rgba(248,113,113,0.12); }
.btn-sm { padding: 6px 12px; font-size: 0.82rem; }
.btn-ghost { border-color: transparent; background: transparent; color: var(--text-muted); }
.btn-ghost:hover { color: var(--text); background: rgba(255,255,255,0.05); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
input, textarea, select {
width: 100%;
background: rgba(0,0,0,0.28);
border: 1px solid var(--border);
color: var(--text);
padding: 11px 14px;
border-radius: 12px;
font-size: 0.92rem;
outline: none;
transition: 0.15s;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea { resize: vertical; min-height: 84px; line-height: 1.5; }
label.field { display: block; margin-bottom: 12px; }
label.field > span { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; font-weight: 500; }
.verified { color: var(--accent-2); }
.pill { display:inline-block; padding: 2px 9px; border-radius: 999px; font-size: 0.72rem; font-weight: 600; background: var(--accent-soft); color: var(--accent-2); }
.pill.staff { background: rgba(52,211,153,0.14); color: var(--success); }
.pill.open { background: rgba(52,211,153,0.15); color: var(--success); }
.pill.closed { background: rgba(248,113,113,0.14); color: var(--danger); }
.muted { color: var(--text-muted); }
.dim { color: var(--text-dim); }
/* ── Login ───────────────────────────────────────────── */
#login-wrap {
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
}
.login-card {
width: 100%; max-width: 400px;
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
border: 1px solid var(--border); border-radius: 22px; padding: 34px 30px;
box-shadow: 0 30px 80px rgba(0,0,0,0.45);
}
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.brand-logo { width: 46px; height: 46px; border-radius: 14px; display: grid; place-items: center; font-size: 1.5rem;
background: linear-gradient(135deg, var(--accent), #6366f1); box-shadow: 0 8px 24px rgba(139,92,246,0.4); }
.brand-name { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.02em; }
.brand-name small { display:block; font-size: 0.72rem; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; text-transform: uppercase; }
.login-card h2 { font-size: 1.05rem; margin: 22px 0 4px; }
.login-card p.sub { color: var(--text-muted); font-size: 0.86rem; margin-bottom: 20px; }
.login-error { background: rgba(248,113,113,0.12); border: 1px solid rgba(248,113,113,0.3); color: #fca5a5;
padding: 10px 14px; border-radius: 12px; font-size: 0.85rem; margin-bottom: 14px; }
.login-hint { margin-top: 20px; font-size: 0.78rem; color: var(--text-dim); line-height: 1.6; text-align: center; }
/* ── App layout ──────────────────────────────────────── */
#shell { display: grid; grid-template-columns: 264px minmax(0, 1fr); min-height: 100vh; max-width: 1180px; margin: 0 auto; }
#sidebar {
position: sticky; top: 0; align-self: start; height: 100vh;
padding: 22px 16px; display: flex; flex-direction: column; gap: 6px;
border-right: 1px solid var(--border);
}
.side-brand { display: flex; align-items: center; gap: 10px; padding: 6px 10px 18px; }
.side-brand .brand-logo { width: 38px; height: 38px; font-size: 1.25rem; }
.side-brand .brand-name { font-size: 1.25rem; }
.nav-item {
display: flex; align-items: center; gap: 13px; padding: 11px 14px; border-radius: 12px;
color: var(--text-muted); font-weight: 600; font-size: 0.95rem; transition: 0.15s; border: none; background: none; width: 100%; text-align: left;
}
.nav-item .ico { width: 22px; text-align: center; font-size: 1.05rem; }
.nav-item:hover { background: rgba(255,255,255,0.05); color: var(--text); }
.nav-item.active { background: var(--accent-soft); color: var(--accent-2); }
.nav-spacer { flex: 1; }
.profile-switch {
margin-top: 10px; border: 1px solid var(--border); border-radius: 14px; padding: 10px; background: rgba(0,0,0,0.2);
}
.profile-switch .cur { display: flex; align-items: center; gap: 10px; }
.avatar { border-radius: 50%; object-fit: cover; background: var(--accent-soft); flex-shrink: 0; display: grid; place-items: center; font-weight: 700; color: var(--accent-2); }
.avatar.sm { width: 34px; height: 34px; font-size: 0.85rem; }
.avatar.md { width: 46px; height: 46px; font-size: 1rem; }
.avatar.lg { width: 84px; height: 84px; font-size: 1.8rem; }
.profile-switch .meta { min-width: 0; }
.profile-switch .meta b { display: block; font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.profile-switch .meta span { font-size: 0.76rem; color: var(--text-muted); }
.profile-switch select { margin-top: 9px; padding: 8px 10px; font-size: 0.84rem; }
.logout { margin-top: 8px; width: 100%; justify-content: center; }
/* ── Main column ─────────────────────────────────────── */
#main { padding: 0 26px 80px; min-width: 0; }
.topbar {
position: sticky; top: 0; z-index: 5; padding: 20px 4px 14px; margin-bottom: 8px;
background: linear-gradient(var(--bg) 62%, transparent);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.topbar h1 { font-size: 1.35rem; font-weight: 800; letter-spacing: -0.02em; }
.topbar .sub { font-size: 0.82rem; color: var(--text-muted); margin-top: 2px; }
.card {
background: var(--surface); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
border: 1px solid var(--border); border-radius: var(--radius); padding: 18px;
margin-bottom: 16px;
}
.card.pad-lg { padding: 22px; }
/* Composer */
.composer textarea { border: none; background: transparent; padding: 6px 2px; min-height: 60px; font-size: 1.02rem; }
.composer textarea:focus { box-shadow: none; }
.composer .row { display: flex; align-items: center; gap: 10px; justify-content: space-between; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
.composer .tools { display: flex; align-items: center; gap: 8px; color: var(--text-muted); }
.char-count { font-size: 0.78rem; color: var(--text-dim); }
/* Post */
.post { display: flex; gap: 13px; }
.post .body { min-width: 0; flex: 1; }
.post .head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.post .head .name { font-weight: 700; }
.post .head .handle, .post .head .time { color: var(--text-muted); font-size: 0.85rem; }
.post .text { margin: 5px 0 10px; line-height: 1.55; white-space: pre-wrap; word-break: break-word; }
.post .media { border-radius: 14px; border: 1px solid var(--border); overflow: hidden; margin: 6px 0 10px; }
.post .media img { display: block; width: 100%; }
.actions { display: flex; gap: 22px; color: var(--text-muted); font-size: 0.86rem; }
.actions button { background: none; border: none; color: inherit; display: inline-flex; align-items: center; gap: 6px; padding: 4px; border-radius: 8px; transition: 0.15s; }
.actions button:hover { color: var(--text); }
.actions button.liked { color: var(--accent-2); }
.actions button.del:hover { color: var(--danger); }
.comments { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 11px; }
.comment { display: flex; gap: 10px; }
.comment .c-body { background: rgba(0,0,0,0.22); border: 1px solid var(--border); border-radius: 12px; padding: 8px 12px; flex: 1; min-width: 0; }
.comment .c-body .chead { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 2px; }
.comment .c-body .chead b { color: var(--text); }
.comment-form { display: flex; gap: 8px; margin-top: 4px; }
.comment-form input { border-radius: 999px; }
/* Grids */
.grid { display: grid; gap: 14px; }
.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
.person { display: flex; align-items: center; gap: 12px; }
.person .meta { min-width: 0; flex: 1; }
.person .meta b { display: flex; align-items: center; gap: 6px; }
.person .meta span { font-size: 0.8rem; color: var(--text-muted); }
.market-card .media { height: 150px; border-radius: 12px; overflow: hidden; border: 1px solid var(--border); margin-bottom: 12px; background: rgba(0,0,0,0.25); }
.market-card .media img { width: 100%; height: 100%; object-fit: cover; }
.market-card .price { color: var(--accent-2); font-weight: 700; }
.cal-day { }
.cal-day h4 { font-size: 0.9rem; margin-bottom: 8px; color: var(--text-muted); }
.event { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; margin-bottom: 8px; background: rgba(0,0,0,0.18); }
.event .time { font-weight: 700; color: var(--accent-2); min-width: 46px; }
.empty { text-align: center; color: var(--text-dim); padding: 48px 20px; }
.empty .big { font-size: 2.4rem; margin-bottom: 10px; opacity: 0.7; }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 8px 16px; border-radius: 999px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--text-muted); font-weight: 600; font-size: 0.86rem; }
.tab.active { background: var(--accent-soft); color: var(--accent-2); border-color: transparent; }
/* Profile header */
.profile-head .banner { height: 130px; border-radius: 14px; background: linear-gradient(135deg, rgba(139,92,246,0.35), rgba(99,102,241,0.25)); border: 1px solid var(--border); overflow: hidden; }
.profile-head .banner img { width: 100%; height: 100%; object-fit: cover; }
.profile-head .ptop { display: flex; align-items: flex-end; gap: 16px; margin-top: -42px; padding: 0 6px; }
.profile-head .ptop .avatar { border: 4px solid var(--surface-solid); }
.profile-head .pmeta { flex: 1; padding-bottom: 4px; }
.profile-head .pmeta h2 { font-size: 1.3rem; display: flex; align-items: center; gap: 8px; }
.stat-row { display: flex; gap: 22px; margin: 12px 4px; }
.stat-row .s b { font-size: 1.05rem; }
.stat-row .s span { color: var(--text-muted); font-size: 0.82rem; margin-left: 5px; }
/* Modal + toast */
#modal-layer { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 20px; z-index: 60; background: rgba(0,0,0,0.6); }
#modal-layer.show { display: flex; }
.modal { width: 100%; max-width: 480px; background: var(--surface-solid); border: 1px solid var(--border-strong); border-radius: 20px; padding: 24px; box-shadow: 0 30px 80px rgba(0,0,0,0.5); }
.modal h3 { font-size: 1.15rem; margin-bottom: 16px; }
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
#toast-layer { position: fixed; top: 20px; right: 20px; z-index: 80; display: flex; flex-direction: column; gap: 10px; }
.toast { background: var(--surface-solid); border: 1px solid var(--border-strong); border-left: 3px solid var(--accent); padding: 12px 18px; border-radius: 12px; font-size: 0.88rem; box-shadow: 0 12px 40px rgba(0,0,0,0.4); animation: slidein 0.2s ease; max-width: 320px; }
.toast.error { border-left-color: var(--danger); }
.toast.success { border-left-color: var(--success); }
@keyframes slidein { from { transform: translateX(20px); opacity: 0; } to { transform: none; opacity: 1; } }
.legal { line-height: 1.7; color: var(--text-muted); }
.legal h3 { color: var(--text); margin: 18px 0 8px; font-size: 1rem; }
.legal p { margin-bottom: 10px; }
/* Mobile */
#mobile-nav { display: none; }
@media (max-width: 860px) {
#shell { grid-template-columns: 1fr; }
#sidebar { display: none; }
#main { padding: 0 14px 90px; }
.grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; }
#mobile-nav { display: flex; position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
background: var(--surface); backdrop-filter: var(--blur); border-top: 1px solid var(--border); padding: 8px; justify-content: space-around; }
#mobile-nav button { background: none; border: none; color: var(--text-muted); font-size: 1.3rem; padding: 8px 12px; border-radius: 10px; }
#mobile-nav button.active { color: var(--accent-2); background: var(--accent-soft); }
}

View file

@ -0,0 +1,65 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { sign, requireAuth } = require('../auth');
const { getAccessibleProfiles } = require('../lib/account');
const { hasLifeinvaderPermission } = require('../lib/permissions');
const router = express.Router();
function publicAccount(account) {
return {
id: account.id,
char_id: account.char_id,
mail_address: account.mail_address,
phone_number: account.phone_number,
};
}
// Login mit Handle ODER Mail + ingame gesetztem Bleeter-Passwort
router.post('/login', async (req, res) => {
try {
const login = String((req.body && req.body.login) || '').trim().toLowerCase();
const password = String((req.body && req.body.password) || '');
if (!login || !password) return res.status(400).json({ error: 'missing_credentials' });
let account = null;
if (login.includes('@')) {
account = await db.q1('SELECT * FROM bleeter_accounts WHERE mail_address = ?', [login]);
} else {
const profile = await db.q1('SELECT account_id FROM bleeter_profiles WHERE handle = ? LIMIT 1', [login]);
if (profile && profile.account_id) {
account = await db.q1('SELECT * FROM bleeter_accounts WHERE id = ?', [profile.account_id]);
}
}
if (!account || !account.web_password_hash) {
return res.status(401).json({ error: 'invalid_login' });
}
if (account.status && account.status !== 'active') {
return res.status(403).json({ error: 'account_disabled' });
}
const ok = await bcrypt.compare(password, account.web_password_hash);
if (!ok) return res.status(401).json({ error: 'invalid_login' });
const token = sign(account);
const profiles = await getAccessibleProfiles(account);
res.json({ token, account: publicAccount(account), profiles });
} catch (err) {
console.error('[bleeter] login error', err);
res.status(500).json({ error: 'server_error' });
}
});
// Aktueller Account + zugaengliche Profile + Moderationsflag
router.get('/me', requireAuth, async (req, res) => {
const profiles = await getAccessibleProfiles(req.account);
const canModerate =
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.verify')) ||
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.staff')) ||
(await hasLifeinvaderPermission(db, req.account.char_id, 'profile.lock'));
res.json({ account: publicAccount(req.account), profiles, can_moderate: canModerate });
});
module.exports = router;

View file

@ -0,0 +1,42 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile } = require('../lib/account');
const { loadBusinesses } = require('../lib/queries');
const router = express.Router();
router.use(requireAuth);
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
async function acting(req) {
const profileId = (req.body && req.body.profileId) || req.query.profileId;
return resolveActingProfile(req.account, profileId);
}
// GET /api/businesses?profileId=
router.get('/businesses', async (req, res) => {
const { profile } = await acting(req);
res.json({ businesses: await loadBusinesses(profile ? profile.id : 0) });
});
// POST /api/business/status {profileId, status: open|closed} -> eigenes Gewerbe schalten
router.post('/business/status', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
if (!BUSINESS_TYPES.includes(profile.profile_type) || !profile.can_edit_profile) {
return res.status(403).json({ error: 'not_allowed' });
}
const status = req.body.status === 'open' ? 'open' : req.body.status === 'closed' ? 'closed' : null;
if (!status) return res.status(400).json({ error: 'invalid_status' });
await db.exec(
`INSERT INTO bleeter_business_status (profile_id, status, source)
VALUES (?, ?, 'web')
ON DUPLICATE KEY UPDATE status = VALUES(status), source = VALUES(source)`,
[profile.id, status]
);
res.json({ ok: true, status });
});
module.exports = router;

View file

@ -0,0 +1,57 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile } = require('../lib/account');
const { loadCalendarDays } = require('../lib/queries');
const router = express.Router();
router.use(requireAuth);
const BUSINESS_TYPES = ['small_business', 'company', 'authority'];
async function acting(req) {
const profileId = (req.body && req.body.profileId) || req.query.profileId;
return resolveActingProfile(req.account, profileId);
}
// GET /api/calendar?profileId=
router.get('/calendar', async (req, res) => {
const { profile } = await acting(req);
res.json({ days: await loadCalendarDays(profile ? profile.id : 0) });
});
// POST /api/events {profileId, date, time, title, location}
router.post('/events', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
if (!BUSINESS_TYPES.includes(profile.profile_type)) return res.status(403).json({ error: 'not_allowed' });
const date = String(req.body.date || '').trim().slice(0, 10);
const time = String(req.body.time || '').trim().slice(0, 5);
const title = String(req.body.title || '').trim().slice(0, 50);
const location = String(req.body.location || '').trim().slice(0, 50);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'invalid_date' });
if (!/^\d{2}:\d{2}$/.test(time)) return res.status(400).json({ error: 'invalid_time' });
if (!title) return res.status(400).json({ error: 'title_required' });
await db.insert(
'INSERT INTO bleeter_events (author_profile_id, title, location, starts_at) VALUES (?, ?, ?, ?)',
[profile.id, title, location || null, `${date} ${time}:00`]
);
res.json({ ok: true });
});
// DELETE /api/events/:id {profileId}
router.delete('/events/:id', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const eventId = Number(req.params.id);
const ev = await db.q1('SELECT author_profile_id FROM bleeter_events WHERE id = ? AND deleted_at IS NULL', [eventId]);
if (!ev) return res.status(404).json({ error: 'not_found' });
if (Number(ev.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
await db.exec("UPDATE bleeter_events SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [eventId]);
res.json({ ok: true });
});
module.exports = router;

126
web-backend/routes/feed.js Normal file
View file

@ -0,0 +1,126 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile } = require('../lib/account');
const { loadPosts, loadCommentsForPost } = require('../lib/queries');
const { canPost } = require('../lib/permissions');
const { isAllowedExternalUrl } = require('../lib/media');
const router = express.Router();
router.use(requireAuth);
// Helfer: aktives Profil aufloesen (profileId aus body/query)
async function acting(req) {
const profileId = (req.body && req.body.profileId) || req.query.profileId;
return resolveActingProfile(req.account, profileId);
}
// GET /api/feed?type=home|advertising&profileId=
router.get('/feed', async (req, res) => {
const { profile } = await acting(req);
const type = req.query.type === 'advertising' ? 'advertising'
: req.query.type === 'home' ? 'home' : null;
const posts = await loadPosts(profile ? profile.id : 0, type);
res.json({ posts });
});
// POST /api/posts {profileId, feedType, body, mediaUrl}
router.post('/posts', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const feedType = String(req.body.feedType || '').trim();
const body = String(req.body.body || '').trim().slice(0, 2000);
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
if (!body && !mediaUrl) return res.status(400).json({ error: 'empty_post' });
if (feedType !== 'home' && feedType !== 'advertising') return res.status(400).json({ error: 'invalid_feed' });
if (!canPost(profile.profile_type, feedType)) return res.status(403).json({ error: 'not_allowed_here' });
let mediaId = null;
if (mediaUrl) {
const check = isAllowedExternalUrl(mediaUrl);
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
mediaId = await db.insert(
'INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)',
[profile.id, 'external_url', mediaUrl]
);
}
await db.insert(
'INSERT INTO bleeter_posts (author_profile_id, feed_type, body, media_id) VALUES (?, ?, ?, ?)',
[profile.id, feedType, body, mediaId]
);
res.json({ ok: true });
});
// DELETE /api/posts/:id {profileId}
router.delete('/posts/:id', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const postId = Number(req.params.id);
const post = await db.q1('SELECT author_profile_id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [postId]);
if (!post) return res.status(404).json({ error: 'not_found' });
if (Number(post.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
await db.exec('UPDATE bleeter_posts SET self_deleted_at = NOW() WHERE id = ?', [postId]);
res.json({ ok: true });
});
// POST /api/posts/:id/like {profileId} -> toggle
router.post('/posts/:id/like', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
await toggleLike(profile.id, 'post', Number(req.params.id));
res.json({ ok: true });
});
// POST /api/posts/:id/comments {profileId, body}
router.post('/posts/:id/comments', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const postId = Number(req.params.id);
const body = String(req.body.body || '').trim().slice(0, 500);
if (!body) return res.status(400).json({ error: 'empty_comment' });
const post = await db.q1('SELECT id FROM bleeter_posts WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL AND hidden_at IS NULL', [postId]);
if (!post) return res.status(404).json({ error: 'not_found' });
await db.insert('INSERT INTO bleeter_comments (post_id, author_profile_id, body) VALUES (?, ?, ?)', [postId, profile.id, body]);
const comments = await loadCommentsForPost(postId, profile.id);
res.json({ ok: true, comments });
});
// DELETE /api/comments/:id {profileId}
router.delete('/comments/:id', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const commentId = Number(req.params.id);
const c = await db.q1('SELECT author_profile_id, post_id FROM bleeter_comments WHERE id = ? AND deleted_at IS NULL AND self_deleted_at IS NULL', [commentId]);
if (!c) return res.status(404).json({ error: 'not_found' });
if (Number(c.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
await db.exec('UPDATE bleeter_comments SET self_deleted_at = NOW() WHERE id = ?', [commentId]);
res.json({ ok: true });
});
// POST /api/comments/:id/like {profileId} -> toggle
router.post('/comments/:id/like', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
await toggleLike(profile.id, 'comment', Number(req.params.id));
res.json({ ok: true });
});
async function toggleLike(profileId, targetType, targetId) {
const existing = await db.q1(
'SELECT id FROM bleeter_likes WHERE profile_id = ? AND target_type = ? AND target_id = ?',
[profileId, targetType, targetId]
);
if (existing) {
await db.exec('DELETE FROM bleeter_likes WHERE id = ?', [existing.id]);
} else {
await db.exec(
'INSERT IGNORE INTO bleeter_likes (profile_id, target_type, target_id) VALUES (?, ?, ?)',
[profileId, targetType, targetId]
);
}
}
module.exports = router;

View file

@ -0,0 +1,41 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
require('dotenv').config();
const router = express.Router();
// Nur vom FiveM-Server erreichbar (interner Key)
function requireInternal(req, res, next) {
const key = req.headers['x-internal-key'];
if (!key || key !== process.env.INTERNAL_API_KEY) {
return res.status(403).json({ error: 'forbidden' });
}
next();
}
// POST /internal/set-password {charId, password}
// Wird ingame vom Spieler ausgeloest; Passwort wird hier gehasht gespeichert.
router.post('/set-password', requireInternal, async (req, res) => {
try {
const charId = String((req.body && req.body.charId) || '').trim();
const password = String((req.body && req.body.password) || '');
if (!charId) return res.status(400).json({ error: 'missing_char' });
if (password.length < 6) return res.status(400).json({ error: 'password_too_short' });
const account = await db.q1('SELECT id FROM bleeter_accounts WHERE char_id = ?', [charId]);
if (!account) return res.status(404).json({ error: 'no_account' });
const hash = await bcrypt.hash(password, 10);
await db.exec(
'UPDATE bleeter_accounts SET web_password_hash = ?, web_password_updated_at = NOW() WHERE id = ?',
[hash, account.id]
);
res.json({ ok: true });
} catch (err) {
console.error('[bleeter] set-password error', err.message);
res.status(500).json({ error: 'server_error' });
}
});
module.exports = router;

View file

@ -0,0 +1,63 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile } = require('../lib/account');
const { loadMarketplace } = require('../lib/queries');
const { isAllowedExternalUrl } = require('../lib/media');
const router = express.Router();
router.use(requireAuth);
async function acting(req) {
const profileId = (req.body && req.body.profileId) || req.query.profileId;
return resolveActingProfile(req.account, profileId);
}
// GET /api/marketplace?profileId=
router.get('/marketplace', async (req, res) => {
const { profile } = await acting(req);
res.json({ items: await loadMarketplace(profile ? profile.id : 0) });
});
// POST /api/marketplace {profileId, title, description, priceLabel, mediaUrl}
router.post('/marketplace', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
let title = String(req.body.title || '').trim().slice(0, 120);
let description = String(req.body.description || '').trim().slice(0, 2000);
const priceLabel = String(req.body.priceLabel || '').trim().slice(0, 80);
const mediaUrl = String(req.body.mediaUrl || '').trim().slice(0, 500);
if (!title && !description) return res.status(400).json({ error: 'empty' });
if (!description) description = title;
if (!title) title = description.slice(0, 120);
let mediaId = null;
if (mediaUrl) {
const check = isAllowedExternalUrl(mediaUrl);
if (!check.ok) return res.status(400).json({ error: 'media_rejected', reason: check.reason });
mediaId = await db.insert('INSERT INTO bleeter_media (owner_profile_id, source_type, url) VALUES (?, ?, ?)', [profile.id, 'external_url', mediaUrl]);
}
await db.insert(
`INSERT INTO bleeter_marketplace (author_profile_id, category, title, description, price_label, media_id)
VALUES (?, 'general', ?, ?, ?, ?)`,
[profile.id, title, description, priceLabel, mediaId]
);
res.json({ ok: true });
});
// DELETE /api/marketplace/:id {profileId}
router.delete('/marketplace/:id', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
const entryId = Number(req.params.id);
const entry = await db.q1('SELECT author_profile_id FROM bleeter_marketplace WHERE id = ? AND deleted_at IS NULL', [entryId]);
if (!entry) return res.status(404).json({ error: 'not_found' });
if (Number(entry.author_profile_id) !== Number(profile.id)) return res.status(403).json({ error: 'not_owner' });
await db.exec("UPDATE bleeter_marketplace SET deleted_at = NOW(), status = 'deleted' WHERE id = ?", [entryId]);
res.json({ ok: true });
});
module.exports = router;

View file

@ -0,0 +1,40 @@
const express = require('express');
const multer = require('multer');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile } = require('../lib/account');
const { uploadToImgbb, ALLOWED_EXT } = require('../lib/media');
const router = express.Router();
router.use(requireAuth);
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB (Config.Media.maxBytes)
});
// POST /api/media/upload (multipart: image) {profileId}
router.post('/media/upload', upload.single('image'), async (req, res) => {
try {
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
if (!profile) return res.status(400).json({ error: 'no_profile' });
if (!req.file) return res.status(400).json({ error: 'no_file' });
const ext = (req.file.originalname.split('.').pop() || '').toLowerCase();
const mimeOk = /^image\/(jpe?g|png)$/i.test(req.file.mimetype || '');
if (!ALLOWED_EXT.includes(ext) && !mimeOk) return res.status(400).json({ error: 'unsupported_type' });
const { url } = await uploadToImgbb(req.file.buffer, req.file.originalname);
await db.insert(
`INSERT INTO bleeter_media (owner_profile_id, source_type, url, original_name, mime_type, size_bytes)
VALUES (?, 'upload', ?, ?, ?, ?)`,
[profile.id, url, req.file.originalname, req.file.mimetype, req.file.size]
);
res.json({ ok: true, url });
} catch (err) {
console.error('[bleeter] media upload error', err.message);
res.status(500).json({ error: 'upload_failed' });
}
});
module.exports = router;

View file

@ -0,0 +1,62 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile, findTargetProfile } = require('../lib/account');
const { hasLifeinvaderPermission, decodeBool } = require('../lib/permissions');
const router = express.Router();
router.use(requireAuth);
const PERMISSION_BY_ACTION = {
verify: 'profile.verify',
staff: 'profile.staff',
lock: 'profile.lock',
};
async function writeAudit(account, actorProfile, action, targetType, targetId, reason, payload) {
await db.insert(
`INSERT INTO bleeter_audit_logs (actor_char_id, actor_profile_id, action, target_type, target_id, reason, payload)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[account.char_id, actorProfile ? actorProfile.id : null, action, targetType, targetId, reason || null,
payload ? JSON.stringify(payload) : null]
);
}
// POST /api/moderation/profile {profileId, action: verify|staff|lock, targetId}
router.post('/moderation/profile', async (req, res) => {
const { profile } = await resolveActingProfile(req.account, req.body.profileId);
const action = String(req.body.action || '').trim();
const target = await findTargetProfile({ targetId: req.body.targetId }, true);
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
const permission = PERMISSION_BY_ACTION[action];
if (!permission || !(await hasLifeinvaderPermission(db, req.account.char_id, permission))) {
return res.status(403).json({ error: 'no_lifeinvader_rights' });
}
if (action === 'lock' && Number(profile.id) === Number(target.id)) {
return res.status(400).json({ error: 'cannot_lock_self' });
}
if (action === 'verify') {
const next = !decodeBool(target.is_verified);
await db.exec('UPDATE bleeter_profiles SET is_verified = ? WHERE id = ?', [next ? 1 : 0, target.id]);
await writeAudit(req.account, profile, next ? 'profile.verify' : 'profile.unverify', 'profile', target.id, null, { handle: target.handle, is_verified: next });
return res.json({ ok: true, is_verified: next });
}
if (action === 'staff') {
const next = !decodeBool(target.is_lifeinvader_staff);
await db.exec('UPDATE bleeter_profiles SET is_lifeinvader_staff = ? WHERE id = ?', [next ? 1 : 0, target.id]);
await writeAudit(req.account, profile, next ? 'profile.staff.add' : 'profile.staff.remove', 'profile', target.id, null, { handle: target.handle, is_lifeinvader_staff: next });
return res.json({ ok: true, is_lifeinvader_staff: next });
}
if (action === 'lock') {
const next = !decodeBool(target.is_locked);
await db.exec('UPDATE bleeter_profiles SET is_locked = ?, locked_reason = ? WHERE id = ?',
[next ? 1 : 0, next ? 'Lifeinvader moderation' : null, target.id]);
await writeAudit(req.account, profile, next ? 'profile.lock' : 'profile.unlock', 'profile', target.id, null, { handle: target.handle, is_locked: next });
return res.json({ ok: true, is_locked: next });
}
res.status(400).json({ error: 'unknown_action' });
});
module.exports = router;

View file

@ -0,0 +1,174 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../auth');
const { resolveActingProfile, findTargetProfile, mapProfile, getAccessibleProfiles } = require('../lib/account');
const { loadProfileDirectory, loadSocial } = require('../lib/queries');
const { canProfileBeBlocked, decodeBool } = require('../lib/permissions');
const router = express.Router();
router.use(requireAuth);
const HANDLE_RE = /^[a-z0-9._-]+$/;
async function acting(req) {
const profileId = (req.body && req.body.profileId) || req.query.profileId;
return resolveActingProfile(req.account, profileId);
}
// GET /api/directory?profileId=
router.get('/directory', async (req, res) => {
const { profile } = await acting(req);
const profiles = await loadProfileDirectory(profile ? profile.id : 0, false);
res.json({ profiles });
});
// GET /api/social?profileId=
router.get('/social', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.json({ following: [], followers: [], blocked: [] });
res.json(await loadSocial(profile.id));
});
// GET /api/profiles/:handle?profileId= -> Profil + eigene Posts
router.get('/profiles/:handle', async (req, res) => {
const { profile: viewer } = await acting(req);
const viewerId = viewer ? viewer.id : 0;
const handle = String(req.params.handle || '').toLowerCase();
const row = await db.q1(
`SELECT p.*,
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.followed_profile_id=p.id) AS followers_count,
(SELECT COUNT(*) FROM bleeter_follows f WHERE f.follower_profile_id=p.id) AS following_count
FROM bleeter_profiles p WHERE p.handle = ? AND p.is_active = 1`,
[handle]
);
if (!row) return res.status(404).json({ error: 'not_found' });
const target = mapProfile(row);
const isFollowing = viewerId ? !!(await db.q1(
'SELECT 1 FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ? LIMIT 1',
[viewerId, target.id]
)) : false;
const isBlocked = viewerId ? !!(await db.q1(
'SELECT 1 FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ? LIMIT 1',
[viewerId, target.id]
)) : false;
const posts = await db.q(
`SELECT posts.id, posts.feed_type, posts.body, posts.created_at, media.url AS media_url,
(SELECT COUNT(*) FROM bleeter_likes l WHERE l.target_type='post' AND l.target_id=posts.id) AS likes,
(SELECT COUNT(*) FROM bleeter_comments c WHERE c.post_id=posts.id AND c.self_deleted_at IS NULL AND c.hidden_at IS NULL AND c.deleted_at IS NULL) AS comments
FROM bleeter_posts posts
LEFT JOIN bleeter_media media ON media.id = posts.media_id
WHERE posts.author_profile_id = ? AND posts.self_deleted_at IS NULL AND posts.hidden_at IS NULL AND posts.deleted_at IS NULL
ORDER BY posts.created_at DESC LIMIT 50`,
[target.id]
);
res.json({
profile: target,
is_following: isFollowing,
is_blocked: isBlocked,
posts: posts.map(p => ({
id: p.id, feed_type: p.feed_type, body: p.body || '', media_url: p.media_url,
created_at: String(p.created_at || ''), likes: Number(p.likes) || 0, comments: Number(p.comments) || 0,
})),
});
});
// POST /api/profiles/:id/follow {profileId}
router.post('/profiles/:id/follow', async (req, res) => {
const { profile } = await acting(req);
const target = await findTargetProfile({ targetId: req.params.id });
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_follow' });
await db.exec('INSERT IGNORE INTO bleeter_follows (follower_profile_id, followed_profile_id) VALUES (?, ?)', [profile.id, target.id]);
res.json({ ok: true });
});
// POST /api/profiles/:id/unfollow {profileId}
router.post('/profiles/:id/unfollow', async (req, res) => {
const { profile } = await acting(req);
const target = await findTargetProfile({ targetId: req.params.id });
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
await db.exec('DELETE FROM bleeter_follows WHERE follower_profile_id = ? AND followed_profile_id = ?', [profile.id, target.id]);
res.json({ ok: true });
});
// POST /api/profiles/:id/block {profileId}
router.post('/profiles/:id/block', async (req, res) => {
const { profile } = await acting(req);
const target = await findTargetProfile({ targetId: req.params.id });
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
if (Number(profile.id) === Number(target.id)) return res.status(400).json({ error: 'self_block' });
if (!canProfileBeBlocked(target.profile_type, target.is_lifeinvader_staff)) return res.status(403).json({ error: 'cannot_block' });
await db.exec('INSERT IGNORE INTO bleeter_blocks (blocker_profile_id, blocked_profile_id) VALUES (?, ?)', [profile.id, target.id]);
await db.exec(
`DELETE FROM bleeter_follows
WHERE (follower_profile_id=? AND followed_profile_id=?) OR (follower_profile_id=? AND followed_profile_id=?)`,
[profile.id, target.id, target.id, profile.id]
);
res.json({ ok: true });
});
// POST /api/profiles/:id/unblock {profileId}
router.post('/profiles/:id/unblock', async (req, res) => {
const { profile } = await acting(req);
const target = await findTargetProfile({ targetId: req.params.id });
if (!profile || !target) return res.status(400).json({ error: 'invalid' });
await db.exec('DELETE FROM bleeter_blocks WHERE blocker_profile_id = ? AND blocked_profile_id = ?', [profile.id, target.id]);
res.json({ ok: true });
});
// PATCH /api/profile {profileId, displayName, avatarUrl, bannerUrl, bio} -> aktives Profil bearbeiten
router.patch('/profile', async (req, res) => {
const { profile } = await acting(req);
if (!profile) return res.status(400).json({ error: 'no_profile' });
if (!profile.can_edit_profile) return res.status(403).json({ error: 'not_allowed' });
const displayName = String(req.body.displayName || '').trim().slice(0, 80);
const avatarUrl = String(req.body.avatarUrl || '').trim().slice(0, 255);
const bannerUrl = String(req.body.bannerUrl || '').trim().slice(0, 255);
const bioMax = profile.profile_type === 'private' ? 200 : 500;
const bio = String(req.body.bio || '').trim().slice(0, bioMax);
if (!displayName) return res.status(400).json({ error: 'name_required' });
await db.exec(
'UPDATE bleeter_profiles SET display_name = ?, avatar_url = ?, banner_url = ?, bio = ? WHERE id = ?',
[displayName, avatarUrl || null, bannerUrl || null, bio, profile.id]
);
res.json({ ok: true });
});
// POST /api/register {handle, displayName} -> privates Profil anlegen (falls noch keins)
router.post('/register', async (req, res) => {
const account = req.account;
let handle = String(req.body.handle || '').trim().toLowerCase();
let displayName = String(req.body.displayName || '').trim().slice(0, 80);
if (handle.length < 2 || handle.length > 32) return res.status(400).json({ error: 'invalid_handle_length' });
if (!HANDLE_RE.test(handle)) return res.status(400).json({ error: 'invalid_handle_chars' });
if (!displayName) displayName = handle;
const existingPrivate = await db.q1(
"SELECT id FROM bleeter_profiles WHERE account_id = ? AND profile_type = 'private' LIMIT 1",
[account.id]
);
if (existingPrivate) return res.status(409).json({ error: 'already_registered' });
const reserved = await db.q1('SELECT 1 FROM bleeter_reserved_handles WHERE handle = ? LIMIT 1', [handle]);
if (reserved) return res.status(409).json({ error: 'reserved' });
const taken = await db.q1('SELECT 1 FROM bleeter_profiles WHERE handle = ? LIMIT 1', [handle]);
if (taken) return res.status(409).json({ error: 'handle_taken' });
await db.insert(
`INSERT INTO bleeter_profiles (account_id, profile_type, handle, display_name, email_contact, bio)
VALUES (?, 'private', ?, ?, ?, '')`,
[account.id, handle, displayName, account.mail_address]
);
const profiles = await getAccessibleProfiles(account);
res.json({ ok: true, profiles });
});
module.exports = router;

38
web-backend/server.js Normal file
View file

@ -0,0 +1,38 @@
require('dotenv').config();
const path = require('path');
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json({ limit: '1mb' }));
// Health
app.get('/api/health', (req, res) => res.json({ ok: true, service: 'zc-bleeter-web' }));
// Interner Endpunkt (FiveM-Server)
app.use('/internal', require('./routes/internal'));
// Auth
app.use('/api/auth', require('./routes/auth'));
// API
app.use('/api', require('./routes/feed'));
app.use('/api', require('./routes/profiles'));
app.use('/api', require('./routes/marketplace'));
app.use('/api', require('./routes/events'));
app.use('/api', require('./routes/business'));
app.use('/api', require('./routes/moderation'));
app.use('/api', require('./routes/media'));
// Statisches Frontend + SPA-Fallback
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir));
app.get(/^(?!\/api|\/internal).*/, (req, res) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
const PORT = Number(process.env.PORT || 4091);
app.listen(PORT, '127.0.0.1', () => {
console.log(`[zc-bleeter-web] Backend laeuft auf 127.0.0.1:${PORT}`);
});