commit 20076b0aee0bcb4d48fd59efdfbb14a71612248c Author: Bjoern Flessing Date: Sun Aug 9 12:00:48 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f22c9e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Abhaengigkeiten +node_modules/ +package-lock.json + +# Zugangsdaten – gehoeren nie ins Repository +.env +.env.* +!.env.example + +# Editor und Betriebssystem +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db + +# Protokolle +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..02b06e5 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# bleeter — soziales Netz im Spiel + +Feed, Werbeanzeigen, Marktplatz, Veranstaltungskalender und Gewerbeverzeichnis. +Jeder Spieler wählt sich ein Handle mit `@`, Unternehmen und Behörden bekommen +eigene Profile, für die sie Mitarbeiter freigeben. + +Zwei Oberflächen, dieselben Daten: + +- **`/bleeter`** — eigenes Fenster im Tablet-Format +- **App im IC-Computer** (`pc-live`) — dieselbe Gestaltung, in ein Fenster gesetzt + +--- + +## Installation + +### 1. Ordner + +``` +resources/[haleoe]/bleeter/ +``` + +### 2. Datenbank + +Reihenfolge einhalten: + +```bash +mysql -u BENUTZER -p DATENBANK < sql/install.sql +mysql -u BENUTZER -p DATENBANK < sql/migration_reservierte_handles.sql +mysql -u BENUTZER -p DATENBANK < sql/migration_fehlende_spalten.sql +``` + +| Datei | | +|---|---| +| `install.sql` | Konten, Profile, Mitglieder, Beiträge, Kommentare, Medien, Markt, Termine | +| `migration_reservierte_handles.sql` | Sperrliste für Handles – **fehlte im Grundschema**, ohne sie scheitert jede Registrierung | +| `migration_fehlende_spalten.sql` | sechs Spalten, die der Code schreibt, die aber nicht angelegt wurden | + +Alle wiederholt ausführbar. + +### 3. server.cfg + +``` +ensure oxmysql +ensure ic-mail # optional, liefert die Mailadressen +ensure bleeter +``` + +### 4. Bilder hochladen einrichten — **wird gebraucht** + +Ohne diesen Schritt lässt sich **kein Bild hochladen**. Beiträge, Profilbilder +und Marktanzeigen bleiben dann auf verlinkte Adressen angewiesen — und die +sind unzuverlässig, weil Bilderdienste das Verlinken zeitweise sperren. + +Bilder gehen zu **imgbb.com**. Der Schlüssel ist kostenlos: + +1. Konto anlegen auf https://imgbb.com +2. Schlüssel holen unter https://api.imgbb.com +3. In die **`server.cfg`** eintragen, **vor** dem `ensure`: + +``` +set bleeter_imgbb_key "DEIN_SCHLUESSEL" + +ensure bleeter +``` + +`set` statt `setr` — so bleibt der Schlüssel auf dem Server und geht nicht an +die Clients. + +> **Warum nicht in die `config.lua`?** +> Die liegt in der Versionsverwaltung. Ein Schlüssel im Code landet damit in +> jeder Kopie und in der gesamten Historie. `Config.Media.imgbbApiKey` gibt es +> weiterhin als Rückfallebene — sinnvoll für eine Testumgebung, nicht für den +> Betrieb. + +Fehlt der Schlüssel, meldet der Upload `missing_imgbb_key`. + +Weitere Stellschrauben in `shared/config.lua`: + +```lua +Config.Media = { + provider = 'imgbb', + maxBytes = 2 * 1024 * 1024, -- 2 MB + allowedExtensions = { 'jpg', 'jpeg', 'png' }, + allowExternalUrls = true, -- Verlinken zusätzlich erlauben + requireHttps = true, +} +``` + +### 5. App im IC-Computer (optional) + +Diese Dateien gehören nach `pc-live`: + +| Datei | Ziel | +|---|---| +| `client/bleeter.lua` | `pc-live/client/` | +| `nui/js/apps/bleeter.js` | `pc-live/nui/js/apps/` | +| `nui/js/apps/bleeteradmin.js` | `pc-live/nui/js/apps/` | +| `nui/css/bleeter-embedded.css` | `pc-live/nui/css/` | + +Dazu in pc-live eintragen: beide Skripte in `fxmanifest.lua` und +`nui/index.html`, die Stildatei in `fxmanifest.lua` und im ``, die +App-Einträge in `desktop.js` (`APP_META` und die `case`-Zweige +`bleeter_data`, `bleeter_notify`, `bleeter_uploaded`) sowie +`AppRegistry.Register` in `server/apps.lua`. + +**Zur Stildatei:** `nui/css/bleeter-embedded.css` wird **erzeugt**, nicht von +Hand gepflegt. Sie ist die gekapselte Fassung von `html/style.css` — jeder +Selektor auf `.bl-root` begrenzt. Ohne diese Kapselung würden Bleeters Regeln +für `*`, `html` und `body` den gesamten Computer einfärben. Neu erzeugen: + +```bash +python3 pc-live/nui/css/erzeuge-bleeter-css.py +``` + +Vom Wurzelelement werden dabei nur Farben, Schrift und Variablen übernommen — +**kein Layout**. Im Original ist `.bleeter-root` ein bildschirmfüllendes +Overlay; übernähme man das, läge die App über dem ganzen Computer, auch über +der Titelleiste. + +--- + +## Die drei Ebenen + +| | wer | darf | +|---|---|---| +| **Anbieter** | am Computer als `admin@liveinvader.ls` angemeldet | Unternehmensprofile einrichten, verwalten, moderieren | +| **Unternehmen** | wer im Profil `can_manage_members` hat | eigene Leute für das Profil freigeben | +| **Spieler** | jeder | eigenes Handle mit `@` wählen | + +Die Anbieterrechte kommen aus der Anmeldung in **`ic-web`** — genau eine Stelle +für alle Verwaltungsrechte. Ein zweites Rechtesystem daneben wäre eine zweite +Stelle, an der man jemanden zu entziehen vergisst. + +Die Tabelle `bleeter_lifeinvader_permissions` bleibt als zweiter Weg bestehen — +der Notausgang, falls `ic-web` nicht läuft. + +Bewusst getrennt: Wer für ein Unternehmen **schreiben** darf, kann sich nicht +selbst zum **Verwalter** machen. + +### Ohne Computer: Konsolenbefehle + +| Befehl | | +|---|---| +| `bleeterprofile` | alle Unternehmensprofile auflisten | +| `bleeterprofil [server-id]` | Profil anlegen; Arten: `small_business`, `company`, `authority`, `lifeinvader` | +| `bleeterfrei [post\|edit\|manage\|weg]` | jemanden freigeben oder entfernen | + +Im Spiel nur als Anbieter, in der Serverkonsole immer. + +--- + +## Handles + +Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich; 2 bis 32 Zeichen. + +`bleeter_reserved_handles` verhindert, dass Spieler sich Namen wie `lspd` oder +`weazel` greifen. **Der Anbieter darf sie sehr wohl vergeben** — dafür ist er +da. Die Sperrliste gilt deshalb nur bei der Selbstregistrierung, nicht in der +Verwaltung. + +--- + +## Voraussetzungen + +| | | +|---|---| +| `oxmysql` | Datenbankzugriff | +| `ic-mail` | Mailadresse als Ausweis bei der Registrierung | +| `es_extended` | Charaktername und Identifier | +| `ic-web` | optional – liefert die Anbieteranmeldung | +| `pc-live` | optional – für die App im Computer | + +Läuft ohne Charaktersystem: fehlt eines, wird der Lizenz-Identifier als +Charakterkennung benutzt. + +--- + +## Web-Backend + +Unter `web-backend/` liegt ein Node-Dienst, der dieselben Daten über HTTP +bereitstellt — für eine Webseite außerhalb des Spiels. + +```bash +cd web-backend +npm install +cp .env.example .env # ausfüllen +node server.js +``` + +Die `.env` enthält Datenbankzugang, JWT-Geheimnis und API-Schlüssel und ist +deshalb von der Versionsverwaltung ausgenommen. + +--- + +## Bekannte Fallstricke + +**Bilder aus fremden Quellen bleiben leer.** Bilderdienste sperren das +Verlinken zeitweise. Alle `` laden mit `referrerpolicy="no-referrer"`, +was bei referer-basierten Sperren hilft — gegen eine IP-Sperre nicht. Der +verlässliche Weg ist der Upload. + +**Nicht die Adresse der Seite kopieren, sondern die des Bildes.** +`imgur.com/a/…` ist ein Album, `imgur.com/…` eine Bildseite. Die App rechnet +Bildseiten selbst um und holt die Adresse auch aus BBCode-, HTML- und +Markdown-Schnipseln heraus. + +**Der Fokus gehört dem Computer.** `SetNuiFocus` gilt global für den Client. +Als App im Computer greift Bleeter deshalb nicht danach — sonst sperren sich +zwei Resources gegenseitig die Maus aus. diff --git a/client/main.lua b/client/main.lua new file mode 100644 index 0000000..9f1fbad --- /dev/null +++ b/client/main.lua @@ -0,0 +1,193 @@ +local isOpen = false +local _openMode = 'phone' -- 'phone' | 'desktop' + +local function nui(action, payload) + payload = payload or {} + payload.action = action + SendNUIMessage(payload) +end + +local function setOpen(nextOpen, mode) + isOpen = nextOpen == true + if isOpen and mode then _openMode = mode end + SetNuiFocus(isOpen, isOpen) + if isOpen then + nui('open', { mode = _openMode }) + TriggerServerEvent('bleeter:server:requestBootstrap') + else + nui('close') + end +end + +-- Kein PC-Modus mehr. +-- +-- Bleeter im PC lief zunaechst als iframe, dessen Nachrichten hierher und +-- wieder zurueck gereicht wurden. Das ging schief, weil SetNuiFocus global +-- gilt: zwei Resourcen, die den Fokus beanspruchen, sperren die Maus aus. +-- +-- pc-live bringt jetzt eine eigene Oberflaeche mit und spricht direkt mit +-- bleeter:server:*. Diese Resource kuemmert sich nur noch um ihr eigenes +-- Fenster – so wie jede andere auch. + +CreateThread(function() + while true do + if isOpen then + DisableAllControlActions(0) + Wait(0) + else + Wait(250) + end + end +end) + +RegisterNetEvent('bleeter:client:open', function(mode) + setOpen(true, mode or 'phone') +end) + +RegisterNetEvent('bleeter:client:close', function() + setOpen(false) +end) + +RegisterNetEvent('bleeter:client:data', function(payload) + nui('data', payload or {}) +end) + +RegisterNetEvent('bleeter:client:notify', function(payload) + nui('notify', payload or {}) +end) + +RegisterNetEvent('bleeter:client:mediaUploaded', function(payload) + nui('mediaUploaded', payload or {}) +end) + +RegisterNUICallback('close', function(_, cb) + setOpen(false) + cb({ ok = true }) +end) + +RegisterNUICallback('checkHandle', function(payload, cb) + TriggerServerEvent('bleeter:server:checkHandle', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('registerAccount', function(payload, cb) + TriggerServerEvent('bleeter:server:registerAccount', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('requestBootstrap', function(_, cb) + TriggerServerEvent('bleeter:server:requestBootstrap') + cb({ ok = true }) +end) + +RegisterNUICallback('setActiveProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:setActiveProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('followProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:followProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('unfollowProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:unfollowProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('blockProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:blockProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('unblockProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:unblockProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('moderateProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:moderateProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('updateProfile', function(payload, cb) + TriggerServerEvent('bleeter:server:updateProfile', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('openMailTo', function(payload, cb) + setOpen(false) + TriggerServerEvent('bleeter:server:openMailTo', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('createPost', function(payload, cb) + TriggerServerEvent('bleeter:server:createPost', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('uploadMedia', function(payload, cb) + TriggerServerEvent('bleeter:server:uploadMedia', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('togglePostLike', function(payload, cb) + TriggerServerEvent('bleeter:server:togglePostLike', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('deleteOwnPost', function(payload, cb) + TriggerServerEvent('bleeter:server:deleteOwnPost', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('createComment', function(payload, cb) + TriggerServerEvent('bleeter:server:createComment', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('toggleCommentLike', function(payload, cb) + TriggerServerEvent('bleeter:server:toggleCommentLike', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('deleteOwnComment', function(payload, cb) + TriggerServerEvent('bleeter:server:deleteOwnComment', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('createMarketplaceEntry', function(payload, cb) + TriggerServerEvent('bleeter:server:createMarketplaceEntry', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('deleteMarketplaceEntry', function(payload, cb) + TriggerServerEvent('bleeter:server:deleteMarketplaceEntry', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('createEvent', function(payload, cb) + TriggerServerEvent('bleeter:server:createEvent', payload or {}) + cb({ ok = true }) +end) + +RegisterNUICallback('deleteEvent', function(payload, cb) + TriggerServerEvent('bleeter:server:deleteEvent', payload or {}) + cb({ ok = true }) +end) + +RegisterCommand(Config.Command, function() + setOpen(true) +end, false) + +exports('OpenBleeter', function(mode) + setOpen(true, mode or 'phone') +end) + +-- Beim Start sicherstellen dass die NUI geschlossen ist +AddEventHandler('onClientResourceStart', function(name) + if name == GetCurrentResourceName() then + SetNuiFocus(false, false) + nui('close') + end +end) diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..f6039a7 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,45 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +author 'Zoey/Codex' +description 'Standalone Bleeter social platform powered by Lifeinvader' +version '0.2.0' + +dependency 'oxmysql' +dependency 'ic-mail' + +-- 'core-characters' und 'zc_ifruit' waren hier harte dependencies. Eine +-- fehlende dependency verhindert den START der gesamten Resource – auf einem +-- ESX-Server waere Bleeter also nie hochgekommen. Beide werden ueber +-- GetResourceState abgefragt und sind optional (siehe shared/config.lua). + +shared_scripts { + 'shared/config.lua' +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/permissions.lua', + 'server/adapters/ifruit.lua', + 'server/adapters/mail.lua', + 'server/adapters/media.lua', + 'server/adapters/superpc.lua', + 'server/adapters/icweb.lua', + 'server/main.lua', + -- nach main.lua: nutzt die dort gebuendelten Hilfen (BleeterInternal) + 'server/management.lua' +} + +client_scripts { + 'client/main.lua' +} + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/style.css', + 'html/script.js', + 'html/assets/*' +} diff --git a/html/assets/.keep b/html/assets/.keep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/html/assets/.keep @@ -0,0 +1 @@ + diff --git a/html/index.html b/html/index.html new file mode 100644 index 0000000..a3bafd6 --- /dev/null +++ b/html/index.html @@ -0,0 +1,40 @@ + + + + + + Bleeter + + + + + + + diff --git a/html/script.js b/html/script.js new file mode 100644 index 0000000..d87c015 --- /dev/null +++ b/html/script.js @@ -0,0 +1,1961 @@ +const resourceName = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'bleeter'; + +const state = { + visible: false, + mode: 'phone', // 'phone' | 'desktop' + page: 'feed', + data: null, + fallback: true, + likedPosts: new Set([201]), + commentsOpen: new Set(), + searchQuery: '', + selectedCalendarDay: 3, + marketQuery: '', + marketSort: 'newest', + likeDeltas: {}, + postComments: {}, + likedComments: new Set(), + commentLikeDeltas: {}, + editingProfile: false, + pendingUploadTarget: null, + composeMedia: {}, + viewedProfileHandle: null +}; + +const fallbackData = { + account: { + displayName: 'Zoey Reed', + mailAddress: 'zoeyreed@mail.ls' + }, + navigation: [], + profiles: [ + { + id: 1, + profile_type: 'private', + handle: 'zoeyreed', + display_name: 'Zoey Reed', + avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1519608487953-e999c86e7455?w=1200&h=320&fit=crop', + bio: 'Los Santos, Kaffee, Geschichten und viel zu viele Benachrichtigungen.', + is_verified: false + }, + { + id: 2, + profile_type: 'small_business', + handle: 'reed.events', + display_name: 'Reed Events', + avatar_url: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1492684223066-81342ee5ff30?w=1200&h=320&fit=crop', + bio: 'Kleine Events, grosse Abende.', + is_verified: true + } + ], + activeProfileId: 1, + profileDirectory: [], + posts: [ + { + id: 101, + feed_type: 'home', + author: { + handle: 'lasky', + display_name: 'A.J. Lasky III', + avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop', + is_verified: false + }, + body: '"Er ist ein stiller Waechter .... ein wachsamer Beschuetzer. Ein dunkler Ritter" - Jim Gordon', + media_url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1100&h=680&fit=crop', + created_at: 'Heute, 22:15', + likes: 12, + comments: 3 + }, + { + id: 102, + feed_type: 'home', + author: { + handle: 'm4ttr1x', + display_name: '@m4ttr1x', + avatar_url: 'https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=256&h=256&fit=crop', + is_verified: false + }, + body: 'Wir haben zugesehen. ShitInvaders Sicherheit? Laecherlich. Ihr System ist genauso kaputt wie ihre Versprechen.\n\nDas hier ist euer Weckruf.', + created_at: '28.12.2024, 22:21:53', + likes: 1, + comments: 0 + }, + { + id: 201, + feed_type: 'advertising', + author: { + handle: 'lsmd', + display_name: 'Los Santos Medical Department', + avatar_url: 'https://images.unsplash.com/photo-1584515933487-779824d29309?w=256&h=256&fit=crop', + is_verified: true + }, + body: 'Heute Blutspendeaktion am Pillbox Medical Center. Kommt vorbei, Los Santos.', + created_at: 'Heute, 19:40', + likes: 44, + comments: 8 + } + ], + businesses: [ + { + handle: 'dmv', + display_name: 'Department of Motorvehicles', + avatar_url: 'https://images.unsplash.com/photo-1511919884226-fd3cad34687c?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=1000&h=260&fit=crop', + status: 'open', + is_verified: true + }, + { + handle: 'doj', + display_name: 'Department of Justice', + avatar_url: 'https://images.unsplash.com/photo-1589829545856-d10d557cf95f?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1453945619913-79ec89a82c51?w=1000&h=260&fit=crop', + status: 'open', + is_verified: true + }, + { + handle: 'gruppe6', + display_name: 'Gruppe 6', + avatar_url: 'https://images.unsplash.com/photo-1517048676732-d65bc937f952?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1521791136064-7986c2920216?w=1000&h=260&fit=crop', + status: 'closed', + is_verified: false + }, + { + handle: 'herrkutz', + display_name: 'Herr Kutz', + avatar_url: 'https://images.unsplash.com/photo-1503951914875-452162b0f3f1?w=256&h=256&fit=crop', + banner_url: 'https://images.unsplash.com/photo-1585747860715-2ba37e788b70?w=1000&h=260&fit=crop', + status: 'closed', + is_verified: true + } + ], + suggestions: [ + { handle: 'lsmd', display_name: 'Los Santos Medical Department' }, + { handle: 'dynasty8', display_name: 'Dynasty 8' }, + { handle: 'usms', display_name: 'US Marshal Service' } + ], + trends: [ + { tag: '#gruppe6', posts: 77 }, + { tag: '#lalintera', posts: 43 }, + { tag: '#JoinTheTeam', posts: 39 }, + { tag: '#LSFD', posts: 39 } + ], + users: [ + { + handle: 'zoeyreed', + display_name: 'Zoey Reed', + avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop', + profile_type: 'private', + can_be_blocked: true + }, + { + handle: 'zoey.reed', + display_name: 'Zoey Reed', + avatar_url: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=256&h=256&fit=crop', + profile_type: 'private', + can_be_blocked: true + }, + { + handle: 'z_reed', + display_name: 'Z. Reed', + avatar_url: 'https://images.unsplash.com/photo-1508214751196-bcfd4ca60f91?w=256&h=256&fit=crop', + profile_type: 'private', + can_be_blocked: true + }, + { + handle: 'lasky', + display_name: 'A.J. Lasky III', + avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop', + profile_type: 'private', + can_be_blocked: true + } + ], + social: { + following: [ + { handle: 'lasky', display_name: 'A.J. Lasky III', avatar_url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=256&h=256&fit=crop', profile_type: 'private', can_be_blocked: true } + ], + followers: [], + blocked: [] + }, + marketplace: [ + { id: 1, title: 'BMX in gutem Zustand', body: 'Schwarzes BMX, kaum gefahren. Abholung in Vespucci.', price: 450, price_label: '$450', created: 4, can_delete: true, author: { handle: 'zoeyreed', display_name: 'Zoey Reed', avatar_url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=256&h=256&fit=crop', email: 'zoeyreed@mail.ls', is_verified: false } }, + { id: 2, title: 'Catering fuer kleine Feiern', body: 'Fingerfood und Getraenke fuer Events bis 25 Personen.', price: 1200, price_label: '$1200', created: 2, can_delete: false, author: { handle: 'reed.events', display_name: 'Reed Events', avatar_url: 'https://images.unsplash.com/photo-1511795409834-ef04bbd61622?w=256&h=256&fit=crop', email: 'reed.events@mail.ls', is_verified: true } }, + { id: 3, title: 'Reinigung nach Clubabend', body: 'Diskret, schnell, mit eigenem Equipment.', price: 800, price_label: '$800', created: 1, can_delete: false, author: { handle: 'cleanls', display_name: 'Clean LS', avatar_url: '', email: 'cleanls@mail.ls', is_verified: false } } + ], + calendarDays: [ + { offset: 0, date: '2026-05-19', title: 'Dienstag 19.05.2026', events: [{ id: 1, time: '18:00', title: 'LSPD Coffe with Cops', location: '', author: 'CoolBeans', can_delete: false }] }, + { offset: 1, date: '2026-05-20', title: 'Mittwoch 20.05.2026', events: [] }, + { offset: 2, date: '2026-05-21', title: 'Donnerstag 21.05.2026', events: [] }, + { offset: 3, date: '2026-05-22', title: 'Freitag 22.05.2026', events: [{ id: 2, time: '20:00', title: 'Happy Hour', location: 'Craftbar', author: 'Craftbar', can_delete: false }, { id: 3, time: '21:00', title: 'All you can eat', location: '', author: 'Burgershot', can_delete: false }] }, + { offset: 4, date: '2026-05-23', title: 'Samstag 23.05.2026', events: [{ id: 4, time: '21:00', title: 'Geocaching Legionsquar', location: '', author: 'Lifeinvader', can_delete: false }] }, + { offset: 5, date: '2026-05-24', title: 'Sonntag 24.05.2026', events: [] }, + { offset: 6, date: '2026-05-25', title: 'Montag 25.05.2026', events: [] }, + { offset: 7, date: '2026-05-26', title: 'Dienstag 26.05.2026', events: [] }, + { offset: 8, date: '2026-05-27', title: 'Mittwoch 27.05.2026', events: [] }, + { offset: 9, date: '2026-05-28', title: 'Donnerstag 28.05.2026', events: [] }, + { offset: 10, date: '2026-05-29', title: 'Freitag 29.05.2026', events: [] }, + { offset: 11, date: '2026-05-30', title: 'Samstag 30.05.2026', events: [] }, + { offset: 12, date: '2026-05-31', title: 'Sonntag 31.05.2026', events: [] }, + { offset: 13, date: '2026-06-01', title: 'Montag 01.06.2026', events: [] }, + { offset: 14, date: '2026-06-02', title: 'Dienstag 02.06.2026', events: [] }, + { offset: 15, date: '2026-06-03', title: 'Mittwoch 03.06.2026', events: [] }, + { offset: 16, date: '2026-06-04', title: 'Donnerstag 04.06.2026', events: [] }, + { offset: 17, date: '2026-06-05', title: 'Freitag 05.06.2026', events: [] }, + { offset: 18, date: '2026-06-06', title: 'Samstag 06.06.2026', events: [] }, + { offset: 19, date: '2026-06-07', title: 'Sonntag 07.06.2026', events: [] }, + { offset: 20, date: '2026-06-08', title: 'Montag 08.06.2026', events: [] }, + { offset: 21, date: '2026-06-09', title: 'Dienstag 09.06.2026', events: [] }, + { offset: 22, date: '2026-06-10', title: 'Mittwoch 10.06.2026', events: [] }, + { offset: 23, date: '2026-06-11', title: 'Donnerstag 11.06.2026', events: [] }, + { offset: 24, date: '2026-06-12', title: 'Freitag 12.06.2026', events: [] }, + { offset: 25, date: '2026-06-13', title: 'Samstag 13.06.2026', events: [] }, + { offset: 26, date: '2026-06-14', title: 'Sonntag 14.06.2026', events: [] }, + { offset: 27, date: '2026-06-15', title: 'Montag 15.06.2026', events: [] } + ], + config: { + navigation: [ + { key: 'feed', label: 'Feed', icon: 'H' }, + { key: 'ads', label: 'Werbefeed', icon: 'W' }, + { key: 'followers', label: 'Follower', icon: 'F' }, + { key: 'market', label: 'Marktplatz', icon: 'M' }, + { key: 'calendar', label: 'Kalender', icon: 'K' }, + { key: 'business', label: 'Gewerbe', icon: 'G' }, + { key: 'profile', label: 'Profil', icon: 'P' }, + { key: 'legal', label: 'Rechtliches', icon: 'R' } + ] + } +}; + +const root = document.getElementById('bleeter-root'); +const nav = document.getElementById('side-nav'); +const content = document.getElementById('content'); +const context = document.getElementById('context'); +const topProfileAvatar = document.getElementById('top-profile-avatar'); +const globalSearch = document.getElementById('global-search'); +const searchResults = document.getElementById('search-results'); +const mediaFileInput = document.getElementById('media-file-input'); + +function post(name, payload = {}) { + return fetch(`https://${resourceName}/${name}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=UTF-8' }, + body: JSON.stringify(payload) + }).catch(() => {}); +} + +function data() { + return state.data || fallbackData; +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +function activeProfile() { + const current = data(); + return (current.profiles || []).find(profile => profile.id === current.activeProfileId) || current.profiles?.[0] || {}; +} + +function allKnownProfiles() { + const current = data(); + const byHandle = new Map(); + const addProfile = item => { + if (!item?.handle) return; + byHandle.set(item.handle, { ...(byHandle.get(item.handle) || {}), ...item }); + }; + + (current.profileDirectory || []).forEach(addProfile); + (current.profiles || []).forEach(addProfile); + (current.users || []).forEach(addProfile); + (current.businesses || []).forEach(addProfile); + (current.social?.following || []).forEach(addProfile); + (current.social?.followers || []).forEach(addProfile); + (current.social?.blocked || []).forEach(addProfile); + (current.posts || []).forEach(postItem => addProfile(postItem.author)); + (current.marketplace || []).forEach(item => addProfile(item.author)); + + return [...byHandle.values()]; +} + +function findKnownProfile(handle) { + const cleanHandle = String(handle || '').replace(/^@/, ''); + if (!cleanHandle) return null; + return allKnownProfiles().find(profile => profile.handle === cleanHandle) || null; +} + +function viewedProfile() { + if (!state.viewedProfileHandle) return activeProfile(); + return findKnownProfile(state.viewedProfileHandle) || activeProfile(); +} + +function isOwnProfile(profile) { + const active = activeProfile(); + return Number(profile?.id) === Number(active.id) || profile?.handle === active.handle; +} + +function openProfile(handle) { + const cleanHandle = String(handle || '').replace(/^@/, ''); + state.viewedProfileHandle = cleanHandle || null; + state.editingProfile = false; + state.page = 'profile'; + state.searchQuery = ''; + render(); +} + +function isServerBacked() { + return state.data !== null; +} + +function lifeinvaderPermissions() { + const permissions = data().permissions?.lifeinvader; + return typeof permissions === 'object' && permissions !== null ? permissions : {}; +} + +function hydrateInteractionState() { + state.likedPosts = new Set(); + state.likedComments = new Set(); + state.postComments = {}; + state.likeDeltas = {}; + state.commentLikeDeltas = {}; + + for (const postItem of state.data?.posts || []) { + if (postItem.liked_by_viewer) { + state.likedPosts.add(postItem.id); + } + if (Array.isArray(postItem.comments_list)) { + state.postComments[postItem.id] = postItem.comments_list; + for (const comment of postItem.comments_list) { + if (comment.liked_by_viewer) { + state.likedComments.add(String(comment.id)); + } + } + } + } +} + +function verified(profile) { + return profile?.is_verified ? `${patchCheckIcon()}` : ''; +} + +function lifeinvaderStaffBadge(profile) { + return profile?.is_lifeinvader_staff ? `${awardIcon()}` : ''; +} + +function profileBadges(profile) { + return `${verified(profile)}${lifeinvaderStaffBadge(profile)}`; +} + +function patchCheckIcon() { + return ` + + `; +} + +function awardIcon() { + return ` + + `; +} + +function slashCircleIcon() { + return ` + + `; +} + +function renderLifeinvaderProfileActions(profile) { + const permissions = lifeinvaderPermissions(); + const buttons = []; + const handle = escapeHtml(profile.handle || ''); + + if (permissions.can_verify) { + buttons.push(` + + `); + } + + if (permissions.can_staff) { + buttons.push(` + + `); + } + + if (permissions.can_lock) { + buttons.push(` + + `); + } + + return buttons.length ? `
${buttons.join('')}
` : ''; +} + +function trashIcon() { + return ` + + `; +} + +function mailIcon() { + return ` + + `; +} + +function profileStatIcon(type) { + const icons = { + posts: ` + + `, + followers: ` + + `, + following: ` + + `, + market: ` + + `, + calendar: ` + + ` + }; + return icons[type] || ''; +} + +function navIconGlyph(icon, label) { + return { + home: 'F', + megaphone: 'W', + users: 'Fo', + basket: 'M', + calendar: 'K', + briefcase: 'G', + user: 'P', + book: 'R' + }[icon] || String(label || '?').slice(0, 2); +} + +function navIconMarkup(item) { + const icons = { + feed: ` + + `, + ads: ` + + `, + followers: ` + + `, + market: ` + + `, + calendar: ` + + `, + business: ` + + `, + profile: ` + + `, + legal: ` + + ` + }; + + return icons[item.key] || escapeHtml(navIconGlyph(item.icon, item.label)); +} + +function show(open, mode) { + state.visible = open; + if (open && mode) state.mode = mode; + root.classList.toggle('hidden', !open); + root.classList.toggle('mode-desktop', open && state.mode === 'desktop'); + root.classList.toggle('mode-phone', open && state.mode !== 'desktop'); + if (open) { + post('requestBootstrap'); + } + render(); +} + +function renderNav() { + const items = data().config?.navigation || fallbackData.config.navigation; + nav.innerHTML = items.map(item => ` + + `).join(''); +} + +function renderTopbar() { + const profile = activeProfile(); + topProfileAvatar.src = profile.avatar_url || ''; + if (globalSearch.value !== state.searchQuery) { + globalSearch.value = state.searchQuery; + } + renderSearchResults(); +} + +function renderSearchResults() { + const query = state.searchQuery.trim().toLowerCase().replace(/^@/, ''); + if (!query) { + searchResults.classList.add('hidden'); + searchResults.innerHTML = ''; + return; + } + + const matches = (data().users || fallbackData.users).filter(user => user.handle.startsWith(query)).slice(0, 5); + searchResults.classList.toggle('hidden', matches.length === 0); + searchResults.innerHTML = matches.map(user => ` + + `).join(''); +} + +function renderCompose(feedType) { + const profile = activeProfile(); + const canPost = (profile.profile_type === 'private' && feedType === 'home') + || (['small_business', 'company'].includes(profile.profile_type) && feedType === 'advertising') + || (profile.profile_type === 'authority'); + + if (!canPost) { + return ` +
+

Nur lesen

+

Dein aktives Profil darf hier liken und kommentieren, aber nicht posten.

+
+ `; + } + + return ` +
+ +
+ +
+
+ + + JPG/PNG, max. 2 MB +
+ +
+
+
+ `; +} + +function renderPost(post) { + const author = post.author || {}; + const liked = state.likedPosts.has(post.id); + const commentsOpen = state.commentsOpen.has(post.id); + const likeCount = Number(post.likes || 0) + Number(state.likeDeltas[post.id] || 0); + const comments = commentsForPost(post.id); + const commentCount = comments.length; + return ` +
+
+ +
+ +
@${escapeHtml(author.handle)}
+
+
${escapeHtml(post.created_at)}
+
+ ${post.body ? `
${escapeHtml(post.body)}
` : ''} + ${post.media_url ? `` : ''} +
+ + + ${post.can_delete ? `` : ''} +
+ ${commentsOpen ? renderComments(post, comments) : ''} +
+ `; +} + +function commentsForPost(postId) { + if (!state.postComments[postId]) { + const postItem = (data().posts || []).find(item => Number(item.id) === Number(postId)); + state.postComments[postId] = Array.isArray(postItem?.comments_list) + ? postItem.comments_list + : [ + { id: `${postId}-1`, author: 'zoeyreed', body: 'Guter Hinweis, danke fuer die Info.', likes: 0 }, + { id: `${postId}-2`, author: 'lasky', body: 'Bin dabei.', likes: 0 } + ]; + } + return state.postComments[postId]; +} + +function renderComments(post, comments) { + return ` +
+
+ + +
+ ${comments.map(comment => renderComment(comment)).join('')} +
+ `; +} + +function renderComment(comment) { + const commentId = String(comment.id); + const liked = state.likedComments.has(commentId); + const likeCount = Number(comment.likes || 0) + Number(state.commentLikeDeltas[comment.id] || 0); + return ` +
+
+ + ${escapeHtml(comment.body)} +
+
+ + ${comment.can_delete ? `` : ''} +
+
+ `; +} + +function renderFeed(feedType) { + const title = feedType === 'home' ? 'Feed' : 'Werbefeed'; + const posts = (data().posts || []).filter(post => post.feed_type === feedType); + + content.innerHTML = ` +
+
+

${title}

+
+
+ ${renderCompose(feedType)} +
${posts.map(renderPost).join('')}
+ `; +} + +function renderContextFeed() { + const current = data(); + context.innerHTML = ` +

Wem folgen?

+
+ ${(current.suggestions || []).map(item => ` +
+
${escapeHtml(item.display_name.slice(0, 1))}
+
+

${escapeHtml(item.display_name)}

+

@${escapeHtml(item.handle)}

+
+ +
+ `).join('')} +
+

Trends

+
+ ${(current.trends || []).map(item => ` +
+

${escapeHtml(item.tag)}

+

${Number(item.posts || 0)} Posts

+
+ `).join('')} +
+ `; +} + +function renderBusiness() { + const current = data(); + const open = (current.businesses || []).filter(item => item.status === 'open'); + const closed = (current.businesses || []).filter(item => item.status !== 'open'); + const card = item => ` + + `; + + content.innerHTML = ` +
+
+

Gewerbe

+
+
+
+
${open.map(card).join('')}
+
+
${closed.map(card).join('')}
+ `; + + context.innerHTML = ` +

Status

+
+

${open.length} geoeffnet

Aktive Leitstellen werden hervorgehoben.

+

${closed.length} geschlossen

Geschlossene Gewerbe sind ausgegraut.

+
+ `; +} + +function renderMarketplace() { + const items = filteredMarketplaceItems(); + + content.innerHTML = ` +
+

Marktplatz

+
+ ${renderMarketplaceCompose()} +
+ + +
+
${items.map(renderMarketplaceItem).join('')}
+ `; + + context.innerHTML = ` +

Sortierung

+
+

${items.length} Treffer

+

Suche nach Begriffen im Titel, Text oder Profilnamen.

+
+ `; +} + +function filteredMarketplaceItems() { + const query = state.marketQuery.trim().toLowerCase(); + let items = (data().marketplace || fallbackData.marketplace).filter(item => { + if (!query) return true; + return `${item.title} ${item.body} ${item.author?.handle || ''} ${item.author?.display_name || ''}`.toLowerCase().includes(query); + }); + + return [...items].sort((a, b) => { + if (state.marketSort === 'price_asc') return Number(a.price || 0) - Number(b.price || 0); + if (state.marketSort === 'price_desc') return Number(b.price || 0) - Number(a.price || 0); + if (state.marketSort === 'oldest') return Number(a.created || 0) - Number(b.created || 0); + return Number(b.created || 0) - Number(a.created || 0); + }); +} + +function refreshMarketplaceResults() { + const items = filteredMarketplaceItems(); + const results = document.getElementById('market-results'); + if (results) { + results.innerHTML = items.map(renderMarketplaceItem).join(''); + } + + const hitCount = document.getElementById('market-hit-count'); + if (hitCount) { + hitCount.textContent = `${items.length} Treffer`; + } +} + +function renderMarketplaceItem(item) { + const author = item.author || {}; + const mailSubject = `Bleeter Marktplatz: ${item.title || 'Inserat'}`; + return ` +
+
+
+ +
+ + + ${author.email ? `` : ''} + +

@${escapeHtml(author.handle || '')}

+
+
+

${escapeHtml(item.title)}

+

${escapeHtml(item.body)}

+ ${item.media_url ? `` : ''} +
+
+
${escapeHtml(item.price_label || ('$' + Number(item.price || 0)))}
+ ${item.can_delete ? `` : ''} +
+
+ `; +} + +function renderProfileEventItem(day, event) { + const location = event.location ? ` ..${escapeHtml(event.location)}..` : ''; + return ` +
+

${escapeHtml(day.title)}

+

${escapeHtml(event.time || '')} ${escapeHtml(event.title || '')}${location}

+

@${escapeHtml(event.author || '')}

+
+ `; +} + +function renderMarketplaceCompose() { + const profile = activeProfile(); + return ` +
+ +
+
+ + +
+ +
+
+ + + JPG/PNG, max. 2 MB +
+ +
+
+
+ `; +} + +function renderCalendar() { + const days = data().calendarDays || fallbackData.calendarDays; + const selected = days.find(day => day.offset === state.selectedCalendarDay) || days[0]; + const profile = activeProfile(); + const canCreateEvent = ['small_business', 'company', 'authority'].includes(profile.profile_type); + + content.innerHTML = ` +
+

Kalender

+
+
+ ${days.map(day => ` +
+
${escapeHtml(day.title)}
+ ${day.events.map(event => renderCalendarEventLine(event)).join('')} +
+ `).join('')} +
+ `; + + context.innerHTML = ` +

Veranstaltung

+ ${canCreateEvent ? renderEventEditor(selected, profile) : ` +
+

Nur lesen

+

Termine koennen von Unternehmen, Kleingewerben und Behoerden eingetragen werden.

+
+ `} + `; +} + +function renderCalendarEventLine(event) { + if (typeof event === 'string') { + return `
${escapeHtml(event)}
`; + } + + const location = event.location ? ` ..${escapeHtml(event.location)}..` : ''; + const line = `${escapeHtml(event.time || '')} ${escapeHtml(event.title || '')} ${location} @${escapeHtml(event.author || '')}`; + return ` +
+ ${line} + ${event.can_delete ? `` : ''} +
+ `; +} + +function renderEventEditor(selected, profile) { + return ` +
+
+ ${escapeHtml(selected.title)} + @${escapeHtml(profile.handle || 'profil')} +
+ + + + +
+ `; +} + +function renderLegal() { + content.innerHTML = ` +
+

Rechtliches

+
+ + `; + context.innerHTML = ''; +} + +function renderProfile() { + const profile = viewedProfile(); + const ownProfile = isOwnProfile(profile); + const social = data().social || fallbackData.social; + const following = socialHandles(social.following).has(profile.handle); + const blocked = socialHandles(social.blocked).has(profile.handle); + const visiblePosts = (data().posts || []).filter(post => post.author?.handle === profile.handle); + const visibleMarketplace = (data().marketplace || []).filter(item => item.author?.handle === profile.handle); + const visibleEvents = (data().calendarDays || []) + .flatMap(day => (day.events || []) + .filter(event => event.author === profile.handle) + .map(event => ({ day, event }))); + const followersFallback = (social.followers || []).filter(item => item.handle === profile.handle).length; + const followingFallback = ownProfile ? (social.following || []).length : 0; + const followersCount = Number(profile.followers_count ?? followersFallback); + const followingCount = Number(profile.following_count ?? followingFallback); + const canBlock = profile.can_be_blocked !== false; + const avatarUrl = profile.avatar_url || ''; + const bannerUrl = profile.banner_url || profile.avatar_url || ''; + const contactMail = profile.email_contact || profile.email || ''; + const moderationActions = renderLifeinvaderProfileActions(profile); + + if (ownProfile && state.editingProfile && profile.can_edit_profile !== false) { + content.innerHTML = renderProfileEditor(profile); + } else { + const profileActions = ownProfile + ? ` +
+ ${profile.can_edit_profile !== false ? `` : ''} + ${moderationActions} +
+ ` + : ` +
+ ${following + ? `` + : ``} + ${contactMail ? `` : ''} + ${blocked + ? `` + : (canBlock + ? `` + : `Geschuetzt`)} + ${moderationActions} +
+ `; + + content.innerHTML = ` +
+ ${bannerUrl ? `` : '
'} +
+ ${avatarUrl ? `` : `
${escapeHtml((profile.display_name || profile.handle || '?').slice(0, 1))}
`} +
+

${escapeHtml(profile.display_name || profile.handle || 'Profil')} ${profileBadges(profile)}

+

@${escapeHtml(profile.handle)}

+

${escapeHtml(profile.bio || '')}

+
+ + + + + +
+
+ ${profileActions} +
+
+
+
+

Profilinhalte

+

${ownProfile ? 'Deine sichtbaren Inhalte.' : `Sichtbare Inhalte von @${escapeHtml(profile.handle)}.`}

+
+
+
+

Posts

+
${visiblePosts.length ? visiblePosts.map(renderPost).join('') : '

Noch keine sichtbaren Posts.

'}
+
+ ${visibleMarketplace.length ? ` +
+

Marktplatz

+
${visibleMarketplace.map(renderMarketplaceItem).join('')}
+
+ ` : ''} + ${visibleEvents.length ? ` +
+

Kalender

+
${visibleEvents.map(item => renderProfileEventItem(item.day, item.event)).join('')}
+
+ ` : ''} + `; + } + + if (ownProfile) { + context.innerHTML = ` +

Profil wechseln

+
+ ${(data().profiles || []).map(item => ` + + `).join('')} +
+ `; + } else { + context.innerHTML = ` +

Profil

+
+ +
+ `; + } +} + +function renderProfileEditor(profile) { + const bioMax = profile.profile_type === 'private' ? 200 : 500; + return ` +
+
+
+

Profil bearbeiten

+

@${escapeHtml(profile.handle)}

+
+
+
+ + + + +
+ + +
+
+
+ `; +} + +function renderSimplePage(title, subtitle, cards, contextTitle = '') { + content.innerHTML = ` +
+
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+
+
+ ${cards.map(card => ` +
+

${escapeHtml(card.title)}

+

${escapeHtml(card.text)}

+
+ `).join('')} +
+ `; + context.innerHTML = contextTitle ? ` +

${escapeHtml(contextTitle)}

+
+

V1

Diese Ansicht ist im Grundgeruest vorbereitet und bekommt als naechstes echte Daten.

+
+ ` : ''; +} + +function socialHandles(list) { + return new Set((list || []).map(item => item.handle)); +} + +function renderSocialRow(item, mode, followingSet, blockedSet) { + const handle = item.handle || ''; + const isFollowing = followingSet.has(handle); + const isBlocked = blockedSet.has(handle); + const canBlock = item.can_be_blocked !== false; + const blockAction = isBlocked + ? `` + : (canBlock + ? `` + : `Geschuetzt`); + const followAction = mode === 'blocked' + ? '' + : (isFollowing + ? `` + : ``); + + return ` + + `; +} + +function renderSocialSection(title, items, mode, followingSet, blockedSet, emptyText) { + const rows = (items || []).map(item => renderSocialRow(item, mode, followingSet, blockedSet)).join(''); + return ` + + `; +} + +function renderFollowers() { + const current = data(); + const social = current.social || fallbackData.social; + const followingSet = socialHandles(social.following); + const blockedSet = socialHandles(social.blocked); + const suggestions = (current.users || fallbackData.users).filter(item => !blockedSet.has(item.handle)).slice(0, 12); + + content.innerHTML = ` +
+
+

Follower

+
+
+ ${renderSocialSection('Entdecken', suggestions, 'discover', followingSet, blockedSet, 'Keine Profile gefunden.')} + ${renderSocialSection('Wem folge ich', social.following, 'following', followingSet, blockedSet, 'Du folgst aktuell niemandem.')} + ${renderSocialSection('Wer folgt mir', social.followers, 'followers', followingSet, blockedSet, 'Noch keine Follower.')} + ${renderSocialSection('Blockiert', social.blocked, 'blocked', followingSet, blockedSet, 'Keine blockierten Profile.')} + `; + + context.innerHTML = ''; +} + +function renderPage() { + if (state.page === 'feed') { + renderFeed('home'); + renderContextFeed(); + return; + } + if (state.page === 'ads') { + renderFeed('advertising'); + renderContextFeed(); + return; + } + if (state.page === 'business') { + renderBusiness(); + return; + } + if (state.page === 'profile') { + renderProfile(); + return; + } + if (state.page === 'followers') { + renderFollowers(); + return; + } + if (state.page === 'market') { + renderMarketplace(); + return; + } + if (state.page === 'calendar') { + renderCalendar(); + return; + } + if (state.page === 'legal') { + renderLegal(); + } +} + +function render() { + renderNav(); + renderTopbar(); + renderPage(); +} + +document.addEventListener('click', event => { + const pageButton = event.target.closest('[data-page]'); + if (pageButton) { + state.page = pageButton.dataset.page; + if (state.page === 'profile') state.viewedProfileHandle = null; + if (state.page !== 'profile') state.editingProfile = false; + render(); + return; + } + + const openOwnProfileButton = event.target.closest('[data-open-own-profile]'); + if (openOwnProfileButton) { + state.viewedProfileHandle = null; + state.page = 'profile'; + state.editingProfile = false; + render(); + return; + } + + const openProfileButton = event.target.closest('[data-open-profile]'); + if (openProfileButton) { + openProfile(openProfileButton.dataset.openProfile); + return; + } + + const mailButton = event.target.closest('[data-mail-to]'); + if (mailButton) { + post('openMailTo', { + mail: mailButton.dataset.mailTo, + subject: mailButton.dataset.mailSubject || '' + }); + return; + } + + const moderateProfileButton = event.target.closest('[data-moderate-profile]'); + if (moderateProfileButton) { + const action = moderateProfileButton.dataset.moderateProfile; + const handle = moderateProfileButton.dataset.moderateProfileHandle; + + if (!isServerBacked()) { + const profile = findKnownProfile(handle); + if (profile) { + if (action === 'verify') profile.is_verified = !profile.is_verified; + if (action === 'staff') profile.is_lifeinvader_staff = !profile.is_lifeinvader_staff; + if (action === 'lock') profile.is_locked = !profile.is_locked; + } + } + + post('moderateProfile', { action, handle }); + render(); + return; + } + + const profileJumpButton = event.target.closest('[data-profile-jump]'); + if (profileJumpButton) { + const target = document.querySelector(`[data-profile-section="${profileJumpButton.dataset.profileJump}"]`); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return; + } + + const profileSocialButton = event.target.closest('[data-profile-social]'); + if (profileSocialButton) { + state.page = 'followers'; + state.editingProfile = false; + render(); + return; + } + + const followButton = event.target.closest('[data-follow-profile]'); + if (followButton) { + const handle = followButton.dataset.followProfile; + if (!isServerBacked()) { + const item = fallbackData.users.find(user => user.handle === handle); + if (item && !fallbackData.social.following.some(user => user.handle === handle)) { + fallbackData.social.following.push(item); + } + } + post('followProfile', { handle }); + render(); + return; + } + + const unfollowButton = event.target.closest('[data-unfollow-profile]'); + if (unfollowButton) { + const handle = unfollowButton.dataset.unfollowProfile; + if (!isServerBacked()) { + fallbackData.social.following = fallbackData.social.following.filter(user => user.handle !== handle); + } + post('unfollowProfile', { handle }); + render(); + return; + } + + const blockButton = event.target.closest('[data-block-profile]'); + if (blockButton) { + const handle = blockButton.dataset.blockProfile; + if (!isServerBacked()) { + const item = fallbackData.users.find(user => user.handle === handle) || fallbackData.social.following.find(user => user.handle === handle); + if (item?.can_be_blocked !== false && !fallbackData.social.blocked.some(user => user.handle === handle)) { + fallbackData.social.blocked.push(item); + fallbackData.social.following = fallbackData.social.following.filter(user => user.handle !== handle); + fallbackData.social.followers = fallbackData.social.followers.filter(user => user.handle !== handle); + } + } + post('blockProfile', { handle }); + render(); + return; + } + + const unblockButton = event.target.closest('[data-unblock-profile]'); + if (unblockButton) { + const handle = unblockButton.dataset.unblockProfile; + if (!isServerBacked()) { + fallbackData.social.blocked = fallbackData.social.blocked.filter(user => user.handle !== handle); + } + post('unblockProfile', { handle }); + render(); + return; + } + + const createPostButton = event.target.closest('[data-create-post]'); + if (createPostButton) { + const feedType = createPostButton.dataset.createPost; + const form = createPostButton.closest('.compose'); + const textarea = form?.querySelector('textarea'); + const body = textarea?.value.trim(); + const mediaInput = form?.querySelector('[data-compose-media]'); + const mediaUrl = mediaInput?.value.trim() || state.composeMedia[feedType] || ''; + if (!body && !mediaUrl) return; + + if (!isServerBacked()) { + const profile = activeProfile(); + fallbackData.posts.unshift({ + id: Date.now(), + feed_type: feedType, + author: { + id: profile.id, + handle: profile.handle, + display_name: profile.display_name, + avatar_url: profile.avatar_url, + is_verified: profile.is_verified + }, + body, + media_url: mediaUrl, + created_at: 'Gerade eben', + likes: 0, + comments: 0, + comments_list: [], + can_delete: true + }); + } + + textarea.value = ''; + if (mediaInput) mediaInput.value = ''; + state.composeMedia[feedType] = ''; + post('createPost', { feedType, body, mediaUrl }); + render(); + return; + } + + const profileButton = event.target.closest('[data-profile]'); + if (profileButton) { + const profileId = Number(profileButton.dataset.profile); + if (state.data) state.data.activeProfileId = profileId; + fallbackData.activeProfileId = profileId; + state.viewedProfileHandle = null; + state.editingProfile = false; + post('setActiveProfile', { profileId }); + render(); + return; + } + + if (event.target.closest('[data-edit-profile]')) { + state.editingProfile = true; + render(); + return; + } + + // Upload-Buttons sind jetzt