commit be502d28343c71f6b0a00f1dee1bfd9683ff49d5 Author: Bjoern Flessing Date: Sun Aug 9 12:02:11 2026 +0000 IC-Computer: Schreibtisch, Fenster und Apps Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen. Andere Resources haengen sich als App ein. Auf ESX umgestellt: - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt. Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die Resource waere nie hochgekommen. - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen. - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in der config.lua. Mail ueberarbeitet: - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute koennen dasselbe Firmenpostfach gleichzeitig offen haben. - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine Eigenschaft der Nachricht, sondern der Person. - Signaturen gehoeren ebenfalls zum Postfach. - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter niemand mehr aendern oder absagen. Neue Apps: Webhosting, Bleeter und dessen Verwaltung. Behoben: - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und kehrt nie zurueck. Ersetzt durch eigene Dialoge. - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht, schreibt ins Leere und das Fenster bleibt leer. Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis zum Schreibtischsymbol. 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/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..8d727b9 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,57 @@ +# pc-live v1.1.0 + +## Funktion +Vollständiges IC-PC-System: Desktop-Umgebung, Software-Store, Mail-Client, +Adressbuch, Kalender, Browser (IC-Seiten), externe Apps via iframe. + +## SQL ausführen +```bash +mysql -u USER -p DB < sql/pc_live.sql +``` + +## Tabellen +| Tabelle | Beschreibung | +|---------|-------------| +| `pc_live_users` | PC-Benutzerkonten (verknüpft mit ic_players) | +| `pc_live_devices` | Registrierte Geräte | +| `pc_live_installs` | Installierte Apps pro Gerät | +| `pc_live_mail` | E-Mails | +| `pc_live_mail_folders` | Mail-Ordner | +| `pc_live_contacts` | Adressbuch-Einträge | +| `pc_live_calendar` | Kalender-Einträge | + +## Konfiguration (config.lua) +| Option | Standard | Beschreibung | +|--------|---------|-------------| +| `Config.Framework` | `standalone` | `qbcore`, `esx` oder `standalone` | +| `Config.AccentColor` | `#00aaff` | PC-UI Akzentfarbe | +| `Config.BootDuration` | `2500` | Boot-Animation in ms | +| `Config.FreezeOnOpen` | `true` | Spieler beim PC-Nutzen einfrieren | +| `Config.InteractKey` | `38` (E) | Interaktionstaste | +| `Config.InteractRange` | `1.8` | Interaktionsreichweite in Metern | +| `Config.PCs` | `{...}` | PC-Standorte (Koordinaten, Typ, Job-Lock) | +| `Config.ICPages` | `{...}` | Interne Browser-Seiten | + +## Apps registrieren (andere Ressourcen) +```lua +exports['pc-live']:RegisterApp({ + app_id = 'meine.app', + name = 'Meine App', + icon = '📦', + version = '1.0.0', + category = 'utility', + permissions = {}, -- {} = für alle, { job = 'police' } = job-locked + price = 0, +}) +``` + +## Abhängigkeiten +- `ic_players` (Benutzer-Verknüpfung) +- `ic_persons` (Anzeigename) +- `ic_bank_accounts` (Geld-Anzeige im HUD) +- `ic_jobs` (für Job-basierte App-Permissions) + +## server.cfg +``` +ensure pc-live +``` diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..b433193 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,321 @@ +# Eigene Apps für pc-live + +Eine App ist kein eigener Ordner und kein Plugin-Format — sie besteht aus vier +Teilen, die an vier Stellen eingetragen werden. Diese Anleitung baut eine +vollständige App von Null auf: **Notizen**, mit Datenbank, Server, Oberfläche +und Schreibtischsymbol. + +Am Ende steht ein Gerüst zum Kopieren. + +--- + +## Wie eine App aufgebaut ist + +``` + NUI (Browser) Client (Lua) Server (Lua) +┌──────────────────┐ ┌────────────────────┐ ┌────────────────────┐ +│ nui/js/apps/ │ │ client/notizen.lua │ │ server/notizen.lua │ +│ notizen.js │ │ │ │ │ +│ │ │ │ │ │ +│ fetchNui('op') ──┼─────►│ RegisterNUICallback├───►│ RegisterNetEvent │ +│ │ │ │ │ │ │ +│ window.message ◄─┼──────┤ SendNUIMessage │◄───┤ TriggerClientEvent │ +└──────────────────┘ └────────────────────┘ └────────────────────┘ +``` + +Das NUI kann **nur** Callbacks der eigenen Resource aufrufen — deshalb der +Umweg über den Client. Antworten laufen umgekehrt über `SendNUIMessage` und +landen im Nachrichtenverteiler in `nui/js/desktop.js`. + +--- + +## Schritt 1 — Tabelle + +`sql/notizen.sql`: + +```sql +CREATE TABLE IF NOT EXISTS `pc_live_notizen` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `identifier` VARCHAR(120) NOT NULL, + `titel` VARCHAR(120) NOT NULL DEFAULT '', + `inhalt` TEXT NOT NULL, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_identifier` (`identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +--- + +## Schritt 2 — Server + +`server/notizen.lua`: + +```lua +-- Notizen. Jede Notiz gehoert genau einer Person. + +RegisterNetEvent('pc-live:server:getNotizen', function() + local src = source + local user = ActiveSessions[src] -- wer sitzt am PC? + if not user then return end + + local rows = MySQL.query.await( + 'SELECT id, titel, inhalt FROM pc_live_notizen WHERE identifier = ? ORDER BY id DESC', + { user.identifier }) or {} + + TriggerClientEvent('pc-live:client:notizen', src, rows) +end) + +RegisterNetEvent('pc-live:server:saveNotiz', function(data) + local src = source + local user = ActiveSessions[src] + if not user then return end + if type(data) ~= 'table' then return end + + -- Immer beschneiden: was aus dem NUI kommt, hat der Spieler in der Hand. + local titel = Utils.Sanitize(tostring(data.titel or '')):sub(1, 120) + local inhalt = Utils.Sanitize(tostring(data.inhalt or '')):sub(1, 5000) + if titel == '' then return end + + local id = tonumber(data.id) + if id then + -- Die Bedingung auf identifier ist die Besitzpruefung: ohne sie + -- koennte jemand mit einer fremden Id die Notiz eines anderen aendern. + MySQL.update.await( + 'UPDATE pc_live_notizen SET titel = ?, inhalt = ? WHERE id = ? AND identifier = ?', + { titel, inhalt, id, user.identifier }) + else + MySQL.insert.await( + 'INSERT INTO pc_live_notizen (identifier, titel, inhalt) VALUES (?,?,?)', + { user.identifier, titel, inhalt }) + end + + TriggerEvent('pc-live:server:getNotizen') -- geht nicht! siehe unten +end) +``` + +> **Falle:** `TriggerEvent` auf ein *Netzwerk*ereignis funktioniert nicht wie +> gedacht — `source` ist dort nicht der Spieler, der Handler bricht ab. Baue +> stattdessen eine Funktion, die beide Wege benutzen: +> +> ```lua +> local function sendeNotizen(src, identifier) … end +> ``` + +--- + +## Schritt 3 — Client als Brücke + +`client/notizen.lua`: + +```lua +-- Die Liste erlaubter Aktionen steht fest. Ohne sie koennte das NUI jedes +-- beliebige Serverereignis ausloesen. +local OPS = { + getNotizen = 'pc-live:server:getNotizen', + saveNotiz = 'pc-live:server:saveNotiz', +} + +RegisterNUICallback('notizen', function(data, cb) + cb({}) -- sofort bestaetigen + + local event = OPS[tostring(data.op or '')] + if not event then return end + + TriggerServerEvent(event, data.payload or {}) +end) + +RegisterNetEvent('pc-live:client:notizen', function(rows) + SendNUIMessage({ action = 'notizen_data', data = rows or {} }) +end) +``` + +--- + +## Schritt 4 — Oberfläche + +`nui/js/apps/notizen.js`: + +```js +const NotizenApp = (() => { + const WIN_ID = 'app-notizen'; + const el = ICWebRender.el; // kleiner Helfer aus webhosting.js + + let notizen = []; + let winEl = null; + + function open() { + const win = WindowManager.create({ + id: WIN_ID, title: 'Notizen', icon: '📝', width: 720, height: 520, + content: '
', + }); + if (!win) return; + + winEl = win; // merken! siehe Falle unten + fetchNui('notizen', { op: 'getNotizen' }); + render(); + } + + function root(id) { + if (!winEl || !winEl.isConnected) { + winEl = document.querySelector('[data-wid="' + WIN_ID + '"]'); + } + return winEl ? winEl.querySelector('#' + id) : null; + } + + function render() { + const main = root('notizen-main'); + if (!main) return; + main.replaceChildren(); + + notizen.forEach(n => { + const karte = el('div', 'wh-row'); + // Spielerinhalte immer ueber textContent – nie ueber innerHTML. + karte.appendChild(el('div', 'wh-row-title', n.titel)); + karte.appendChild(el('div', 'wh-row-sub', n.inhalt)); + main.appendChild(karte); + }); + + const neu = el('button', 'icweb-btn-primary', 'Neue Notiz'); + neu.addEventListener('click', () => { + // Kein prompt() – siehe Fallen. + ICWebRender.promptText('Neue Notiz', { placeholder: 'Titel' }, (titel) => { + fetchNui('notizen', { op: 'saveNotiz', payload: { titel, inhalt: '' } }); + }); + }); + main.appendChild(neu); + } + + /** Vom Nachrichtenverteiler in desktop.js. */ + function onData(rows) { + notizen = rows || []; + render(); + } + + return { open, onData }; +})(); +``` + +--- + +## Schritt 5 — Einhängen + +**`fxmanifest.lua`** + +```lua +client_scripts { …, 'client/notizen.lua' } +server_scripts { …, 'server/notizen.lua' } +files { …, 'nui/js/apps/notizen.js' } +``` + +**`nui/index.html`** — vor `desktop.js`, und nach `webhosting.js`, falls du +`ICWebRender` benutzt: + +```html + +``` + +**`nui/js/desktop.js`** — Symbol und Antwortkanal: + +```js +const APP_META = { + … + 'pc.notizen': { name: 'Notizen', icon: '📝', open: () => NotizenApp.open() }, +}; + +// im Nachrichtenverteiler: +case 'notizen_data': NotizenApp.onData(data); break; +``` + +**`server/apps.lua`** — damit die App auf dem Schreibtisch landet: + +```lua +AppRegistry.Register({ + app_id = "pc.notizen", + name = "Notizen", + icon = "📝", + version = "1.0.0", + category = "utility", + permissions = {}, + dependencies = {}, + default = true, -- true = bei jedem vorinstalliert + description = "Notizen schreiben und wiederfinden.", + price = 0, -- > 0: muss im Store gekauft werden +}) +``` + +Fertig. Neu starten, PC öffnen — das Symbol ist da. + +--- + +## Aus einer anderen Resource + +Der Serverteil kann auch woanders liegen. Die Registrierung geht dann über +einen Export: + +```lua +exports['pc-live']:RegisterApp({ app_id = 'meine.app', name = 'Meine App', … }) +``` + +Die **Oberfläche muss trotzdem in `pc-live/nui/`** liegen: Das NUI lädt nur +Dateien der eigenen Resource. Deine Resource liefert also die Serverlogik, und +in pc-live liegen Brücke und Oberfläche. So machen es `ic-web` und `bleeter`. + +Der Client deiner Resource kann direkt mit deinem Server sprechen — pc-live +muss nichts weiterreichen. Nur der Weg **ins** NUI führt über pc-live, weil +`SendNUIMessage` immer im eigenen Fenster landet. + +--- + +## Fallen, die Zeit kosten + +**Das Fenster hat keine `id`.** `WindowManager.create` setzt `data-wid`. +`document.getElementById(WIN_ID)` findet nichts, deine App schreibt ins Leere +und das Fenster bleibt leer. Merke dir das zurückgegebene Element oder suche +mit `document.querySelector('[data-wid="…"]')`. + +**`prompt()`, `confirm()` und `alert()` frieren das NUI ein.** FiveMs CEF hat +keinen Handler für die eingebauten Browserdialoge — der Aufruf öffnet nichts +und kehrt nie zurück. Nimm `ICWebRender.promptText(titel, optionen, rückruf)` +und `ICWebRender.confirmBox(titel, text, rückruf, optionen)`. + +**Kein `innerHTML` für Spielerinhalte.** Das NUI ist derselbe Kontext wie der +PC: Wer dort Markup einschleusen kann, kann auch dessen Callbacks aufrufen. +`textContent` und `setAttribute`, immer. + +**`TINYINT(1)` ist mal `1`, mal `true`.** `oxmysql` wandelt es bei +`query`/`single` in einen Boolean, bei `prepare` nicht. `== 1` geht deshalb +irgendwann schief — und zwar still. Schreib dir einen Helfer wie `ICWeb.Bool`. + +**Keine Datenbankarbeit in `onResourceStop`.** Ein `await` wartet dort auf eine +Antwort, die niemand mehr entgegennimmt: „Execution of function reference in +script host failed". Aufräumen gehört in den Start — das erwischt auch den +Absturz, den ein Stop-Handler nie sieht. + +**`TriggerEvent` auf ein Netzwerkereignis** hat kein `source`. Zieh die Logik +in eine Funktion und ruf die von beiden Seiten. + +**Eigene Stile kapseln.** Bindest du eine fremde CSS-Datei ein, färbt sie +womöglich den ganzen PC. Setz deine Regeln unter eine eigene Wurzelklasse — +siehe `nui/css/erzeuge-bleeter-css.py`, das genau das mechanisch erledigt. + +--- + +## Was schon da ist + +Bevor du etwas nachbaust: + +| | aus | | +|---|---|---| +| `ICWebRender.el(tag, klasse, text)` | `webhosting.js` | Element bauen | +| `ICWebRender.modal(titel, aufbau, breit)` | `webhosting.js` | Dialograhmen | +| `ICWebRender.promptText` / `confirmBox` | `webhosting.js` | Eingabe und Rückfrage | +| `Desktop.showNotification(text)` | `desktop.js` | kurze Meldung | +| `WindowManager.create/close/focus` | `windowManager.js` | Fenster | +| `fetchNui(name, daten)` | `desktop.js` | Aufruf an den Client | + +Für das Aussehen kannst du die vorhandenen Klassen benutzen: `wh-page`, +`wh-row`, `wh-field`, `wh-label`, `icweb-input`, `icweb-btn-primary`, +`icweb-btn-ghost`, `icweb-btn-danger`, `wh-tag`, `wh-hint`. Dann fügt sich +deine App ohne eigenes CSS ein. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6812085 --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +# pc-live — IC-Computer + +Ein Computer im Spiel: Schreibtisch, Fenster, Apps. Man tritt an ein +Terminal, drückt **E**, und bekommt eine Oberfläche mit Mail, Browser, +Adressbuch, Kalender und weiteren Anwendungen. + +Andere Resources hängen sich als App ein — so laufen hier das IC-Webhosting +(`ic-web`) und Bleeter. + +--- + +## Installation + +### 1. Ordner + +``` +resources/[haleoe]/pc-live/ +``` + +### 2. Datenbank + +Reihenfolge einhalten, die Migrationen setzen auf dem Grundschema auf: + +```bash +mysql -u BENUTZER -p DATENBANK < sql/pc_live.sql +mysql -u BENUTZER -p DATENBANK < sql/pc_live_devices.sql +mysql -u BENUTZER -p DATENBANK < sql/migration_postfach_ordner.sql +mysql -u BENUTZER -p DATENBANK < sql/migration_kalender.sql +``` + +| Datei | legt an | +|---|---| +| `pc_live.sql` | Benutzer, Apps, Mail, Ordner, Kalender, Kontakte | +| `pc_live_devices.sql` | **feste PC-Standorte** – fehlte im ursprünglichen Schema, ohne sie ist kein fester PC ansprechbar | +| `migration_postfach_ordner.sql` | Ordner je Postfach, Zustellungskennung | +| `migration_kalender.sql` | private, geteilte und öffentliche Kalender | + +Alle wiederholt ausführbar. + +### 3. server.cfg + +``` +ensure oxmysql +ensure es_extended +ensure pc-live +``` + +### 4. Einen PC in die Welt stellen + +Ein fester PC braucht **zwei Einträge mit derselben Kennung** — das ist die +häufigste Stolperfalle: + +**a) Datenbank** – Standort, Beschriftung, Jobsperre: + +```sql +INSERT INTO pc_live_devices (id, label, x, y, z, heading, type, job, active) +VALUES ('mission_row_1', 'LSPD Terminal', + 441.615, -979.645, 30.425, 195.0, 'public', NULL, 1); +``` + +**b) `config.lua`** – Kamerafahrt und Einfrieren beim Öffnen: + +```lua +{ + id = "mission_row_1", -- muss zur Datenbank passen + label = "LSPD Terminal", + coords = vector3(441.615, -979.645, 30.425), + heading = 195.0, + type = "public", -- oder: type = "job", job = "police" +} +``` + +Fehlt der Eintrag in `config.lua`, lässt sich der PC benutzen — aber ohne +Kamera und ohne dass die Spielfigur einfriert. + +Zum `heading`: **nicht** die Ausrichtung des Props, sondern die Seite, von der +aus man auf den Bildschirm schaut. Am einfachsten: davorstellen, in die +gewünschte Richtung schauen, eigene Position und Blickrichtung ablesen — die +Blickrichtung plus 180° ist der Wert. + +### 5. Apps anderer Resources einhängen + +Eine App besteht aus vier Teilen. Beispiel `ic-web`: + +| Teil | Ort | +|---|---| +| Brücke NUI → Server | `client/…lua`, eingetragen in `fxmanifest.lua` | +| Oberfläche | `nui/js/apps/….js`, eingetragen in `fxmanifest.lua` **und** `nui/index.html` | +| Schreibtischsymbol | `APP_META` in `nui/js/desktop.js` | +| Registrierung | `AppRegistry.Register` in `server/apps.lua` | + +Antworten des Servers laufen über den Nachrichtenverteiler in `desktop.js` +(`window.addEventListener('message', …)`). Jede App bekommt dort ihren eigenen +`case`. + +Resources können sich auch von außen eintragen: + +```lua +exports['pc-live']:RegisterApp({ app_id = 'meine.app', name = 'Meine App', … }) +``` + +Die Oberfläche muss trotzdem in `nui/` liegen — das NUI lädt nur Dateien der +eigenen Resource. + +**Eigene App bauen: [PLUGINS.md](PLUGINS.md)** — vollständiges Beispiel von der +Tabelle bis zum Schreibtischsymbol, dazu die Fallen, die sonst Zeit kosten. + +--- + +## Was drin ist + +| App | | +|---|---| +| **Browser** | interne Seiten unter `ic://…`, externe über iframe | +| **Mail** | mehrere Postfächer, Ordner je Postfach, Signaturen, Kalender | +| **Adressbuch** | Kontakte, verknüpft mit Mail | +| **Webhosting** | Domänen und Webseiten (`ic-web`) | +| **Bleeter** | Feed, Werbung, Markt, Kalender, Gewerbe, Profil | +| **Bleeter-Verwaltung** | Unternehmensprofile und Freigaben | +| **Software Store** | Apps nachinstallieren | + +--- + +## Mail: was hier anders ist + +**Postfächer hängen an einer Anmeldung, nicht an der Person.** Über *Postfächer +→ + Postfach hinzufügen* schaltet man ein Postfach mit Adresse und Passwort +frei (geprüft von `ic-web`). Mehrere Personen können dasselbe Postfach +gleichzeitig offen haben — ein Firmenpostfach gehört der Firma. + +**Ordner gehören zum Postfach.** Legt jemand im Firmenpostfach einen Ordner an, +sehen ihn alle, die es bedienen. Sortiert jemand eine Mail ein, liegt sie für +alle im selben Ordner. + +Das ist nicht selbstverständlich, denn eine Mail an ein geteiltes Postfach wird +**je Empfänger einmal** gespeichert. Damit das Einsortieren trotzdem für alle +gilt, tragen alle Kopien einer Zustellung dieselbe `delivery_id` +(`pc_live_mail.delivery_id`) — wird eine verschoben, wandern die anderen mit. + +Gelesen und gelöscht bleibt dagegen persönlich. Das ist keine Eigenschaft der +Nachricht, sondern der Person. + +**Signaturen** gehören ebenfalls zum Postfach (`ic_mail_accounts.signature`). +Wer es bedient, schreibt mit derselben Fußzeile. + +--- + +## Kalender + +| Sichtbarkeit | gehört | sieht | bearbeitet | +|---|---|---|---| +| privat | der Person | nur sie | nur sie | +| geteilt | einem Postfach | wer es bedient | wer es bedient | +| öffentlich | einem Postfach | jeder auf dem Server | wer es bedient | + +Ein öffentlicher Termin hängt bewusst auch an einem Postfach — sonst gäbe es +niemanden, der ihn später ändern oder absagen könnte. + +Änderungen erreichen alle Beteiligten sofort, sonst hätte jeder eine andere +Vorstellung vom Dienstplan, bis er den PC neu öffnet. + +--- + +## Sicherheit + +**Welches Postfach gemeint ist, schickt der Client mit.** Der Server prüft das +bei jeder Ordner- und Mailaktion über `ic-mail:CanAccessAddress` und fällt ohne +Zugriff auf das persönliche Postfach zurück, statt fremde Ordner herauszugeben. +Jede Ordneraktion hängt zusätzlich an einer Besitzprüfung, nicht an der +mitgeschickten Id allein. + +**Keine eingebauten Browserdialoge.** `prompt()`, `confirm()` und `alert()` +haben in FiveMs CEF keinen Handler: der Aufruf öffnet nichts und lässt das NUI +stehen. Stattdessen `ICWebRender.promptText` und `ICWebRender.confirmBox` aus +`nui/js/apps/webhosting.js`. + +**Spielerinhalte werden über `textContent` gesetzt, nie über `innerHTML`.** +Das NUI ist derselbe Kontext wie der PC — wer dort Markup einschleusen kann, +kann auch dessen Callbacks aufrufen. + +--- + +## Voraussetzungen + +| | | +|---|---| +| `oxmysql` | Datenbankzugriff | +| `es_extended` | Spieler, Jobs, Benachrichtigungen | +| `ic-mail` | optional – ohne sie gibt es keine Mailadressen | +| `ic-web` | optional – liefert Webhosting-App und Postfachanmeldung | + +--- + +## Bekannte Fallstricke + +**Der Fensterrahmen hat keine `id`.** `WindowManager.create` setzt +`data-wid`. Wer sein Fenster mit `document.getElementById(WIN_ID)` sucht, +findet nichts und schreibt ins Leere — das Fenster bleibt leer. Richtig ist +das von `create()` zurückgegebene Element oder +`document.querySelector('[data-wid="…"]')`. + +**Ja/Nein-Spalten aus der Datenbank.** `TINYINT(1)` kommt je nach Treiberweg +als `1` oder als `true` an — `oxmysql` wandelt es bei `query`/`single` in einen +Boolean um, bei `prepare` nicht. Ein Vergleich mit `== 1` geht deshalb +irgendwann schief, und zwar still. Siehe `ICWeb.Bool` in `ic-web`. + +**Keine Datenbankarbeit beim Herunterfahren.** Ein `await` in `onResourceStop` +wartet auf eine Antwort, die niemand mehr entgegennimmt — der Server meldet +„Execution of function reference in script host failed". Aufräumen gehört in +den Start. diff --git a/bridge/bridge_esx.lua b/bridge/bridge_esx.lua new file mode 100644 index 0000000..399f1a6 --- /dev/null +++ b/bridge/bridge_esx.lua @@ -0,0 +1,190 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Bridge – ESX Legacy +-- Nur aktiv, wenn Config.Framework == "esx" +-- +-- Angepasst an ESX Legacy 1.13+. Drei Dinge waren gegen aeltere ESX-Staende +-- geschrieben und haetten hier nicht funktioniert: +-- +-- 1. Clientseitig wurde das Objekt ueber TriggerEvent('esx:getSharedObject') +-- geholt. Dieses Event gibt es in ESX Legacy nicht mehr; ESX waere +-- dauerhaft nil geblieben und der PC haette weder Namen noch Identifier +-- gehabt. Richtig ist der Export. +-- 2. Benachrichtigungen liefen ueber 'ESX:ShowNotification'. Das Event heisst +-- 'esx:showNotification' (klein) – der Aufruf ging ins Leere. Serverseitig +-- gibt es dafuer ohnehin xPlayer.showNotification. +-- 3. HasPermission verglich die Gruppe auf Gleichheit, konnte also immer nur +-- genau eine Gruppe zulassen. Jetzt sind Listen moeglich. +-- ───────────────────────────────────────────────────────────────────────────── + +if not Bridge then Bridge = {} end + +if Config.Framework ~= "esx" then return end + +-- ESX erst bei Bedarf holen, nicht beim Laden der Datei. +-- Beim Start ist es je nach Reihenfolge noch nicht bereit; ein einmal +-- fehlgeschlagener Versuch haette die Bridge dauerhaft lahmgelegt. +local ESX + +local function esx() + if ESX then return ESX end + local ok, obj = pcall(function() + return exports['es_extended']:getSharedObject() + end) + if ok and obj then ESX = obj end + return ESX +end + +if IsDuplicityVersion() then + -- ── SERVER ──────────────────────────────────────────────────────────────── + + local function player(source) + local api = esx() + return api and api.GetPlayerFromId(source) or nil + end + + function Bridge.GetIdentifier(source) + local xPlayer = player(source) + if not xPlayer then return tostring(source) end + return xPlayer.identifier + end + + function Bridge.GetName(source) + local xPlayer = player(source) + if not xPlayer then return "Unknown" end + if xPlayer.getName then return xPlayer.getName() end + return xPlayer.name or "Unknown" + end + + function Bridge.Notify(source, message, notifType) + local xPlayer = player(source) + if xPlayer and xPlayer.showNotification then + xPlayer.showNotification(message, notifType) + return + end + -- Rueckfall, falls der Spieler (noch) nicht geladen ist. + TriggerClientEvent('esx:showNotification', source, message, notifType) + end + + -- perm darf ein einzelner Gruppenname oder eine Liste sein. + -- Vorher war nur Gleichheit moeglich, also genau eine erlaubte Gruppe. + function Bridge.HasPermission(source, perm) + local xPlayer = player(source) + if not xPlayer or not xPlayer.getGroup then return false end + + local group = xPlayer.getGroup() + if not group then return false end + + if type(perm) == "table" then + -- Beide Schreibweisen: Liste { 'admin', 'superadmin' } + -- und Menge { admin = true, superadmin = true }. + if perm[group] == true then return true end + for _, allowed in ipairs(perm) do + if allowed == group then return true end + end + return false + end + + return group == perm + end + + function Bridge.GetMoney(source, moneyType) + local xPlayer = player(source) + if not xPlayer then return 0 end + if moneyType == "cash" or moneyType == "money" then + return xPlayer.getMoney() + end + local account = xPlayer.getAccount(moneyType) + return account and account.money or 0 + end + + function Bridge.RemoveMoney(source, moneyType, amount) + local xPlayer = player(source) + if not xPlayer then return false end + + amount = tonumber(amount) or 0 + if amount <= 0 then return false end + + -- Deckung pruefen: ohne das erzeugt ESX negative Kontostaende. + if Bridge.GetMoney(source, moneyType) < amount then return false end + + if moneyType == "cash" or moneyType == "money" then + xPlayer.removeMoney(amount) + else + xPlayer.removeAccountMoney(moneyType, amount) + end + return true + end + + function Bridge.AddMoney(source, moneyType, amount) + local xPlayer = player(source) + if not xPlayer then return false end + + amount = tonumber(amount) or 0 + if amount <= 0 then return false end + + if moneyType == "cash" or moneyType == "money" then + xPlayer.addMoney(amount) + else + xPlayer.addAccountMoney(moneyType, amount) + end + return true + end + + function Bridge.GetJob(source) + local xPlayer = player(source) + if not xPlayer then return nil end + return xPlayer.job and xPlayer.job.name or nil + end + + -- Firmenzugehoerigkeit. + -- + -- Im Ursprungssystem war das eine numerische Firmen-ID aus einer eigenen + -- Tabelle. ESX kennt so etwas nicht – dort ist die Zugehoerigkeit der Job. + -- Ueber Config.CompanyJobs laesst sich eine ID auf einen ESX-Job abbilden; + -- ohne Eintrag gibt es keine Zugehoerigkeit, statt sie zu erfinden. + function Bridge.IsCompanyEmployee(source, companyId) + if not companyId then return false end + + local map = Config.CompanyJobs or {} + local wanted = map[companyId] or map[tostring(companyId)] + if not wanted then return false end + + local job = Bridge.GetJob(source) + return job ~= nil and job == wanted + end + +else + -- ── CLIENT ──────────────────────────────────────────────────────────────── + + function Bridge.GetLocalIdentifier() + local api = esx() + local data = api and api.GetPlayerData() + return (data and data.identifier) or "" + end + + function Bridge.GetLocalName() + local api = esx() + local data = api and api.GetPlayerData() + if not data then return "Unknown" end + + -- ESX Legacy fuehrt den Namen je nach Aufbau unterschiedlich. + if data.name and data.name ~= "" then return data.name end + if data.firstName then + return ("%s %s"):format(data.firstName, data.lastName or ""):gsub("%s+$", "") + end + return GetPlayerName(PlayerId()) or "Unknown" + end + + -- pc-live sendet Benachrichtigungen teils ueber ein eigenes Event. + -- Auf ESX wird daraus eine ESX-Notification. + RegisterNetEvent('pc-live:notification', function(message, notifType) + local api = esx() + if api and api.ShowNotification then + api.ShowNotification(message, notifType) + else + SetNotificationTextEntry("STRING") + AddTextComponentString(tostring(message)) + DrawNotification(false, true) + end + end) +end diff --git a/bridge/bridge_qbcore.lua b/bridge/bridge_qbcore.lua new file mode 100644 index 0000000..ca613e8 --- /dev/null +++ b/bridge/bridge_qbcore.lua @@ -0,0 +1,73 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Bridge – QBCore +-- Only activates when Config.Framework == "qbcore" +-- ───────────────────────────────────────────────────────────────────────────── + +-- Declare Bridge table on first load +if not Bridge then Bridge = {} end + +if Config.Framework ~= "qbcore" then return end + +if IsDuplicityVersion() then + -- SERVER SIDE + local QBCore = exports['qb-core']:GetCoreObject() + + function Bridge.GetIdentifier(source) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return tostring(source) end + return Player.PlayerData.citizenid + end + + function Bridge.GetName(source) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return "Unknown" end + local pd = Player.PlayerData.charinfo + return ("%s %s"):format(pd.firstname, pd.lastname) + end + + function Bridge.Notify(source, message, notifType) + TriggerClientEvent('QBCore:Notify', source, message, notifType or "primary") + end + + function Bridge.HasPermission(source, perm) + return QBCore.Functions.HasPermission(source, perm) + end + + function Bridge.GetMoney(source, moneyType) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return 0 end + return Player.Functions.GetMoney(moneyType) + end + + function Bridge.RemoveMoney(source, moneyType, amount) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return false end + return Player.Functions.RemoveMoney(moneyType, amount, "pc-live-purchase") + end + + function Bridge.AddMoney(source, moneyType, amount) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return false end + return Player.Functions.AddMoney(moneyType, amount, "pc-live-refund") + end + + function Bridge.GetJob(source) + local Player = QBCore.Functions.GetPlayer(source) + if not Player then return nil end + return Player.PlayerData.job.name + end + +else + -- CLIENT SIDE + local QBCore = exports['qb-core']:GetCoreObject() + + function Bridge.GetLocalIdentifier() + return QBCore.Functions.GetPlayerData().citizenid or "" + end + + function Bridge.GetLocalName() + local pd = QBCore.Functions.GetPlayerData().charinfo + if not pd then return "Unknown" end + return ("%s %s"):format(pd.firstname, pd.lastname) + end +end diff --git a/bridge/bridge_standalone.lua b/bridge/bridge_standalone.lua new file mode 100644 index 0000000..e1cefd1 --- /dev/null +++ b/bridge/bridge_standalone.lua @@ -0,0 +1,68 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Bridge – Standalone +-- Nur aktiv, wenn Config.Framework == "standalone" +-- +-- Diese Fassung kommt OHNE Fremdresourcen und ohne Fremdtabellen aus. +-- +-- Vorher hing sie an core-characters und am ic_*-Schema (ic_persons, +-- ic_bank_accounts, ic_jobs, ic_company_employees) – also an genau dem +-- System, aus dem pc-live urspruenglich stammt. Auf einem Server, der das +-- nicht hat, war "standalone" damit nicht standalone, sondern schlicht kaputt. +-- +-- Was dadurch entfaellt: Geld und Jobs. Ein Server ohne Framework hat weder +-- Konten noch Jobs; hier etwas vorzutaeuschen waere schlimmer als es +-- wegzulassen. Wer beides braucht, nimmt Config.Framework = "esx". +-- ───────────────────────────────────────────────────────────────────────────── + +if not Bridge then Bridge = {} end + +if Config.Framework ~= "standalone" then return end + +if IsDuplicityVersion() then + -- ── SERVER ──────────────────────────────────────────────────────────────── + + function Bridge.GetIdentifier(source) + return GetPlayerIdentifierByType(tostring(source), 'license') + or ("player:" .. tostring(source)) + end + + function Bridge.GetName(source) + return GetPlayerName(source) or "Unbekannt" + end + + function Bridge.Notify(source, message, notifType) + TriggerClientEvent('pc-live:notification', source, message, notifType or "primary") + end + + function Bridge.HasPermission(source, perm) + return IsPlayerAceAllowed(source, "pc-live." .. tostring(perm)) + or IsPlayerAceAllowed(source, "admin") + end + + -- Ohne Framework gibt es kein Geld. Konsequent 0 bzw. false zurueckgeben, + -- damit Kaeufe sauber scheitern statt Guthaben zu erfinden. + function Bridge.GetMoney(_source, _moneyType) return 0 end + function Bridge.RemoveMoney(_source, _t, _a) return false end + function Bridge.AddMoney(_source, _t, _a) return false end + + -- Ebenso ohne Jobs: keine job-gebundenen Terminals und Apps. + function Bridge.GetJob(_source) return nil end + +else + -- ── CLIENT ──────────────────────────────────────────────────────────────── + + function Bridge.GetLocalIdentifier() + return GetPlayerIdentifierByType(tostring(PlayerId()), 'license') + or ("player:" .. GetPlayerServerId(PlayerId())) + end + + function Bridge.GetLocalName() + return GetPlayerName(PlayerId()) or "Unbekannt" + end + + RegisterNetEvent('pc-live:notification', function(message, _notifType) + SetNotificationTextEntry("STRING") + AddTextComponentString(tostring(message)) + DrawNotification(false, true) + end) +end diff --git a/client/bleeter.lua b/client/bleeter.lua new file mode 100644 index 0000000..04ae0b4 --- /dev/null +++ b/client/bleeter.lua @@ -0,0 +1,73 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- pc-live | Bruecke zu Bleeter +-- +-- Bleeter lief hier zunaechst als iframe, dessen Nachrichten hin- und +-- zurueckgereicht wurden. Das ging schief: SetNuiFocus gilt global fuer den +-- Client, und zwei Resourcen, die den Fokus beanspruchen, sperren die Maus aus. +-- +-- Jetzt spricht der PC direkt mit dem Server von Bleeter – wie jede andere +-- App auch. Bleeter selbst bleibt unberuehrt und oeffnet weiterhin sein +-- eigenes Fenster ueber /bleeter. +-- +-- Wie bei ic-web steht die Liste erlaubter Aktionen fest, sonst koennte das +-- NUI jedes beliebige Serverereignis ausloesen. +-- ───────────────────────────────────────────────────────────────────────────── + +local OPS = { + -- Anzeigen und mitmachen + requestBootstrap = 'bleeter:server:requestBootstrap', + registerAccount = 'bleeter:server:registerAccount', + checkHandle = 'bleeter:server:checkHandle', + setActiveProfile = 'bleeter:server:setActiveProfile', + createPost = 'bleeter:server:createPost', + togglePostLike = 'bleeter:server:togglePostLike', + deleteOwnPost = 'bleeter:server:deleteOwnPost', + createComment = 'bleeter:server:createComment', + deleteOwnComment = 'bleeter:server:deleteOwnComment', + followProfile = 'bleeter:server:followProfile', + unfollowProfile = 'bleeter:server:unfollowProfile', + updateProfile = 'bleeter:server:updateProfile', + uploadMedia = 'bleeter:server:uploadMedia', + createMarketplaceEntry = 'bleeter:server:createMarketplaceEntry', + deleteMarketplaceEntry = 'bleeter:server:deleteMarketplaceEntry', + createEvent = 'bleeter:server:createEvent', + deleteEvent = 'bleeter:server:deleteEvent', + + -- Verwaltung + listBusinessProfiles = 'bleeter:server:listBusinessProfiles', + createBusinessProfile = 'bleeter:server:createBusinessProfile', + listProfileMembers = 'bleeter:server:listProfileMembers', + listCandidates = 'bleeter:server:listCandidates', + setProfileMember = 'bleeter:server:setProfileMember', +} + +RegisterNUICallback('bleeter', function(data, cb) + cb({}) + + local event = OPS[tostring(data.op or '')] + if not event then return end + + if data.payload ~= nil then + TriggerServerEvent(event, data.payload) + else + TriggerServerEvent(event) + end +end) + +-- Der Server schickt alles ueber dieses eine Ereignis: den vollstaendigen +-- Zustand und die Antworten der Verwaltung. Beides geht unveraendert ins NUI, +-- dort wird unterschieden. +RegisterNetEvent('bleeter:client:data', function(message) + if type(message) ~= 'table' then return end + SendNUIMessage({ action = 'bleeter_data', data = message }) +end) + +RegisterNetEvent('bleeter:client:notify', function(payload) + SendNUIMessage({ action = 'bleeter_notify', data = payload or {} }) +end) + +-- Antwort auf einen Bilder-Upload. Bleeter laedt die Datei zu ImgBB hoch und +-- meldet die fertige Adresse zurueck. +RegisterNetEvent('bleeter:client:mediaUploaded', function(payload) + SendNUIMessage({ action = 'bleeter_uploaded', data = payload or {} }) +end) diff --git a/client/camera.lua b/client/camera.lua new file mode 100644 index 0000000..595bcd6 --- /dev/null +++ b/client/camera.lua @@ -0,0 +1,45 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Camera system +-- ───────────────────────────────────────────────────────────────────────────── + +Camera = {} + +local _cam = nil + +--- Dolly to a point in front of the PC, looking at the screen. +---@param pcCoords vector3 +---@param pcHeading number +function Camera.FocusPC(pcCoords, pcHeading) + if not Config.CameraOnOpen then return end + if _cam then Camera.Restore() end + + -- Position cam slightly behind and above the PC position (facing forward) + local rad = math.rad(pcHeading) + local offset = 0.6 + local cx = pcCoords.x + math.sin(rad) * offset + local cy = pcCoords.y - math.cos(rad) * offset + local cz = pcCoords.z + 0.55 + + _cam = CreateCameraWithParams( + "DEFAULT_SCRIPTED_CAMERA", + cx, cy, cz, + 0.0, 0.0, 0.0, + 60.0, false, 2 + ) + SetCamActive(_cam, true) + RenderScriptCams(true, true, 600, true, false) + + -- Point at screen face + local tx = pcCoords.x - math.sin(rad) * 0.3 + local ty = pcCoords.y + math.cos(rad) * 0.3 + local tz = pcCoords.z + 0.5 + PointCamAtCoord(_cam, tx, ty, tz) +end + +--- Return to player camera. +function Camera.Restore() + if not _cam then return end + RenderScriptCams(false, true, 500, true, false) + DestroyCam(_cam, false) + _cam = nil +end diff --git a/client/interaction.lua b/client/interaction.lua new file mode 100644 index 0000000..0a9dcab --- /dev/null +++ b/client/interaction.lua @@ -0,0 +1,163 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Interaction system – proximity detection, key prompt +-- ───────────────────────────────────────────────────────────────────────────── + +Interaction = {} + +local _nearbyPC = nil -- current nearby PC config or nil +local _inSession = false +local _camCfg = nil -- aktive PC-Kamera-Config (coords/heading) oder nil (mobil) +local _pcs = {} -- keyed by id, populated from server + +-- Sync-Liste vom Server empfangen +RegisterNetEvent('pc-live:client:syncDevices', function(list) + _pcs = {} + for _, pc in ipairs(list) do + pc.coords = vector3(pc.coords.x, pc.coords.y, pc.coords.z) + _pcs[pc.id] = pc + end +end) + +-- Device-Liste beim Start anfordern +AddEventHandler('onClientResourceStart', function(res) + if res == GetCurrentResourceName() then + TriggerServerEvent('pc-live:server:requestDevices') + end +end) + +local function DrawText3D(x, y, z, text) + local onScreen, sx, sy = World3dToScreen2d(x, y, z) + if not onScreen then return end + SetTextScale(0.0, 0.45) + SetTextFont(4) + SetTextProportional(1) + SetTextColour(255, 255, 255, 215) + SetTextEntry("STRING") + SetTextCentre(true) + AddTextComponentString(text) + DrawText(sx, sy) +end + +local function DrawRect2D(x, y, width, height, r, g, b, a) + DrawRect(x, y, width, height, r, g, b, a) +end + +--- Main proximity loop. +function Interaction.StartLoop() + CreateThread(function() + while true do + local sleep = 1000 + if not _inSession then + local ped = PlayerPedId() + local coords = GetEntityCoords(ped) + + local found = nil + for _, pc in pairs(_pcs) do + local dist = #(coords - pc.coords) + if dist < Config.InteractRange + 8.0 then + sleep = 0 + if dist < Config.InteractRange then + found = pc + break + end + end + end + _nearbyPC = found + else + _nearbyPC = nil + end + Wait(sleep) + end + end) +end + +--- Draw prompt and handle key. +function Interaction.StartPromptLoop() + CreateThread(function() + while true do + Wait(0) + if _nearbyPC and not _inSession then + -- Draw 3D label + DrawText3D( + _nearbyPC.coords.x, + _nearbyPC.coords.y, + _nearbyPC.coords.z + 1.1, + ("[E] %s"):format(_nearbyPC.label) + ) + + if IsControlJustReleased(0, Config.InteractKey) then + Interaction.Open(_nearbyPC) + end + end + end + end) +end + +--- Open PC – request session from server. +---@param pc table +function Interaction.Open(pc) + if _inSession then return end + TriggerServerEvent('pc-live:server:openPC', pc.id) +end + +--- Called when the session starts (from server confirmation). +---@param data table +function Interaction.OnSessionStart(data) + _inSession = true + _nearbyPC = nil + + -- PC-Config finden; mobile Terminals (Streifenwagen) haben keinen Standort/Kamera. + local pcCfg + for _, pc in ipairs(Config.PCs) do + if pc.id == data.pcId then pcCfg = pc; break end + end + local isMobile = pcCfg and pcCfg.mobile + + if Config.FreezeOnOpen and not isMobile then + FreezeEntityPosition(PlayerPedId(), true) + end + + if pcCfg and not isMobile then + _camCfg = { coords = pcCfg.coords, heading = pcCfg.heading } + Camera.FocusPC(pcCfg.coords, pcCfg.heading) + else + _camCfg = nil + end +end + +-- Interaktionsmodus: PC-Kamera lösen (Gameplay-Kamera zurück -> umsehen möglich) +-- bzw. wieder auf den PC richten, wenn man zurückkehrt. +function Interaction.EnterInteract() + if _camCfg then Camera.Restore() end +end +function Interaction.ExitInteract() + if _camCfg then Camera.FocusPC(_camCfg.coords, _camCfg.heading) end +end + +--- Öffnet ein mobiles Terminal (z.B. Streifenwagen-PC) – kein Standort/Distanz nötig. +---@param pcId string|nil +function Interaction.OpenMobile(pcId) + if _inSession then return end + TriggerServerEvent('pc-live:server:openPC', pcId or 'police_mobile') +end + +-- Wird vom M-Menü (core-interaction) im Einsatzfahrzeug getriggert. +AddEventHandler('pc-live:openMobileTerminal', function(pcId) + Interaction.OpenMobile(pcId) +end) + +--- Called when closing the session. +function Interaction.OnSessionClose() + _inSession = false + + if Config.FreezeOnOpen then + FreezeEntityPosition(PlayerPedId(), false) + end + + Camera.Restore() + _camCfg = nil + TriggerServerEvent('pc-live:server:closePC') +end + +Interaction.StartLoop() +Interaction.StartPromptLoop() diff --git a/client/main.lua b/client/main.lua new file mode 100644 index 0000000..e6a4e7d --- /dev/null +++ b/client/main.lua @@ -0,0 +1,141 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Client Main – event listeners, glue +-- ───────────────────────────────────────────────────────────────────────────── + +Bridge = Bridge or {} + +-- ── Server → Client events ─────────────────────────────────────────────────── + +-- Session granted by server +RegisterNetEvent('pc-live:client:sessionStart', function(data) + Interaction.OnSessionStart(data) + -- Add accent color to data + data.accentColor = Config.AccentColor + NUIBridge.Boot(data) +end) + +-- Desktop app list update +RegisterNetEvent('pc-live:client:installedApps', function(apps) + NUIBridge.SetInstalledApps(apps) +end) + +-- Mail +RegisterNetEvent('pc-live:client:inboxData', function(rows, folders) + NUIBridge.SetInbox(rows, folders) +end) + +RegisterNetEvent('pc-live:client:mailContent', function(mail) + NUIBridge.SetMailContent(mail) +end) + +RegisterNetEvent('pc-live:client:mailSent', function() + NUIBridge.MailSent() +end) + +RegisterNetEvent('pc-live:client:mailDeleted', function(id) + NUIBridge.MailDeleted(id) +end) + +RegisterNetEvent('pc-live:client:newMailNotify', function() + NUIBridge.NewMailNotify() +end) + +RegisterNetEvent('pc-live:client:mailMoved', function(mailId, folderId) + SendNUIMessage({ action = 'mailMoved', data = { id = mailId, folderId = folderId } }) +end) + +RegisterNetEvent('pc-live:client:sentData', function(rows) + SendNUIMessage({ action = 'setSent', data = rows }) +end) + +RegisterNetEvent('pc-live:client:folderCreated', function(folder) + SendNUIMessage({ action = 'folderCreated', data = folder }) +end) + +RegisterNetEvent('pc-live:client:folderDeleted', function(folderId, address) + SendNUIMessage({ action = 'folderDeleted', data = { id = folderId, address = address or '' } }) +end) + +RegisterNetEvent('pc-live:client:folderMailsData', function(folderId, rows) + SendNUIMessage({ action = 'folderMailsData', data = { folderId = folderId, rows = rows } }) +end) + +-- Calendar +RegisterNetEvent('pc-live:client:calendarData', function(rows) + SendNUIMessage({ action = 'calendarData', data = rows }) +end) + +RegisterNetEvent('pc-live:client:calendarEventAdded', function(ev) + SendNUIMessage({ action = 'calendarEventAdded', data = ev }) +end) + +RegisterNetEvent('pc-live:client:calendarEventUpdated', function(ev) + SendNUIMessage({ action = 'calendarEventUpdated', data = ev }) +end) + +RegisterNetEvent('pc-live:client:calendarEventDeleted', function(id) + SendNUIMessage({ action = 'calendarEventDeleted', data = { id = id } }) +end) + +-- Contacts +RegisterNetEvent('pc-live:client:contactsData', function(rows) + SendNUIMessage({ action = 'contactsData', data = rows }) +end) + +RegisterNetEvent('pc-live:client:contactAdded', function(c) + SendNUIMessage({ action = 'contactAdded', data = c }) +end) + +RegisterNetEvent('pc-live:client:contactUpdated', function(c) + SendNUIMessage({ action = 'contactUpdated', data = c }) +end) + +RegisterNetEvent('pc-live:client:contactDeleted', function(id) + SendNUIMessage({ action = 'contactDeleted', data = { id = id } }) +end) + +-- Geteilte Postfächer +RegisterNetEvent('pc-live:client:foldersData', function(address, folders) + SendNUIMessage({ action = 'foldersData', data = { address = address, folders = folders } }) +end) + +RegisterNetEvent('pc-live:client:signatureSaved', function(ok, err) + SendNUIMessage({ action = 'signatureSaved', data = { ok = ok, error = err } }) +end) + +RegisterNetEvent('pc-live:client:sharedMailboxes', function(mailboxes, personalAddr, personalSig) + SendNUIMessage({ action = 'sharedMailboxes', data = { + mailboxes = mailboxes, personalAddress = personalAddr, + personalSignature = personalSig or '', + } }) +end) + +-- Store +RegisterNetEvent('pc-live:client:storeData', function(apps) + NUIBridge.SetStoreList(apps) +end) + +-- Disable attack controls while PC is open (prevents character punching on mouse click) +CreateThread(function() + while true do + Wait(0) + if IsNuiFocused() then + DisableControlAction(0, 24, true) -- Attack (left click) + DisableControlAction(0, 25, true) -- Aim (right click) + DisableControlAction(0, 140, true) -- Melee Attack Light + DisableControlAction(0, 141, true) -- Melee Attack Heavy + DisableControlAction(0, 142, true) -- Melee Attack Alternate + DisableControlAction(0, 143, true) -- Melee Attack 2 + end + end +end) + +Utils.Log("Client main loaded.") + +RegisterNetEvent('pc-live:client:mailAddressStatus', function(hasAddress, address, realms) + NUIBridge.MailAddressStatus(hasAddress, address, realms) +end) + +RegisterNetEvent('pc-live:client:mailCreateResult', function(success, err, address) + NUIBridge.MailCreateResult(success, err, address) +end) diff --git a/client/nui.lua b/client/nui.lua new file mode 100644 index 0000000..d83b5d0 --- /dev/null +++ b/client/nui.lua @@ -0,0 +1,315 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- NUI bridge – all SendNUIMessage / RegisterNUICallback calls +-- ───────────────────────────────────────────────────────────────────────────── + +NUIBridge = {} + +local pcOpen = false -- ist der PC gerade offen? (für Interaktionsmodus-Taste) + +local function Send(action, data) + SendNUIMessage({ action = action, data = data or {} }) +end + +-- ── Outbound (Lua -> NUI) ─────────────────────────────────────────────────── + +function NUIBridge.Boot(sessionData) + SetNuiFocus(true, true) + pcOpen = true + Send("boot", sessionData) +end + +function NUIBridge.SetInstalledApps(apps) + Send("setInstalledApps", apps) +end + +function NUIBridge.SetInbox(mails, folders) + Send("setInbox", { mails = mails, folders = folders or {} }) +end + +function NUIBridge.MailAddressStatus(hasAddress, address, realms) + Send("mailAddressStatus", { hasAddress = hasAddress, address = address, realms = realms or {} }) +end + +function NUIBridge.MailCreateResult(success, err, address) + Send("mailCreateResult", { success = success, error = err, address = address }) +end + +function NUIBridge.SetMailContent(mail) + Send("setMailContent", mail) +end + +function NUIBridge.MailSent() + Send("mailSent") +end + +function NUIBridge.MailDeleted(id) + Send("mailDeleted", { id = id }) +end + +function NUIBridge.SetStoreList(apps) + Send("setStoreList", apps) +end + +function NUIBridge.NewMailNotify() + Send("newMailNotify") +end + +function NUIBridge.Close() + SetNuiFocus(false, false) + pcOpen = false + Send("close") +end + +-- Interaktionsmodus: NUI-Fokus vom PC lösen (Spiel-Interaktion) bzw. zurückholen. +RegisterNUICallback('setFocus', function(data, cb) + local focus = data and data.focus and true or false + SetNuiFocus(focus, focus) + if focus then + if Interaction and Interaction.ExitInteract then Interaction.ExitInteract() end + else + if Interaction and Interaction.EnterInteract then Interaction.EnterInteract() end + end + cb({}) +end) + +-- Taste (Standard: END, in den FiveM-Einstellungen umbelegbar), um aus dem +-- Interaktionsmodus wieder zum PC (Fokus) zurückzukehren. +RegisterCommand('pclive_refocus', function() + if pcOpen then + SetNuiFocus(true, true) + if Interaction and Interaction.ExitInteract then Interaction.ExitInteract() end + Send('exitInteract') + end +end, false) +RegisterKeyMapping('pclive_refocus', 'PC-Live: Zurück zum PC (Fokus)', 'keyboard', 'END') + +function NUIBridge.SetAccentColor(color) + Send("setAccentColor", { color = color }) +end + +-- ── Inbound (NUI -> Lua) ──────────────────────────────────────────────────── + +RegisterNUICallback('closePC', function(data, cb) + NUIBridge.Close() + Interaction.OnSessionClose() + cb({}) +end) + +-- Mail +RegisterNUICallback('checkMailAddress', function(data, cb) + TriggerServerEvent('pc-live:server:checkMailAddress') + cb({}) +end) + +RegisterNUICallback('createMailAddress', function(data, cb) + TriggerServerEvent('pc-live:server:createMailAddress', data.username) + cb({}) +end) + +RegisterNUICallback('getInbox', function(data, cb) + TriggerServerEvent('pc-live:server:getInbox') + cb({}) +end) + +RegisterNUICallback('readMail', function(data, cb) + TriggerServerEvent('pc-live:server:readMail', tonumber(data.id)) + cb({}) +end) + +RegisterNUICallback('sendMail', function(data, cb) + TriggerServerEvent('pc-live:server:sendMail', { + to = tostring(data.to or ""), + subject = tostring(data.subject or ""), + body = tostring(data.body or ""), + fromMailbox = tostring(data.fromMailbox or ""), + }) + cb({}) +end) + +RegisterNUICallback('setSignature', function(data, cb) + TriggerServerEvent('pc-live:server:setSignature', + tostring(data.address or ""), tostring(data.signature or "")) + cb({}) +end) + +RegisterNUICallback('getSharedMailboxes', function(data, cb) + TriggerServerEvent('pc-live:server:getSharedMailboxes') + cb({}) +end) + +RegisterNUICallback('deleteMail', function(data, cb) + TriggerServerEvent('pc-live:server:deleteMail', tonumber(data.id)) + cb({}) +end) + +RegisterNUICallback('getSent', function(data, cb) + TriggerServerEvent('pc-live:server:getSent') + cb({}) +end) + +RegisterNUICallback('getFolders', function(data, cb) + TriggerServerEvent('pc-live:server:getFolders', tostring(data.address or "")) + cb({}) +end) + +RegisterNUICallback('getFolderMails', function(data, cb) + TriggerServerEvent('pc-live:server:getFolderMails', tonumber(data.folderId), + tostring(data.address or "")) + cb({}) +end) + +RegisterNUICallback('createFolder', function(data, cb) + TriggerServerEvent('pc-live:server:createFolder', tostring(data.name or ""), + tostring(data.address or "")) + cb({}) +end) + +RegisterNUICallback('deleteFolder', function(data, cb) + TriggerServerEvent('pc-live:server:deleteFolder', tonumber(data.id), + tostring(data.address or "")) + cb({}) +end) + +RegisterNUICallback('moveMail', function(data, cb) + local folderId = data.folderId ~= nil and tonumber(data.folderId) or nil + TriggerServerEvent('pc-live:server:moveMail', tonumber(data.id), folderId, + tostring(data.address or "")) + cb({}) +end) + +-- Calendar +RegisterNUICallback('getCalendar', function(data, cb) + TriggerServerEvent('pc-live:server:getCalendar') + cb({}) +end) + +RegisterNUICallback('addCalendarEvent', function(data, cb) + TriggerServerEvent('pc-live:server:addCalendarEvent', data) + cb({}) +end) + +RegisterNUICallback('updateCalendarEvent', function(data, cb) + TriggerServerEvent('pc-live:server:updateCalendarEvent', tonumber(data.id), data) + cb({}) +end) + +RegisterNUICallback('deleteCalendarEvent', function(data, cb) + TriggerServerEvent('pc-live:server:deleteCalendarEvent', tonumber(data.id)) + cb({}) +end) + +-- Contacts +RegisterNUICallback('getContacts', function(data, cb) + TriggerServerEvent('pc-live:server:getContacts') + cb({}) +end) + +RegisterNUICallback('addContact', function(data, cb) + TriggerServerEvent('pc-live:server:addContact', data) + cb({}) +end) + +RegisterNUICallback('updateContact', function(data, cb) + TriggerServerEvent('pc-live:server:updateContact', tonumber(data.id), data) + cb({}) +end) + +RegisterNUICallback('deleteContact', function(data, cb) + TriggerServerEvent('pc-live:server:deleteContact', tonumber(data.id)) + cb({}) +end) + +-- Store +RegisterNUICallback('getStore', function(data, cb) + TriggerServerEvent('pc-live:server:getStore') + cb({}) +end) + +RegisterNUICallback('installApp', function(data, cb) + TriggerServerEvent('pc-live:server:installApp', tostring(data.appId or "")) + cb({}) +end) + +RegisterNUICallback('uninstallApp', function(data, cb) + TriggerServerEvent('pc-live:server:uninstallApp', tostring(data.appId or "")) + cb({}) +end) + +-- Browser – IC pages fetched locally from config +RegisterNUICallback('getBrowserPage', function(data, cb) + local slug = tostring(data.slug or "home") + local page = Config.ICPages[slug] + cb({ found = page ~= nil, page = page, slug = slug }) +end) + +-- ── IC-Verwaltung ──────────────────────────────────────────────────────────── + +-- Relay messages from ic-verwaltung to pc-live NUI (→ iframe postMessage) +AddEventHandler('ic-verwaltung:relayToPC', function(msg) + SendNUIMessage({ action = 'ic_verwaltung_relay', data = msg }) +end) + +RegisterNUICallback('openIcVerwaltung', function(data, cb) + cb({}) + -- Open ic-verwaltung in pcMode: no focus changes, UI relayed via postMessage + TriggerEvent('ic-verwaltung:openForPC') +end) + +RegisterNUICallback('closeIcVerwaltung', function(data, cb) + cb({}) + -- Forward close action into ic-verwaltung (user closed the PC window) + TriggerEvent('ic-verwaltung:closeFromPC') +end) + +-- ── Parkuhr Integration ────────────────────────────────────────────────────── + +local _pendingParkuhrCb = nil + +-- Antwort vom Parkuhr-Server empfangen und an NUI weiterleiten +RegisterNetEvent('parkuhr:panelData', function(payload) + if _pendingParkuhrCb then + _pendingParkuhrCb(payload or { devices = {}, typeTariffs = {}, currency = "$" }) + _pendingParkuhrCb = nil + end +end) + +RegisterNUICallback('getParkuhrPanel', function(data, cb) + _pendingParkuhrCb = cb + TriggerServerEvent('parkuhr:requestPanelData') +end) + +RegisterNUICallback('saveParkuhrTariff', function(data, cb) + TriggerServerEvent('parkuhr:saveTariff', + tonumber(data.deviceId), + tonumber(data.duration), + tonumber(data.price) + ) + cb({}) +end) + +RegisterNUICallback('deleteParkuhrTariff', function(data, cb) + TriggerServerEvent('parkuhr:deleteTariff', + tonumber(data.deviceId), + tonumber(data.duration) + ) + cb({}) +end) + +-- ── PBS Dashboard ──────────────────────────────────────────────────────────── + +RegisterNUICallback('getPbsUrl', function(data, cb) + cb({ url = GetConvar('pbs_web_url', 'http://localhost:4088') }) +end) + +-- ── Bleeter App ─────────────────────────────────────────────────────────────── + +RegisterNUICallback('openBleeter', function(data, cb) + local mode = (data and data.mode) or 'desktop' + -- PC bleibt offen; Bleeter öffnet sich als Overlay darüber + if exports['bleeter'] then + exports['bleeter']:OpenBleeter(mode) + else + TriggerEvent('bleeter:client:open') + end + cb({ ok = true }) +end) diff --git a/client/webhosting.lua b/client/webhosting.lua new file mode 100644 index 0000000..66c81b1 --- /dev/null +++ b/client/webhosting.lua @@ -0,0 +1,73 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- pc-live | Bruecke zu ic-web +-- +-- Das NUI kann nur Callbacks der eigenen Resource aufrufen. Diese Datei +-- uebersetzt sie in Netzwerkereignisse von ic-web und schiebt die Antwort +-- ueber die Anfrage-Id zurueck ins NUI. +-- +-- Die Liste erlaubter Aktionen steht hier bewusst fest: sonst koennte das NUI +-- jedes beliebige Serverereignis ausloesen. +-- +-- Passwoerter laufen hier durch, werden aber weder gespeichert noch +-- protokolliert – nur weitergereicht. +-- ───────────────────────────────────────────────────────────────────────────── + +local OPS = { + -- Anmeldung + login = 'ic-web:server:login', + logout = 'ic-web:server:logout', + session = 'ic-web:server:session', + changePassword = 'ic-web:server:changePassword', + -- Postfaecher im Mailclient + openMailbox = 'ic-web:server:openMailbox', + closeMailbox = 'ic-web:server:closeMailbox', + listMailboxes = 'ic-web:server:listMailboxes', + -- Seiten + getSite = 'ic-web:server:getSite', + listMine = 'ic-web:server:listMine', + getEditable = 'ic-web:server:getEditable', + savePage = 'ic-web:server:savePage', + deletePage = 'ic-web:server:deletePage', + updateSite = 'ic-web:server:updateSite', + report = 'ic-web:server:report', + register = 'ic-web:server:register', + -- Zugaenge + listAccounts = 'ic-web:server:listAccounts', + createAccount = 'ic-web:server:createAccount', + setAccountPassword = 'ic-web:server:setAccountPassword', + setAccountActive = 'ic-web:server:setAccountActive', + setAccountRole = 'ic-web:server:setAccountRole', + deleteAccount = 'ic-web:server:deleteAccount', + -- Anbieterebene + adminListSites = 'ic-web:server:adminListSites', + adminListAccounts = 'ic-web:server:adminListAccounts', + adminListJobs = 'ic-web:server:adminListJobs', + adminRegister = 'ic-web:server:adminRegister', + adminSetBlocked = 'ic-web:server:adminSetBlocked', + adminSetOwner = 'ic-web:server:adminSetOwner', + adminDeleteSite = 'ic-web:server:adminDeleteSite', + adminListReports = 'ic-web:server:adminListReports', + adminSetReportStatus = 'ic-web:server:adminSetReportStatus', +} + +RegisterNUICallback('icweb', function(data, cb) + cb({}) + + local event = OPS[tostring(data.op or '')] + if not event then return end + + local reqId = tostring(data.reqId or '') + if reqId == '' then return end + + -- args kommt als JSON-Array an; leere Stellen schickt das NUI als ''. + local args = data.args or {} + TriggerServerEvent(event, reqId, + args[1], args[2], args[3], args[4], args[5], args[6]) +end) + +RegisterNetEvent('ic-web:client:response', function(reqId, payload) + SendNUIMessage({ + action = 'icweb_response', + data = { reqId = reqId, payload = payload }, + }) +end) diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..f4d57ed --- /dev/null +++ b/config.lua @@ -0,0 +1,116 @@ +---@class Config +Config = {} + +-- ───────────────────────────────────────────── +-- Framework: "qbcore" | "esx" | "standalone" +-- ───────────────────────────────────────────── +Config.Framework = "esx" + +-- ───────────────────────────────────────────── +-- Firmen-ID -> ESX-Job +-- ───────────────────────────────────────────── +-- Das Ursprungssystem kannte numerische Firmen-IDs aus einer eigenen Tabelle. +-- ESX kennt so etwas nicht – dort ist die Zugehoerigkeit der Job. Ohne Eintrag +-- hier gilt niemand als Firmenangehoeriger; erfundene Zuordnungen waeren +-- schlimmer als gar keine. +-- +-- Config.CompanyJobs = { [1] = 'police', [2] = 'ambulance' } +Config.CompanyJobs = {} + + +-- ───────────────────────────────────────────── +-- UI +-- ───────────────────────────────────────────── +Config.AccentColor = "#00aaff" +Config.BootDuration = 2500 -- ms +Config.FreezeOnOpen = true +Config.CameraOnOpen = true +Config.InteractKey = 38 -- E +Config.InteractRange = 1.8 -- metres + +-- ───────────────────────────────────────────── +-- Security +-- ───────────────────────────────────────────── +Config.MailRateLimit = 5 -- mails per minute per player +Config.MailMaxSubject = 80 +Config.MailMaxBody = 2000 + +-- ───────────────────────────────────────────── +-- PC Locations +-- ───────────────────────────────────────────── +Config.PCs = { + -- Mobiler Streifenwagen-Terminal (kein fester Standort). Wird über das M-Menü + -- geöffnet, wenn man in einem Einsatzfahrzeug sitzt. mobile = true -> kein + -- Distanz-/Kamera-Check; auto_open -> App wird direkt geöffnet. + -- Kein Job-Lock hier: Der Zugang wird über das M-Menü geregelt (nur sichtbar, + -- wenn man als PD in einem Einsatzfahrzeug (Fahrzeugklasse 18) sitzt). Die + -- eigentlichen Daten schützt die IC-Verwaltung serverseitig selbst. + { + id = "police_mobile", + label = "Streifenwagen-Terminal", + type = "public", + mobile = true, + auto_open = "pc.ic_verwaltung", + -- Nur diese Apps sind auf dem Streifenwagen-Terminal verfügbar. + apps = { "pc.ic_verwaltung", "pc.mail", "pc.browser" }, + }, + { + id = "legion_public_1", + label = "Public Terminal", + coords = vector3(217.69, -937.54, 30.69), + heading = 180.0, + type = "public", + }, + { + id = "legion_public_2", + label = "Public Terminal 2", + coords = vector3(213.69, -937.54, 30.69), + heading = 180.0, + type = "public", + }, + { + id = "pillbox_hospital_1", + label = "Hospital Terminal", + coords = vector3(301.21, -594.04, 43.28), + heading = 90.0, + type = "job", + -- War 'doctor' – diesen Job gibt es in ESX nicht. Der Sanitaeterjob + -- heisst hier 'ambulance' (Label: EMS). Mit 'doctor' waere das + -- Terminal fuer niemanden nutzbar gewesen. + job = "ambulance", + }, + -- Monitor im Mission Row PD (prop_monitor_01a, Interior 137473). + -- + -- coords ist die Position des Props selbst. heading meint hier nicht die + -- Ausrichtung des Props (170°), sondern die Seite, von der aus man auf den + -- Bildschirm schaut – daraus rechnet Camera.FocusPC die Kameraposition. + -- 195° entspricht der Stelle, an der man beim Aufmessen davor stand. + { + id = "mission_row_1", + label = "LSPD Terminal", + coords = vector3(441.615, -979.645, 30.425), + heading = 195.0, + type = "public", + -- Nur fuer die Polizei? Dann stattdessen: + -- type = "job", job = "police", + }, +} + +-- ───────────────────────────────────────────── +-- Built-in IC Pages (browser ic:// schema) +-- ───────────────────────────────────────────── +Config.ICPages = { + ["home"] = { title = "New Tab", content = "home" }, + ["dpa"] = { title = "DPA News", content = "dpa" }, + ["psb"] = { title = "Police Service Board", content = "psb" }, + ["weazel"] = { title = "Weazel News", content = "weazel" }, + ["classifieds"] = { title = "LS Classifieds",content = "classifieds" }, + + -- Org-Seiten (Slug = org.name) + ["police"] = { title = "LSPD – Los Santos Police Department", content = "org_police" }, + ["ambulance"] = { title = "LSMD – Los Santos Medical Department", content = "org_ambulance" }, + ["fire"] = { title = "LSFD – Los Santos Fire Department", content = "org_fire" }, + ["doj"] = { title = "DOJ – Department of Justice", content = "org_doj" }, + ["dpa"] = { title = "DPA – Department of Public Administration", content = "org_dpa" }, + ["parking"] = { title = "Parking @ Los Santos", content = "org_parking" }, +} diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..bb26a82 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,77 @@ +fx_version 'cerulean' +game 'gta5' + +name 'pc-live' +description 'IC PC System – Modular, Framework-Agnostic' +author 'Antony Ravenmoor (Haleoe)' +version '1.1.0' + +lua54 'yes' + +shared_scripts { + 'config.lua', + 'shared/utils.lua', +} + +-- Bridge selection is done inside the bridge files themselves. +-- All three bridge files are loaded; each one checks Config.Framework. +client_scripts { + 'bridge/bridge_qbcore.lua', + 'bridge/bridge_esx.lua', + 'bridge/bridge_standalone.lua', + 'client/camera.lua', + 'client/interaction.lua', + 'client/nui.lua', + 'client/webhosting.lua', + 'client/bleeter.lua', + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'bridge/bridge_qbcore.lua', + 'bridge/bridge_esx.lua', + 'bridge/bridge_standalone.lua', + 'server/database.lua', + 'server/apps.lua', + 'server/mail.lua', + 'server/calendar.lua', + 'server/contacts.lua', + 'server/store.lua', + 'server/main.lua', +} + +ui_page 'nui/index.html' + +files { + 'nui/index.html', + 'nui/css/style.css', + 'nui/css/rp-ds.css', + 'nui/css/bleeter-embedded.css', + 'nui/js/desktop.js', + 'nui/js/windowManager.js', + 'nui/js/apps/browser.js', + 'nui/js/apps/mail.js', + 'nui/js/apps/addressbook.js', + 'nui/js/apps/store.js', + 'nui/js/apps/parkuhr.js', + 'nui/js/apps/ic_verwaltung.js', + 'nui/js/apps/gesetzbuch.js', + 'nui/js/apps/pbs_dashboard.js', + 'nui/js/apps/webhosting.js', + 'nui/js/apps/bleeter.js', + 'nui/js/apps/bleeteradmin.js', +} + +dependency '/server:5104' +dependency 'oxmysql' +dependency 'es_extended' + +-- HINWEIS: core, core-characters, organizations, ic-mail und aktenverwaltung +-- waren hier als harte dependency eingetragen. Auf einem ESX-Server gibt es +-- diese Resources nicht, und eine fehlende dependency verhindert den START der +-- gesamten Resource – pc-live waere also nie hochgekommen. +-- +-- Charakterdaten, Aktenverwaltung und Immobilien sind inzwischen vollstaendig +-- ausgebaut. ic-mail bleibt optional: der Code fragt es ueber GetResourceState +-- ab und laeuft ohne es weiter, nur ohne Mailadressen. diff --git a/nui/css/bleeter-embedded.css b/nui/css/bleeter-embedded.css new file mode 100644 index 0000000..b5454bc --- /dev/null +++ b/nui/css/bleeter-embedded.css @@ -0,0 +1,694 @@ +/* Erzeugt aus bleeter/html/style.css – nicht von Hand aendern. + Jeder Selektor ist auf .bl-root gekapselt, damit Bleeters Farben und + Schrift nicht den uebrigen PC einfaerben. */ +.bl-root{--bg: #020414;--panel: #061325;--panel-2: #091a31;--panel-3: #0d203a;--line: rgba(255, 255, 255, 0.08);--text: #f5f8ff;--muted: #71809b;--green: #19d889;--green-2: #00b978;--red: #ff4f5f;--gold: #f2b866;--shadow: 0 24px 80px rgba(0, 0, 0, 0.58)} +.bl-root *{box-sizing: border-box;} +.bl-root{color: var(--text);font-family: Inter, Arial, Helvetica, sans-serif} +.bl-root button,.bl-root input,.bl-root textarea{font: inherit;} +.bl-root button{cursor: pointer;} +.bl-root .hidden{display: none !important;} +.bl-root .tablet{position: relative; + width: min(1680px, 96vw); + aspect-ratio: 16 / 7.45; + min-height: 700px; + border: 13px solid #313943; + border-radius: 72px; + background: #020414; + box-shadow: var(--shadow), inset 0 0 0 5px #77818b; + overflow: hidden;} +.bl-root .tablet-glass{position: absolute; + inset: 7px; + border-radius: 58px; + pointer-events: none; + box-shadow: inset 0 2px 12px rgba(255, 255, 255, 0.22), inset 0 -24px 60px rgba(255, 255, 255, 0.04); + z-index: 5;} +.bl-root .screen{position: absolute; + inset: 22px; + border-radius: 48px; + background: var(--bg); + overflow: hidden;} +.bl-root .topbar{height: 92px; + display: grid; + + grid-template-columns: 212px minmax(0, 1fr) 320px 58px 44px; + gap: 18px; + align-items: center; + padding: 0 54px;} +.bl-root .top-close{width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.66); + font-size: 16px; + line-height: 1; + cursor: pointer; + transition: background .15s, color .15s, border-color .15s;} +.bl-root .top-close:hover{background: rgba(229, 57, 53, 0.16); + border-color: rgba(229, 57, 53, 0.5); + color: #ff8a80;} +.bl-root .brand{height: 58px; + border: 0; + border-radius: 0 0 8px 8px; + padding: 0; + background: transparent; + overflow: hidden;} +.bl-root .brand img{width: 100%; + height: 100%; + display: block; + object-fit: contain;} +.bl-root .search-wrap{position: relative;} +.bl-root .global-search{width: 100%; + height: 44px; + border: 1px solid rgba(25, 216, 137, 0.38); + border-radius: 999px; + outline: 0; + padding: 0 18px; + color: var(--text); + background: rgba(5, 20, 38, 0.94); + font-size: 14px; + font-weight: 800;} +.bl-root .global-search::placeholder{color: #53627a;} +.bl-root .search-results{position: absolute; + top: 52px; + right: 0; + width: 100%; + z-index: 20; + display: grid; + gap: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: #051426; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);} +.bl-root .search-result{display: grid; + grid-template-columns: 36px minmax(0, 1fr); + gap: 10px; + align-items: center; + border: 0; + border-radius: 8px; + padding: 8px; + text-align: left; + color: var(--text); + background: rgba(255, 255, 255, 0.04);} +.bl-root .search-result img{width: 36px; + height: 36px; + border-radius: 999px; + object-fit: cover;} +.bl-root .top-profile{width: 54px; + height: 54px; + border: 2px solid var(--green); + border-radius: 999px; + padding: 0; + background: #0b1a2c; + overflow: hidden;} +.bl-root .top-profile img{width: 100%; + height: 100%; + object-fit: cover; + display: block;} +.bl-root .app-grid{height: calc(100% - 92px); + display: grid; + grid-template-columns: 86px minmax(0, 1fr) 300px; + gap: 22px; + padding: 0 54px 38px;} +.bl-root .side-nav{display: flex; + flex-direction: column; + gap: 9px; + padding-top: 20px; + padding-bottom: 8px; + overflow: hidden;} +.bl-root .nav-button{width: 64px; + height: 58px; + border: 0; + border-radius: 8px; + color: #52617a; + background: #071b34; + display: grid; + place-items: center; + gap: 3px; + transition: color 0.15s ease, background 0.15s ease;} +.bl-root .nav-button.active{color: var(--green); + background: #092541; + box-shadow: inset 3px 0 0 var(--green);} +.bl-root .nav-icon{display: grid; + place-items: center; + min-height: 22px; + font-size: 20px; + line-height: 1;} +.bl-root .nav-icon svg{width: 20px; + height: 20px; + display: block;} +.bl-root .nav-label{font-size: 9px; + font-weight: 700; + line-height: 1.05; + max-width: 58px; + overflow-wrap: anywhere;} +.bl-root .content,.bl-root .context{min-width: 0; + overflow: auto; + scrollbar-color: var(--green) transparent; + scrollbar-width: thin;} +.bl-root .content{padding: 36px 0 8px;} +.bl-root .context{padding: 36px 0 8px;} +.bl-root .section-title{display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 18px;} +.bl-root .section-title h1{margin: 0; + font-size: 25px; + line-height: 1.1;} +.bl-root .section-title p{margin: 4px 0 0; + color: var(--muted); + font-size: 13px;} +.bl-root .compose{display: grid; + grid-template-columns: 52px minmax(0, 1fr); + gap: 14px; + padding: 16px; + margin-bottom: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel);} +.bl-root .avatar{width: 52px; + height: 52px; + border: 2px solid var(--green); + border-radius: 999px; + object-fit: cover; + background: #12243a;} +.bl-root .compose textarea{width: 100%; + min-height: 74px; + resize: none; + border: 0; + outline: 0; + color: var(--text); + background: transparent;} +.bl-root .compose-actions{display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 10px;} +.bl-root .upload-row{display: flex; + align-items: center; + gap: 10px; + min-width: 0; + flex-wrap: wrap;} +.bl-root .upload-button{border: 1px solid rgba(25, 216, 137, 0.45); + border-radius: 999px; + padding: 8px 13px; + color: var(--green); + background: rgba(25, 216, 137, 0.08); + font-size: 12px; + font-weight: 900;} +.bl-root .media-url-input{width: min(360px, 42vw); + min-height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + padding: 0 10px; + color: var(--text); + background: rgba(255, 255, 255, 0.04); + font-size: 12px;} +.bl-root .url-upload-row{display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center;} +.bl-root .toast-layer{position: absolute; + right: 34px; + bottom: 32px; + z-index: 30; + display: grid; + gap: 10px; + width: min(360px, 30vw); + pointer-events: none;} +.bl-root .toast{padding: 13px 15px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--text); + background: rgba(6, 19, 37, 0.96); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32); + font-size: 13px; + font-weight: 800;} +.bl-root .toast-error{border-color: rgba(255, 79, 95, 0.55); + color: #ffd7dc;} +.bl-root .toast-success{border-color: rgba(25, 216, 137, 0.55); + color: #b8ffdf;} +.bl-root .hint{color: var(--muted); + font-size: 12px;} +.bl-root .primary-button,.bl-root .ghost-button,.bl-root .danger-button{border-radius: 999px; + padding: 10px 18px; + font-size: 13px; + font-weight: 900;} +.bl-root .primary-button{border: 0; + color: #001527; + background: var(--green);} +.bl-root .ghost-button{border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--text); + background: rgba(255, 255, 255, 0.04);} +.bl-root .danger-text{border-color: rgba(255, 79, 95, 0.42); + color: #ff6d7b;} +.bl-root .danger-button{border: 1px solid rgba(255, 79, 95, 0.6); + color: var(--red); + background: rgba(255, 79, 95, 0.08);} +.bl-root .feed-list{display: grid; + gap: 18px;} +.bl-root .post-card{overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel);} +.bl-root .post-head{display: grid; + grid-template-columns: 54px minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 16px 18px;} +.bl-root .author-link{display: inline-flex; + align-items: center; + gap: 6px; + border: 0; + padding: 0; + color: var(--text); + background: transparent; + text-align: left;} +.bl-root .author-name{display: flex; + align-items: center; + gap: 6px; + font-weight: 900;} +.bl-root .handle{color: var(--muted); + font-size: 12px; + font-weight: 700;} +.bl-root .verified,.bl-root .staff-badge{display: inline-grid; + width: 18px; + height: 18px; + place-items: center; + border-radius: 999px; + color: #001527; + background: var(--green); + font-size: 13px; + font-weight: 1000;} +.bl-root .verified svg,.bl-root .staff-badge svg{width: 18px; + height: 18px;} +.bl-root .staff-badge{color: #fff; + background: transparent;} +.bl-root .verified + .staff-badge{margin-left: 4px;} +.bl-root .post-time{color: var(--muted); + font-size: 12px;} +.bl-root .post-body{padding: 0 18px 16px 86px; + white-space: pre-wrap; + font-size: 14px; + line-height: 1.45;} +.bl-root .post-image{display: block; + width: 100%; + max-height: 480px; + object-fit: cover; + background: #101a29;} +.bl-root .post-actions{display: flex; + gap: 22px; + align-items: center; + padding: 14px 18px; + color: var(--text); + border-top: 1px solid var(--line);} +.bl-root .action-link{border: 0; + color: var(--text); + background: transparent; + font-size: 13px; + font-weight: 400;} +.bl-root .danger-text{color: var(--red);} +.bl-root .icon-action{width: 28px; + height: 28px; + display: inline-grid; + place-items: center; + border-radius: 8px; + background: transparent;} +.bl-root .icon-action svg{width: 16px; + height: 16px; + display: block;} +.bl-root .danger-icon{border: 1px solid rgba(255, 79, 95, 0.35); + color: var(--red);} +.bl-root .danger-icon:hover{background: rgba(255, 79, 95, 0.1);} +.bl-root .comment-actions{display: flex; + align-items: center; + gap: 12px;} +.bl-root .comments-panel{display: grid; + gap: 10px; + padding: 14px 18px 18px 86px; + border-top: 1px solid var(--line); + background: rgba(0, 0, 0, 0.1);} +.bl-root .comment-form{display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px;} +.bl-root .comment-form input,.bl-root .market-search,.bl-root .market-sort,.bl-root .event-input{min-height: 38px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + padding: 0 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.04);} +.bl-root .comment-item{display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04);} +.bl-root .comment-item strong{display: block; + margin-bottom: 3px;} +.bl-root .market-tools{display: grid; + grid-template-columns: minmax(0, 1fr) 210px; + gap: 12px; + margin-bottom: 18px;} +.bl-root .market-post{display: grid; + grid-template-columns: minmax(0, 1fr) 110px; + gap: 12px; + align-items: start;} +.bl-root .market-compose-grid{display: grid; + grid-template-columns: minmax(0, 1fr) 220px; + gap: 10px; + margin-bottom: 10px;} +.bl-root .price-input{border-color: rgba(25, 216, 137, 0.36);} +.bl-root .market-author{display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; + align-items: center; + margin-bottom: 12px;} +.bl-root .market-avatar-link{width: 42px; + height: 42px; + border: 0; + border-radius: 999px; + padding: 0; + background: transparent;} +.bl-root .market-avatar-link img,.bl-root .market-avatar-link span,.bl-root .market-author > img,.bl-root .market-author > span{width: 42px; + height: 42px; + border-radius: 999px; + object-fit: cover; + display: grid; + place-items: center; + border: 2px solid var(--green); + background: #102036; + font-weight: 900;} +.bl-root .market-author p{margin: 2px 0 0;} +.bl-root .market-author-name{display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap;} +.bl-root .mail-icon-button{width: 28px; + height: 28px; + border: 1px solid rgba(25, 216, 137, 0.35); + border-radius: 999px; + padding: 0; + display: inline-grid; + place-items: center; + color: var(--green); + background: rgba(25, 216, 137, 0.08);} +.bl-root .mail-icon-button svg{width: 15px; + height: 15px;} +.bl-root .market-image{width: 100%; + max-height: 260px; + object-fit: cover; + border-radius: 8px; + margin-top: 12px;} +.bl-root .market-side{display: grid; + justify-items: end; + align-content: start; + gap: 12px;} +.bl-root .price-tag{justify-self: end; + padding: 8px 12px; + border-radius: 999px; + color: #001527; + background: var(--green); + font-weight: 900;} +.bl-root .calendar-list{display: grid; + gap: 10px;} +.bl-root .day-card{border: 1px solid var(--line); + border-radius: 8px; + padding: 14px 16px; + color: var(--text); + background: var(--panel); + text-align: left; + cursor: pointer;} +.bl-root .day-card.active{border-color: rgba(25, 216, 137, 0.72); + box-shadow: inset 3px 0 0 var(--green);} +.bl-root .day-title{font-size: 18px; + font-weight: 900;} +.bl-root .event-line{margin-top: 5px; + color: var(--text); + font-size: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px;} +.bl-root .event-editor{display: grid; + gap: 12px;} +.bl-root .event-editor-head{display: flex; + justify-content: space-between; + gap: 12px; + font-weight: 900;} +.bl-root .legal-text{max-width: 980px; + white-space: pre-line; + font-size: 15px; + line-height: 1.55;} +.bl-root .profile-editor{max-width: 820px;} +.bl-root .edit-form{display: grid; + gap: 14px; + padding: 18px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel);} +.bl-root .edit-form label,.bl-root .event-editor label{display: grid; + gap: 7px; + color: var(--text); + font-size: 13px; + font-weight: 900;} +.bl-root .edit-textarea{min-height: 120px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + padding: 11px 12px; + color: var(--text); + background: rgba(255, 255, 255, 0.04);} +.bl-root .edit-actions{display: flex; + justify-content: flex-end; + gap: 10px;} +.bl-root .page-grid{display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px;} +.bl-root .business-card{position: relative; + width: 100%; + min-height: 114px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + padding: 0; + color: var(--text); + text-align: left;} +.bl-root .business-card.closed{filter: grayscale(0.8); + opacity: 0.62;} +.bl-root .business-banner{position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover;} +.bl-root .business-overlay{position: relative; + min-height: 114px; + display: flex; + align-items: center; + gap: 18px; + padding: 20px 24px; + background: linear-gradient(90deg, rgba(2, 4, 20, 0.16), rgba(2, 4, 20, 0.48));} +.bl-root .business-avatar{width: 78px; + height: 78px; + border: 4px solid var(--green); + border-radius: 999px; + object-fit: cover;} +.bl-root .business-label{max-width: 74%; + padding: 12px 20px; + border-radius: 8px; + background: rgba(3, 12, 28, 0.94);} +.bl-root .business-label strong{display: flex; + align-items: center; + gap: 7px; + font-size: 23px; + line-height: 1.05;} +.bl-root .status-strip{height: 14px; + margin-bottom: 22px; + border-radius: 0 0 999px 999px; + background: var(--green);} +.bl-root .status-strip.closed{background: #586477;} +.bl-root .profile-hero{position: relative; + overflow: hidden; + min-height: 310px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel);} +.bl-root .comment-author-link{border: 0; + padding: 0; + color: var(--text); + background: transparent; + font-weight: 900;} +.bl-root .profile-banner{width: 100%; + height: 210px; + object-fit: cover; + display: block;} +.bl-root .profile-banner-empty{background: linear-gradient(135deg, #082443, #0e332f);} +.bl-root .profile-meta{display: grid; + grid-template-columns: 140px minmax(0, 1fr) auto; + gap: 18px; + align-items: end; + padding: 0 24px 24px; + margin-top: -58px;} +.bl-root .profile-avatar{width: 128px; + height: 128px; + border: 4px solid var(--green); + border-radius: 999px; + object-fit: cover; + background: #102036;} +.bl-root .profile-avatar-empty{display: grid; + place-items: center; + color: var(--green); + font-size: 42px; + font-weight: 900;} +.bl-root .profile-actions{display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap;} +.bl-root .moderation-actions{display: inline-flex; + align-items: center; + gap: 8px;} +.bl-root .moderation-icon-button{width: 40px; + height: 40px; + border: 1px solid rgba(25, 216, 137, 0.35); + border-radius: 8px; + padding: 0; + display: inline-grid; + place-items: center; + color: var(--green); + background: rgba(25, 216, 137, 0.06);} +.bl-root .moderation-icon-button svg{width: 18px; + height: 18px;} +.bl-root .moderation-icon-button.active{color: #001527; + background: var(--green);} +.bl-root .moderation-icon-button.danger{border-color: rgba(255, 79, 95, 0.42); + color: var(--red); + background: rgba(255, 79, 95, 0.08);} +.bl-root .moderation-icon-button.danger.active{color: #fff; + background: rgba(255, 79, 95, 0.92);} +.bl-root .profile-mail-button{width: 40px; + height: 40px;} +.bl-root .profile-mail-button svg{width: 18px; + height: 18px;} +.bl-root .profile-meta h1{display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 32px;} +.bl-root .profile-meta p{margin: 8px 0 0; + color: var(--muted);} +.bl-root .profile-stats{display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px;} +.bl-root .profile-stats button{position: relative; + min-width: 48px; + height: 30px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 0 8px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + color: var(--green); + background: rgba(255, 255, 255, 0.035);} +.bl-root .profile-stats button::after{content: attr(title); + position: absolute; + left: 50%; + bottom: calc(100% + 7px); + transform: translateX(-50%); + opacity: 0; + pointer-events: none; + border: 1px solid var(--line); + border-radius: 8px; + padding: 5px 8px; + color: var(--text); + background: #051426; + font-size: 11px; + font-weight: 800; + white-space: nowrap; + transition: opacity 0.12s ease;} +.bl-root .profile-stats button:hover::after{opacity: 1;} +.bl-root .profile-stats svg{width: 15px; + height: 15px; + flex: 0 0 auto;} +.bl-root .profile-stats strong{color: var(--text); + font-size: 13px; + font-weight: 400; + line-height: 1.1;} +.bl-root .profile-content-section{scroll-margin-top: 18px;} +.bl-root .profile-content-section{display: grid; + gap: 12px; + margin-top: 18px;} +.bl-root .profile-content-section h2{margin: 0; + font-size: 18px;} +.bl-root .profile-event-card h3{margin-bottom: 8px;} +.bl-root .simple-list{display: grid; + gap: 12px;} +.bl-root .list-card,.bl-root .context-card{padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel);} +.bl-root .list-card h3,.bl-root .context-card h3{margin: 0 0 6px; + font-size: 15px;} +.bl-root .list-card p,.bl-root .context-card p{margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.35;} +.bl-root .context-title{display: flex; + align-items: center; + gap: 10px; + margin: 0 0 14px; + font-size: 16px;} +.bl-root .context-stack{display: grid; + gap: 12px;} +.bl-root .suggestion{display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + gap: 12px; + align-items: center;} +.bl-root .suggestion-avatar{width: 42px; + height: 42px; + border-radius: 999px; + background: #8b97a8; + display: grid; + place-items: center; + font-weight: 900;} +.bl-root .mini-button{border: 0; + border-radius: 999px; + padding: 9px 15px; + color: #001527; + background: var(--green); + font-size: 12px; + font-weight: 900;} +.bl-root .social-section{display: grid; + gap: 10px; + margin-bottom: 18px;} +.bl-root .social-section h2{margin: 0; + font-size: 16px;} +.bl-root .social-row{display: flex; + justify-content: space-between; + gap: 16px; + align-items: center;} +.bl-root .social-actions{display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end;} +.bl-root .protected-pill{border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 999px; + padding: 8px 12px; + color: var(--muted); + font-size: 12px; + font-weight: 900;} +@media (max-width: 1280px) {.bl-root .tablet{min-height: 640px;} +.bl-root .app-grid{grid-template-columns: 78px minmax(0, 1fr) 260px; + gap: 16px; + padding-inline: 36px;} +.bl-root .topbar{padding-inline: 36px;}} diff --git a/nui/css/erzeuge-bleeter-css.py b/nui/css/erzeuge-bleeter-css.py new file mode 100644 index 0000000..4474226 --- /dev/null +++ b/nui/css/erzeuge-bleeter-css.py @@ -0,0 +1,116 @@ +""" +Kapselt bleeter/html/style.css fuer die Einbettung in pc-live. + +Warum nicht einfach einbinden: die Datei setzt Regeln auf *, html und body. +Die wuerden den gesamten PC einfaerben – Schrift, Hintergrund, alles. Also +bekommt jeder Selektor ein .bl-root davor, und die globalen Regeln werden +umgeschrieben statt uebernommen. + +Rein mechanisch, damit es sich wiederholen laesst, wenn bleeter sein Aussehen +aendert: python3 nui/css/erzeuge-bleeter-css.py +""" +import io, re + +import os +# Aus dem Verzeichnis dieser Datei heraus aufrufbar: +# python3 nui/css/erzeuge-bleeter-css.py +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, '..', '..', '..', 'bleeter', 'html', 'style.css') +DST = os.path.join(HERE, 'bleeter-embedded.css') +SCOPE = '.bl-root' + + +# Vom Wurzelelement uebernehmen wir Farben, Schrift und Variablen – aber kein +# Layout. Im Original ist .bleeter-root ein bildschirmfuellendes Overlay +# (position:fixed; inset:0). Uebernaehme man das, laege die App ueber dem +# gesamten PC, inklusive Titelleiste – man kaeme nicht mehr heraus. +ROOT_SELECTORS = (':root', 'html', 'body') +ROOT_KEEP = ('color', 'font-family', 'font-size', 'font-weight', + 'letter-spacing', 'line-height') + + +def is_root_selector(sel): + return sel in ROOT_SELECTORS or sel.startswith('.bleeter-root') + + +def filter_root_declarations(body): + """Nur Variablen und Schrift behalten, alles Raeumliche verwerfen.""" + kept = [] + for decl in body.split(';'): + decl = decl.strip() + if not decl: + continue + prop = decl.split(':', 1)[0].strip().lower() + if prop.startswith('--') or prop in ROOT_KEEP: + kept.append(decl) + return ';'.join(kept) + + +def scope_selector(sel): + sel = sel.strip() + if not sel: + return sel + if is_root_selector(sel): + return SCOPE + if sel == '*': + return SCOPE + ' *' + return SCOPE + ' ' + sel + + +def rule(selector_list, body): + sels_raw = [s.strip() for s in selector_list.split(',')] + if any(is_root_selector(s) for s in sels_raw): + body = filter_root_declarations(body) + if not body: + return None + sels = ','.join(scope_selector(s) for s in sels_raw) + return sels + '{' + body.strip() + '}' + + +def scope_block(block): + parts = [] + for m in re.finditer(r'([^{}]+)\{([^{}]*)\}', block, flags=re.S): + r = rule(m.group(1), m.group(2)) + if r: + parts.append(r) + return '\n'.join(parts) + + +css = re.sub(r'/\*.*?\*/', '', io.open(SRC, encoding='utf-8').read(), flags=re.S) + +out, i, n = [], 0, len(css) +while i < n: + m = re.match(r'\s*@(media|supports|keyframes|-webkit-keyframes)([^{]*)\{', css[i:]) + if m: + depth, j = 1, i + m.end() + while j < n and depth: + if css[j] == '{': + depth += 1 + elif css[j] == '}': + depth -= 1 + j += 1 + inner = css[i + m.end(): j - 1] + head = '@' + m.group(1) + m.group(2) + '{' + # Prozentschritte in keyframes sind keine Selektoren + out.append(head + (inner if 'keyframes' in m.group(1) else scope_block(inner)) + '}') + i = j + continue + + m = re.match(r'([^{}]+)\{([^{}]*)\}', css[i:], flags=re.S) + if not m: + i += 1 + continue + + r = rule(m.group(1), m.group(2)) + if r: + out.append(r) + i += m.end() + +header = ( + '/* Erzeugt aus bleeter/html/style.css – nicht von Hand aendern.\n' + ' Jeder Selektor ist auf .bl-root gekapselt, damit Bleeters Farben und\n' + ' Schrift nicht den uebrigen PC einfaerben. */\n' +) + +io.open(DST, 'w', encoding='utf-8').write(header + '\n'.join(out) + '\n') +print(f'{len(out)} Regeln -> {DST}') diff --git a/nui/css/rp-ds.css b/nui/css/rp-ds.css new file mode 100644 index 0000000..746958e --- /dev/null +++ b/nui/css/rp-ds.css @@ -0,0 +1,463 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + RP Design System – Unified Component Library + Additive: existierende Stile werden nicht überschrieben. + Alle Klassen tragen das Präfix .ds- um Konflikte zu vermeiden. + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── Design Tokens (Canonical Variables) ────────────────────────────────── */ +:root { + /* Brand */ + --ds-accent: #1a3d6e; + --ds-accent-light: #2461b0; + --ds-accent-hover: #0f2847; + --ds-accent-pale: #e8f0fc; + + /* Status */ + --ds-danger: #b22222; + --ds-danger-hover: #8b0000; + --ds-danger-pale: #fdecea; + --ds-success: #2e7d32; + --ds-success-pale: #e8f5e9; + --ds-warning: #c47800; + --ds-warning-pale: #fff8e1; + --ds-info: #0277bd; + --ds-info-pale: #e1f0fa; + + /* Surfaces */ + --ds-bg-app: #eaecef; + --ds-bg-sidebar: #1e2a3a; + --ds-bg-header: #162032; + --ds-bg-card: #ffffff; + --ds-bg-card-alt: #f4f5f7; + --ds-bg-input: #ffffff; + --ds-bg-modal: #ffffff; + --ds-bg-overlay: rgba(0,0,0,0.55); + --ds-bg-tbl-hd: #2c3e55; + --ds-bg-tbl-alt: #f8f9fb; + + /* Text */ + --ds-text: #1a1f2e; + --ds-text-sub: #4a5568; + --ds-text-muted: #7a8494; + --ds-text-light: #f0f2f5; + + /* Borders */ + --ds-border: #c8cdd6; + --ds-border-dark: #9aa3b0; + + /* Radii */ + --ds-r-sm: 3px; + --ds-r: 4px; + --ds-r-md: 6px; + --ds-r-lg: 10px; + + /* Shadows */ + --ds-shadow-sm: 0 1px 3px rgba(0,0,0,.12); + --ds-shadow: 0 2px 8px rgba(0,0,0,.15); + --ds-shadow-lg: 0 4px 20px rgba(0,0,0,.2); + + /* Typography */ + --ds-font: 'Segoe UI', Arial, sans-serif; + --ds-font-mono: 'Consolas', 'Courier New', monospace; + --ds-text-xs: 11px; + --ds-text-sm: 12px; + --ds-text-base: 13px; + --ds-text-lg: 15px; +} + +/* ── Utility: Text ──────────────────────────────────────────────────────── */ +.ds-text-muted { color: var(--ds-text-muted); } +.ds-text-sub { color: var(--ds-text-sub); } +.ds-text-accent { color: var(--ds-accent-light); } +.ds-text-danger { color: var(--ds-danger); } +.ds-text-success { color: var(--ds-success); } +.ds-text-sm { font-size: var(--ds-text-sm); } +.ds-text-xs { font-size: var(--ds-text-xs); } +.ds-mono { font-family: var(--ds-font-mono); } +.ds-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ── Buttons ────────────────────────────────────────────────────────────── */ +.ds-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + border: none; + border-radius: var(--ds-r); + font-family: var(--ds-font); + font-size: var(--ds-text-sm); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background .15s, opacity .15s; + text-decoration: none; + line-height: 1.5; +} +.ds-btn:disabled { opacity: .5; cursor: not-allowed; } + +.ds-btn-primary { + background: var(--ds-accent); + color: #fff; +} +.ds-btn-primary:hover:not(:disabled) { background: var(--ds-accent-hover); } + +.ds-btn-secondary { + background: var(--ds-bg-card-alt); + color: var(--ds-text); + border: 1px solid var(--ds-border); +} +.ds-btn-secondary:hover:not(:disabled) { background: var(--ds-border); } + +.ds-btn-danger { + background: var(--ds-danger); + color: #fff; +} +.ds-btn-danger:hover:not(:disabled) { background: var(--ds-danger-hover); } + +.ds-btn-success { + background: var(--ds-success); + color: #fff; +} +.ds-btn-success:hover:not(:disabled) { background: #1b5e20; } + +.ds-btn-ghost { + background: transparent; + color: var(--ds-accent-light); + border: 1px solid var(--ds-accent-light); +} +.ds-btn-ghost:hover:not(:disabled) { background: var(--ds-accent-pale); } + +.ds-btn-sm { padding: 3px 8px; font-size: var(--ds-text-xs); } +.ds-btn-lg { padding: 8px 18px; font-size: var(--ds-text-base); } +.ds-btn-icon { padding: 5px 7px; } + +/* ── Inputs ─────────────────────────────────────────────────────────────── */ +.ds-input { + display: block; + width: 100%; + padding: 5px 9px; + background: var(--ds-bg-input); + border: 1px solid var(--ds-border); + border-radius: var(--ds-r); + font-family: var(--ds-font); + font-size: var(--ds-text-sm); + color: var(--ds-text); + transition: border-color .15s, box-shadow .15s; + outline: none; +} +.ds-input:focus { + border-color: var(--ds-accent-light); + box-shadow: 0 0 0 2px rgba(36,97,176,.2); +} +.ds-input::placeholder { color: var(--ds-text-muted); } +.ds-input-sm { padding: 3px 7px; font-size: var(--ds-text-xs); } + +.ds-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='%237a8494'%3E%3Cpath d='M6 8 0 0h12z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; + padding-right: 26px; +} + +.ds-textarea { + resize: vertical; + min-height: 80px; +} + +/* ── Form Group ─────────────────────────────────────────────────────────── */ +.ds-form-group { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} +.ds-label { + font-size: var(--ds-text-xs); + font-weight: 600; + color: var(--ds-text-sub); + text-transform: uppercase; + letter-spacing: .5px; +} + +/* ── Search Bar ─────────────────────────────────────────────────────────── */ +.ds-search-wrap { + position: relative; +} +.ds-search-wrap i { + position: absolute; + left: 9px; + top: 50%; + transform: translateY(-50%); + color: var(--ds-text-muted); + font-size: 12px; + pointer-events: none; +} +.ds-search-wrap .ds-input { + padding-left: 28px; +} + +/* ── Table ──────────────────────────────────────────────────────────────── */ +.ds-table { + width: 100%; + border-collapse: collapse; + font-size: var(--ds-text-sm); + color: var(--ds-text); +} +.ds-table th { + background: var(--ds-bg-tbl-hd); + color: #fff; + font-size: var(--ds-text-xs); + font-weight: 600; + text-align: left; + padding: 7px 10px; + white-space: nowrap; + text-transform: uppercase; + letter-spacing: .4px; +} +.ds-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--ds-border); + vertical-align: middle; +} +.ds-table tbody tr:nth-child(even) { background: var(--ds-bg-tbl-alt); } +.ds-table tbody tr:hover { background: var(--ds-accent-pale); } +.ds-table-sm th, .ds-table-sm td { padding: 4px 8px; } + +/* ── Badge / Chip ───────────────────────────────────────────────────────── */ +.ds-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 7px; + border-radius: 20px; + font-size: var(--ds-text-xs); + font-weight: 600; + white-space: nowrap; +} +.ds-badge-primary { background: var(--ds-accent-pale); color: var(--ds-accent); } +.ds-badge-danger { background: var(--ds-danger-pale); color: var(--ds-danger); } +.ds-badge-success { background: var(--ds-success-pale); color: var(--ds-success); } +.ds-badge-warning { background: var(--ds-warning-pale); color: var(--ds-warning); } +.ds-badge-info { background: var(--ds-info-pale); color: var(--ds-info); } +.ds-badge-neutral { background: var(--ds-bg-card-alt); color: var(--ds-text-sub); border: 1px solid var(--ds-border); } + +/* ── Alert / Notice ─────────────────────────────────────────────────────── */ +.ds-alert { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 12px; + border-radius: var(--ds-r-md); + font-size: var(--ds-text-sm); + border-left: 3px solid currentColor; + margin: 4px 0; +} +.ds-alert i { margin-top: 1px; flex-shrink: 0; } +.ds-alert-danger { background: var(--ds-danger-pale); color: var(--ds-danger); } +.ds-alert-success { background: var(--ds-success-pale); color: var(--ds-success); } +.ds-alert-warning { background: var(--ds-warning-pale); color: var(--ds-warning); } +.ds-alert-info { background: var(--ds-info-pale); color: var(--ds-info); } + +/* ── Modal ──────────────────────────────────────────────────────────────── */ +.ds-modal-overlay { + position: fixed; + inset: 0; + background: var(--ds-bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(2px); +} +.ds-modal-overlay.hidden { display: none; } +.ds-modal { + background: var(--ds-bg-modal); + border-radius: var(--ds-r-lg); + box-shadow: var(--ds-shadow-lg); + min-width: 320px; + max-width: 600px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +.ds-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--ds-bg-sidebar); + color: var(--ds-text-light); + flex-shrink: 0; +} +.ds-modal-title { font-size: var(--ds-text-base); font-weight: 600; } +.ds-modal-close { + background: none; + border: none; + color: var(--ds-text-muted); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: var(--ds-r); +} +.ds-modal-close:hover { color: #fff; } +.ds-modal-body { padding: 16px; overflow-y: auto; flex: 1; } +.ds-modal-footer { padding: 10px 16px; border-top: 1px solid var(--ds-border); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; } + +/* ── Confirm Dialog ─────────────────────────────────────────────────────── */ +.ds-confirm { + max-width: 380px; + text-align: center; +} +.ds-confirm .ds-modal-body { padding: 24px 20px 12px; } +.ds-confirm p { color: var(--ds-text-sub); margin-top: 8px; font-size: var(--ds-text-sm); } + +/* ── Loading ────────────────────────────────────────────────────────────── */ +.ds-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + gap: 10px; + color: var(--ds-text-muted); + font-size: var(--ds-text-sm); +} +.ds-spinner { + width: 24px; height: 24px; + border: 3px solid var(--ds-border); + border-top-color: var(--ds-accent-light); + border-radius: 50%; + animation: ds-spin .7s linear infinite; +} +@keyframes ds-spin { to { transform: rotate(360deg); } } + +/* ── Empty State ────────────────────────────────────────────────────────── */ +.ds-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + gap: 8px; + color: var(--ds-text-muted); + text-align: center; +} +.ds-empty i { font-size: 32px; opacity: .4; } +.ds-empty-title { font-size: var(--ds-text-base); font-weight: 600; } +.ds-empty-sub { font-size: var(--ds-text-sm); } + +/* ── Audit Hinweis ──────────────────────────────────────────────────────── */ +.ds-audit-notice { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: rgba(194,120,0,.08); + border: 1px solid rgba(194,120,0,.25); + border-radius: var(--ds-r); + font-size: var(--ds-text-xs); + color: var(--ds-warning); +} +.ds-audit-notice i { flex-shrink: 0; } + +/* ── Filter / Toolbar ───────────────────────────────────────────────────── */ +.ds-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + background: var(--ds-bg-card-alt); + border-bottom: 1px solid var(--ds-border); +} +.ds-toolbar-spacer { flex: 1; } + +/* ── Pagination ─────────────────────────────────────────────────────────── */ +.ds-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + font-size: var(--ds-text-sm); + color: var(--ds-text-sub); +} +.ds-page-btn { + padding: 3px 8px; + border: 1px solid var(--ds-border); + border-radius: var(--ds-r); + background: var(--ds-bg-card); + color: var(--ds-text); + cursor: pointer; + font-size: var(--ds-text-xs); + transition: background .1s; +} +.ds-page-btn:hover:not(:disabled) { background: var(--ds-accent-pale); border-color: var(--ds-accent-light); } +.ds-page-btn:disabled { opacity: .4; cursor: default; } +.ds-page-btn.active { background: var(--ds-accent); color: #fff; border-color: var(--ds-accent); } + +/* ── Section Divider ────────────────────────────────────────────────────── */ +.ds-divider { + border: none; + border-top: 1px solid var(--ds-border); + margin: 10px 0; +} +.ds-section-title { + font-size: var(--ds-text-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: .6px; + color: var(--ds-text-muted); + padding: 8px 0 4px; +} + +/* ── Status Dot ─────────────────────────────────────────────────────────── */ +.ds-status-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.ds-status-dot.online { background: var(--ds-success); } +.ds-status-dot.offline { background: var(--ds-text-muted); } +.ds-status-dot.busy { background: var(--ds-warning); } + +/* ── Card ───────────────────────────────────────────────────────────────── */ +.ds-card { + background: var(--ds-bg-card); + border: 1px solid var(--ds-border); + border-radius: var(--ds-r-md); + box-shadow: var(--ds-shadow-sm); + overflow: hidden; +} +.ds-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--ds-border); + background: var(--ds-bg-card-alt); +} +.ds-card-title { font-size: var(--ds-text-base); font-weight: 600; } +.ds-card-body { padding: 14px; } + +/* ── Flex Utilities ─────────────────────────────────────────────────────── */ +.ds-flex { display: flex; } +.ds-flex-col { display: flex; flex-direction: column; } +.ds-items-center { align-items: center; } +.ds-justify-between { justify-content: space-between; } +.ds-gap-1 { gap: 4px; } +.ds-gap-2 { gap: 8px; } +.ds-gap-3 { gap: 12px; } +.ds-gap-4 { gap: 16px; } +.ds-flex-1 { flex: 1; } +.ds-mt-1 { margin-top: 4px; } +.ds-mt-2 { margin-top: 8px; } +.ds-mb-1 { margin-bottom: 4px; } +.ds-mb-2 { margin-bottom: 8px; } +.ds-p-2 { padding: 8px; } +.ds-hidden { display: none; } +.ds-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } diff --git a/nui/css/style.css b/nui/css/style.css new file mode 100644 index 0000000..3e93926 --- /dev/null +++ b/nui/css/style.css @@ -0,0 +1,856 @@ +/* ───────────────────────────────────────────────────────── + pc-live | Dark Modern Desktop Theme + ───────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --pc-vp: 88; /* viewport-% used for width & height */ + --pc-fs: 13; /* base font size in px (controlled by settings) */ + --accent: #00aaff; + --bg-desktop: #0d0f14; + --bg-window: #13161e; + --bg-sidebar: #0f1117; + --bg-input: #1a1d27; + --bg-hover: #1e2230; + --titlebar: #0a0c12; + --border: #222637; + --text: #d4d8e8; + --text-muted: #6b7394; + --text-dim: #3d4260; + --success: #22c55e; + --error: #ef4444; + --warning: #f59e0b; + --radius: 8px; + --radius-sm: 4px; + --shadow: 0 8px 32px rgba(0,0,0,0.6); + --font: 'Segoe UI', system-ui, sans-serif; +} + +html, body { + width: 100%; height: 100%; + overflow: hidden; + font-family: var(--font); + background: transparent; + color: var(--text); + font-size: calc(var(--pc-fs) * 1px); + user-select: none; +} + +/* ── SETTINGS PANEL ─────────────────────────────────────── */ +#pc-settings-panel { + position: absolute; + top: 42px; + right: 8px; + z-index: 9998; + background: var(--titlebar); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 14px 16px; + min-width: 220px; + display: none; + flex-direction: column; + gap: 14px; +} +#pc-settings-panel.open { display: flex; } +.settings-group { display: flex; flex-direction: column; gap: 6px; } +.settings-group-label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-dim); +} +.settings-row { + display: flex; + align-items: center; + gap: 8px; +} +.settings-value { + flex: 1; + text-align: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + min-width: 46px; +} +.settings-btn { + width: 28px; height: 28px; + border-radius: var(--radius-sm); + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + font-size: 0.78rem; + font-weight: 700; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + flex-shrink: 0; + padding: 0; + transition: background 0.12s; +} +.settings-btn:hover { background: var(--border); opacity: 1; } +.settings-slider { + -webkit-appearance: none; + width: 100%; + height: 4px; + border-radius: 99px; + background: var(--border); + outline: none; + border: none; + padding: 0; +} +.settings-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; +} +#pc-settings-btn { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 14px; + width: 22px; height: 22px; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + padding: 0; + transition: color 0.12s, background 0.12s; + flex-shrink: 0; +} +#pc-settings-btn:hover { color: var(--text); background: var(--bg-hover); opacity: 1; } +#pc-settings-btn.active { color: var(--accent); background: rgba(0,170,255,0.1); } + +/* ── APP ROOT ────────────────────────────────────────────── */ +#app { display: none; width: 100%; height: 100%; position: relative; } +#app.visible { display: block; } + +/* ── DIM OVERLAY (Spielwelt bleibt sichtbar) ─────────────── */ +#dim-overlay { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.52); + pointer-events: none; + z-index: 0; +} + +/* ── PC CONTAINER (schwebendes Panel) ────────────────────── */ +#pc-container { + position: fixed; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + /* width/height driven by --pc-vp (set via JS, default 88%) */ + width: calc(var(--pc-vp, 88) * 1vw); + height: calc(var(--pc-vp, 88) * 1vh); + background: var(--bg-desktop); + background-image: + radial-gradient(ellipse at 20% 30%, rgba(0,170,255,0.05) 0%, transparent 60%), + radial-gradient(ellipse at 80% 80%, rgba(0,170,255,0.03) 0%, transparent 50%); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 24px 80px rgba(0,0,0,0.85); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1; +} + +/* minimized: nur Titelleiste sichtbar */ +#pc-container.minimized { height: auto; min-height: 0; } +#pc-container.minimized #desktop { display: none !important; } +#pc-container.minimized #pc-titlebar { border-radius: 10px; border-bottom-color: transparent; } + +/* ── PC TITELLEISTE ──────────────────────────────────────── */ +#pc-titlebar { + height: 38px; + background: var(--titlebar); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 12px; + gap: 8px; + flex-shrink: 0; + cursor: move; + user-select: none; + border-radius: 10px 10px 0 0; +} +.pc-title-icon { font-size: 14px; pointer-events: none; } +#pc-label { + flex: 1; + font-size: 0.78rem; font-weight: 600; + color: var(--text-muted); + pointer-events: none; +} +.pc-title-controls { display: flex; gap: 5px; } + +#pc-minimize-btn, +#close-pc-btn { + width: 22px; height: 22px; + border-radius: 50%; + border: none; + cursor: pointer; + font-size: 10px; + display: flex; align-items: center; justify-content: center; + padding: 0; + transition: opacity 0.12s; + flex-shrink: 0; +} +#pc-minimize-btn:hover, #close-pc-btn:hover { opacity: 0.75; } +#pc-minimize-btn { background: #f59e0b; color: #000; } +#close-pc-btn { background: #ef4444; color: #fff; } + +/* ── BOOT SCREEN ────────────────────────────────────────── */ +#boot-screen { + position: absolute; inset: 0; + background: #000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 9999; + transition: opacity 0.6s ease; +} +#boot-screen.fade-out { opacity: 0; pointer-events: none; } + +.boot-logo { + font-size: 2rem; + font-weight: 700; + letter-spacing: 0.2em; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 2rem; + animation: pulse 1.5s ease-in-out infinite; +} +.boot-bar { + width: 280px; height: 3px; + background: var(--border); + border-radius: 99px; + overflow: hidden; +} +.boot-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 99px; + width: 0%; + transition: width linear; +} +.boot-version { + margin-top: 1.5rem; + font-size: 0.7rem; + color: var(--text-muted); + letter-spacing: 0.15em; +} + +/* ── DESKTOP ────────────────────────────────────────────── */ +#desktop { + flex: 1; + position: relative; + overflow: hidden; + display: none; +} +#desktop.visible { display: block; } + +/* ── DESKTOP ICONS ─────────────────────────────────────── */ +#icon-grid { + position: absolute; + top: 20px; left: 20px; right: 20px; bottom: 64px; /* Platz für Taskbar */ + display: flex; + /* Icons stapeln sich in Spalten und brechen in neue Spalten um, + sobald die Desktop-Höhe nicht mehr reicht (responsiv bei kleinem Desktop). */ + flex-flow: column wrap; + align-content: flex-start; + gap: 18px 12px; + pointer-events: none; /* nur die Icons selbst klickbar, nicht die Lücken */ +} +#icon-grid > .desktop-icon { pointer-events: auto; } +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 72px; + cursor: pointer; + padding: 8px 4px; + border-radius: var(--radius-sm); + transition: background 0.15s; +} +.desktop-icon:hover { background: rgba(255,255,255,0.06); } +.desktop-icon:active { background: rgba(255,255,255,0.1); } +.icon-glyph { + width: 44px; height: 44px; + border-radius: var(--radius-sm); + background: var(--bg-window); + border: 1px solid var(--border); + display: flex; align-items: center; justify-content: center; + font-size: 22px; +} +.icon-label { + font-size: 0.7rem; + color: var(--text); + text-align: center; + text-shadow: 0 1px 3px rgba(0,0,0,0.8); + line-height: 1.2; + word-break: break-word; +} + +/* ── TASKBAR ────────────────────────────────────────────── */ +#taskbar { + position: absolute; + bottom: 0; left: 0; right: 0; + height: 40px; + background: rgba(10,12,18,0.92); + backdrop-filter: blur(12px); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 12px; + gap: 8px; + z-index: 100; +} +#taskbar-apps { + flex: 1; + display: flex; + gap: 4px; +} +.taskbar-btn { + height: 28px; + padding: 0 10px; + border-radius: var(--radius-sm); + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + font-size: 0.72rem; + display: flex; align-items: center; gap: 5px; + transition: background 0.12s; + white-space: nowrap; +} +.taskbar-btn:hover { background: var(--border); } +.taskbar-btn.active { border-color: var(--accent); color: var(--accent); } +.taskbar-btn.minimized { opacity: 0.55; } + +#taskbar-right { + display: flex; align-items: center; gap: 12px; + color: var(--text-muted); + font-size: 0.72rem; +} +#clock { font-variant-numeric: tabular-nums; } + +/* ── WINDOW MANAGER ─────────────────────────────────────── */ +#window-layer { + position: absolute; + inset: 0; bottom: 40px; + pointer-events: none; +} +.window { + position: absolute; + min-width: 400px; + min-height: 300px; + background: var(--bg-window); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + pointer-events: all; + overflow: hidden; +} +.window.minimized { display: none; } +.window.focused { border-color: rgba(0,170,255,0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(0,170,255,0.15); } + +.window-titlebar { + height: 34px; + background: var(--titlebar); + display: flex; + align-items: center; + padding: 0 10px; + gap: 8px; + cursor: move; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.window-title-icon { font-size: 14px; } +.window-title-text { flex: 1; font-size: 0.75rem; font-weight: 600; color: var(--text-muted); } +.window-controls { display: flex; gap: 5px; } +.wc-btn { + width: 22px; height: 22px; + border-radius: 50%; + border: none; + cursor: pointer; + font-size: 10px; + display: flex; align-items: center; justify-content: center; + transition: opacity 0.12s; +} +.wc-btn:hover { opacity: 0.8; } +.wc-minimize { background: #f59e0b; color: #000; } +.wc-maximize { background: #22c55e; color: #000; font-size: 11px; } +.wc-close { background: #ef4444; color: #fff; } + +/* ── Resize handles ─────────────────────────────────────── */ +.wc-resize { position: absolute; z-index: 10; } +.wc-resize-n { top: 0; left: 6px; right: 6px; height: 5px; cursor: n-resize; } +.wc-resize-s { bottom: 0; left: 6px; right: 6px; height: 5px; cursor: s-resize; } +.wc-resize-e { right: 0; top: 6px; bottom: 6px; width: 5px; cursor: e-resize; } +.wc-resize-w { left: 0; top: 6px; bottom: 6px; width: 5px; cursor: w-resize; } +.wc-resize-nw { top: 0; left: 0; width: 10px; height: 10px; cursor: nw-resize; } +.wc-resize-ne { top: 0; right: 0; width: 10px; height: 10px; cursor: ne-resize; } +.wc-resize-sw { bottom: 0; left: 0; width: 10px; height: 10px; cursor: sw-resize; } +.wc-resize-se { bottom: 0; right: 0; width: 10px; height: 10px; cursor: se-resize; } + +/* maximized: no border-radius, fills window-layer */ +.window.maximized { border-radius: 0; } + +.window-body { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── SCROLLBAR ──────────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; } + +/* ── COMMON LAYOUT ─────────────────────────────────────── */ +.app-layout { + display: flex; + height: 100%; + overflow: hidden; +} +.app-sidebar { + width: 200px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 8px 0; + flex-shrink: 0; +} +.sidebar-item { + padding: 8px 14px; + cursor: pointer; + font-size: 0.78rem; + color: var(--text-muted); + display: flex; align-items: center; gap: 8px; + border-radius: 0; + transition: background 0.1s, color 0.1s; +} +.sidebar-item:hover { background: var(--bg-hover); color: var(--text); } +.sidebar-item.active { background: rgba(0,170,255,0.12); color: var(--accent); } +.sidebar-section { + padding: 12px 14px 4px; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); +} +.app-content { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ── FORM ELEMENTS ─────────────────────────────────────── */ +input, textarea, select { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--font); + font-size: 0.82rem; + padding: 7px 10px; + outline: none; + width: 100%; + transition: border-color 0.15s; +} +input:focus, textarea:focus, select:focus { + border-color: var(--accent); +} +textarea { resize: vertical; min-height: 80px; } +label { + font-size: 0.72rem; + color: var(--text-muted); + margin-bottom: 3px; + display: block; +} +.form-group { display: flex; flex-direction: column; gap: 3px; } +.form-row { display: flex; gap: 8px; } +.form-row .form-group { flex: 1; } + +button { + cursor: pointer; + font-family: var(--font); + border: none; + border-radius: var(--radius-sm); + font-size: 0.78rem; + padding: 7px 14px; + transition: opacity 0.12s, background 0.12s; +} +button:hover { opacity: 0.85; } +.btn-primary { background: var(--accent); color: #000; font-weight: 600; } +.btn-ghost { background: var(--bg-hover); color: var(--text); border: 1px solid var(--border); } +.btn-danger { background: rgba(239,68,68,0.2); color: var(--error); border: 1px solid rgba(239,68,68,0.3); } +.btn-success { background: rgba(34,197,94,0.2); color: var(--success); border: 1px solid rgba(34,197,94,0.3); } +.btn-sm { padding: 4px 10px; font-size: 0.7rem; } + +/* ── TAGS / BADGES ─────────────────────────────────────── */ +.badge { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 18px; + border-radius: 99px; + padding: 0 5px; + font-size: 0.65rem; + font-weight: 700; + background: var(--error); + color: #fff; +} +.badge-accent { background: var(--accent); color: #000; } +.badge-success { background: var(--success); color: #000; } + +.tag { + display: inline-flex; align-items: center; + padding: 2px 8px; + border-radius: 99px; + font-size: 0.65rem; + font-weight: 600; + background: var(--bg-hover); + color: var(--text-muted); + border: 1px solid var(--border); +} +.tag-accent { background: rgba(0,170,255,0.12); color: var(--accent); border-color: rgba(0,170,255,0.25); } + +/* ── DIVIDER ─────────────────────────────────────────────── */ +.divider { height: 1px; background: var(--border); margin: 4px 0; } + +/* ── EMPTY STATE ─────────────────────────────────────────── */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-dim); + font-size: 0.8rem; + padding: 40px; + text-align: center; +} +.empty-state .empty-icon { font-size: 2.5rem; opacity: 0.4; } + +/* ── MAIL SPECIFIC ─────────────────────────────────────── */ +.mail-list { display: flex; flex-direction: column; gap: 0; } +.mail-item { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.1s; + display: flex; + flex-direction: column; + gap: 3px; +} +.mail-item:hover { background: var(--bg-hover); } +.mail-item.unread { border-left: 2px solid var(--accent); } +.mail-item.active { background: rgba(0,170,255,0.08); } +.mail-from { font-size: 0.72rem; color: var(--text-muted); } +.mail-subject { + font-size: 0.8rem; + color: var(--text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mail-item.unread .mail-subject { color: #fff; font-weight: 700; } +.mail-date { font-size: 0.65rem; color: var(--text-dim); } + +.mail-detail { padding: 16px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; } +.mail-detail-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; } +.mail-detail-subject { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 6px; } +.mail-detail-meta { font-size: 0.72rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 2px; } +.mail-detail-body { font-size: 0.82rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; } + +/* ── STORE SPECIFIC ─────────────────────────────────────── */ +.store-statusbar { + padding: 7px 16px; + border-top: 1px solid var(--border); + font-size: 0.7rem; + color: var(--text-dim); + background: var(--bg-sidebar); + flex-shrink: 0; + text-align: center; +} +.store-statusbar strong { color: var(--text-muted); } +.store-card--installed { border-color: rgba(0,170,255,0.2); } +.store-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} +.store-card { + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.15s; +} +.store-card:hover { border-color: var(--border); } +.store-card-icon { font-size: 1.8rem; } +.store-card-name { font-size: 0.85rem; font-weight: 600; color: #fff; } +.store-card-desc { font-size: 0.72rem; color: var(--text-muted); line-height: 1.4; flex: 1; } +.store-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; } +.store-price { font-size: 0.72rem; color: var(--text-muted); } +.store-price.free { color: var(--success); } + +/* ── BROWSER SPECIFIC ─────────────────────────────────────── */ +.browser-chrome { + padding: 8px 10px; + background: var(--titlebar); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.browser-chrome input { + flex: 1; + font-size: 0.78rem; + height: 26px; + padding: 0 10px; +} +.browser-nav-btn { + width: 26px; height: 26px; + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + display: flex; align-items: center; justify-content: center; + cursor: pointer; + font-size: 11px; + flex-shrink: 0; +} +.browser-nav-btn:hover { color: var(--text); } +.browser-viewport { + flex: 1; + overflow-y: auto; + padding: 24px 32px; +} +.browser-viewport.browser-viewport-external { + padding: 0; + overflow: hidden; +} +.browser-iframe { + width: 100%; + height: 100%; + border: none; + display: block; + background: #fff; +} +.browser-address-input { + flex: 1; + font-size: 0.78rem; + height: 26px; + padding: 0 10px; +} +.browser-go-btn { + font-weight: bold; + color: var(--accent) !important; +} +.browser-404 { + display: flex; flex-direction: column; + align-items: center; justify-content: center; + height: 100%; + gap: 12px; + color: var(--text-dim); +} +.browser-404 h2 { font-size: 2rem; color: var(--error); } +.browser-404 p { font-size: 0.82rem; } + +/* IC Page styles */ +.ic-page h1 { font-size: 1.5rem; margin-bottom: 6px; color: #fff; } +.ic-page h2 { font-size: 1.1rem; margin: 16px 0 6px; color: var(--text); } +.ic-page p { font-size: 0.82rem; line-height: 1.7; color: var(--text-muted); margin-bottom: 8px; } +.ic-page .article { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; + background: var(--bg-sidebar); +} +.ic-page .article-date { font-size: 0.65rem; color: var(--text-dim); margin-bottom: 4px; } +.ic-home-hero { + text-align: center; + padding: 40px 0; +} +.ic-home-hero h1 { font-size: 2rem; color: var(--accent); margin-bottom: 8px; } +.ic-home-links { display: flex; gap: 12px; justify-content: center; margin-top: 20px; flex-wrap: wrap; } +.ic-link-card { + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 18px; + cursor: pointer; + font-size: 0.8rem; + color: var(--text); + transition: border-color 0.15s, background 0.15s; + text-decoration: none; +} +.ic-link-card:hover { border-color: var(--accent); background: var(--bg-hover); } + +/* ── SEARCH ──────────────────────────────────────────────── */ +.search-bar { + display: flex; gap: 8px; align-items: center; + margin-bottom: 4px; +} +.search-bar input { flex: 1; } + +/* ── ANIMATIONS ─────────────────────────────────────────── */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.fade-in { animation: fadeIn 0.2s ease forwards; } + +/* ── MAIL: folder sidebar items ─────────────────────────── */ +.sidebar-folder-item { justify-content: space-between; } +.sidebar-folder-del { + background: none; border: none; color: var(--text-dim); + cursor: pointer; font-size: 10px; padding: 0 2px; line-height:1; + opacity: 0; +} +.sidebar-folder-item:hover .sidebar-folder-del { opacity: 1; } + +.mail-move-select { + font-size: .65rem; padding: 1px 4px; cursor: pointer; + background: var(--bg-input); border: 1px solid var(--border); + color: var(--text-muted); border-radius: var(--radius-sm); + max-width: 120px; +} + +/* ── CALENDAR ────────────────────────────────────────────── */ +.cal-month-header { + padding: 10px 14px; + font-weight: 700; font-size: .82rem; color: var(--text); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + flex: 1; + overflow-y: auto; + border-bottom: 1px solid var(--border); +} +.cal-header-cell { + text-align: center; font-size: .65rem; font-weight: 600; + color: var(--text-muted); padding: 4px 0; + border-bottom: 1px solid var(--border); + background: var(--bg-sidebar); +} +.cal-cell { + min-height: 54px; padding: 3px 4px; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + cursor: pointer; transition: background .1s; +} +.cal-cell:hover { background: var(--bg-hover); } +.cal-cell-empty { background: var(--bg-sidebar); cursor: default; } +.cal-today { background: rgba(0,170,255,.08); } +.cal-today .cal-day-num { color: var(--accent); font-weight: 700; } +.cal-day-num { font-size: .68rem; color: var(--text-muted); } +.cal-dots { display: flex; flex-wrap: wrap; gap: 2px; margin-top: 2px; } +.cal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.cal-event-item { + padding: 6px 8px; border-radius: var(--radius-sm); + background: var(--bg-input); + font-size: .75rem; +} + +/* Schild am Termin: privat, Postfach oder öffentlich */ +.cal-badge { + font-size: 0.6rem; + padding: 1px 6px; + border-radius: 8px; + border: 1px solid var(--border); + color: var(--text-dim); + white-space: nowrap; + flex-shrink: 0; +} +.cal-edit-form { + background: var(--bg-input); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px; +} + +/* ── ADDRESSBOOK ─────────────────────────────────────────── */ +.contact-avatar { + width: 34px; height: 34px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: .78rem; color: #fff; flex-shrink: 0; +} +.contact-avatar-lg { + width: 56px; height: 56px; font-size: 1.2rem; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-weight: 700; color: #fff; flex-shrink: 0; +} +.contact-field-group { display: flex; flex-direction: column; gap: 8px; } +.contact-field { + display: flex; flex-direction: column; gap: 2px; + background: var(--bg-input); border-radius: var(--radius-sm); + padding: 8px 10px; +} +.contact-field-label { font-size: .68rem; color: var(--text-dim); } +.contact-field-value { font-size: .8rem; color: var(--text); word-break: break-all; } + +/* ── Interaktionsmodus (PC transparent, Fokus im Spiel) ─────────── */ +#pc-interact-hint { + display: none; + position: absolute; + top: 14px; left: 50%; + transform: translateX(-50%); + z-index: 9999; + background: rgba(10,14,22,0.85); + color: #e8eef7; + border: 1px solid rgba(255,255,255,0.15); + border-radius: 8px; + padding: 8px 16px; + font-size: 0.85rem; + box-shadow: 0 6px 24px rgba(0,0,0,0.5); + pointer-events: none; + white-space: nowrap; +} +#pc-interact-hint b { color: #4aa3ff; } + +#app.interact-mode #pc-container { + opacity: 0.18; + pointer-events: none; /* Klicks/Interaktion gehen an das Spiel */ + transition: opacity 0.2s ease; +} +#app.interact-mode #dim-overlay { opacity: 0 !important; } /* Spielwelt voll sichtbar */ +#app.interact-mode #pc-interact-hint { display: block; } + +/* 👁-Button im selben Stil wie das ⚙-Settings-Icon oben */ +#pc-interact-btn { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 14px; + width: 22px; height: 22px; + border-radius: var(--radius-sm); + cursor: pointer; + display: flex; align-items: center; justify-content: center; + padding: 0; + transition: color 0.12s, background 0.12s; + flex-shrink: 0; +} +#pc-interact-btn:hover { color: var(--text); background: var(--bg-hover); opacity: 1; } +#pc-interact-btn.active { color: var(--accent); background: rgba(0,170,255,0.1); } diff --git a/nui/index.html b/nui/index.html new file mode 100644 index 0000000..91a070e --- /dev/null +++ b/nui/index.html @@ -0,0 +1,107 @@ + + + + + + pc-live + + + + + + +
+ + +
+ + +
+ + +
+ +
+
+
+
OS 1.0.0  ·  Terminal
+
+ + +
+ + Terminal +
+ + + + +
+
+ + +
+
+
Schriftgröße
+
+ + + +
+
+ 13px +
+
+
+
Fenstergröße
+
+ + + +
+
+ 88% +
+
+
+ + +
+
+
+
+
+
+ 00:00:00 +
+
+
+ +
+ + +
+ 🎮 Interaktionsmodus aktiv — [END] drücken, um zum PC zurückzukehren +
+ +
+ + + + + + + + + + + + + + + + + + diff --git a/nui/js/apps/addressbook.js b/nui/js/apps/addressbook.js new file mode 100644 index 0000000..e35842e --- /dev/null +++ b/nui/js/apps/addressbook.js @@ -0,0 +1,307 @@ +/** + * pc-live | Adressbuch App + * Kontakte verwalten, an Mail-App weitergeben, Kontaktkarte per Mail senden. + */ +const AddressBookApp = (() => { + const WIN_ID = 'app-addressbook'; + + let _contacts = []; + let _active = null; // contact id + let _editing = false; + let _editData = null; + + /* ── Helpers ──────────────────────────────────────────── */ + function esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>'); + } + function fmtDate(d) { + if (!d) return ''; + const dt = new Date(d); + return isNaN(dt) ? '' : dt.toLocaleDateString('de-DE'); + } + function initials(name) { + return (name || '?').split(' ').map(p => p[0] || '').slice(0,2).join('').toUpperCase(); + } + function avatarColor(name) { + const colors = ['#3b82f6','#8b5cf6','#ec4899','#f59e0b','#10b981','#06b6d4','#ef4444']; + let h = 0; + for (const c of (name||'?')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff; + return colors[Math.abs(h) % colors.length]; + } + + /* ── Sidebar list ─────────────────────────────────────── */ + function renderList() { + if (!_contacts.length) + return `
👤

Keine Kontakte

`; + + return `
` + _contacts.map(c => ` +
+
+
${esc(initials(c.name))}
+
+
${esc(c.name)}
+ ${c.email ? `
${esc(c.email)}
` : ''} + ${c.phone ? `
${esc(c.phone)}
` : ''} +
+
+
`).join('') + `
`; + } + + /* ── Detail / Edit ────────────────────────────────────── */ + function renderDetail() { + if (_editing) { + const d = _editData || {}; + return ` +
+

+ ${d.id ? '✏ Kontakt bearbeiten' : '➕ Neuer Kontakt'} +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
`; + } + + const c = _contacts.find(x => x.id === _active); + if (!c) + return `
👤

Kontakt auswählen

`; + + const bg = avatarColor(c.name); + return ` +
+
+
${esc(initials(c.name))}
+
+
${esc(c.name)}
+
Hinzugefügt: ${fmtDate(c.created_at)}
+
+
+ +
+ ${c.email ? ` +
+ ✉ E-Mail / ID + ${esc(c.email)} +
` : ''} + ${c.phone ? ` +
+ 📞 Telefon + ${esc(c.phone)} +
` : ''} + ${c.notes ? ` +
+ 📝 Notizen + ${esc(c.notes)} +
` : ''} +
+ +
+ ${c.email ? ` + + ` : ''} + + +
+
`; + } + + /* ── Full refresh ─────────────────────────────────────── */ + function _refresh() { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + + // Push contacts into mail app for autocomplete + MailApp.setContacts(_contacts); + + body.innerHTML = ` +
+
+ +
+ +
+
+ ${renderList()} +
+
+
+ ${renderDetail()} +
+
`; + } + + /* ── Actions ──────────────────────────────────────────── */ + function _select(id) { + _active = id; + _editing = false; + _editData = null; + _refresh(); + } + + function _startEdit(id) { + _editing = true; + _editData = id ? { ...(_contacts.find(c => c.id === id) || {}) } : {}; + _active = id; + _refresh(); + } + + function _cancelEdit() { + _editing = false; + _editData = null; + _refresh(); + } + + function _save() { + const name = document.getElementById('ab-name')?.value.trim(); + const email = document.getElementById('ab-email')?.value.trim(); + const phone = document.getElementById('ab-phone')?.value.trim(); + const notes = document.getElementById('ab-notes')?.value.trim(); + const status = document.getElementById('ab-status'); + if (!name) { if (status) status.textContent = 'Name ist Pflichtfeld.'; return; } + + const payload = { name, email, phone, notes }; + if (_editData && _editData.id) { + fetchNui('updateContact', { id: _editData.id, ...payload }); + } else { + fetchNui('addContact', payload); + } + _editing = false; + _editData = null; + } + + function _delete(id) { + // Kein confirm(): FiveMs CEF hat keinen Handler für die eingebauten + // JS-Dialoge – der Aufruf öffnet nichts und lässt das NUI stehen. + ICWebRender.confirmBox('Kontakt löschen', 'Kontakt wirklich löschen?', + () => fetchNui('deleteContact', { id }), + { danger: true, okLabel: 'Löschen' }); + } + + function _mailTo(emailOrId) { + // Open mail app in compose mode with prefilled To + MailApp.open(); + setTimeout(() => { + MailApp._setView('compose'); + setTimeout(() => { + const t = document.getElementById('mail-to'); + if (t) { t.value = emailOrId; t.focus(); } + }, 50); + }, 50); + } + + function _forwardContact(id) { + const c = _contacts.find(x => x.id === id); + if (!c) return; + const body = [ + `── Kontaktkarte ──`, + `Name: ${c.name}`, + c.email ? `E-Mail: ${c.email}` : null, + c.phone ? `Telefon: ${c.phone}` : null, + c.notes ? `Notizen: ${c.notes}` : null, + ].filter(Boolean).join('\n'); + + MailApp.open(); + setTimeout(() => { + MailApp._setView('compose'); + setTimeout(() => { + const s = document.getElementById('mail-subject'); + const b = document.getElementById('mail-body'); + if (s) s.value = `Kontakt: ${c.name}`; + if (b) b.value = body; + document.getElementById('mail-to')?.focus(); + }, 50); + }, 50); + } + + /* ── Called from MailApp ("Als Kontakt speichern") ─────── */ + function openAddNew(prefill) { + open(); + setTimeout(() => { + _editing = true; + _editData = prefill || {}; + _active = null; + _refresh(); + }, 100); + } + + /* ── NUI events ───────────────────────────────────────── */ + function onContactsData(rows) { + _contacts = rows || []; + MailApp.setContacts(_contacts); + if (WindowManager.getBody(WIN_ID)) _refresh(); + } + + function onContactAdded(c) { + _contacts.push(c); + _contacts.sort((a,b) => a.name.localeCompare(b.name)); + _active = c.id; + _editing = false; + MailApp.setContacts(_contacts); + _refresh(); + } + + function onContactUpdated(c) { + const idx = _contacts.findIndex(x => x.id === c.id); + if (idx !== -1) _contacts[idx] = c; else _contacts.push(c); + _contacts.sort((a,b) => a.name.localeCompare(b.name)); + _active = c.id; + _editing = false; + MailApp.setContacts(_contacts); + _refresh(); + } + + function onContactDeleted(id) { + _contacts = _contacts.filter(c => c.id !== id); + if (_active === id) _active = null; + MailApp.setContacts(_contacts); + _refresh(); + } + + /* ── Open window ──────────────────────────────────────── */ + function open() { + const created = WindowManager.create({ + id: WIN_ID, + title: 'Adressbuch', + icon: '👤', + width: 720, + height: 500, + content: '', + }); + if (created) { + _active = null; + _editing = false; + fetchNui('getContacts', {}); + _refresh(); + } + } + + return { + open, openAddNew, + _select, _startEdit, _cancelEdit, _save, _delete, + _mailTo, _forwardContact, + onContactsData, onContactAdded, onContactUpdated, onContactDeleted, + }; +})(); diff --git a/nui/js/apps/bleeter.js b/nui/js/apps/bleeter.js new file mode 100644 index 0000000..2f56246 --- /dev/null +++ b/nui/js/apps/bleeter.js @@ -0,0 +1,1420 @@ +/** + * pc-live | Bleeter + * + * Eigene Oberfläche, kein iframe. Der frühere Weg – Bleeters Fenster im PC + * einbetten und Nachrichten hin- und zurückreichen – ging nicht auf: + * SetNuiFocus gilt global für den Client, und zwei Resourcen, die ihn + * beanspruchen, sperren die Maus aus. + * + * Jetzt spricht der PC direkt mit bleeter:server:*. Bleeter selbst bleibt + * unberührt und öffnet weiterhin sein eigenes Fenster über /bleeter. + * + * Die Gestaltung ist nicht nachgebaut, sondern übernommen: css/bleeter-embedded.css + * wird mechanisch aus bleeter/html/style.css erzeugt, jeder Selektor auf + * `.bl-root` gekapselt. Ohne die Kapselung würden Bleeters Regeln für `*`, + * `html` und `body` den gesamten PC einfärben. + * + * Deshalb tragen die Bausteine hier Bleeters eigene Klassennamen – .post-card, + * .compose, .avatar, .nav-button. Ändert Bleeter sein Aussehen, reicht es, die + * Datei neu zu erzeugen. + * + * Inhalte kommen von Spielern und werden über textContent gesetzt, nie über + * innerHTML. + */ +const BleeterApp = (() => { + const WIN_ID = 'app-bleeter'; + const el = ICWebRender.el; + + let state = null; // vollständiger Zustand vom Server + let view = 'home'; // 'home' | 'ads' | 'profile' + let winEl = null; + let loading = true; + let regError = ''; // Fehlermeldung der Registrierung + const openComments = new Set(); // Beiträge mit ausgeklappten Kommentaren + let viewProfileId = null; // fremdes Profil, das gerade offen ist + + /* Der Server schickt alles über ein Ereignis. Drei Sorten: + – Antworten der Verwaltung → gehören der anderen App + – Unteraktionen wie registerError → betreffen nur eine Maske + – alles andere → der vollständige Zustand + Ohne diese Trennung überschreibt eine Fehlermeldung den ganzen Zustand. */ + const MANAGEMENT_ACTIONS = new Set([ + 'listBusinessProfiles', 'createBusinessProfile', + 'listProfileMembers', 'listCandidates', 'setProfileMember', + ]); + const SUB_ACTIONS = new Set(['registerError', 'handleCheck']); + + /* ── Transport ────────────────────────────────────────── */ + function send(op, payload) { + fetchNui('bleeter', payload === undefined ? { op } : { op, payload }); + } + + /** Von desktop.js, wenn action === 'bleeter_data'. */ + function onData(message) { + if (!message) return; + const d = message.data; + + if (d && d.action && MANAGEMENT_ACTIONS.has(d.action)) { + if (typeof BleeterAdminApp !== 'undefined') BleeterAdminApp.onResponse(d); + return; + } + + if (d && d.action && SUB_ACTIONS.has(d.action)) { + if (d.action === 'registerError') { + loading = false; + regError = { + handle_taken: 'Dieses Handle ist bereits vergeben.', + reserved: 'Dieses Handle ist reserviert.', + }[d.reason] || 'Registrierung fehlgeschlagen.'; + if (isOpen()) render(); + } + return; + } + + loading = false; + regError = ''; + + if (message.ok === false) { + state = { error: message.reason || 'unbekannt' }; + } else { + state = message.data || null; + } + + if (isOpen()) render(); + } + + function onNotify(payload) { + if (payload && payload.message) Desktop.showNotification('🐦 ' + payload.message); + } + + /* ── Fenster ──────────────────────────────────────────── */ + function isOpen() { + return !!(winEl && winEl.isConnected) + || !!document.querySelector('[data-wid="' + WIN_ID + '"]'); + } + + function open() { + injectStyles(); + + const win = WindowManager.create({ + id: WIN_ID, title: 'Bleeter', icon: '🐦', width: 1000, height: 680, + content: ` +
+
+
+ +
+ +
+
`, + }); + if (!win) return; + + winEl = win; + loading = true; + render(); + send('requestBootstrap'); + } + + function root(id) { + if (!winEl || !winEl.isConnected) { + winEl = document.querySelector('[data-wid="' + WIN_ID + '"]'); + } + return winEl ? winEl.querySelector('#' + id) : null; + } + function setMain(node) { + const m = root('bl-main'); + if (m) m.replaceChildren(node); + } + function info(icon, text, extra) { + const d = el('div', 'bl-empty'); + d.appendChild(el('div', 'bl-empty-icon', icon)); + d.appendChild(el('div', null, text)); + if (extra) d.appendChild(extra); + return d; + } + + /* ── Bildadressen ─────────────────────────────────────── */ + /* Der häufigste Stolperstein: Leute kopieren die Adresse der *Seite*, auf + der ein Bild liegt, statt die des Bildes. https://imgur.com/a/BCvnigB ist + ein Album, kein Bild – der Server lehnt es ab, und im Profil bliebe ein + Link stehen, der nie lädt. + + Einzelbildseiten lassen sich umrechnen. Alben nicht: die Album-Kennung + ist nicht die Bildkennung, dafür bräuchte man die imgur-Schnittstelle. */ + const IMAGE_HINT = + 'Es muss die Adresse des Bildes sein, nicht die der Seite – sie endet auf ' + + '.jpg oder .png. Rechtsklick aufs Bild → „Bildadresse kopieren".'; + + /* Bilderdienste geben gern fertige Schnipsel zum Einbetten heraus – BBCode, + HTML, Markdown. Darin steckt die richtige Adresse schon drin, sie ist nur + von Beiwerk umgeben. Also holen wir sie heraus, statt den Block abzulehnen. */ + function extractImageUrl(raw) { + const text = String(raw || '').trim(); + + const bb = /\[img\]\s*(https?:\/\/[^\s\]]+)\s*\[\/img\]/i.exec(text); + if (bb) return bb[1]; + + const html = /]+src=["']([^"']+)["']/i.exec(text); + if (html) return html[1]; + + const md = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i.exec(text); + if (md) return md[1]; + + // Mehrere Adressen (etwa Seite + Bild): die mit Bildendung gewinnt. + const urls = text.match(/https?:\/\/[^\s"'<>\]]+/gi) || []; + const image = urls.find(u => /\.(jpe?g|png)(\?.*)?$/i.test(u)); + if (image) return image; + + return urls.length === 1 ? urls[0] : text; + } + + function checkImageUrl(raw) { + const url = extractImageUrl(raw); + if (!url) return { ok: true, url: '' }; // leer ist erlaubt + + if (!/^https:\/\//i.test(url)) { + return { ok: false, error: 'Die Adresse muss mit https:// beginnen.' }; + } + + // imgur-Album: nicht auflösbar + if (/^https:\/\/(www\.)?imgur\.com\/(a|gallery)\//i.test(url)) { + return { ok: false, error: + 'Das ist ein imgur-Album, kein einzelnes Bild. Öffne das Bild und ' + + 'kopiere seine Adresse (Rechtsklick → „Bildadresse kopieren").' }; + } + + // imgur-Einzelbildseite → direkte Adresse + const single = /^https:\/\/(www\.)?imgur\.com\/([A-Za-z0-9]{5,12})$/.exec(url); + if (single) return { ok: true, url: 'https://i.imgur.com/' + single[2] + '.png' }; + + if (!/\.(jpe?g|png)(\?.*)?$/i.test(url)) { + return { ok: false, error: IMAGE_HINT }; + } + return { ok: true, url }; + } + + /* ── Datei hochladen ──────────────────────────────────── */ + /* Bleeter lädt Bilder zu ImgBB hoch (Zugang steht in bleeter/shared/config.lua). + Das ist der verlässlichere Weg als fremde Links: der Hoster kann das + Verlinken nicht sperren, weil das Bild dort selbst liegt. */ + const uploadTargets = new Map(); // Kennung → Eingabefeld + + function uploadButton(field, key) { + const btn = el('label', 'upload-button', 'Datei hochladen'); + + const input = el('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', 'image/jpeg,image/png'); + input.style.display = 'none'; + + input.addEventListener('change', () => { + const file = input.files && input.files[0]; + if (!file) return; + + if (file.size > 2 * 1024 * 1024) { + Desktop.showNotification('⚠ Die Datei ist größer als 2 MB.'); + input.value = ''; + return; + } + + const reader = new FileReader(); + reader.addEventListener('load', () => { + const result = String(reader.result || ''); + const base64 = result.includes(',') ? result.split(',').pop() : result; + + uploadTargets.set(key, field); + btn.textContent = 'Lädt hoch…'; + + send('uploadMedia', { + target: key, name: file.name, mime: file.type, + size: file.size, data: base64, + }); + }); + reader.readAsDataURL(file); + input.value = ''; + }); + + btn.addEventListener('click', () => input.click()); + + const wrap = el('span', 'bl-upload'); + wrap.append(btn, input); + return wrap; + } + + /** Von desktop.js, wenn action === 'bleeter_uploaded'. */ + function onUploaded(payload) { + const field = payload && uploadTargets.get(payload.target); + uploadTargets.delete(payload && payload.target); + + // Beschriftung zurücksetzen, egal wie es ausging. + document.querySelectorAll('.bl-root .upload-button').forEach(b => { + if (b.textContent === 'Lädt hoch…') b.textContent = 'Datei hochladen'; + }); + + if (!payload || !payload.ok) { + const texts = { + file_too_large: 'Die Datei ist größer als 2 MB.', + unsupported_extension: 'Nur JPG und PNG.', + upload_failed: 'Der Bilderdienst hat den Upload abgelehnt.', + missing_imgbb_key: 'Für Uploads fehlt der Zugang zum Bilderdienst.', + }; + return Desktop.showNotification( + '⚠ ' + (texts[payload && payload.reason] || 'Upload fehlgeschlagen.')); + } + + if (field) field.value = payload.url; + Desktop.showNotification('✔ Bild hochgeladen.'); + } + + /* Empfohlene Maße. Steht direkt am Eingabefeld, damit man nicht raten muss. */ + const SIZE_HINTS = { + avatar: 'Empfohlen: quadratisch, etwa 400 × 400 px. Wird rund beschnitten.', + banner: 'Empfohlen: 1500 × 500 px. Wird auf die Fensterbreite beschnitten.', + post: 'Empfohlen: höchstens 1200 px breit, JPG oder PNG. ' + + 'Wenn ein Bild leer bleibt, sperrt der Hoster vermutlich das Verlinken – ' + + 'imgur tut das zeitweise. Zuverlässig sind ibb.co und Discord-Anhänge.', + }; + + /* ── Bausteine ────────────────────────────────────────── */ + /* Avatar. Ohne Bild ein farbiger Kreis mit dem Anfangsbuchstaben – aus dem + Handle abgeleitet, damit dieselbe Person immer dieselbe Farbe hat. */ + function avatar(profile, size) { + const px = size || 52; + const handle = (profile && profile.handle) || '?'; + + if (profile && profile.avatar_url) { + const img = el('img', 'avatar'); + img.setAttribute('src', String(profile.avatar_url)); + // Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand + // der Herkunft, und die eines NUI kennen sie nicht. + img.setAttribute('referrerpolicy', 'no-referrer'); + img.setAttribute('loading', 'lazy'); + img.style.width = px + 'px'; + img.style.height = px + 'px'; + img.addEventListener('error', () => { + const fallback = initialAvatar(handle, px); + if (img.parentNode) img.parentNode.replaceChild(fallback, img); + }); + return img; + } + return initialAvatar(handle, px); + } + + function initialAvatar(handle, px) { + let hash = 0; + for (let i = 0; i < handle.length; i++) hash = (hash * 31 + handle.charCodeAt(i)) >>> 0; + const hue = hash % 360; + + const d = el('div', 'avatar bl-avatar-letter', handle.charAt(0).toUpperCase()); + d.style.width = px + 'px'; + d.style.height = px + 'px'; + d.style.fontSize = Math.round(px * 0.42) + 'px'; + d.style.background = `hsl(${hue} 42% 26%)`; + d.style.color = `hsl(${hue} 70% 78%)`; + return d; + } + + /* Abzeichen wie im Original: .verified und .staff-badge. */ + function verifiedMark(profile) { + if (profile && profile.is_lifeinvader_staff) { + const b = el('span', 'staff-badge', '★'); + b.setAttribute('title', 'Lifeinvader Mitarbeiter'); + return b; + } + if (profile && profile.is_verified) { + const b = el('span', 'verified', '✓'); + b.setAttribute('title', 'Verifiziert'); + return b; + } + return null; + } + + /* ── Zeichnen ─────────────────────────────────────────── */ + function render() { + renderChrome(); + + if (loading) return setMain(info('⏳', 'Lade Bleeter…')); + if (!state) return setMain(info('⚠', 'Bleeter ist nicht erreichbar.')); + if (state.error) return setMain(renderError(state.error)); + if (state.needs_registration) return setMain(renderRegistration()); + + const pages = { + profile: renderProfile, + followers: renderFollowers, + market: renderMarket, + calendar: renderCalendar, + business: renderBusinesses, + }; + if (pages[view]) return setMain(pages[view]()); + setMain(renderFeed(view === 'ads' ? 'advertising' : 'home')); + } + + function renderError(reason) { + const texts = { + missing_char: 'Kein Charakter geladen.', + no_mail: 'Du brauchst zuerst eine IC-Mailadresse. Richte sie in der Mail-App ein.', + missing_profile: 'Kein Profil vorhanden.', + }; + return info('⚠', texts[reason] || ('Bleeter meldet: ' + reason)); + } + + const activeProfile = () => + ((state && state.profiles) || []).find(p => p.id === state.activeProfileId) || null; + + /* Alle Profile, die man ansehen darf – kommt als profileDirectory mit. */ + const directoryProfile = (id) => + ((state && state.profileDirectory) || []).find(p => p.id === id) || null; + + const isFollowing = (id) => + (((state && state.social) || {}).following || []).some(p => p.id === id); + + /* Fremdes Profil öffnen. Das eigene führt zur gewohnten Profilseite. */ + function openProfile(id) { + const me = activeProfile(); + viewProfileId = (me && me.id === id) ? null : id; + view = 'profile'; + render(); + } + + /* Name und Bild eines Autors, anklickbar. */ + function authorLink(profile, node) { + const btn = el('button', 'author-link'); + btn.appendChild(node); + if (profile && profile.id) { + btn.addEventListener('click', () => openProfile(profile.id)); + } + return btn; + } + + /* ── Kopfzeile, Navigationsschiene, Randspalte ────────── */ + /* Aufbau und Klassennamen wie im echten Bleeter: topbar, side-nav mit + nav-button, content, context. */ + function renderChrome() { + const bar = root('bl-topbar'); + if (bar) { + bar.replaceChildren(); + + const brand = el('div', 'brand'); + brand.appendChild(el('span', 'bl-wordmark', 'Bleeter')); + bar.appendChild(brand); + + bar.appendChild(el('div')); // Platzhalterspalte wie im Original + + const me = activeProfile(); + const top = el('div', 'top-profile'); + if (me) top.appendChild(avatar(me, 50)); + bar.appendChild(top); + } + + renderSide(); + renderContext(); + } + + function renderSide() { + const side = root('bl-side'); + if (!side) return; + side.replaceChildren(); + + const ready = state && !state.error && !state.needs_registration; + + [['home', '🏠', 'Feed'], ['ads', '📣', 'Werbung'], ['followers', '👥', 'Follower'], + ['market', '🛒', 'Markt'], ['calendar', '📅', 'Kalender'], + ['business', '💼', 'Gewerbe'], ['profile', '👤', 'Profil'], + ['reload', '↻', 'Neu laden']] + .forEach(([id, icon, label]) => { + const b = el('button', 'nav-button' + (view === id ? ' active' : '')); + b.appendChild(el('span', 'nav-icon', icon)); + b.appendChild(el('span', 'nav-label', label)); + + if (id === 'reload') { + b.addEventListener('click', () => { + loading = true; render(); send('requestBootstrap'); + }); + } else { + b.disabled = !ready; + if (ready) b.addEventListener('click', () => { + view = id; + viewProfileId = null; // Navigation führt immer zum eigenen Bereich + render(); + }); + } + side.appendChild(b); + }); + } + + /* Randspalte: aktives Profil und Profilwechsel. Wer für eine Firma + schreibt, sieht hier, unter welchem Namen er gerade auftritt. */ + function renderContext() { + const ctx = root('bl-context'); + if (!ctx) return; + ctx.replaceChildren(); + + const me = activeProfile(); + if (!me) return; + + const card = el('div', 'post-card bl-ctx-card'); + + const head = el('div', 'post-head'); + head.appendChild(avatar(me, 52)); + + const who = el('div'); + const name = el('div', 'author-name'); + name.appendChild(el('span', null, me.display_name || me.handle)); + const mark = verifiedMark(me); + if (mark) name.appendChild(mark); + who.appendChild(name); + who.appendChild(el('div', 'handle', '@' + me.handle)); + head.appendChild(who); + card.appendChild(head); + + if ((state.profiles || []).length > 1) { + const wrap = el('div', 'bl-ctx-switch'); + wrap.appendChild(el('div', 'hint', 'Auftreten als')); + const sel = el('select', 'market-sort'); + (state.profiles || []).forEach(p => { + const o = el('option', null, '@' + p.handle); + o.value = String(p.id); + if (p.id === me.id) o.selected = true; + sel.appendChild(o); + }); + sel.addEventListener('change', () => { + loading = true; + render(); + send('setActiveProfile', { profileId: Number(sel.value) }); + }); + wrap.appendChild(sel); + card.appendChild(wrap); + } + + ctx.appendChild(card); + } + + /* ── Registrierung ────────────────────────────────────── */ + function renderRegistration() { + const wrap = el('div', 'bl-narrow'); + + const card = el('div', 'post-card bl-welcome'); + card.appendChild(el('div', 'bl-welcome-mark', '🐦')); + card.appendChild(el('div', 'bl-title', 'Willkommen bei Bleeter')); + card.appendChild(el('div', 'bl-sub', + 'Such dir dein Handle aus – damit trittst du auf Bleeter auf. ' + + 'Es lässt sich später nicht mehr ändern.')); + + if (state.reason === 'no_mail') { + card.appendChild(el('div', 'bl-banner', + 'Dafür brauchst du zuerst eine IC-Mailadresse. Richte sie in der Mail-App ' + + 'ein und komm dann zurück.')); + wrap.appendChild(card); + return wrap; + } + + const handleField = el('div', 'bl-field'); + handleField.appendChild(el('label', 'bl-label', 'Handle')); + const inline = el('div', 'bl-inline'); + inline.appendChild(el('span', 'bl-at', '@')); + const handle = el('input', 'event-input'); + handle.value = state.suggested_handle || ''; + handle.setAttribute('placeholder', 'deinname'); + inline.appendChild(handle); + handleField.appendChild(inline); + card.appendChild(handleField); + + const nameField = el('div', 'bl-field'); + nameField.appendChild(el('label', 'bl-label', 'Anzeigename')); + const display = el('input', 'event-input'); + display.value = state.display_name || ''; + nameField.appendChild(display); + card.appendChild(nameField); + + const err = el('div', 'bl-error'); + err.textContent = regError; + card.appendChild(err); + + const go = el('button', 'primary-button', 'Account anlegen'); + go.addEventListener('click', () => { + const value = (handle.value || '').trim().toLowerCase(); + if (!/^[a-z0-9._-]{2,32}$/.test(value)) { + err.textContent = '2 bis 32 Zeichen, nur a–z, 0–9, Punkt, Unterstrich, Bindestrich.'; + return; + } + err.textContent = ''; + loading = true; + render(); + send('registerAccount', { handle: value, displayName: display.value }); + }); + card.appendChild(go); + + card.appendChild(el('div', 'bl-hint', 'Deine Mailadresse: ' + (state.mail || '—'))); + + wrap.appendChild(card); + return wrap; + } + + /* ── Feed ─────────────────────────────────────────────── */ + function renderFeed(feedType) { + const wrap = el('div'); + + const head = el('div', 'bl-pagehead'); + head.appendChild(el('div', 'bl-title', + feedType === 'advertising' ? 'Werbefeed' : 'Feed')); + head.appendChild(el('div', 'bl-sub', feedType === 'advertising' + ? 'Anzeigen von Gewerben und Behörden.' + : 'Was auf Bleeter geschrieben wird.')); + wrap.appendChild(head); + + const me = activeProfile(); + if (me && me.can_post !== false) wrap.appendChild(renderComposer(feedType, me)); + + const posts = (state.posts || []).filter(p => (p.feed_type || 'home') === feedType); + if (!posts.length) { + wrap.appendChild(info('🐦', 'Hier ist noch nichts.')); + return wrap; + } + + const list = el('div', 'bl-feed'); + posts.forEach(p => list.appendChild(renderPost(p))); + wrap.appendChild(list); + return wrap; + } + + function renderComposer(feedType, me) { + const card = el('div', 'compose'); + card.appendChild(avatar(me, 52)); + + const right = el('div'); + const ta = el('textarea'); + ta.setAttribute('maxlength', '500'); + ta.setAttribute('placeholder', 'Was gibt es Neues in Los Santos?'); + right.appendChild(ta); + + const actions = el('div', 'compose-actions'); + + const uploadRow = el('div', 'upload-row'); + const media = el('input', 'media-url-input'); + media.setAttribute('placeholder', 'Bildadresse oder Einbettungscode einfügen'); + media.setAttribute('title', SIZE_HINTS.post); + uploadRow.appendChild(media); + uploadRow.appendChild(uploadButton(media, 'compose-' + feedType)); + + const counter = el('span', 'hint', '500'); + ta.addEventListener('input', () => { + counter.textContent = String(500 - ta.value.length); + }); + uploadRow.appendChild(counter); + actions.appendChild(uploadRow); + + const problem = el('div', 'bl-error'); + right.appendChild(problem); + right.appendChild(el('div', 'hint', SIZE_HINTS.post)); + + const go = el('button', 'primary-button', 'Posten'); + go.addEventListener('click', () => { + const body = ta.value.trim(); + + // Vorher prüfen statt den Server ablehnen zu lassen: die Meldung steht + // dann direkt am Feld, und der geschriebene Text bleibt erhalten. + const image = checkImageUrl(media.value); + if (!image.ok) { problem.textContent = image.error; return; } + problem.textContent = ''; + + if (!body && !image.url) return; + ta.value = ''; + media.value = ''; + counter.textContent = '500'; + send('createPost', { body, feedType, mediaUrl: image.url }); + }); + actions.appendChild(go); + + right.appendChild(actions); + card.appendChild(right); + return card; + } + + function renderPost(p) { + const card = el('article', 'post-card'); + const author = p.author || {}; + + const head = el('header', 'post-head'); + head.appendChild(authorLink(author, avatar(author, 52))); + + const who = el('div'); + const name = el('div', 'author-name'); + name.appendChild(authorLink(author, + el('span', null, author.display_name || ('@' + author.handle)))); + const mark = verifiedMark(author); + if (mark) name.appendChild(mark); + who.appendChild(name); + who.appendChild(el('div', 'handle', '@' + (author.handle || '?'))); + head.appendChild(who); + + head.appendChild(el('div', 'post-time', p.created_at || '')); + card.appendChild(head); + + if (p.body) card.appendChild(el('div', 'post-body', p.body)); + + if (p.media_url) { + const img = el('img', 'post-image'); + img.setAttribute('src', String(p.media_url)); + // Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand + // der Herkunft, und die eines NUI kennen sie nicht. + img.setAttribute('referrerpolicy', 'no-referrer'); + img.setAttribute('loading', 'lazy'); + // Nicht still entfernen: sonst rätselt der Verfasser, warum sein Bild + // fehlt. Meist ist es die Adresse einer Seite statt die des Bildes. + img.addEventListener('error', () => { + // Die Adresse mit anzeigen: sonst rätselt man, was überhaupt versucht + // wurde. Meist ist es eine Seitenadresse statt der des Bildes – oder + // der Rechner kommt an den Bildhoster nicht heran. + const note = el('div', 'bl-image-broken'); + note.appendChild(el('div', null, '🖼 Bild konnte nicht geladen werden')); + note.appendChild(el('div', 'bl-image-url', String(p.media_url))); + if (img.parentNode) img.parentNode.replaceChild(note, img); + }); + card.appendChild(img); + } + + const actions = el('footer', 'post-actions'); + + const like = el('button', 'action-link', + (p.liked_by_viewer ? '♥ ' : '♡ ') + (p.likes || 0)); + like.addEventListener('click', () => send('togglePostLike', { postId: p.id })); + actions.appendChild(like); + + const commentBtn = el('button', 'action-link', 'Kommentare ' + (p.comments || 0)); + commentBtn.addEventListener('click', () => { + if (openComments.has(p.id)) openComments.delete(p.id); + else openComments.add(p.id); + render(); + }); + actions.appendChild(commentBtn); + + if (p.can_delete) { + const del = el('button', 'action-link danger-text', 'Löschen'); + del.style.marginLeft = 'auto'; + del.addEventListener('click', () => { + ICWebRender.confirmBox('Beitrag löschen', 'Diesen Beitrag löschen?', + () => send('deleteOwnPost', { postId: p.id }), + { danger: true, okLabel: 'Löschen' }); + }); + actions.appendChild(del); + } + + card.appendChild(actions); + + if (openComments.has(p.id)) card.appendChild(renderComments(p)); + return card; + } + + /* Kommentare unter einem Beitrag. Aufbau wie im Original: comments-panel + mit comment-form. */ + function renderComments(p) { + const panel = el('div', 'comments-panel'); + + (p.comments_list || []).forEach(c => { + const row = el('div', 'bl-comment'); + + // author ist hier der Handle als Text, kein Objekt – anders als beim + // Beitrag. Beides abfangen, damit es nicht wieder an der Form scheitert. + const handle = typeof c.author === 'string' + ? c.author + : ((c.author && c.author.handle) || c.author_handle || ''); + + const head = el('div', 'author-name'); + const link = el('button', 'author-link', '@' + (handle || '?')); + if (c.author_profile_id) { + link.addEventListener('click', () => openProfile(c.author_profile_id)); + } + head.appendChild(link); + row.appendChild(head); + row.appendChild(el('div', 'bl-comment-body', c.body || '')); + + const meta = el('div', 'comment-actions'); + meta.appendChild(el('span', 'post-time', c.created_at || '')); + if (c.can_delete) { + const del = el('button', 'action-link danger-text', 'Löschen'); + del.addEventListener('click', () => + send('deleteOwnComment', { commentId: c.id })); + meta.appendChild(del); + } + row.appendChild(meta); + panel.appendChild(row); + }); + + const me = activeProfile(); + if (me && me.can_post !== false) { + const form = el('div', 'comment-form'); + const input = el('input'); + input.setAttribute('placeholder', 'Antworten…'); + input.setAttribute('maxlength', '1000'); + + const go = el('button', 'primary-button', 'Senden'); + const submit = () => { + const body = input.value.trim(); + if (!body) return; + input.value = ''; + send('createComment', { postId: p.id, body }); + }; + go.addEventListener('click', submit); + input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); + + form.append(input, go); + panel.appendChild(form); + } + + return panel; + } + + /* ── Seitenkopf ───────────────────────────────────────── */ + function pageHead(title, sub) { + const head = el('div', 'bl-pagehead'); + head.appendChild(el('div', 'bl-title', title)); + if (sub) head.appendChild(el('div', 'bl-sub', sub)); + return head; + } + + /* Zeile mit Profil und Knopf – für Follower, Gewerbe, Vorschläge. */ + function profileRow(profile, action) { + const row = el('div', 'post-card bl-row'); + + const head = el('div', 'post-head'); + head.appendChild(authorLink(profile, avatar(profile, 46))); + + const who = el('div'); + const name = el('div', 'author-name'); + name.appendChild(authorLink(profile, + el('span', null, profile.display_name || profile.handle))); + const mark = verifiedMark(profile); + if (mark) name.appendChild(mark); + who.appendChild(name); + who.appendChild(el('div', 'handle', '@' + profile.handle)); + head.appendChild(who); + + if (action) head.appendChild(action); + row.appendChild(head); + return row; + } + + /* ── Follower ─────────────────────────────────────────── */ + function renderFollowers() { + const wrap = el('div'); + wrap.appendChild(pageHead('Follower', + 'Wer dir folgt und wem du folgst.')); + + const social = state.social || {}; + const section = (title, list, follows) => { + wrap.appendChild(el('div', 'bl-section', title + ' (' + (list || []).length + ')')); + if (!list || !list.length) { + wrap.appendChild(info('👥', 'Hier ist noch niemand.')); + return; + } + const box = el('div', 'bl-feed'); + list.forEach(p => { + const btn = el('button', follows ? 'ghost-button' : 'primary-button', + follows ? 'Entfolgen' : 'Folgen'); + btn.addEventListener('click', () => { + send(follows ? 'unfollowProfile' : 'followProfile', { profileId: p.id }); + }); + box.appendChild(profileRow(p, btn)); + }); + wrap.appendChild(box); + }; + + section('Ich folge', social.following, true); + section('Folgen mir', social.followers, false); + + return wrap; + } + + /* ── Gewerbe ──────────────────────────────────────────── */ + function renderBusinesses() { + const wrap = el('div'); + wrap.appendChild(pageHead('Gewerbe', + 'Unternehmen und Behörden auf Bleeter.')); + + const list = state.businesses || []; + if (!list.length) { + wrap.appendChild(info('💼', 'Noch keine Gewerbe eingetragen.')); + return wrap; + } + + const box = el('div', 'bl-feed'); + list.forEach(b => { + const status = el('span', 'bl-badge' + (b.status === 'open' ? ' bl-badge-open' : ''), + b.status === 'open' ? 'geöffnet' : 'geschlossen'); + box.appendChild(profileRow(b, status)); + }); + wrap.appendChild(box); + return wrap; + } + + /* ── Marktplatz ───────────────────────────────────────── */ + function renderMarket() { + const wrap = el('div'); + wrap.appendChild(pageHead('Marktplatz', + 'Angebote und Gesuche aus Los Santos.')); + + const me = activeProfile(); + if (me && me.can_post !== false) { + const form = el('div', 'compose bl-market-form'); + form.appendChild(avatar(me, 52)); + + const right = el('div'); + const title = el('input', 'event-input'); + title.setAttribute('placeholder', 'Was bietest du an?'); + title.setAttribute('maxlength', '120'); + right.appendChild(title); + + const desc = el('textarea'); + desc.setAttribute('placeholder', 'Beschreibung'); + desc.setAttribute('maxlength', '2000'); + right.appendChild(desc); + + const row = el('div', 'compose-actions'); + const price = el('input', 'event-input bl-price'); + price.setAttribute('placeholder', 'Preis, z. B. 2500 $'); + price.setAttribute('maxlength', '80'); + row.appendChild(price); + + const go = el('button', 'primary-button', 'Einstellen'); + go.addEventListener('click', () => { + if (!title.value.trim()) return; + send('createMarketplaceEntry', { + title: title.value, description: desc.value, priceLabel: price.value, + }); + title.value = ''; desc.value = ''; price.value = ''; + }); + row.appendChild(go); + + right.appendChild(row); + form.appendChild(right); + wrap.appendChild(form); + } + + const items = state.marketplace || []; + if (!items.length) { + wrap.appendChild(info('🛒', 'Der Marktplatz ist leer.')); + return wrap; + } + + const box = el('div', 'bl-feed'); + items.forEach(item => { + const card = el('article', 'post-card'); + + const head = el('header', 'post-head'); + head.appendChild(avatar(item.author || {}, 46)); + + const who = el('div'); + who.appendChild(el('div', 'author-name', item.title || '')); + who.appendChild(el('div', 'handle', '@' + ((item.author || {}).handle || '?'))); + head.appendChild(who); + + if (item.price_label) head.appendChild(el('div', 'bl-price-tag', item.price_label)); + card.appendChild(head); + + if (item.body) card.appendChild(el('div', 'post-body', item.body)); + + if (item.media_url) { + const img = el('img', 'post-image'); + img.setAttribute('src', String(item.media_url)); + // Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand + // der Herkunft, und die eines NUI kennen sie nicht. + img.setAttribute('referrerpolicy', 'no-referrer'); + img.addEventListener('error', () => img.remove()); + card.appendChild(img); + } + + const actions = el('footer', 'post-actions'); + actions.appendChild(el('span', 'post-time', item.created_at || '')); + if (item.can_delete) { + const del = el('button', 'action-link danger-text', 'Löschen'); + del.style.marginLeft = 'auto'; + del.addEventListener('click', () => { + ICWebRender.confirmBox('Anzeige löschen', 'Diese Anzeige löschen?', + () => send('deleteMarketplaceEntry', { entryId: item.id }), + { danger: true, okLabel: 'Löschen' }); + }); + actions.appendChild(del); + } + card.appendChild(actions); + + box.appendChild(card); + }); + wrap.appendChild(box); + return wrap; + } + + /* ── Kalender ─────────────────────────────────────────── */ + function renderCalendar() { + const wrap = el('div'); + wrap.appendChild(pageHead('Kalender', + 'Termine der nächsten vier Wochen.')); + + const me = activeProfile(); + const mayCreate = me && ['small_business', 'company', 'authority'] + .includes(me.profile_type); + + if (mayCreate) { + const form = el('div', 'post-card bl-form'); + form.appendChild(el('div', 'bl-section', 'Termin eintragen')); + + const mk = (placeholder, cls) => { + const i = el('input', 'event-input' + (cls ? ' ' + cls : '')); + i.setAttribute('placeholder', placeholder); + return i; + }; + const date = mk('2026-08-15', 'bl-narrow-input'); + const time = mk('19:30', 'bl-narrow-input'); + const title = mk('Wovon handelt der Termin?'); + const place = mk('Ort'); + + const row1 = el('div', 'bl-inline'); + row1.append(date, time); + form.append(row1, title, place); + + const go = el('button', 'primary-button', 'Eintragen'); + go.addEventListener('click', () => { + if (!date.value || !time.value || !title.value.trim()) return; + send('createEvent', { + date: date.value, time: time.value, + title: title.value, location: place.value, + }); + title.value = ''; place.value = ''; + }); + form.appendChild(go); + wrap.appendChild(form); + } + + const days = (state.calendarDays || []).filter(d => (d.events || []).length); + if (!days.length) { + wrap.appendChild(info('📅', 'Keine Termine in den nächsten vier Wochen.')); + return wrap; + } + + const box = el('div', 'bl-feed'); + days.forEach(day => { + const card = el('article', 'post-card bl-day'); + card.appendChild(el('div', 'bl-day-title', day.title || day.date)); + + (day.events || []).forEach(e => { + const row = el('div', 'bl-event'); + row.appendChild(el('span', 'bl-event-time', e.time || '')); + + const mid = el('div', 'bl-event-mid'); + mid.appendChild(el('div', 'bl-event-title', e.title || '')); + const meta = []; + if (e.location) meta.push(e.location); + if (e.author) meta.push('@' + e.author); + if (meta.length) mid.appendChild(el('div', 'handle', meta.join(' · '))); + row.appendChild(mid); + + if (e.can_delete) { + const del = el('button', 'action-link danger-text', '✕'); + del.addEventListener('click', () => send('deleteEvent', { eventId: e.id })); + row.appendChild(del); + } + card.appendChild(row); + }); + + box.appendChild(card); + }); + wrap.appendChild(box); + return wrap; + } + + /* ── Profil ───────────────────────────────────────────── */ + function zurueckKnopf() { + const back = el('button', 'ghost-button', '← Zurück'); + back.addEventListener('click', () => { viewProfileId = null; render(); }); + return back; + } + + /* Fremdes Profil. Bearbeiten gibt es hier nicht – nur ansehen, folgen und + die Beiträge dieser Person. */ + function renderForeignProfile(p) { + const wrap = el('div'); + + const kopf = el('div', 'bl-foreign-head'); + kopf.appendChild(zurueckKnopf()); + wrap.appendChild(kopf); + + const card = el('div', 'post-card bl-profile'); + + const banner = el('div', 'bl-banner-strip'); + if (p.banner_url) { + const img = el('img', 'bl-banner-img'); + img.setAttribute('src', String(p.banner_url)); + img.setAttribute('referrerpolicy', 'no-referrer'); + img.addEventListener('error', () => img.remove()); + banner.appendChild(img); + } + card.appendChild(banner); + + const head = el('div', 'bl-profile-head'); + head.appendChild(avatar(p, 76)); + + const who = el('div', 'bl-profile-who'); + const line = el('div', 'bl-profile-name'); + line.appendChild(el('span', null, p.display_name || p.handle)); + const mark = verifiedMark(p); + if (mark) line.appendChild(mark); + who.appendChild(line); + who.appendChild(el('div', 'handle', '@' + p.handle)); + + const tags = el('div', 'bl-tags'); + tags.appendChild(el('span', 'bl-badge', p.profile_type || '')); + if (p.location) tags.appendChild(el('span', 'bl-badge', '📍 ' + p.location)); + who.appendChild(tags); + head.appendChild(who); + + const ich = activeProfile(); + if (ich && ich.id !== p.id) { + const folgt = isFollowing(p.id); + const btn = el('button', folgt ? 'ghost-button' : 'primary-button', + folgt ? 'Entfolgen' : 'Folgen'); + btn.style.marginLeft = 'auto'; + btn.addEventListener('click', () => { + send(folgt ? 'unfollowProfile' : 'followProfile', { profileId: p.id }); + }); + head.appendChild(btn); + } + card.appendChild(head); + + if (p.bio) card.appendChild(el('div', 'bl-profile-bio', p.bio)); + + const eigene = (state.posts || []).filter(x => x.author && x.author.id === p.id); + const stats = el('div', 'bl-stats'); + const stat = (n, label) => { + const box = el('div', 'bl-stat'); + box.appendChild(el('span', 'bl-stat-num', String(n))); + box.appendChild(el('span', 'bl-stat-label', label)); + return box; + }; + stats.appendChild(stat(p.followers_count || 0, 'Follower')); + stats.appendChild(stat(p.following_count || 0, 'gefolgt')); + stats.appendChild(stat(eigene.length, 'Beiträge')); + card.appendChild(stats); + + if (p.email_contact) { + const kontakt = el('div', 'bl-profile-bio'); + kontakt.appendChild(el('span', 'handle', '✉ ' + p.email_contact)); + card.appendChild(kontakt); + } + + wrap.appendChild(card); + + wrap.appendChild(el('div', 'bl-section', 'Beiträge (' + eigene.length + ')')); + if (!eigene.length) { + wrap.appendChild(info('🐦', 'Diese Person hat hier noch nichts geschrieben.')); + } else { + const list = el('div', 'bl-feed'); + eigene.forEach(x => list.appendChild(renderPost(x))); + wrap.appendChild(list); + } + + return wrap; + } + + function renderProfile() { + const me = activeProfile(); + + /* Fremdes Profil: nur ansehen und folgen, nicht bearbeiten. */ + if (viewProfileId) { + const other = directoryProfile(viewProfileId); + if (!other) return info('👤', 'Dieses Profil ist nicht abrufbar.', zurueckKnopf()); + return renderForeignProfile(other); + } + + if (!me) return info('👤', 'Kein Profil ausgewählt.'); + + const wrap = el('div'); + + /* Kopfkarte mit Banner */ + const card = el('div', 'post-card bl-profile'); + + const banner = el('div', 'bl-banner-strip'); + if (me.banner_url) { + const img = el('img', 'bl-banner-img'); + img.setAttribute('src', String(me.banner_url)); + // Ohne Referer laden: manche Bildhoster sperren Hotlinks anhand + // der Herkunft, und die eines NUI kennen sie nicht. + img.setAttribute('referrerpolicy', 'no-referrer'); + img.addEventListener('error', () => img.remove()); + banner.appendChild(img); + } + card.appendChild(banner); + + const head = el('div', 'bl-profile-head'); + head.appendChild(avatar(me, 76)); + + const who = el('div', 'bl-profile-who'); + const line = el('div', 'bl-profile-name'); + line.appendChild(el('span', null, me.display_name || me.handle)); + const mark = verifiedMark(me); + if (mark) line.appendChild(mark); + who.appendChild(line); + who.appendChild(el('div', 'handle', '@' + me.handle)); + + const tags = el('div', 'bl-tags'); + tags.appendChild(el('span', 'bl-badge', me.profile_type || '')); + if (me.can_post === false) { + tags.appendChild(el('span', 'bl-badge bl-badge-red', 'darf nicht schreiben')); + } + who.appendChild(tags); + head.appendChild(who); + card.appendChild(head); + + if (me.bio) card.appendChild(el('div', 'bl-profile-bio', me.bio)); + + const social = state.social || {}; + const stats = el('div', 'bl-stats'); + const stat = (n, label) => { + const s = el('div', 'bl-stat'); + s.appendChild(el('span', 'bl-stat-num', String(n))); + s.appendChild(el('span', 'bl-stat-label', label)); + return s; + }; + stats.appendChild(stat(me.followers_count ?? social.followers_count ?? 0, 'Follower')); + stats.appendChild(stat(me.following_count ?? social.following_count ?? 0, 'gefolgt')); + stats.appendChild(stat((state.posts || []).filter(p => p.author && p.author.id === me.id).length, + 'Beiträge')); + card.appendChild(stats); + + wrap.appendChild(card); + + /* Bearbeiten */ + if (me.can_edit_profile !== false) { + const form = el('div', 'post-card bl-form'); + form.appendChild(el('div', 'bl-section', 'Profil bearbeiten')); + + const mkArea = (label, value) => { + const f = el('div', 'bl-field'); + f.appendChild(el('label', 'bl-label', label)); + const input = el('textarea', 'event-input'); + input.setAttribute('rows', '3'); + input.setAttribute('maxlength', '500'); + input.value = value || ''; + f.appendChild(input); + form.appendChild(f); + return input; + }; + const mk = (label, value, placeholder) => { + const f = el('div', 'bl-field'); + f.appendChild(el('label', 'bl-label', label)); + const input = el('input', 'event-input'); + input.value = value || ''; + if (placeholder) input.setAttribute('placeholder', placeholder); + f.appendChild(input); + form.appendChild(f); + return input; + }; + + const bio = mkArea('Über mich', me.bio); + const display = mk('Anzeigename', me.display_name); + const location = mk('Ort', me.location, 'Los Santos'); + + const avatarIn = mk('Profilbild', me.avatar_url, 'Adresse einfügen oder Datei wählen'); + form.appendChild(uploadButton(avatarIn, 'profile-avatar')); + form.appendChild(el('div', 'hint', SIZE_HINTS.avatar)); + + const bannerIn = mk('Titelbild', me.banner_url, 'Adresse einfügen oder Datei wählen'); + form.appendChild(uploadButton(bannerIn, 'profile-banner')); + form.appendChild(el('div', 'hint', SIZE_HINTS.banner)); + + const problem = el('div', 'bl-error'); + form.appendChild(problem); + + const save = el('button', 'primary-button', 'Speichern'); + save.addEventListener('click', () => { + // Der Server speichert Profilbilder ungeprüft. Ohne diese Prüfung + // stünde eine Adresse in der Datenbank, die nie ein Bild liefert – + // und niemand erführe, warum nichts erscheint. + const av = checkImageUrl(avatarIn.value); + if (!av.ok) { problem.textContent = 'Profilbild: ' + av.error; return; } + + const bn = checkImageUrl(bannerIn.value); + if (!bn.ok) { problem.textContent = 'Titelbild: ' + bn.error; return; } + + problem.textContent = ''; + avatarIn.value = av.url; + bannerIn.value = bn.url; + + send('updateProfile', { + profileId: me.id, + displayName: display.value, + bio: bio.value, + location: location.value, + avatarUrl: av.url, + bannerUrl: bn.url, + }); + Desktop.showNotification('✔ Profil gespeichert.'); + }); + form.appendChild(save); + wrap.appendChild(form); + } + + const own = (state.posts || []).filter(p => p.author && p.author.id === me.id); + wrap.appendChild(el('div', 'bl-section', 'Eigene Beiträge (' + own.length + ')')); + + if (!own.length) { + wrap.appendChild(info('🐦', 'Noch nichts geschrieben.')); + } else { + const list = el('div', 'bl-feed'); + own.forEach(p => list.appendChild(renderPost(p))); + wrap.appendChild(list); + } + + return wrap; + } + + /* ── Anpassungen fürs PC-Fenster ────────────────────── + Die Gestaltung kommt aus css/bleeter-embedded.css – der mechanisch + gekapselten Fassung von bleeter/html/style.css. Hier steht nur, was das + Original nicht wissen kann: dass es in einem Fenster steckt statt in + einem Tablet-Rahmen. */ + let stylesDone = false; + function injectStyles() { + if (stylesDone) return; + stylesDone = true; + + const s = document.createElement('style'); + s.textContent = ` +/* Beim Maximieren soll der Inhalt nicht mitwachsen. + Im Original hat .post-image width:100% – das Bild folgt also der Spalte und + wird beim Vergrößern des Fensters mitgezogen und hochskaliert. Gewollt ist: + das Bild behält seine Größe und schrumpft nur, wenn der Platz nicht reicht. + Dazu bekommt die Spalte eine Höchstbreite, sonst wandert der Text quer über + den ganzen Bildschirm und wird unlesbar. */ +.bl-root .app-grid{max-width:1180px;margin:0 auto;width:100%} +.bl-root .content{max-width:640px} + +.bl-root .post-image,.bl-root .market-image,.bl-root .bl-post-image{ + width:auto;max-width:100%;height:auto;max-height:420px;object-fit:contain; + background:#101a29} + +/* Kein Tablet-Rahmen: der Inhalt füllt das Fenster. */ +.bl-root{display:flex;flex-direction:column;flex:1;min-height:0;height:100%; + overflow:hidden;background:var(--bg);color:var(--text); + font-family:Inter,Arial,Helvetica,sans-serif} + +/* Die Kopfzeile des Originals hat fünf Spalten für Suche und Schließen-Knopf, + die es hier nicht gibt. */ +.bl-root .topbar{height:64px;grid-template-columns:minmax(0,1fr) auto auto; + padding:0 26px;gap:14px} +.bl-root .bl-wordmark{font-weight:900;font-size:1.05rem;color:var(--green); + letter-spacing:.02em} +.bl-root .brand{height:auto;display:flex;align-items:center} +.bl-root .top-profile{width:auto;height:auto;border:0;background:transparent} + +.bl-root .app-grid{height:calc(100% - 64px);grid-template-columns:74px minmax(0,1fr) 240px; + gap:16px;padding:0 26px 20px} +.bl-root .content{padding:16px 0 8px;display:flex;flex-direction:column;gap:14px} +.bl-root .context{padding-top:16px} +.bl-root .nav-button{width:60px;height:52px} + +/* Avatar ohne Bild: farbiger Kreis mit dem Anfangsbuchstaben. Die Farbe wird + aus dem Handle abgeleitet, damit dieselbe Person immer dieselbe hat. */ +.bl-root .bl-avatar-letter{display:grid;place-items:center;font-weight:900;line-height:1} + +.bl-root .bl-feed{display:flex;flex-direction:column;gap:14px} +.bl-root .bl-narrow{max-width:420px;margin:0 auto} +.bl-root .bl-title{font-size:1.15rem;font-weight:900} +.bl-root .bl-sub{font-size:.76rem;color:var(--muted);margin-top:4px;line-height:1.5} +.bl-root .bl-pagehead{margin-bottom:2px} +.bl-root .bl-section{font-size:.66rem;text-transform:uppercase;letter-spacing:.13em; + color:var(--muted);margin-top:6px} +.bl-root .bl-hint{font-size:.7rem;color:var(--muted);line-height:1.5} +.bl-root .bl-error{font-size:.74rem;color:var(--red);min-height:1em} +.bl-root .bl-banner{background:rgba(242,184,102,.1);border:1px solid rgba(242,184,102,.32); + border-radius:8px;padding:11px 13px;font-size:.76rem;color:var(--gold);line-height:1.5} +.bl-root .bl-empty{display:flex;flex-direction:column;align-items:center;gap:10px; + padding:44px 0;color:var(--muted);font-size:.82rem} +.bl-root .bl-empty-icon{font-size:2rem;opacity:.7} + +.bl-root .bl-welcome{padding:24px;display:flex;flex-direction:column;gap:12px; + text-align:center} +.bl-root .bl-welcome-mark{font-size:2.2rem} +.bl-root .bl-welcome .bl-field,.bl-root .bl-welcome .bl-error{text-align:left} +.bl-root .bl-field{display:flex;flex-direction:column;gap:5px} +.bl-root .bl-label{font-size:.64rem;text-transform:uppercase;letter-spacing:.1em; + color:var(--muted)} +.bl-root .bl-inline{display:flex;align-items:center;gap:8px} +.bl-root .bl-at{font-size:1rem;font-weight:900;color:var(--green)} +.bl-root .event-input{width:100%;box-sizing:border-box;background:var(--panel-2); + font-family:inherit;padding:9px 12px;min-height:38px} + +.bl-root .bl-form{padding:16px;display:flex;flex-direction:column;gap:11px} +.bl-root .bl-profile{padding-bottom:16px} +.bl-root .bl-banner-strip{height:84px;background:linear-gradient(120deg,#0d203a,#123a2c); + overflow:hidden} +.bl-root .bl-banner-img{width:100%;height:100%;object-fit:cover;display:block} +.bl-root .bl-profile-head{display:flex;align-items:flex-end;gap:14px;padding:0 18px; + margin-top:-30px} +.bl-root .bl-profile-who{padding-bottom:4px;min-width:0} +.bl-root .bl-profile-name{display:flex;align-items:center;gap:7px;font-size:1rem; + font-weight:900} +.bl-root .bl-tags{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px} +.bl-root .bl-badge{font-size:.6rem;padding:2px 8px;border-radius:999px; + border:1px solid var(--line);color:var(--muted);white-space:nowrap} +.bl-root .bl-badge-red{color:var(--red);border-color:rgba(255,79,95,.4)} +.bl-root .bl-profile-bio{padding:12px 18px 0;font-size:.82rem;line-height:1.5; + color:#c8d2e4;white-space:pre-wrap} +.bl-root .bl-stats{display:flex;gap:22px;padding:14px 18px 0} +.bl-root .bl-stat{display:flex;align-items:baseline;gap:5px} +.bl-root .bl-stat-num{font-weight:900;font-size:.9rem} +.bl-root .bl-stat-label{font-size:.72rem;color:var(--muted)} + +/* Neue Seiten: Follower, Gewerbe, Markt, Kalender, Kommentare. */ +.bl-root .bl-row .post-head{padding:12px 16px} +.bl-root .bl-row .post-head > div:last-child{margin-left:auto} +.bl-root .bl-badge-open{color:var(--green);border-color:rgba(25,216,137,.4)} + +.bl-root .bl-market-form textarea{min-height:56px;margin-top:8px} +.bl-root .bl-market-form .event-input{width:100%;box-sizing:border-box; + background:var(--panel-2);font-family:inherit;padding:8px 11px;min-height:36px} +.bl-root .bl-price{max-width:190px} +.bl-root .bl-price-tag{margin-left:auto;font-weight:900;color:var(--green); + font-size:.88rem;white-space:nowrap} + +.bl-root .bl-day{padding:14px 16px} +.bl-root .bl-day-title{font-weight:900;font-size:.86rem;margin-bottom:9px} +.bl-root .bl-event{display:flex;align-items:center;gap:12px;padding:7px 0; + border-top:1px solid var(--line)} +.bl-root .bl-event:first-of-type{border-top:0} +.bl-root .bl-event-time{font-weight:900;color:var(--green);font-size:.8rem; + min-width:44px} +.bl-root .bl-event-mid{flex:1;min-width:0} +.bl-root .bl-event-title{font-size:.82rem;font-weight:700} +.bl-root .bl-narrow-input{max-width:130px} + +.bl-root .bl-foreign-head{margin-bottom:10px} +.bl-root .author-link{border:0;background:transparent;padding:0;color:inherit; + font:inherit;text-align:left;cursor:pointer} +.bl-root .author-link:hover{color:var(--green)} +.bl-root .bl-upload{display:inline-flex} +.bl-root .upload-button{display:inline-block;border-radius:999px;padding:7px 14px; + font-size:.72rem;font-weight:900;cursor:pointer;white-space:nowrap; + border:1px solid var(--line);background:var(--panel-2);color:var(--text)} +.bl-root .upload-button:hover{border-color:var(--green);color:var(--green)} +.bl-root .bl-image-broken{padding:22px;text-align:center;color:var(--muted); + border-top:1px solid var(--line);border-bottom:1px solid var(--line); + font-size:.76rem;background:rgba(0,0,0,.15)} +.bl-root .bl-image-url{margin-top:5px;font-size:.66rem;color:var(--muted); + opacity:.7;word-break:break-all} +.bl-root .bl-comment{padding:8px 0;border-top:1px solid var(--line)} +.bl-root .bl-comment:first-child{border-top:0} +.bl-root .bl-comment-body{font-size:.82rem;line-height:1.45;margin:3px 0; + white-space:pre-wrap} +.bl-root .media-url-input{flex:1;min-width:0;background:var(--panel-2); + border:1px solid var(--line);border-radius:8px;padding:6px 10px; + color:var(--text);font-family:inherit;font-size:.76rem} + +.bl-root .bl-ctx-card{padding-bottom:14px} +.bl-root .bl-ctx-switch{padding:0 16px} +.bl-root .bl-ctx-switch .market-sort{width:100%;margin-top:6px} +`; + document.head.appendChild(s); + } + + return { open, onData, onNotify, onUploaded }; +})(); diff --git a/nui/js/apps/bleeteradmin.js b/nui/js/apps/bleeteradmin.js new file mode 100644 index 0000000..8bf7f6f --- /dev/null +++ b/nui/js/apps/bleeteradmin.js @@ -0,0 +1,481 @@ +/** + * pc-live | Bleeter-Verwaltung + * + * Was das Bleeter-Fenster selbst nicht kann: Unternehmensprofile einrichten und + * Mitarbeiter freigeben. Es hängt an derselben Anmeldung wie das Webhosting – + * wer als admin@liveinvader.ls angemeldet ist, ist auch hier der Anbieter. + * + * Die Anmeldung sitzt unten links in der Seitenleiste, wie in der + * Webhosting-App. Beide teilen sich die Sitzung: einmal anmelden genügt. + * + * Inhalte kommen von Spielern (Handles, Anzeigenamen). Sie werden über + * textContent gesetzt, nie über innerHTML. + */ +const BleeterAdminApp = (() => { + const WIN_ID = 'app-bleeteradmin'; + const el = ICWebRender.el; + + /* Muss zu BUSINESS_TYPES in bleeter/server/management.lua passen. */ + const PROFILE_TYPES = [ + ['company', 'Unternehmen'], + ['small_business', 'Kleingewerbe'], + ['authority', 'Behörde'], + ['lifeinvader', 'Lifeinvader'], + ]; + + const typeLabel = (t) => (PROFILE_TYPES.find(p => p[0] === t) || [t, t])[1]; + + let session = null; // ic-web-Anmeldung + let profiles = []; + let provider = false; + let winEl = null; + let openId = null; // Profil, dessen Mitarbeiter gerade angezeigt werden + + /* ── Transport ────────────────────────────────────────── */ + /* Bleeter antwortet über ein gemeinsames Ereignis, nicht über Anfrage-Ids. + Deshalb wird je Aktion auf die nächste Antwort gewartet. */ + const waiting = new Map(); + + function call(op, payload) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (waiting.get(op) === resolve) waiting.delete(op); + resolve({ ok: false, error: 'Keine Antwort vom Server.' }); + }, 10000); + + waiting.set(op, (data) => { clearTimeout(timer); resolve(data); }); + fetchNui('bleeter', { op, payload: payload || {} }); + }); + } + + function onResponse(data) { + if (!data || !data.action) return; + const fn = waiting.get(data.action); + if (!fn) return; + waiting.delete(data.action); + fn(data); + } + + /* ── Gerüst ───────────────────────────────────────────── */ + async function open() { + const win = WindowManager.create({ + id: WIN_ID, title: 'Bleeter-Verwaltung', icon: '🐦', width: 940, height: 640, + content: ` +
+
+
+
`, + }); + if (!win) return; + + winEl = win; + await refresh(); + } + + function root(id) { + if (!winEl || !winEl.isConnected) { + winEl = document.querySelector('[data-wid="' + WIN_ID + '"]'); + } + return winEl ? winEl.querySelector('#' + id) : null; + } + function setMain(node) { + const m = root('ba-main'); + if (m) m.replaceChildren(node); + } + function info(icon, text) { + const d = el('div', 'wh-empty'); + d.appendChild(el('div', 'wh-empty-icon', icon)); + d.appendChild(el('div', null, text)); + return d; + } + function notify(res, okText) { + Desktop.showNotification(res && res.ok ? '✔ ' + okText + : '⚠ ' + ((res && res.error) || 'Fehlgeschlagen.')); + return res && res.ok; + } + + async function refresh() { + setMain(info('⏳', 'Lade…')); + + const s = await ICWebNet.call('session'); + session = (s.ok && s.data && s.data.session) || null; + + const res = await call('listBusinessProfiles'); + profiles = (res.ok && res.payload && res.payload.profiles) || []; + provider = !!(res.ok && res.payload && res.payload.provider); + + renderSide(); + showProfiles(); + } + + /* ── Seitenleiste mit Anmeldung unten links ───────────── */ + function renderSide() { + const side = root('ba-side'); + if (!side) return; + side.replaceChildren(); + + side.appendChild(el('div', 'wh-side-title', 'Bleeter')); + + const nav = el('div', 'wh-nav active'); + nav.appendChild(el('span', 'wh-nav-icon', '🏢')); + nav.appendChild(el('span', null, provider ? 'Alle Profile' : 'Meine Profile')); + nav.addEventListener('click', () => showProfiles()); + side.appendChild(nav); + + if (provider) { + const add = el('div', 'wh-nav'); + add.appendChild(el('span', 'wh-nav-icon', '+')); + add.appendChild(el('span', null, 'Profil einrichten')); + add.addEventListener('click', () => showCreate()); + side.appendChild(add); + } + + /* Anmeldung – unten links, wie im Webhosting. */ + const foot = el('div', 'wh-side-user'); + + if (session) { + foot.appendChild(el('div', 'wh-side-username', session.username)); + foot.appendChild(el('div', 'wh-side-role', + session.role === 'superadmin' ? 'Anbieter (Lifeinvader)' + : { admin: 'Domänenadmin', editor: 'Redakteur' }[session.role] || session.role)); + + const out = el('a', 'wh-side-logout', 'Abmelden'); + out.addEventListener('click', async () => { + await ICWebNet.call('logout'); + Desktop.showNotification('Abgemeldet.'); + await refresh(); + }); + foot.appendChild(out); + } else { + foot.appendChild(el('div', 'wh-side-role', 'Nicht angemeldet')); + const go = el('a', 'wh-side-logout', '🔑 Als Anbieter anmelden'); + go.style.color = 'var(--accent)'; + go.addEventListener('click', showLogin); + foot.appendChild(go); + } + + side.appendChild(foot); + } + + /* ── Anmeldung ────────────────────────────────────────── */ + function showLogin() { + ICWebRender.modal('Anmelden', (box, close) => { + box.appendChild(el('div', 'icweb-modal-sub', + 'Zugangsdaten der Lifeinvader-Verwaltung. Es ist dieselbe Anmeldung wie ' + + 'beim Webhosting – einmal anmelden genügt für beides.')); + + const mk = (label, type, value, placeholder) => { + const row = el('div', 'wh-field'); + row.appendChild(el('label', 'wh-label', label)); + const input = el('input', 'icweb-input'); + input.setAttribute('type', type); + if (placeholder) input.setAttribute('placeholder', placeholder); + input.value = value || ''; + row.appendChild(input); + box.appendChild(row); + return input; + }; + + const user = mk('Benutzername', 'text', 'admin@liveinvader.ls', 'name@domäne.ls'); + const pass = mk('Passwort', 'password', ''); + const err = el('div', 'wh-login-error'); + box.appendChild(err); + + const row = el('div', 'icweb-modal-actions'); + const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen'); + cancel.addEventListener('click', close); + + const go = el('button', 'icweb-btn-primary', 'Anmelden'); + const submit = async () => { + err.textContent = ''; + if (!user.value || !pass.value) { + err.textContent = 'Bitte Benutzername und Passwort eingeben.'; + return; + } + go.disabled = true; + const res = await ICWebNet.call('login', user.value, pass.value); + go.disabled = false; + pass.value = ''; + + if (!res.ok) { err.textContent = res.error || 'Anmeldung fehlgeschlagen.'; return; } + close(); + await refresh(); + }; + go.addEventListener('click', submit); + [user, pass].forEach(i => + i.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); })); + + row.append(cancel, go); + box.appendChild(row); + setTimeout(() => pass.focus(), 0); + }); + } + + /* ── Profilliste ──────────────────────────────────────── */ + function header(title, sub) { + const h = el('div', 'wh-head'); + h.appendChild(el('div', 'wh-head-title', title)); + if (sub) h.appendChild(el('div', 'wh-head-sub', sub)); + return h; + } + + function showProfiles() { + const wrap = el('div', 'wh-page'); + wrap.appendChild(header(provider ? 'Unternehmensprofile' : 'Meine Profile', + provider + ? 'Alle Profile auf Bleeter, die keiner Privatperson gehören.' + : 'Profile, für die du Mitarbeiter freigeben darfst.')); + + if (!session) { + wrap.appendChild(info('🔑', + 'Melde dich unten links an, um Bleeter zu verwalten.')); + return setMain(wrap); + } + + if (!profiles.length) { + wrap.appendChild(info('🐦', provider + ? 'Noch kein Unternehmensprofil eingerichtet.' + : 'Du verwaltest kein Bleeter-Profil.')); + return setMain(wrap); + } + + const table = el('div', 'wh-table'); + profiles.forEach(p => { + const row = el('div', 'wh-row'); + + const col = el('div'); + col.appendChild(el('div', 'wh-row-title', '@' + p.handle)); + col.appendChild(el('div', 'wh-row-sub', + (p.display_name || '—') + ' · ' + typeLabel(p.profile_type) + + ' · ' + (p.member_count || 0) + ' Mitarbeiter')); + row.appendChild(col); + + const right = el('div', 'wh-row-right'); + if (p.is_verified) right.appendChild(el('span', 'wh-tag ok', 'verifiziert')); + if (p.is_locked) right.appendChild(el('span', 'wh-tag bad', 'gesperrt')); + + const members = el('button', 'icweb-btn-primary', 'Mitarbeiter'); + members.addEventListener('click', () => showMembers(p)); + right.appendChild(members); + + row.appendChild(right); + table.appendChild(row); + }); + + wrap.appendChild(table); + setMain(wrap); + } + + /* ── Profil einrichten (nur Anbieter) ─────────────────── */ + function showCreate() { + const wrap = el('div', 'wh-page'); + wrap.appendChild(header('Profil einrichten', + 'Das Profil gehört keinem Konto – bedient wird es über die freigegebenen ' + + 'Mitarbeiter. Deshalb gehört gleich ein Verantwortlicher dazu.')); + + const form = el('div', 'wh-form'); + + const mk = (label, placeholder) => { + const row = el('div', 'wh-field'); + row.appendChild(el('label', 'wh-label', label)); + const input = el('input', 'icweb-input'); + if (placeholder) input.setAttribute('placeholder', placeholder); + row.appendChild(input); + form.appendChild(row); + return input; + }; + + const handleRow = el('div', 'wh-field'); + handleRow.appendChild(el('label', 'wh-label', 'Handle')); + const handleWrap = el('div', 'wh-inline'); + handleWrap.appendChild(el('span', 'wh-inline-suffix', '@')); + const handle = el('input', 'icweb-input'); + handle.setAttribute('placeholder', 'weazelnews'); + handleWrap.appendChild(handle); + handleRow.appendChild(handleWrap); + form.appendChild(handleRow); + + const display = mk('Anzeigename', 'Weazel News'); + const mail = mk('Kontaktadresse (optional)', 'presse@weazel-news.ls'); + + const typeRow = el('div', 'wh-field'); + typeRow.appendChild(el('label', 'wh-label', 'Art')); + const type = el('select', 'icweb-input'); + PROFILE_TYPES.forEach(([v, l]) => { + const o = el('option', null, l); + o.value = v; + type.appendChild(o); + }); + typeRow.appendChild(type); + form.appendChild(typeRow); + + const ownerRow = el('div', 'wh-field'); + ownerRow.appendChild(el('label', 'wh-label', 'Verantwortlich (muss online sein)')); + const owner = el('select', 'icweb-input'); + // Ohne ausdrücklichen Wert liefert select.value den Text der Option. + const noOwner = el('option', null, '— später festlegen —'); + noOwner.value = ''; + owner.appendChild(noOwner); + ownerRow.appendChild(owner); + form.appendChild(ownerRow); + + call('listCandidates', {}).then(res => { + const list = (res.ok && res.payload && res.payload.candidates) || []; + list.forEach(c => { + const o = el('option', null, c.name); + o.value = String(c.source); + owner.appendChild(o); + }); + }); + + const go = el('button', 'icweb-btn-primary', 'Einrichten'); + go.addEventListener('click', async () => { + go.disabled = true; + const res = await call('createBusinessProfile', { + handle: handle.value, + displayName: display.value, + emailContact: mail.value, + profileType: type.value, + ownerSource: owner.value ? Number(owner.value) : null, + }); + go.disabled = false; + if (notify(res, 'Profil eingerichtet.')) refresh(); + }); + form.appendChild(go); + + form.appendChild(el('div', 'wh-hint', + 'Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Unterstrich und Bindestrich. ' + + 'Behörden- und Systemnamen sind reserviert.')); + + wrap.appendChild(form); + setMain(wrap); + } + + /* ── Mitarbeiter ──────────────────────────────────────── */ + async function showMembers(profile) { + openId = profile.id; + setMain(info('⏳', 'Lade Mitarbeiter…')); + + const [membersRes, candRes] = await Promise.all([ + call('listProfileMembers', { profileId: profile.id }), + call('listCandidates', { profileId: profile.id }), + ]); + + const wrap = el('div', 'wh-page'); + + const head = el('div', 'wh-domainhead'); + const left = el('div'); + left.appendChild(el('div', 'wh-card-domain', '@' + profile.handle)); + left.appendChild(el('div', 'wh-card-title', + (profile.display_name || '') + ' · ' + typeLabel(profile.profile_type))); + head.appendChild(left); + + const back = el('div', 'wh-tab-back', '← Übersicht'); + back.addEventListener('click', () => showProfiles()); + head.appendChild(back); + wrap.appendChild(head); + + if (!membersRes.ok) { + wrap.appendChild(el('div', 'wh-hint', membersRes.error || 'Keine Berechtigung.')); + return setMain(wrap); + } + + wrap.appendChild(el('div', 'wh-hint', + 'Freigegebene schreiben unter diesem Profil. „Verwalten" gibt zusätzlich das ' + + 'Recht, weitere Leute freizugeben – wer nur schreiben darf, kann sich ' + + 'nicht selbst hochstufen.')); + + const members = (membersRes.payload && membersRes.payload.members) || []; + const table = el('div', 'wh-table'); + if (!members.length) table.appendChild(el('div', 'wh-hint', 'Noch niemand freigegeben.')); + + members.forEach(m => { + const row = el('div', 'wh-row'); + + const col = el('div'); + col.appendChild(el('div', 'wh-row-title', m.name || m.char_id)); + col.appendChild(el('div', 'wh-row-sub', m.role || 'member')); + row.appendChild(col); + + const right = el('div', 'wh-row-right'); + right.appendChild(el('span', 'wh-tag ' + (m.online ? 'ok' : ''), + m.online ? 'online' : 'offline')); + if (m.can_post) right.appendChild(el('span', 'wh-tag', 'schreibt')); + if (m.can_edit_profile) right.appendChild(el('span', 'wh-tag', 'Profil')); + if (m.can_manage_members) right.appendChild(el('span', 'wh-tag warn', 'verwaltet')); + + const rm = el('button', 'icweb-btn-danger', 'Entfernen'); + rm.addEventListener('click', () => { + ICWebRender.confirmBox('Freigabe entfernen', + (m.name || m.char_id) + ' schreibt dann nicht mehr unter @' + profile.handle + '.', + async () => { + const res = await call('setProfileMember', { + profileId: profile.id, charId: m.char_id, remove: true, + }); + if (notify(res, 'Freigabe entfernt.')) showMembers(profile); + }, { danger: true, okLabel: 'Entfernen' }); + }); + right.appendChild(rm); + + row.appendChild(right); + table.appendChild(row); + }); + wrap.appendChild(table); + + /* Freigeben */ + wrap.appendChild(el('div', 'wh-subhead', 'Mitarbeiter freigeben')); + + const candidates = (candRes.ok && candRes.payload && candRes.payload.candidates) || []; + if (!candidates.length) { + wrap.appendChild(el('div', 'wh-hint', + 'Niemand verfügbar. Die Person muss online sein, um freigegeben zu werden.')); + return setMain(wrap); + } + + const form = el('div', 'wh-form wh-form-inline'); + + const pick = el('div', 'wh-field'); + pick.appendChild(el('label', 'wh-label', 'Person')); + const sel = el('select', 'icweb-input'); + candidates.forEach(c => { + const o = el('option', null, c.name); + o.value = String(c.source); + sel.appendChild(o); + }); + pick.appendChild(sel); + form.appendChild(pick); + + const roleRow = el('div', 'wh-field'); + roleRow.appendChild(el('label', 'wh-label', 'Rechte')); + const rights = el('select', 'icweb-input'); + [['post', 'Schreiben'], + ['edit', 'Schreiben und Profil bearbeiten'], + ['manage', 'Schreiben, Profil bearbeiten und Leute freigeben']].forEach(([v, l]) => { + const o = el('option', null, l); + o.value = v; + rights.appendChild(o); + }); + roleRow.appendChild(rights); + form.appendChild(roleRow); + + const add = el('button', 'icweb-btn-primary', 'Freigeben'); + add.addEventListener('click', async () => { + const level = rights.value; + const res = await call('setProfileMember', { + profileId: profile.id, + targetSource: Number(sel.value), + role: level === 'manage' ? 'manager' : 'member', + canPost: true, + canEditProfile: level === 'edit' || level === 'manage', + canManageMembers: level === 'manage', + }); + if (notify(res, 'Freigabe gespeichert.')) showMembers(profile); + }); + form.appendChild(add); + + wrap.appendChild(form); + setMain(wrap); + } + + return { open, onResponse }; +})(); diff --git a/nui/js/apps/browser.js b/nui/js/apps/browser.js new file mode 100644 index 0000000..e54b626 --- /dev/null +++ b/nui/js/apps/browser.js @@ -0,0 +1,538 @@ +/** + * pc-live | Browser App + * IC URL schema: ic://slug + */ +const BrowserApp = (() => { + const WIN_ID = 'app-browser'; + const history = []; + let histIdx = -1; + + /* ── IC page templates ─────────────────────────────────── */ + const PageTemplates = { + home: () => ` +
+
+

⚡ IC Browser

+

Browse the internal network. Enter an ic:// address above.

+ +
+
`, + + dpa: () => ` +
+

📰 DPA – Deutsche Presse-Agentur

+
+ +

New residential district approved in Vinewood Hills

+

The city council has approved plans for 120 new residential units in the Vinewood Hills area. Construction is set to begin next month pending environmental review.

+
+
+ +

Port Authority reports record cargo volume

+

Los Santos Port Authority announced a 14% increase in cargo processing year-over-year, citing improved logistics infrastructure and expanded berth capacity.

+
+
+ +

Traffic advisory: Freeway 1 maintenance

+

LSDOT warns of lane closures on Freeway 1 near Palomino Highlands between 22:00 and 06:00 for the next three nights.

+
+
`, + + psb: () => ` +
+

🚔 Los Santos Police Service Board

+

Official information and public notices from the Los Santos Police Department.

+
+ +

Public Safety Campaign – Q1

+

The LSPD is launching a public outreach campaign focused on traffic safety and community engagement. Residents are encouraged to report suspicious activity through official channels.

+
+
+ +

LSPD Cadet Applications Open

+

Applications for the upcoming cadet class are now open. Qualified candidates should contact the department for details on requirements and the application process.

+
+
`, + + weazel: () => ` +
+

📺 Weazel News

+

Los Santos' #1 Trusted News Source

+
+ +

Explosion reported near LSIA

+

Witnesses report a large explosion near the Los Santos International Airport. LSFD units are on scene. Air traffic has been temporarily halted as a precaution.

+
+
+ +

Vinewood Music Awards – Full List of Nominees

+

The annual Vinewood Music Awards nominees have been announced. This year's ceremony will take place at the Maze Bank Arena. Tickets available starting next week.

+
+
`, + + // ── Org pages ──────────────────────────────────────────── + org_police: () => ` +
+
+ 🚔 +

Los Santos Police Department

ic://police  ·  pd.gov

+
+
+
+
📞
Notruf 110
+
Polizeilicher Notruf
+
+
+
✉️
aktensystem@pd.gov
+
Offizielles Aktensystem
+
+
+
+

Strafverfolgung & Öffentliche Sicherheit

+

Das LSPD ist zuständig für die öffentliche Sicherheit, Strafverfolgung und Verbrechensbekämpfung in Los Santos. Bürger können sich jederzeit an unsere Beamten wenden.

+
+
+

Stellenausschreibung – Cadet Program

+

Das LSPD nimmt regelmäßig neue Bewerber für das Cadet-Programm an. Kontakt über das offizielle Aktensystem oder persönlich beim Department.

+
+
`, + + org_ambulance: () => ` +
+
+ 🚑 +

Los Santos Medical Department

ic://ambulance  ·  md.gov

+
+
+
+
📞
Notruf 911
+
Medizinischer Notfall
+
+
+
✉️
aktensystem@md.gov
+
Patientenakten & Anfragen
+
+
+
+

Notfallversorgung rund um die Uhr

+

Das LSMD bietet medizinische Erstversorgung, Krankentransport und chirurgische Eingriffe. Im Notfall bitte sofort den Notruf 911 wählen.

+
+
`, + + org_fire: () => ` +
+
+ 🚒 +

Los Santos Fire Department

ic://fire  ·  fd.gov

+
+
+
+
📞
Notruf 112
+
Feuerwehr & Technische Hilfe
+
+
+
✉️
aktensystem@fd.gov
+
Offizielle Korrespondenz
+
+
+
+

Brandbekämpfung & Technische Hilfe

+

Die LSFD schützt Leben und Eigentum bei Bränden, Unfällen und Naturkatastrophen. Für technische Hilfeleistungen steht das Department rund um die Uhr bereit.

+
+
`, + + org_doj: () => ` +
+
+ ⚖️ +

Department of Justice

ic://doj  ·  doj.gov

+
+
+
+
📞
Hotline 200
+
DOJ Auskunft
+
+
+
✉️
aktensystem@doj.gov
+
Juristische Anfragen
+
+
+
+

Strafrecht & Zivilrecht in Los Santos

+

Das Department of Justice verwaltet das Rechtssystem von Los Santos. Anwälte, Richter und Staatsanwälte koordinieren alle rechtlichen Vorgänge über dieses Department.

+
+
`, + + org_dpa: () => ` +
+
+ 🏛️ +

Department of Public Administration

ic://dpa  ·  dpa.gov

+
+
+
+
✉️
public@dpa.gov
+
Öffentliche Anfragen
+
+
+
✉️
traffic@dpa.gov
+
Verkehr & Infrastruktur
+
+
+
+

Staatliche Verwaltung & Öffentlicher Dienst

+

Die DPA koordiniert Verwaltungsaufgaben, Genehmigungen und öffentliche Dienstleistungen. Anfragen können per Mail oder telefonisch gestellt werden.

+
+
`, + + org_parking: () => ` +
+
+ 🅿️ +

Parkraumüberwachung Los Santos

ic://parking  ·  parking.gov

+
+
+
+
✉️
aktensystem@parking.gov
+
Einsprüche & Anfragen
+
+
+
+

Knöllchen & Einsprüche

+

Bei Fragen zu Knöllchen oder Einsprüchen bitte schriftlich über aktensystem@parking.gov oder persönlich am Schalter.

+
+
`, + + + classifieds: () => ` +
+ + +
+
+ 📋 LS CLASSIFIEDS +
+
+ Los Santos' largest private marketplace  ·  ic://classifieds +
+
+ + +
+
+
🚗
+
Vehicles
+
47 listings
+
+
+
🏠
+
Real Estate
+
23 listings
+
+
+
💼
+
Jobs
+
12 listings
+
+
+
🔧
+
Services
+
31 listings
+
+
+ + +
+ ⭐ Featured Listings +
+
+ + +
+
+ 🚗 + FEATURED +
+
2023 Ocelot Pariah
+
+ Low mileage, fully tuned at Benny's. Custom matte black wrap. Clean history, no damage. +
+
$285,000
+
📍 Vinewood  ·  Posted 2h ago
+
+ + +
+
+ 🏍 + Vehicles +
+
Western Daemon Custom
+
+ Full performance upgrade, custom exhaust. Runs clean. Selling due to relocation. +
+
$68,500
+
📍 Strawberry  ·  Posted 6h ago
+
+ + +
+
+ 🏠 + Real Estate +
+
3-Bed House · Mirror Park
+
+ Modern detached house, double garage, pool. Move-in ready. Quiet, well-connected area. +
+
$1,200,000
+
📍 Mirror Park  ·  Posted 5h ago
+
+ + +
+
+ 💼 + Jobs +
+
Mechanic – Premium Auto
+
+ Looking for an experienced mechanic. Flexible shifts, good cut. Experience required. +
+
$4,500 / week
+
📍 LSIA Area  ·  Posted 1d ago
+
+ + +
+
+ 🏢 + Real Estate +
+
Penthouse · Alta St.
+
+ Floor 28, panoramic city view, 2 bed/2 bath, concierge. Fully furnished, immediate occupancy. +
+
$3,800,000
+
📍 Downtown LS  ·  Posted 12h ago
+
+ + +
+
+ 🔧 + Services +
+
24/7 Mobile Mechanic
+
+ On-call repairs, towing, custom builds. Competitive rates. Contact via IC mail. +
+
Contact via mail
+
📍 Citywide  ·  Posted 3d ago
+
+ +
+ + +
+ LS Classifieds is not responsible for the accuracy of listings. All trades are at your own risk. +  ·  To post an ad, send a mail to classifieds@ls-net.ic +
+ +
`, + }; + + /* Fehlerseite für ic-web-Domänen. Als DOM gebaut, weil der Fehlertext vom + Server kommt und die Domäne aus der Adressleiste stammt. */ + function renderICError(domain, message) { + const wrap = document.createElement('div'); + wrap.className = 'browser-404 fade-in'; + + const h = document.createElement('h2'); + h.textContent = '404'; + const p1 = document.createElement('p'); + p1.textContent = 'ic://' + domain; + const p2 = document.createElement('p'); + p2.textContent = message || 'Diese Adresse existiert nicht.'; + + wrap.append(h, p1, p2); + return wrap; + } + + function render404(slug) { + return ` +
+

404

+

Page not found: ic://${slug}

+

Check the address and try again.

+
`; + } + + /* ── URL helpers ────────────────────────────────────────── */ + function isExternal(url) { + return /^https?:\/\//i.test(url); + } + + function normalizeUrl(raw) { + raw = (raw || '').trim(); + if (!raw) return 'ic://home'; + // Already ic:// or http(s):// + if (/^ic:\/\//i.test(raw) || /^https?:\/\//i.test(raw)) return raw; + // Looks like a domain (contains dot) → assume https + if (/^[a-z0-9\-]+\.[a-z]{2,}/i.test(raw)) return 'https://' + raw; + // Otherwise treat as ic:// slug + return 'ic://' + raw; + } + + /* ── Navigate ─────────────────────────────────────────── */ + async function navigate(rawUrl) { + if (!rawUrl) return; + const url = normalizeUrl(rawUrl); + + // Update address bar + const bar = document.getElementById('browser-address'); + if (bar) bar.value = url; + + // History + if (histIdx < 0 || history[histIdx] !== url) { + history.splice(histIdx + 1); + history.push(url); + histIdx = history.length - 1; + } + updateNavButtons(); + + const viewport = document.getElementById('browser-viewport'); + if (!viewport) return; + + // ── External URL → iframe ────────────────────────────── + if (isExternal(url)) { + viewport.classList.add('browser-viewport-external'); + viewport.innerHTML = + ``; + return; + } + + // ── IC internal page ─────────────────────────────────── + viewport.classList.remove('browser-viewport-external'); + const slug = url.replace(/^ic:\/\//i, '').replace(/\/$/, '').trim() || 'home'; + if (bar) bar.value = 'ic://' + slug; + + viewport.innerHTML = '

Loading…

'; + + if (PageTemplates[slug]) { + viewport.innerHTML = PageTemplates[slug](); + return; + } + + // ── Von Spielern gepflegte Seiten (ic-web) ───────────── + // Erkennbar an der Domänenform name.endung, optional mit Unterseite. + // Vorrang haben die fest eingebauten Vorlagen oben. + const parts = slug.split('/'); + if (/^[a-z0-9\-]+\.[a-z]{2,}$/i.test(parts[0]) && typeof ICWebNet !== 'undefined') { + const res = await ICWebNet.call('getSite', parts[0].toLowerCase(), parts[1] || 'home'); + if (res && res.ok && res.data) { + // Kein innerHTML: der Inhalt stammt von Spielern und wird als + // DOM-Knoten aufgebaut, damit Markup darin niemals ausgeführt wird. + viewport.replaceChildren(ICWebRender.renderPage(res.data)); + return; + } + viewport.replaceChildren(renderICError(parts[0], res && res.error)); + return; + } + + const resp = await fetchNui('getBrowserPage', { slug }); + if (resp && resp.found && resp.page) { + const p = resp.page; + if (p.content && PageTemplates[p.content]) { + viewport.innerHTML = PageTemplates[p.content](); + } else { + viewport.innerHTML = `

${p.title || slug}

This page has no content yet.

`; + } + } else { + viewport.innerHTML = render404(slug); + } + } + + function goBack() { + if (histIdx > 0) { + histIdx--; + navigate(history[histIdx]); + } + } + function goForward() { + if (histIdx < history.length - 1) { + histIdx++; + navigate(history[histIdx]); + } + } + function updateNavButtons() { + const b = document.getElementById('browser-back'); + const f = document.getElementById('browser-fwd'); + if (b) b.style.opacity = histIdx > 0 ? '1' : '0.35'; + if (f) f.style.opacity = histIdx < history.length - 1 ? '1' : '0.35'; + } + + /* ── Open window ────────────────────────────────────────── */ + function open(startUrl) { + const el = WindowManager.create({ + id: WIN_ID, + title: 'Browser', + icon: '🌐', + width: 900, + height: 600, + content: ` +
+ + + + + +
+
+ `, + }); + + if (!el) return; + navigate(startUrl || 'ic://home'); + } + + function refresh() { + if (histIdx >= 0) navigate(history[histIdx]); + } + + return { open, navigate, goBack, goForward, refresh }; +})(); diff --git a/nui/js/apps/gesetzbuch.js b/nui/js/apps/gesetzbuch.js new file mode 100644 index 0000000..654d912 --- /dev/null +++ b/nui/js/apps/gesetzbuch.js @@ -0,0 +1,26 @@ +/** + * pc-live | Gesetzbuch (external) + * Öffnet die öffentliche Gesetze-Übersicht des Aktensystems im eingebetteten Fenster. + */ +const GesetzbuchApp = (() => { + const WIN_ID = 'app-gesetzbuch'; + const URL = 'https://aktensystem.naturalbornplayers.de/gesetze.html'; + + function open() { + WindowManager.create({ + id: WIN_ID, + title: 'Gesetzbuch', + icon: '📖', + width: 1000, + height: 680, + content: ``, + }); + } + + return { open }; +})(); diff --git a/nui/js/apps/ic_verwaltung.js b/nui/js/apps/ic_verwaltung.js new file mode 100644 index 0000000..f779320 --- /dev/null +++ b/nui/js/apps/ic_verwaltung.js @@ -0,0 +1,74 @@ +/** + * pc-live | IC-Verwaltung App + * Renders the ic-verwaltung UI inside a WindowManager window via iframe. + * Messages are relayed: Lua TriggerEvent → pc-live SendNUIMessage → postMessage → iframe. + */ +const ICVerwaltungApp = (() => { + const WIN_ID = 'app-ic-verwaltung'; + const IFRAME_ID = 'ic-verwaltung-frame'; + const IC_ORIGIN = 'https://cfx-nui-ic-verwaltung'; + + let _iframe = null; + + function open() { + if (windows_has(WIN_ID)) { + WindowManager.focus(WIN_ID); + return; + } + + const content = ` + `; + + const winEl = WindowManager.create({ + id: WIN_ID, + title: 'IC-Verwaltung', + icon: '🏛', + width: 960, + height: 620, + content, + }); + + // Intercept titlebar X so Lua is notified when user closes the window manually + if (winEl) { + const closeBtn = winEl.querySelector('.wc-close'); + if (closeBtn) { + closeBtn.addEventListener('click', () => { + _iframe = null; + fetchNui('closeIcVerwaltung', {}); + }, { once: true }); + } + } + + _iframe = document.getElementById(IFRAME_ID); + + // Tell Lua to open ic-verwaltung in pcMode — relay will carry the open message + fetchNui('openIcVerwaltung', {}); + } + + // Called by desktop.js when action === 'ic_verwaltung_relay' + function onRelay(msg) { + // ic-verwaltung's close action → close the PC window instead of posting back + if (msg && msg.action === 'close') { + WindowManager.close(WIN_ID); + _iframe = null; + return; + } + if (!_iframe) { + _iframe = document.getElementById(IFRAME_ID); + } + if (_iframe && _iframe.contentWindow) { + _iframe.contentWindow.postMessage(msg, IC_ORIGIN); + } + } + + // Helper: check if a window id is already open (WindowManager has no .exists method) + function windows_has(id) { + return !!document.querySelector(`[data-wid="${id}"]`); + } + + return { open, onRelay }; +})(); diff --git a/nui/js/apps/mail.js b/nui/js/apps/mail.js new file mode 100644 index 0000000..11cdae8 --- /dev/null +++ b/nui/js/apps/mail.js @@ -0,0 +1,1153 @@ +/** + * pc-live | Mail App + * Multi-mailbox: persönliches Postfach + geteilte Postfächer (ic-mail) + */ +const MailApp = (() => { + const WIN_ID = 'app-mail'; + + // ── Mail state ────────────────────────────────────────── + let _inbox = []; + let _sent = []; + // Ordner gehören zum Postfach, nicht zur Person: ein geteiltes Postfach + // wird von mehreren bedient und hat für alle dieselbe Struktur. + // Schlüssel: Adresse, '' = persönliches Postfach. + let _foldersByBox = {}; + let _folderMails = {}; + let _calendar = []; + let _active = null; + let _view = 'inbox'; + let _editEvent = null; + let _contacts = []; + + // ── Mailbox state (ic-mail) ───────────────────────────── + let _mailboxes = []; // [{address, display_name, type, realm_type}] + let _personalAddr = ''; // primäre persönliche Adresse + let _activeAddr = null; // null = persönlich, sonst Adress-String + let _personalSig = ''; // Signatur des persönlichen Postfachs + let _sigAddr = ''; // Postfach, dessen Signatur gerade bearbeitet wird + let _openBoxes = []; // per Passwort freigeschaltete Postfächer (ic-web) + + /* ── Helpers ──────────────────────────────────────────── */ + function fmtDate(d) { + if (!d) return ''; + const dt = new Date(d); + return isNaN(dt) ? d : dt.toLocaleString('de-DE', { + day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' + }); + } + function fmtDateInput(d) { + if (!d) return ''; + const dt = new Date(d); + if (isNaN(dt)) return ''; + return dt.toISOString().slice(0, 16); + } + function esc(s) { + return String(s || '').replace(/&/g,'&').replace(//g,'>'); + } + + /* Adresse des gerade gewählten Postfachs, '' = persönlich. */ + function boxKey() { return _activeAddr || ''; } + + /* Ordner des gewählten Postfachs. */ + function currentFolders() { return _foldersByBox[boxKey()] || []; } + + /* Signatur eines Postfachs. '' wenn keine gepflegt ist. */ + function signatureFor(address) { + if (!address || address === _personalAddr) return _personalSig || ''; + const mb = _mailboxes.find(m => m.address === address); + return (mb && mb.signature) || ''; + } + + /* Postfächer, die über "Postfach hinzufügen" freigeschaltet wurden und sich + deshalb auch wieder entfernen lassen. Das eigene und dienstlich + zugewiesene gehören einem nicht. */ + function isRemovable(address) { + return _openBoxes.includes(address); + } + + /* Trennzeile vor der Signatur – wie in jedem Mailprogramm. */ + const SIG_SEP = '\n\n-- \n'; + + /* Alten Signaturblock abschneiden und den neuen anhängen. So bleibt der + geschriebene Text erhalten, wenn man den Absender wechselt. */ + function applySignature(text, address) { + const idx = text.lastIndexOf(SIG_SEP); + const body = idx >= 0 ? text.slice(0, idx) : text; + const sig = signatureFor(address); + return sig ? body + SIG_SEP + sig : body; + } + function currentFolderId() { + if (_view.startsWith('folder:')) return parseInt(_view.split(':')[1]); + return null; + } + + // Mails des aktiven Postfachs aus dem gesamten Inbox-Array filtern + function _mailboxFilter(mails) { + const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address)); + if (!_activeAddr) { + // Persönlich: alles was NICHT zu einem geteilten Postfach gehört + return mails.filter(m => !m.to_address || !sharedSet.has(m.to_address)); + } + return mails.filter(m => m.to_address === _activeAddr); + } + + function currentMails() { + if (_view === 'inbox') return _mailboxFilter(_inbox); + if (_view === 'sent') { + if (!_activeAddr) { + const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address)); + return _sent.filter(m => !sharedSet.has(m.from_identifier)); + } + return _sent.filter(m => m.from_identifier === _activeAddr); + } + const fid = currentFolderId(); + if (fid != null) return _mailboxFilter(_folderMails[fid] || []); + return []; + } + + /* ── Mailbox icon ─────────────────────────────────────── */ + function _mbIcon(mb) { + if (mb.type === 'personal') return '✉'; + if (mb.realm_type === 'faction') return '🏛'; + if (mb.realm_type === 'company') return '🏢'; + return '📮'; + } + + /* ── Sidebar ──────────────────────────────────────────── */ + function renderSidebar() { + const fid = currentFolderId(); + + // Unread pro Postfach berechnen + const sharedSet = new Set(_mailboxes.filter(m => m.type !== 'personal').map(m => m.address)); + const personalUnread = _inbox.filter(m => !m.is_read && (!m.to_address || !sharedSet.has(m.to_address))).length; + + // Persönliches Postfach + const personalLabel = _personalAddr || 'Mein Postfach'; + const personalActive = !_activeAddr; + let mailboxItems = ` + `; + + // Geteilte Postfächer + for (const mb of _mailboxes) { + if (mb.type === 'personal') continue; + const mbUnread = _inbox.filter(m => m.to_address === mb.address && !m.is_read).length; + const isActive = _activeAddr === mb.address; + const label = mb.display_name || mb.address; + const removeBtn = isRemovable(mb.address) ? ` + ` : ''; + mailboxItems += ` + `; + } + + mailboxItems += ` + `; + + // Navigation für aktives Postfach + const viewMails = currentMails(); + const viewUnread = _view === 'inbox' ? viewMails.filter(m => !m.is_read).length : 0; + + // Ordner + let folderItems = currentFolders().map(f => ` + `).join(''); + + return ` +
+ + ${mailboxItems} + +
+ + + + + + + + ${folderItems} + +
+ + +
`; + } + + /* ── Mail list ────────────────────────────────────────── */ + function renderMailList(mails, isSent) { + if (!mails.length) + return `
📭

Keine Nachrichten

`; + + const fid = currentFolderId(); + const folderOptions = currentFolders().map(f => + ``).join(''); + const moveSelect = (fid == null && !isSent) ? ` + ` : ''; + const toInboxBtn = (fid != null) ? ` + ` : ''; + + return `
` + mails.map(m => { + const addr = isSent + ? esc(m.to_address || m.to_identifier) + : esc(m.from_identifier); + const label = isSent ? `An: ${addr}` : addr; + const moveBtn = moveSelect.replaceAll('{id}', m.id); + const inboxBtn = toInboxBtn.replaceAll('{id}', m.id); + return ` +
+
${label}
+
${esc(m.subject)}
+
+
${fmtDate(m.sent_at)}
+ ${moveBtn}${inboxBtn} +
+
`; + }).join('') + `
`; + } + + /* ── Mail detail ──────────────────────────────────────── */ + function renderMailDetail(mail, isSent) { + if (!mail) + return `
📧

Nachricht auswählen

`; + + const fid = currentFolderId(); + const folderOptions = currentFolders().map(f => + ``).join(''); + const moveRow = (!isSent) ? ` + ` : ''; + + const replyBtn = !isSent ? `` : ''; + const contactMatch = _contacts.find(c => c.email === (isSent ? mail.to_identifier : mail.from_identifier)); + const contactBtn = !contactMatch ? ` + ` : ''; + + // Zeige Postfach-Badge wenn Shared-Mail + const mbBadge = mail.to_address && mail.to_address !== _personalAddr ? ` + + 📮 ${esc(mail.to_address)} + ` : ''; + + return ` +
+
+
${esc(mail.subject)}${mbBadge}
+
+ ${isSent ? 'An' : 'Von'}: ${esc(isSent ? (mail.to_address || mail.to_identifier) : mail.from_identifier)} + Datum: ${fmtDate(mail.sent_at)} +
+
+ ${replyBtn} + ${moveRow} + ${contactBtn} + ${!isSent ? `` : ''} +
+
+
${esc(mail.body)}
+
`; + } + + /* ── Signatur ─────────────────────────────────────────── */ + function renderSignature() { + // Alle Postfächer, für die eine Signatur gepflegt werden kann. + const boxes = []; + if (_personalAddr) boxes.push({ address: _personalAddr, label: _personalAddr }); + for (const mb of _mailboxes) { + if (mb.address === _personalAddr) continue; + boxes.push({ address: mb.address, label: mb.display_name + ? `${mb.display_name} (${mb.address})` : mb.address }); + } + + if (!boxes.length) { + return `
+

Für dieses Postfach gibt es keine Adresse.

`; + } + + // Vorauswahl: das gewählte Postfach, sonst das zuletzt bearbeitete. + if (!boxes.some(b => b.address === _sigAddr)) { + _sigAddr = _activeAddr || _personalAddr || boxes[0].address; + } + const addr = _sigAddr; + + const opts = boxes.map(b => + `` + ).join(''); + + return ` +
+

✒ Signatur

+
+ + +
+
+ Die Signatur gehört zum Postfach: Wer es bedient, schreibt mit derselben + Fußzeile. +
+
+ +
+
+ + +
+
+
+ Beim Schreiben wird die Signatur unter den Text gesetzt, getrennt durch eine + Zeile mit --. Wechselst du den Absender, tauscht sie sich mit. +
+
`; + } + + function _sigBoxChanged() { + const sel = document.getElementById('mail-sig-box'); + if (!sel) return; + _sigAddr = sel.value; + _refresh(); + } + + function _saveSignature() { + const addr = _sigAddr || _activeAddr || _personalAddr; + const el = document.getElementById('mail-signature'); + const st = document.getElementById('mail-sig-status'); + if (!addr || !el) return; + + if (st) st.textContent = 'Wird gespeichert…'; + fetchNui('setSignature', { address: addr, signature: el.value }); + } + + function onSignatureSaved(data) { + const st = document.getElementById('mail-sig-status'); + if (!st) return; + st.textContent = (data && data.ok) + ? 'Gespeichert.' + : '⚠ ' + ((data && data.error) || 'Speichern fehlgeschlagen.'); + } + + /* ── Postfächer freischalten (ic-web) ─────────────────── */ + /* Ein Postfach ist ein Zugang mit Passwort. Wer die Daten kennt, bekommt es + hier dazu – dieselbe Prüfung wie bei der Anmeldung am Webhosting. */ + function _addMailbox() { + if (typeof ICWebRender === 'undefined') { + return Desktop.showNotification('⚠ Postfachverwaltung nicht verfügbar.'); + } + + ICWebRender.modal('Postfach hinzufügen', (box, close) => { + const el = ICWebRender.el; + box.appendChild(el('div', 'icweb-modal-sub', + 'Adresse und Passwort des Postfachs. Es bleibt geöffnet, bis du es ' + + 'entfernst oder den Server verlässt.')); + + const mkField = (label, type, placeholder) => { + const row = el('div', 'wh-field'); + row.appendChild(el('label', 'wh-label', label)); + const input = el('input', 'icweb-input'); + input.setAttribute('type', type); + if (placeholder) input.setAttribute('placeholder', placeholder); + row.appendChild(input); + box.appendChild(row); + return input; + }; + + const addr = mkField('Adresse', 'text', 'presse@weazel-news.ls'); + const pass = mkField('Passwort', 'password', ''); + const err = el('div', 'wh-login-error'); + box.appendChild(err); + + const row = el('div', 'icweb-modal-actions'); + const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen'); + cancel.addEventListener('click', close); + + const go = el('button', 'icweb-btn-primary', 'Hinzufügen'); + const submit = async () => { + err.textContent = ''; + if (!addr.value || !pass.value) { + err.textContent = 'Bitte Adresse und Passwort eingeben.'; + return; + } + go.disabled = true; + const res = await ICWebNet.call('openMailbox', addr.value, pass.value); + go.disabled = false; + pass.value = ''; + + if (!res.ok) { err.textContent = res.error || 'Fehlgeschlagen.'; return; } + close(); + refreshMailboxes(); + }; + go.addEventListener('click', submit); + [addr, pass].forEach(i => + i.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); })); + + row.append(cancel, go); + box.appendChild(row); + setTimeout(() => addr.focus(), 0); + }); + } + + function _removeMailbox(address) { + ICWebRender.confirmBox('Postfach entfernen', + 'Postfach ' + address + ' entfernen?\n' + + 'Die Nachrichten bleiben erhalten, du siehst sie nur nicht mehr.', async () => { + const res = await ICWebNet.call('closeMailbox', address); + if (!res.ok) return Desktop.showNotification('⚠ ' + (res.error || 'Fehlgeschlagen.')); + + if (_activeAddr === address) _activeAddr = null; + refreshMailboxes(); + }, { danger: true, okLabel: 'Entfernen' }); + } + + /* Liste der freigeschalteten Postfächer holen und die Mailboxliste neu + laden. Beides zusammen, damit die ✕-Knöpfe zur Anzeige passen. */ + async function refreshMailboxes() { + if (typeof ICWebNet !== 'undefined') { + const res = await ICWebNet.call('listMailboxes'); + _openBoxes = (res.ok && Array.isArray(res.data)) ? res.data : []; + } + fetchNui('getSharedMailboxes', {}); + } + + /* ── Compose ──────────────────────────────────────────── */ + function renderCompose(prefillTo = '', prefillSubject = '') { + // Von-Dropdown: alle Postfächer aus denen der Spieler senden darf + const fromAddresses = []; + if (_personalAddr) fromAddresses.push({ address: _personalAddr, label: _personalAddr }); + for (const mb of _mailboxes) { + if (mb.type !== 'personal') { + const lbl = mb.display_name ? `${mb.display_name} (${mb.address})` : mb.address; + fromAddresses.push({ address: mb.address, label: lbl }); + } + } + const defaultFrom = _activeAddr || _personalAddr || ''; + + let fromField = ''; + if (fromAddresses.length > 1) { + const opts = fromAddresses.map(o => + `` + ).join(''); + fromField = ` +
+ + +
`; + } else if (fromAddresses.length === 1) { + fromField = ``; + } + + const contactOptions = _contacts.map(c => + `` + ).join(''); + const datalist = _contacts.length ? ` + ${contactOptions}` : ''; + + return ` +
+

✏ Neue Nachricht

+ ${datalist} + ${fromField} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
`; + } + + /* ── Calendar ─────────────────────────────────────────── */ + /* Kalender, in die diese Person eintragen darf: privat und jedes Postfach. */ + function writableCalendars() { + const out = [{ address: '', label: '🔒 Privat' }]; + for (const mb of _mailboxes) { + out.push({ + address: mb.address, + label: '👥 ' + (mb.display_name || mb.address), + }); + } + return out; + } + + /* Kurzes Schild am Termin, damit man sieht, wessen Kalender er gehört. */ + function calBadge(e) { + if (e.visibility === 'public') return { text: '🌐 Öffentlich', title: e.address || '' }; + if (e.address) { + const mb = _mailboxes.find(m => m.address === e.address); + return { text: '👥 ' + ((mb && mb.display_name) || e.address), title: e.address }; + } + return { text: '🔒 Privat', title: '' }; + } + + function renderCalendar() { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const monthNames = ['Januar','Februar','März','April','Mai','Juni', + 'Juli','August','September','Oktober','November','Dezember']; + + let cells = ''; + const dayHeaders = ['Mo','Di','Mi','Do','Fr','Sa','So'].map(d => + `
${d}
`).join(''); + + const offset = (firstDay + 6) % 7; + for (let i = 0; i < offset; i++) cells += `
`; + + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; + const dayEvents = _calendar.filter(e => e.start_at && e.start_at.startsWith(dateStr)); + const dots = dayEvents.map(e => + `
` + ).join(''); + const isToday = (d === now.getDate()); + cells += ` +
+ ${d} +
${dots}
+
`; + } + + const upcomingItems = _calendar + .filter(e => new Date(e.start_at) >= new Date(year, month, 1)) + .slice(0, 8) + .map(e => { + const badge = calBadge(e); + // Fremde öffentliche Termine sieht man, ändern darf sie nur, wer das + // zugehörige Postfach bedient. + const actions = e.can_edit ? ` +
+ + +
` : ''; + return ` +
+
+
${esc(e.title)}
+ ${esc(badge.text)} +
+
${fmtDate(e.start_at)}${e.end_at ? ' – ' + fmtDate(e.end_at) : ''}
+ ${e.description ? `
${esc(e.description)}
` : ''} + ${actions} +
`; + }).join(''); + + const editForm = _editEvent ? ` +
+

+ ${_editEvent.id ? 'Eintrag bearbeiten' : 'Neuer Eintrag'} +

+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ ${(_editEvent.address || '') !== '' ? ` +
+ + +
` : ` +
+ Ein privater Termin ist nur für dich sichtbar. +
`} +
+ + +
+
+ + +
+
` : ` + `; + + return ` +
+
+
📅 ${monthNames[month]} ${year}
+
+ ${dayHeaders} + ${cells} +
+
+
+ ${editForm} + + ${upcomingItems || '
Keine Termine
'} +
+
`; + } + + /* ── Full layout refresh ──────────────────────────────── */ + function _refresh() { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + + const isSent = _view === 'sent'; + const mails = currentMails(); + const activeMail = mails.find(m => m.id === _active) || null; + + let rightContent; + if (_view === 'compose') { + rightContent = renderCompose(); + } else if (_view === 'signature') { + rightContent = renderSignature(); + } else if (_view === 'calendar') { + rightContent = renderCalendar(); + } else { + rightContent = ` +
+
+ ${renderMailList(mails, isSent)} +
+
+ ${renderMailDetail(activeMail, isSent)} +
+
`; + } + + body.innerHTML = ` +
+ ${renderSidebar()} +
+ ${rightContent} +
+
`; + } + + /* ── Mailbox actions ──────────────────────────────────── */ + function _setMailbox(address) { + _activeAddr = address || null; + _view = 'inbox'; + _active = null; + _editEvent = null; + + // Jedes Postfach hat eigene Ordner – die des vorherigen passen nicht. + if (_foldersByBox[boxKey()] === undefined) { + fetchNui('getFolders', { address: boxKey() }); + } + _refresh(); + } + + function initMailboxes(mailboxes, personalAddress, personalSignature) { + _mailboxes = mailboxes || []; + _personalAddr = personalAddress || ''; + _personalSig = personalSignature || ''; + const body = WindowManager.getBody(WIN_ID); + if (body) _refresh(); + } + + function onSharedMailboxes(mailboxes, personalAddress, personalSignature) { + _mailboxes = mailboxes || []; + _personalAddr = personalAddress || ''; + _personalSig = personalSignature || ''; + + // Ein neu geöffnetes Postfach bringt seinen Kalender mit. + fetchNui('getCalendar', {}); + + const body = WindowManager.getBody(WIN_ID); + if (body) _refresh(); + } + + /* ── View actions ─────────────────────────────────────── */ + function _setView(v) { + _view = v; + _active = null; + _editEvent = null; + if (v === 'sent' && !_sent.length) fetchNui('getSent', {}); + if (v === 'calendar' && !_calendar.length) fetchNui('getCalendar', {}); + if (v.startsWith('folder:')) { + const fid = parseInt(v.split(':')[1]); + if (!_folderMails[fid]) fetchNui('getFolderMails', { folderId: fid, address: boxKey() }); + } + _refresh(); + } + + function _openMail(id) { + _active = id; + const isSent = _view === 'sent'; + if (!isSent) { + fetchNui('readMail', { id }); + const m = currentMails().find(m => m.id === id); + if (m) m.is_read = 1; + } + _refresh(); + } + + /* Absender gewechselt: alten Signaturblock ersetzen, Text behalten. */ + function _fromChanged() { + const from = document.getElementById('mail-from')?.value || ''; + const body = document.getElementById('mail-body'); + if (!body) return; + + const pos = body.selectionStart; + body.value = applySignature(body.value, from); + if (pos != null) { try { body.setSelectionRange(pos, pos); } catch (e) {} } + } + + function _sendMail() { + const to = (document.getElementById('mail-to')?.value || '').trim(); + const subject = (document.getElementById('mail-subject')?.value || '').trim(); + const body = (document.getElementById('mail-body')?.value || '').trim(); + const from = document.getElementById('mail-from')?.value || ''; + const status = document.getElementById('mail-send-status'); + if (!to || !subject || !body) { + if (status) status.textContent = 'Bitte alle Felder ausfüllen.'; + return; + } + if (status) status.textContent = 'Wird gesendet…'; + fetchNui('sendMail', { to, subject, body, fromMailbox: from }); + } + + function _reply() { + const mail = currentMails().find(m => m.id === _active); + if (!mail) return; + _view = 'compose'; + _refresh(); + setTimeout(() => { + const t = document.getElementById('mail-to'); + const s = document.getElementById('mail-subject'); + if (t) t.value = mail.from_identifier || ''; + if (s) s.value = 'RE: ' + (mail.subject || ''); + }, 0); + } + + function _deleteMail(id) { fetchNui('deleteMail', { id }); } + + function _moveMail(id, folderId) { + fetchNui('moveMail', { + id, + folderId: folderId === '' || folderId === 'null' ? null : folderId, + address: boxKey(), + }); + } + + function _promptNewFolder() { + // Kein prompt(): FiveMs CEF hat keinen Handler für die eingebauten + // JS-Dialoge, das NUI bliebe stehen. + const box = boxKey(); + ICWebRender.promptText('Neuer Ordner', { + hint: box ? 'Der Ordner gehört zu ' + box + ' – alle, die dieses Postfach ' + + 'bedienen, sehen ihn.' + : 'Der Ordner gehört zu deinem persönlichen Postfach.', + placeholder: 'z. B. Anfragen', + okLabel: 'Anlegen', + }, (name) => { + fetchNui('createFolder', { name, address: box }); + }); + } + + function _deleteFolder(id) { + const box = boxKey(); + ICWebRender.confirmBox('Ordner löschen', + 'E-Mails kommen zurück in den Posteingang.' + + (box ? '\nDer Ordner verschwindet für alle, die ' + box + ' bedienen.' : ''), + () => fetchNui('deleteFolder', { id, address: box }), + { danger: true, okLabel: 'Löschen' }); + } + + function _saveAsContact(emailOrId) { + AddressBookApp.openAddNew({ email: emailOrId }); + } + + /* ── Calendar actions ─────────────────────────────────── */ + /* Vorbelegung: der Kalender des gerade gewählten Postfachs. Wer im + Firmenpostfach arbeitet, trägt meist auch dort ein. */ + function newCalDraft(start) { + return { + title: '', description: '', start_at: start || '', end_at: '', + color: '#00aaff', address: boxKey(), + visibility: boxKey() ? 'shared' : 'private', + }; + } + + function _newCalEvent(prefillDate) { + _editEvent = newCalDraft(prefillDate); + _refresh(); + } + + function _calDayClick(dateStr) { + if (_view !== 'calendar') return; + _editEvent = newCalDraft(dateStr + 'T08:00'); + _refresh(); + } + + /* Kalender gewechselt: Formular neu zeichnen, damit die Sichtbarkeitsauswahl + erscheint bzw. verschwindet. Eingaben bleiben erhalten. */ + function _calAddressChanged() { + if (!_editEvent) return; + readCalForm(); + const sel = document.getElementById('cal-address'); + _editEvent.address = sel ? sel.value : ''; + if (!_editEvent.address) _editEvent.visibility = 'private'; + else if (_editEvent.visibility === 'private') _editEvent.visibility = 'shared'; + _refresh(); + } + + /* Aktuelle Formularwerte in den Entwurf übernehmen. */ + function readCalForm() { + if (!_editEvent) return; + const v = (id) => document.getElementById(id)?.value; + _editEvent.title = v('cal-title') ?? _editEvent.title; + _editEvent.description = v('cal-desc') ?? _editEvent.description; + _editEvent.start_at = v('cal-start') ?? _editEvent.start_at; + _editEvent.end_at = v('cal-end') ?? _editEvent.end_at; + _editEvent.color = v('cal-color') ?? _editEvent.color; + const vis = v('cal-visibility'); + if (vis) _editEvent.visibility = vis; + } + + function _openCalEdit(id) { + const ev = _calendar.find(e => e.id === id); + if (!ev) return; + if (!ev.can_edit) { + return Desktop.showNotification('⚠ Diesen Termin darfst du nicht ändern.'); + } + _editEvent = { ...ev, address: ev.address || '' }; + _refresh(); + } + + function _deleteCalEvent(id) { + const ev = _calendar.find(e => e.id === id); + if (!ev || !ev.can_edit) { + return Desktop.showNotification('⚠ Diesen Termin darfst du nicht löschen.'); + } + + const badge = calBadge(ev); + ICWebRender.confirmBox('Termin löschen', + '„' + ev.title + '" wirklich löschen?' + + (ev.address ? '\nDer Termin verschwindet für alle in ' + badge.text + '.' : ''), + () => fetchNui('deleteCalendarEvent', { id }), + { danger: true, okLabel: 'Löschen' }); + } + + function _cancelCalEdit() { + _editEvent = null; + _refresh(); + } + + function _saveCalEvent() { + const title = document.getElementById('cal-title')?.value.trim(); + const desc = document.getElementById('cal-desc')?.value.trim(); + const start = document.getElementById('cal-start')?.value; + const end = document.getElementById('cal-end')?.value; + const color = document.getElementById('cal-color')?.value || '#00aaff'; + const address = document.getElementById('cal-address')?.value || ''; + const visibility = address + ? (document.getElementById('cal-visibility')?.value || 'shared') + : 'private'; + if (!title || !start) return; + const payload = { title, description: desc, start_at: start, end_at: end || null, + color, address, visibility }; + if (_editEvent && _editEvent.id) { + fetchNui('updateCalendarEvent', { id: _editEvent.id, ...payload }); + } else { + fetchNui('addCalendarEvent', payload); + } + _editEvent = null; + } + + /* ── NUI events ───────────────────────────────────────── */ + function onInbox(payload) { + _inbox = (payload && payload.mails) ? payload.mails : (Array.isArray(payload) ? payload : []); + _foldersByBox[''] = (payload && payload.folders) ? payload.folders : []; + if (_view === 'inbox' || _view.startsWith('folder:')) _refresh(); + } + + function onMailContent(mail) { + const idx = _inbox.findIndex(m => m.id === mail.id); + if (idx !== -1) _inbox[idx] = { ..._inbox[idx], ...mail }; + if (_active === mail.id && _view === 'inbox') _refresh(); + } + + function onMailSent() { + const status = document.getElementById('mail-send-status'); + if (status) status.textContent = 'Gesendet!'; + _sent = []; + setTimeout(() => _setView('inbox'), 800); + fetchNui('getInbox', {}); + } + + function onMailDeleted(id) { + _inbox = _inbox.filter(m => m.id !== id); + for (const fid in _folderMails) _folderMails[fid] = _folderMails[fid].filter(m => m.id !== id); + if (_active === id) _active = null; + _refresh(); + } + + function onSetSent(rows) { + // Duplikate entfernen (Rundmails an mehrere Empfänger) + const seen = new Set(); + _sent = (rows || []).filter(m => { + const key = `${m.from_identifier}|${m.subject}|${m.sent_at}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (_view === 'sent') _refresh(); + } + + function onFolderCreated(folder) { + const key = (folder && folder.address) || ''; + _foldersByBox[key] = (_foldersByBox[key] || []).concat([folder]); + _folderMails[folder.id] = []; + _refresh(); + } + + function onFolderDeleted(folderId, address) { + const key = address || ''; + _foldersByBox[key] = (_foldersByBox[key] || []).filter(f => f.id !== folderId); + delete _folderMails[folderId]; + _inbox = []; + fetchNui('getInbox', {}); + if (_view === 'folder:' + folderId) _view = 'inbox'; + _refresh(); + } + + /* Ordnerliste eines Postfachs vom Server. Kommt beim Wechsel und wenn + jemand anderes im geteilten Postfach einen Ordner anlegt oder löscht. */ + function onFoldersData(address, folders) { + _foldersByBox[address || ''] = folders || []; + _refresh(); + } + + function onFolderMailsData(folderId, rows) { + _folderMails[folderId] = rows || []; + if (_view === 'folder:' + folderId) _refresh(); + } + + function onMailMoved(id, folderId) { + _inbox = _inbox.filter(m => m.id !== id); + for (const fid in _folderMails) _folderMails[fid] = _folderMails[fid].filter(m => m.id !== id); + if (folderId) fetchNui('getFolderMails', { folderId }); + if (_active === id) _active = null; + _refresh(); + } + + function onCalendarData(rows) { _calendar = rows || []; if (_view === 'calendar') _refresh(); } + function onCalendarEventAdded(ev) { _calendar.push(ev); _calendar.sort((a,b)=>new Date(a.start_at)-new Date(b.start_at)); if (_view==='calendar') _refresh(); } + function onCalendarEventUpdated(ev) { const i=_calendar.findIndex(e=>e.id===ev.id); if(i!==-1)_calendar[i]=ev; else _calendar.push(ev); _calendar.sort((a,b)=>new Date(a.start_at)-new Date(b.start_at)); if(_view==='calendar') _refresh(); } + function onCalendarEventDeleted(id) { _calendar=_calendar.filter(e=>e.id!==id); if(_view==='calendar') _refresh(); } + + function setContacts(contacts) { _contacts = contacts || []; } + + /* ── Setup wizard ─────────────────────────────────────── */ + function _showSetupWizard(realms) { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + const defaultRealm = (realms && realms.length > 0) ? realms[0].realm : 'mail.ls'; + body.innerHTML = ` +
+
📧
+
E-Mail-Adresse einrichten
+
+ Du hast noch keine persönliche E-Mail-Adresse.
+ Wähle einen Benutzernamen für @${defaultRealm}. +
+
+ + @${defaultRealm} +
+
+ +
+
`; + } + + function _setupPreview(realm) { + const input = document.getElementById('mail-setup-input'); + const preview = document.getElementById('mail-setup-preview'); + if (!input || !preview) return; + const val = input.value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, ''); + if (val.length >= 2) { + preview.textContent = '✓ ' + val + '@' + realm; + preview.style.color = '#4caf50'; + } else { + preview.textContent = val.length ? 'Mindestens 2 Zeichen' : ''; + preview.style.color = '#888'; + } + } + + function _submitSetup(realm) { + const input = document.getElementById('mail-setup-input'); + const errEl = document.getElementById('mail-setup-error'); + if (!input) return; + const username = input.value.trim(); + if (username.length < 2) { + if (errEl) errEl.textContent = 'Bitte mindestens 2 Zeichen eingeben'; + return; + } + if (errEl) errEl.textContent = ''; + input.disabled = true; + fetchNui('createMailAddress', { username }); + } + + function onMailAddressStatus(data) { + if (data.hasAddress) { + // Adresse vorhanden → normal laden + fetchNui('getInbox', {}); + fetchNui('getSharedMailboxes', {}); + _refresh(); + } else { + _showSetupWizard(data.realms || []); + } + } + + function onMailCreateResult(data) { + if (data.success) { + const body = WindowManager.getBody(WIN_ID); + if (body) { + body.innerHTML = ` +
+
+
Adresse erstellt!
+
+ ${data.address} +
+
Postfach wird geladen…
+
`; + } + _personalAddr = data.address; + setTimeout(() => { + fetchNui('getInbox', {}); + fetchNui('getSharedMailboxes', {}); + _refresh(); + }, 1500); + } else { + const errEl = document.getElementById('mail-setup-error'); + const input = document.getElementById('mail-setup-input'); + if (errEl) errEl.textContent = data.error || 'Fehler beim Erstellen'; + if (input) input.disabled = false; + } + } + + /* ── Open window ──────────────────────────────────────── */ + function open() { + const created = WindowManager.create({ + id: WIN_ID, + title: 'Mail', + icon: '✉', + width: 920, + height: 580, + content: '', + }); + if (created) { + _view = 'inbox'; + _active = null; + _activeAddr = null; + // Erst prüfen ob eine Mailadresse existiert + fetchNui('checkMailAddress', {}); + } + } + + return { + open, initMailboxes, onSharedMailboxes, + onMailAddressStatus, onMailCreateResult, + _setupPreview, _submitSetup, + _setMailbox, _setView, _openMail, _sendMail, _reply, _deleteMail, + _addMailbox, _removeMailbox, _saveSignature, _sigBoxChanged, _fromChanged, + onSignatureSaved, refreshMailboxes, + _moveMail, _promptNewFolder, _deleteFolder, _saveAsContact, + _newCalEvent, _calDayClick, _openCalEdit, _deleteCalEvent, _cancelCalEdit, _saveCalEvent, + _calAddressChanged, + onInbox, onMailContent, onMailSent, onMailDeleted, + onSetSent, onFolderCreated, onFolderDeleted, onFoldersData, onFolderMailsData, onMailMoved, + onCalendarData, onCalendarEventAdded, onCalendarEventUpdated, onCalendarEventDeleted, + setContacts, + }; +})(); diff --git a/nui/js/apps/parkuhr.js b/nui/js/apps/parkuhr.js new file mode 100644 index 0000000..665c165 --- /dev/null +++ b/nui/js/apps/parkuhr.js @@ -0,0 +1,344 @@ +/** + * pc-live | Parkuhr Dashboard + * Integration with qb-parkuhr (data via qb-parkuhr:getPanelData callback) + */ +const ParkuhrApp = (() => { + const WIN_ID = 'app-parkuhr'; + let _data = null; // { devices[], typeTariffs{}, currency } + let _selectedDevice = null; // currently shown device or null = overview + + const TYPE_META = { + machine: { label: 'Automat', icon: '🏧', color: 'var(--accent)' }, + meter_small: { label: 'Parkuhr (S)', icon: '🅿', color: 'var(--success)' }, + meter_big: { label: 'Parkuhr (L)', icon: '🅿', color: 'var(--warning)' }, + }; + + /* ── Format helpers ──────────────────────────────────────── */ + function fmtMoney(val, cur = '$') { + const n = parseFloat(val) || 0; + return `${cur}${n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + function fmtDuration(min) { + if (min >= 10080) return `${min / 10080} W`; + if (min >= 1440) return `${min / 1440} T`; + if (min >= 60) return `${min / 60} Std.`; + return `${min} Min.`; + } + + function fmtPos(pos) { + if (!pos) return '—'; + return `${Math.round(pos.x)}, ${Math.round(pos.y)}`; + } + + function fmtTime(ts) { + if (!ts) return '—'; + try { + return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } catch { return String(ts); } + } + + /* ── KPI card ────────────────────────────────────────────── */ + function kpiCard(icon, label, value, color = 'var(--text-muted)') { + return ` +
+
${icon} ${label}
+
${value}
+
`; + } + + /* ── Overview page ───────────────────────────────────────── */ + function renderOverview() { + if (!_data) { + return `

Daten werden geladen…

`; + } + const devices = _data.devices || []; + const cur = _data.currency || '$'; + + const totalRevenue = devices.reduce((s, d) => s + (parseFloat(d.revenue) || 0), 0); + const totalSales = devices.reduce((s, d) => s + (d.sales?.length || 0), 0); + const machineCount = devices.filter(d => d.type === 'machine').length; + const meterCount = devices.filter(d => d.type !== 'machine').length; + + // Merge & sort all sales across devices, show last 12 + const allSales = []; + for (const dev of devices) { + for (const s of (dev.sales || []).slice(-8)) { + allSales.push({ ...s, devId: dev.id, devType: dev.type }); + } + } + allSales.sort((a, b) => new Date(b.ts) - new Date(a.ts)); + const recent = allSales.slice(0, 12); + + return ` +
+ + +
+ ${kpiCard('💰', 'Gesamteinnahmen', fmtMoney(totalRevenue, cur), 'var(--accent)')} + ${kpiCard('📋', 'Transaktionen', totalSales)} + ${kpiCard('🏧', 'Automaten', machineCount)} + ${kpiCard('🅿', 'Parkuhren', meterCount)} +
+ + + ${devices.length > 0 ? ` +
+ +
+ ${devices.map(d => { + const meta = TYPE_META[d.type] || { icon: '📍', label: d.type, color: 'var(--text-muted)' }; + const rev = parseFloat(d.revenue) || 0; + const pct = totalRevenue > 0 ? Math.round((rev / totalRevenue) * 100) : 0; + return ` +
+ ${meta.icon} #${d.id} +
+
+
+ ${fmtMoney(rev, cur)} +
`; + }).join('')} +
+
` : ''} + + +
+ + ${recent.length === 0 + ? `

Keine Transaktionen vorhanden

` + : `
+ ${recent.map(s => { + const meta = TYPE_META[s.devType] || { icon: '📍' }; + return ` +
+ ${fmtTime(s.ts)} + ${meta.icon} #${s.devId} + ${s.label || '—'} + + ${s.price != null ? fmtMoney(s.price, cur) : '—'} + +
`; + }).join('')} +
` + } +
+ +
`; + } + + /* ── Device detail page ──────────────────────────────────── */ + function renderDevice(device) { + const cur = _data?.currency || '$'; + const meta = TYPE_META[device.type] || { icon: '📍', label: device.type, color: 'var(--accent)' }; + const sales = (device.sales || []).slice(-20).reverse(); + const isMach = device.type === 'machine'; + + // Tariff list: device-specific for machines, type-wide for meters + const tariffList = isMach + ? (device.tariffs || []) + : (_data?.typeTariffs?.[device.type] || []); + + return ` +
+ + +
+
+ ${meta.icon} +
+
Gerät #${device.id}
+
+ ${meta.label}  ·  ${device.model || '—'} +
+
+
+
${fmtMoney(device.revenue, cur)}
+
Kassenstand
+
+
+
+ 📍 ${fmtPos(device.pos)} + 👤 ${device.placed_by || '—'} + 📋 ${device.sales?.length || 0} Verkäufe (gespeichert) +
+
+ + +
+ + +
+ ${tariffList.length === 0 + ? `Keine Tarife definiert` + : tariffList.map(t => { + const dur = t.duration ?? t.duration_min; + return ` +
+
${fmtDuration(dur)}
+
${fmtMoney(t.price, cur)}
+ ${isMach ? ` + ` : ''} +
`; + }).join('')} +
+ + ${isMach ? ` +
+ + + +
` : ''} +
+ + +
+ + ${sales.length === 0 + ? `

Keine Einträge

` + : `
+ ${sales.map(s => ` +
+ ${fmtTime(s.ts)} + ${s.label || '—'} + + ${s.price != null ? fmtMoney(s.price, cur) : '—'} + +
`).join('')} +
`} +
+ +
`; + } + + /* ── Full window render ──────────────────────────────────── */ + function _refresh() { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + + const devices = _data?.devices || []; + const cur = _data?.currency || '$'; + + body.innerHTML = ` +
+ + +
+
+ + Geräte (${devices.length}) + + +
+ + +
+ + ${devices.length === 0 + ? `
Keine Geräte
` + : devices.map(d => { + const m = TYPE_META[d.type] || { icon: '📍', label: d.type }; + const sel = _selectedDevice?.id === d.id; + return ` + `; + }).join('')} +
+ + +
+ ${_selectedDevice === null ? renderOverview() : renderDevice(_selectedDevice)} +
+ +
`; + } + + /* ── Public actions ──────────────────────────────────────── */ + function _select(deviceId) { + _selectedDevice = deviceId === null + ? null + : (_data?.devices || []).find(d => d.id === deviceId) || null; + _refresh(); + } + + function _saveTariff(deviceId) { + const dur = parseInt(document.getElementById('tariff-dur')?.value); + const price = parseFloat(document.getElementById('tariff-price')?.value); + if (isNaN(dur) || isNaN(price) || price < 0) return; + fetchNui('saveParkuhrTariff', { deviceId, duration: dur, price }).then(_reload); + } + + function _deleteTariff(deviceId, duration) { + fetchNui('deleteParkuhrTariff', { deviceId, duration }).then(_reload); + } + + function _reload() { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + body.innerHTML = `

Aktualisiere…

`; + fetchNui('getParkuhrPanel', {}).then(data => { + _data = data || { devices: [], typeTariffs: {}, currency: '$' }; + // Re-select from fresh data + if (_selectedDevice) { + _selectedDevice = (_data.devices || []).find(d => d.id === _selectedDevice.id) || null; + } + _refresh(); + }); + } + + /* ── Open ────────────────────────────────────────────────── */ + function open() { + const created = WindowManager.create({ + id: WIN_ID, + title: 'Parkuhr Dashboard', + icon: '🅿', + width: 860, + height: 540, + content: '', + }); + if (!created) return; + + _data = null; + _selectedDevice = null; + _refresh(); + + fetchNui('getParkuhrPanel', {}).then(data => { + _data = data || { devices: [], typeTariffs: {}, currency: '$' }; + _refresh(); + }); + } + + return { open, _select, _saveTariff, _deleteTariff, _reload }; +})(); diff --git a/nui/js/apps/pbs_dashboard.js b/nui/js/apps/pbs_dashboard.js new file mode 100644 index 0000000..33864a2 --- /dev/null +++ b/nui/js/apps/pbs_dashboard.js @@ -0,0 +1,30 @@ +/** + * pc-live | PBS Dashboard – RP PhoneSystem Pro Admin + * Bettet das PBS Web-Dashboard per iframe ein. + * URL kommt vom Server-Convar `setr pbs_web_url "http://..."` (server.cfg). + */ +const PBSDashboardApp = (() => { + const WIN_ID = 'app-pbs-dashboard'; + + async function open() { + // URL vom FiveM-Server lesen (Convar pbs_web_url) + const res = await fetchNui('getPbsUrl'); + const url = (res && res.url) ? res.url : 'http://localhost:4088'; + + WindowManager.create({ + id: WIN_ID, + title: 'PBS – PhoneSystem Dashboard', + icon: '📞', + width: 1200, + height: 720, + content: ``, + }); + } + + return { open }; +})(); diff --git a/nui/js/apps/store.js b/nui/js/apps/store.js new file mode 100644 index 0000000..ad4f0ce --- /dev/null +++ b/nui/js/apps/store.js @@ -0,0 +1,192 @@ +/** + * pc-live | Software Store App + */ +const StoreApp = (() => { + const WIN_ID = 'app-store'; + let _apps = []; + let _appsMap = {}; // app_id -> app (for dependency lookups) + let _filter = { category: 'all', search: '', installedOnly: false }; + + // Category definitions – icon + label. + // Order here determines sidebar order. + const CATEGORY_META = { + all: { label: 'All Apps', icon: '🗂' }, + system: { label: 'System', icon: '🖥' }, + utility: { label: 'Utility', icon: '🔧' }, + communication: { label: 'Communication', icon: '📡' }, + security: { label: 'Security', icon: '🔐' }, + business: { label: 'Business', icon: '💼' }, + government: { label: 'Government', icon: '🏛' }, + entertainment: { label: 'Entertainment', icon: '🎮' }, + darknet: { label: 'Darknet', icon: '🌑' }, + }; + + /* ── Helpers ─────────────────────────────────────────────── */ + + // Only show categories that actually have apps in the current list + function getActiveCategories() { + const present = new Set(['all']); + for (const app of _apps) { + if (app.category) present.add(app.category); + } + return Object.keys(CATEGORY_META).filter(c => present.has(c)); + } + + function filtered() { + let list = _apps; + if (_filter.installedOnly) list = list.filter(a => a.installed); + if (_filter.category !== 'all') list = list.filter(a => a.category === _filter.category); + if (_filter.search) { + const q = _filter.search.toLowerCase(); + list = list.filter(a => + (a.name || '').toLowerCase().includes(q) || + (a.description || '').toLowerCase().includes(q) + ); + } + return list; + } + + /* ── Render ─────────────────────────────────────────────── */ + + function renderSidebar() { + const cats = getActiveCategories(); + return ` +
+ + ${cats.map(c => { + const m = CATEGORY_META[c] || { icon: '📂', label: c }; + return ` + `; + }).join('')} +
`; + } + + function renderCard(app) { + const icon = app.icon || '📦'; + const priceLabel = app.price > 0 ? `$${app.price.toLocaleString()}` : 'Free'; + const priceClass = app.price > 0 ? '' : 'free'; + const catMeta = CATEGORY_META[app.category] || { icon: '📂' }; + + // Resolve missing dependencies + const missingDeps = (app.dependencies || []) + .filter(depId => { const d = _appsMap[depId]; return !d || !d.installed; }) + .map(depId => { const d = _appsMap[depId]; return d ? d.name : depId; }); + + // Action buttons + let action = ''; + if (app.installed) { + if (app.update_available) { + action = ``; + } else { + action = ``; + } + if (!app.default) { + action += ``; + } + } else { + const locked = missingDeps.length > 0; + action = ``; + } + + // Info badges + let badges = `${catMeta.icon} ${app.category || 'other'}`; + badges += `v${app.version || '?'}`; + if (app.installed) badges += ``; + if (app.update_available) badges += `↑ Update`; + if (app.permissions?.job) badges += `🔑 ${app.permissions.job}`; + if (missingDeps.length) badges += `⚠ Needs: ${missingDeps.join(', ')}`; + + return ` +
+
${icon}
+
${app.name || app.app_id}
+
${app.description || ''}
+ + +
`; + } + + function _refresh() { + const body = WindowManager.getBody(WIN_ID); + if (!body) return; + + const list = filtered(); + const instCount = _apps.filter(a => a.installed).length; + const totalCount = _apps.length; + + body.innerHTML = ` +
+ ${renderSidebar()} +
+
+ + ${list.length === 0 + ? `
📦

No apps found

` + : `
${list.map(renderCard).join('')}
` + } +
+
+ 📦 ${instCount} installed  ·  ${totalCount} available +
+
+
`; + } + + /* ── Filter actions ─────────────────────────────────────── */ + function _setCategory(cat) { _filter.category = cat; _refresh(); } + function _setSearch(q) { _filter.search = q; _refresh(); } + function _toggleInstalled() { + _filter.installedOnly = !_filter.installedOnly; + _refresh(); + } + + /* ── Install / Uninstall ─────────────────────────────────── */ + function _install(appId) { fetchNui('installApp', { appId }); } + function _uninstall(appId) { fetchNui('uninstallApp', { appId }); } + + /* ── NUI event – store data received ────────────────────── */ + function onStoreData(apps) { + _apps = apps || []; + _appsMap = {}; + for (const a of _apps) _appsMap[a.app_id] = a; + _refresh(); + } + + /* ── Open ───────────────────────────────────────────────── */ + function open() { + const created = WindowManager.create({ + id: WIN_ID, + title: 'Software Store', + icon: '🛒', + width: 860, + height: 560, + content: '', + }); + + if (created) { + _apps = []; + _appsMap = {}; + _filter = { category: 'all', search: '', installedOnly: false }; + _refresh(); + fetchNui('getStore', {}); + } + } + + return { open, _setCategory, _setSearch, _toggleInstalled, _install, _uninstall, onStoreData }; +})(); diff --git a/nui/js/apps/webhosting.js b/nui/js/apps/webhosting.js new file mode 100644 index 0000000..d805791 --- /dev/null +++ b/nui/js/apps/webhosting.js @@ -0,0 +1,1489 @@ +/** + * pc-live | Webhosting (ic-web) + * + * Drei Teile in dieser Datei: + * ICWebNet – Transport zum Server (Anfrage-Id → Promise) + * ICWebRender – Blockrenderer, wird auch vom Browser benutzt + * WebhostingApp – die App: Anmeldung, Seiteneditor, Zugänge, Verwaltung + * + * Die gesamte Verwaltung läuft über Anmeldungen. Der Anbieter meldet sich mit + * admin@liveinvader.ls an, legt Domänen an und richtet je Domäne einen Zugang + * ein. Ein Zugang ist zugleich ein Postfach. + * + * WICHTIG: Inhalte kommen von Spielern. Sie werden ausschliesslich über + * textContent und setAttribute gesetzt, niemals über innerHTML. Ein + * eingeschleustes