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.
This commit is contained in:
commit
be502d2834
46 changed files with 13119 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
57
INSTALL.md
Normal file
57
INSTALL.md
Normal file
|
|
@ -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
|
||||
```
|
||||
321
PLUGINS.md
Normal file
321
PLUGINS.md
Normal file
|
|
@ -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: '<div class="app-content" id="notizen-main"></div>',
|
||||
});
|
||||
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
|
||||
<script src="js/apps/notizen.js"></script>
|
||||
```
|
||||
|
||||
**`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.
|
||||
210
README.md
Normal file
210
README.md
Normal file
|
|
@ -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.
|
||||
190
bridge/bridge_esx.lua
Normal file
190
bridge/bridge_esx.lua
Normal file
|
|
@ -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
|
||||
73
bridge/bridge_qbcore.lua
Normal file
73
bridge/bridge_qbcore.lua
Normal file
|
|
@ -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
|
||||
68
bridge/bridge_standalone.lua
Normal file
68
bridge/bridge_standalone.lua
Normal file
|
|
@ -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
|
||||
73
client/bleeter.lua
Normal file
73
client/bleeter.lua
Normal file
|
|
@ -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)
|
||||
45
client/camera.lua
Normal file
45
client/camera.lua
Normal file
|
|
@ -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
|
||||
163
client/interaction.lua
Normal file
163
client/interaction.lua
Normal file
|
|
@ -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()
|
||||
141
client/main.lua
Normal file
141
client/main.lua
Normal file
|
|
@ -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)
|
||||
315
client/nui.lua
Normal file
315
client/nui.lua
Normal file
|
|
@ -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)
|
||||
73
client/webhosting.lua
Normal file
73
client/webhosting.lua
Normal file
|
|
@ -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)
|
||||
116
config.lua
Normal file
116
config.lua
Normal file
|
|
@ -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" },
|
||||
}
|
||||
77
fxmanifest.lua
Normal file
77
fxmanifest.lua
Normal file
|
|
@ -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.
|
||||
694
nui/css/bleeter-embedded.css
Normal file
694
nui/css/bleeter-embedded.css
Normal file
|
|
@ -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;}}
|
||||
116
nui/css/erzeuge-bleeter-css.py
Normal file
116
nui/css/erzeuge-bleeter-css.py
Normal file
|
|
@ -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}')
|
||||
463
nui/css/rp-ds.css
Normal file
463
nui/css/rp-ds.css
Normal file
|
|
@ -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); }
|
||||
856
nui/css/style.css
Normal file
856
nui/css/style.css
Normal file
|
|
@ -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); }
|
||||
107
nui/index.html
Normal file
107
nui/index.html
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>pc-live</title>
|
||||
<link rel="stylesheet" href="css/style.css"/>
|
||||
<link rel="stylesheet" href="css/rp-ds.css">
|
||||
<link rel="stylesheet" href="css/bleeter-embedded.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- DIM OVERLAY (Spielwelt sichtbar, aber leicht abgedunkelt) -->
|
||||
<div id="dim-overlay"></div>
|
||||
|
||||
<!-- PC CONTAINER (schwebendes Panel) -->
|
||||
<div id="pc-container">
|
||||
|
||||
<!-- BOOT SCREEN (absolut positioniert, überdeckt Container während Boot) -->
|
||||
<div id="boot-screen">
|
||||
<div class="boot-logo">⚡ PC-LIVE</div>
|
||||
<div class="boot-bar">
|
||||
<div class="boot-bar-fill" id="boot-bar-fill"></div>
|
||||
</div>
|
||||
<div class="boot-version">OS 1.0.0 · <span id="pc-boot-label">Terminal</span></div>
|
||||
</div>
|
||||
|
||||
<!-- PC TITELLEISTE (Drag, Minimize, Close) -->
|
||||
<div id="pc-titlebar">
|
||||
<span class="pc-title-icon">⚡</span>
|
||||
<span id="pc-label">Terminal</span>
|
||||
<div class="pc-title-controls">
|
||||
<button id="pc-settings-btn" title="Einstellungen" onclick="PCSettings.toggle()">⚙</button>
|
||||
<button id="pc-interact-btn" title="Durchsichtig / im Spiel interagieren" onclick="toggleInteractMode()">👁</button>
|
||||
<button id="pc-minimize-btn" title="Minimize" onclick="toggleMinimizePC()">─</button>
|
||||
<button id="close-pc-btn" title="Close PC" onclick="doClosePC()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EINSTELLUNGEN PANEL -->
|
||||
<div id="pc-settings-panel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-label">Schriftgröße</div>
|
||||
<div class="settings-row">
|
||||
<button class="settings-btn" onclick="PCFontSize.step(-1)" title="Kleiner">A−</button>
|
||||
<input class="settings-slider" type="range" min="11" max="20" step="1"
|
||||
id="pc-fs-slider" oninput="PCFontSize.set(this.value)"/>
|
||||
<button class="settings-btn" onclick="PCFontSize.step(+1)" title="Größer">A+</button>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:0.7rem;color:var(--text-muted)">
|
||||
<span id="pc-fs-display">13px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-label">Fenstergröße</div>
|
||||
<div class="settings-row">
|
||||
<button class="settings-btn" onclick="PCScale.step(-1)" title="Kleiner">−</button>
|
||||
<input class="settings-slider" type="range" min="40" max="98" step="5"
|
||||
id="pc-vp-slider" oninput="PCScale.setVP(this.value)"/>
|
||||
<button class="settings-btn" onclick="PCScale.step(+1)" title="Größer">+</button>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:0.7rem;color:var(--text-muted)">
|
||||
<span id="pc-vp-display">88%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESKTOP -->
|
||||
<div id="desktop">
|
||||
<div id="icon-grid"></div>
|
||||
<div id="window-layer"></div>
|
||||
<div id="taskbar">
|
||||
<div id="taskbar-apps"></div>
|
||||
<div id="taskbar-right">
|
||||
<span id="clock">00:00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /pc-container -->
|
||||
|
||||
<!-- Interaktionsmodus-Hinweis (sichtbar wenn PC transparent/Fokus frei) -->
|
||||
<div id="pc-interact-hint">
|
||||
🎮 Interaktionsmodus aktiv — <b>[END]</b> drücken, um zum PC zurückzukehren
|
||||
</div>
|
||||
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Scripts – order matters -->
|
||||
<script src="js/windowManager.js"></script>
|
||||
<script src="js/apps/browser.js"></script>
|
||||
<script src="js/apps/mail.js"></script>
|
||||
<script src="js/apps/addressbook.js"></script>
|
||||
<script src="js/apps/store.js"></script>
|
||||
<script src="js/apps/parkuhr.js"></script>
|
||||
<script src="js/apps/ic_verwaltung.js"></script>
|
||||
<script src="js/apps/gesetzbuch.js"></script>
|
||||
<script src="js/apps/pbs_dashboard.js"></script>
|
||||
<script src="js/apps/webhosting.js"></script>
|
||||
<script src="js/apps/bleeter.js"></script>
|
||||
<script src="js/apps/bleeteradmin.js"></script>
|
||||
<script src="js/desktop.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
307
nui/js/apps/addressbook.js
Normal file
307
nui/js/apps/addressbook.js
Normal file
|
|
@ -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,'<').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 `<div class="empty-state" style="padding:24px"><div class="empty-icon">👤</div><p>Keine Kontakte</p></div>`;
|
||||
|
||||
return `<div class="mail-list">` + _contacts.map(c => `
|
||||
<div class="mail-item ${_active === c.id ? 'active' : ''}" onclick="AddressBookApp._select(${c.id})">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="contact-avatar" style="background:${avatarColor(c.name)}">${esc(initials(c.name))}</div>
|
||||
<div style="flex:1;overflow:hidden">
|
||||
<div class="mail-from" style="font-weight:600">${esc(c.name)}</div>
|
||||
${c.email ? `<div class="mail-date">${esc(c.email)}</div>` : ''}
|
||||
${c.phone ? `<div class="mail-date">${esc(c.phone)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('') + `</div>`;
|
||||
}
|
||||
|
||||
/* ── Detail / Edit ────────────────────────────────────── */
|
||||
function renderDetail() {
|
||||
if (_editing) {
|
||||
const d = _editData || {};
|
||||
return `
|
||||
<div class="app-content fade-in" style="padding:20px;max-width:480px">
|
||||
<h3 style="color:#fff;font-size:.9rem;margin-bottom:14px">
|
||||
${d.id ? '✏ Kontakt bearbeiten' : '➕ Neuer Kontakt'}
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input id="ab-name" type="text" value="${esc(d.name||'')}" maxlength="100" placeholder="Max Mustermann"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>E-Mail / Identifier</label>
|
||||
<input id="ab-email" type="text" value="${esc(d.email||'')}" maxlength="120" placeholder="license:abc… oder email@…"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telefon</label>
|
||||
<input id="ab-phone" type="text" value="${esc(d.phone||'')}" maxlength="30" placeholder="+49 123 456789"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notizen</label>
|
||||
<textarea id="ab-notes" rows="4" maxlength="500" placeholder="Notizen…">${esc(d.notes||'')}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-primary" onclick="AddressBookApp._save()">💾 Speichern</button>
|
||||
<button class="btn-ghost" onclick="AddressBookApp._cancelEdit()">Abbrechen</button>
|
||||
</div>
|
||||
<div id="ab-status" style="font-size:.72rem;margin-top:6px;color:var(--error)"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const c = _contacts.find(x => x.id === _active);
|
||||
if (!c)
|
||||
return `<div class="empty-state"><div class="empty-icon">👤</div><p>Kontakt auswählen</p></div>`;
|
||||
|
||||
const bg = avatarColor(c.name);
|
||||
return `
|
||||
<div class="mail-detail fade-in" style="padding:24px">
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||||
<div class="contact-avatar contact-avatar-lg" style="background:${bg}">${esc(initials(c.name))}</div>
|
||||
<div>
|
||||
<div style="font-size:1.1rem;font-weight:700;color:#fff">${esc(c.name)}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted)">Hinzugefügt: ${fmtDate(c.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-field-group">
|
||||
${c.email ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">✉ E-Mail / ID</span>
|
||||
<span class="contact-field-value">${esc(c.email)}</span>
|
||||
</div>` : ''}
|
||||
${c.phone ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">📞 Telefon</span>
|
||||
<span class="contact-field-value">${esc(c.phone)}</span>
|
||||
</div>` : ''}
|
||||
${c.notes ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">📝 Notizen</span>
|
||||
<span class="contact-field-value" style="white-space:pre-wrap">${esc(c.notes)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:20px;flex-wrap:wrap">
|
||||
${c.email ? `
|
||||
<button class="btn-primary" onclick="AddressBookApp._mailTo('${esc(c.email)}')">
|
||||
✉ Mail schreiben
|
||||
</button>
|
||||
<button class="btn-ghost" onclick="AddressBookApp._forwardContact(${c.id})">
|
||||
📤 Kontakt weiterleiten
|
||||
</button>` : ''}
|
||||
<button class="btn-ghost" onclick="AddressBookApp._startEdit(${c.id})">✏ Bearbeiten</button>
|
||||
<button class="btn-danger" onclick="AddressBookApp._delete(${c.id})">🗑 Löschen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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 = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
<div class="app-sidebar" style="min-width:220px;max-width:250px">
|
||||
<div class="sidebar-section">Kontakte (${_contacts.length})</div>
|
||||
<div style="padding:6px 10px">
|
||||
<button class="btn-primary" style="width:100%;font-size:.75rem"
|
||||
onclick="AddressBookApp._startEdit(null)">➕ Neuer Kontakt</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;flex:1">
|
||||
${renderList()}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto">
|
||||
${renderDetail()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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,
|
||||
};
|
||||
})();
|
||||
1420
nui/js/apps/bleeter.js
Normal file
1420
nui/js/apps/bleeter.js
Normal file
File diff suppressed because it is too large
Load diff
481
nui/js/apps/bleeteradmin.js
Normal file
481
nui/js/apps/bleeteradmin.js
Normal file
|
|
@ -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: `
|
||||
<div class="wh-root">
|
||||
<div class="wh-side" id="ba-side"></div>
|
||||
<div class="wh-main" id="ba-main"></div>
|
||||
</div>`,
|
||||
});
|
||||
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 };
|
||||
})();
|
||||
538
nui/js/apps/browser.js
Normal file
538
nui/js/apps/browser.js
Normal file
|
|
@ -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: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div class="ic-home-hero">
|
||||
<h1>⚡ IC Browser</h1>
|
||||
<p style="color:var(--text-muted);font-size:.82rem">Browse the internal network. Enter an <code>ic://</code> address above.</p>
|
||||
<div class="ic-home-links">
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://dpa')">📰 DPA News</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://police')">🚔 LSPD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://ambulance')">🚑 LSMD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://fire')">🚒 LSFD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://doj')">⚖️ DOJ</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://parking')">🅿️ Parking</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://weazel')">📺 Weazel News</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://classifieds')">📋 LS Classifieds</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
dpa: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>📰 DPA – Deutsche Presse-Agentur</h1>
|
||||
<div class="article">
|
||||
<div class="article-date">Today, 09:15</div>
|
||||
<h2>New residential district approved in Vinewood Hills</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Today, 07:42</div>
|
||||
<h2>Port Authority reports record cargo volume</h2>
|
||||
<p>Los Santos Port Authority announced a 14% increase in cargo processing year-over-year, citing improved logistics infrastructure and expanded berth capacity.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Yesterday, 18:00</div>
|
||||
<h2>Traffic advisory: Freeway 1 maintenance</h2>
|
||||
<p>LSDOT warns of lane closures on Freeway 1 near Palomino Highlands between 22:00 and 06:00 for the next three nights.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
psb: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>🚔 Los Santos Police Service Board</h1>
|
||||
<p>Official information and public notices from the Los Santos Police Department.</p>
|
||||
<div class="article">
|
||||
<div class="article-date">Official Notice</div>
|
||||
<h2>Public Safety Campaign – Q1</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Recruitment</div>
|
||||
<h2>LSPD Cadet Applications Open</h2>
|
||||
<p>Applications for the upcoming cadet class are now open. Qualified candidates should contact the department for details on requirements and the application process.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
weazel: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>📺 Weazel News</h1>
|
||||
<p style="color:var(--accent);font-size:.72rem;margin-bottom:16px">Los Santos' #1 Trusted News Source</p>
|
||||
<div class="article">
|
||||
<div class="article-date">Breaking</div>
|
||||
<h2>Explosion reported near LSIA</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Entertainment</div>
|
||||
<h2>Vinewood Music Awards – Full List of Nominees</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
// ── Org pages ────────────────────────────────────────────
|
||||
org_police: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚔</span>
|
||||
<div><h1 style="margin:0">Los Santos Police Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://police · pd.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">Notruf 110</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Polizeilicher Notruf</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@pd.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Offizielles Aktensystem</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Dienstlich</div>
|
||||
<h2>Strafverfolgung & Öffentliche Sicherheit</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Bewerbungen</div>
|
||||
<h2>Stellenausschreibung – Cadet Program</h2>
|
||||
<p>Das LSPD nimmt regelmäßig neue Bewerber für das Cadet-Programm an. Kontakt über das offizielle Aktensystem oder persönlich beim Department.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_ambulance: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚑</span>
|
||||
<div><h1 style="margin:0">Los Santos Medical Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://ambulance · md.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:#e53935;margin:6px 0 2px">Notruf 911</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Medizinischer Notfall</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@md.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Patientenakten & Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Medizinische Versorgung</div>
|
||||
<h2>Notfallversorgung rund um die Uhr</h2>
|
||||
<p>Das LSMD bietet medizinische Erstversorgung, Krankentransport und chirurgische Eingriffe. Im Notfall bitte sofort den Notruf 911 wählen.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_fire: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚒</span>
|
||||
<div><h1 style="margin:0">Los Santos Fire Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://fire · fd.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:#ff6f00;margin:6px 0 2px">Notruf 112</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Feuerwehr & Technische Hilfe</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@fd.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Offizielle Korrespondenz</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Aufgaben</div>
|
||||
<h2>Brandbekämpfung & Technische Hilfe</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_doj: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">⚖️</span>
|
||||
<div><h1 style="margin:0">Department of Justice</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://doj · doj.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">Hotline 200</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">DOJ Auskunft</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@doj.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Juristische Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Justizsystem</div>
|
||||
<h2>Strafrecht & Zivilrecht in Los Santos</h2>
|
||||
<p>Das Department of Justice verwaltet das Rechtssystem von Los Santos. Anwälte, Richter und Staatsanwälte koordinieren alle rechtlichen Vorgänge über dieses Department.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_dpa: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🏛️</span>
|
||||
<div><h1 style="margin:0">Department of Public Administration</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://dpa · dpa.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">public@dpa.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Öffentliche Anfragen</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">traffic@dpa.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Verkehr & Infrastruktur</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Verwaltung</div>
|
||||
<h2>Staatliche Verwaltung & Öffentlicher Dienst</h2>
|
||||
<p>Die DPA koordiniert Verwaltungsaufgaben, Genehmigungen und öffentliche Dienstleistungen. Anfragen können per Mail oder telefonisch gestellt werden.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_parking: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🅿️</span>
|
||||
<div><h1 style="margin:0">Parkraumüberwachung Los Santos</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://parking · parking.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@parking.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Einsprüche & Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Information</div>
|
||||
<h2>Knöllchen & Einsprüche</h2>
|
||||
<p>Bei Fragen zu Knöllchen oder Einsprüchen bitte schriftlich über aktensystem@parking.gov oder persönlich am Schalter.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
|
||||
classifieds: () => `
|
||||
<div class="ic-page fade-in">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background:linear-gradient(135deg,rgba(0,170,255,0.07) 0%,transparent 70%);
|
||||
border:1px solid var(--border);border-radius:8px;
|
||||
padding:22px 24px;text-align:center;margin-bottom:22px">
|
||||
<div style="font-size:1.7rem;font-weight:700;color:var(--accent);letter-spacing:.06em">
|
||||
📋 LS CLASSIFIEDS
|
||||
</div>
|
||||
<div style="color:var(--text-muted);font-size:.74rem;margin-top:5px">
|
||||
Los Santos' largest private marketplace · <span style="color:var(--text-dim)">ic://classifieds</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category tiles -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:26px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🚗</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Vehicles</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">47 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🏠</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Real Estate</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">23 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">💼</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Jobs</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">12 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🔧</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Services</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">31 listings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Featured -->
|
||||
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.12em;color:var(--text-dim);
|
||||
margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)">
|
||||
⭐ Featured Listings
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(235px,1fr));gap:10px;margin-bottom:24px">
|
||||
|
||||
<!-- Listing: Car -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🚗</span>
|
||||
<span class="tag tag-accent" style="font-size:.6rem">FEATURED</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">2023 Ocelot Pariah</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Low mileage, fully tuned at Benny's. Custom matte black wrap. Clean history, no damage.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$285,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Vinewood · Posted 2h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Motorcycle -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏍</span>
|
||||
<span class="tag">Vehicles</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Western Daemon Custom</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Full performance upgrade, custom exhaust. Runs clean. Selling due to relocation.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$68,500</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Strawberry · Posted 6h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: House -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏠</span>
|
||||
<span class="tag">Real Estate</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">3-Bed House · Mirror Park</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Modern detached house, double garage, pool. Move-in ready. Quiet, well-connected area.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$1,200,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Mirror Park · Posted 5h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Job -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">💼</span>
|
||||
<span class="tag" style="color:var(--success);border-color:rgba(34,197,94,.3)">Jobs</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Mechanic – Premium Auto</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Looking for an experienced mechanic. Flexible shifts, good cut. Experience required.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--success);font-weight:700">$4,500 / week</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 LSIA Area · Posted 1d ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Apartment -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏢</span>
|
||||
<span class="tag">Real Estate</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Penthouse · Alta St.</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Floor 28, panoramic city view, 2 bed/2 bath, concierge. Fully furnished, immediate occupancy.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$3,800,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Downtown LS · Posted 12h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Service -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🔧</span>
|
||||
<span class="tag">Services</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">24/7 Mobile Mechanic</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
On-call repairs, towing, custom builds. Competitive rates. Contact via IC mail.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--text-muted);font-weight:600">Contact via mail</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Citywide · Posted 3d ago</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align:center;color:var(--text-dim);font-size:.62rem;
|
||||
border-top:1px solid var(--border);padding-top:12px">
|
||||
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 <span style="color:var(--text-muted)">classifieds@ls-net.ic</span>
|
||||
</div>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
|
||||
/* 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 `
|
||||
<div class="browser-404 fade-in">
|
||||
<h2>404</h2>
|
||||
<p>Page not found: <code>ic://${slug}</code></p>
|
||||
<p>Check the address and try again.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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 =
|
||||
`<iframe class="browser-iframe"
|
||||
src="${url.replace(/"/g, '"')}"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
allowfullscreen></iframe>`;
|
||||
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 = '<div class="empty-state"><div class="empty-icon">⏳</div><p>Loading…</p></div>';
|
||||
|
||||
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 = `<div class="ic-page fade-in"><h1>${p.title || slug}</h1><p>This page has no content yet.</p></div>`;
|
||||
}
|
||||
} 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: `
|
||||
<div class="browser-chrome">
|
||||
<button class="browser-nav-btn" id="browser-back" onclick="BrowserApp.goBack()" title="Zurück">◀</button>
|
||||
<button class="browser-nav-btn" id="browser-fwd" onclick="BrowserApp.goForward()" title="Vor">▶</button>
|
||||
<button class="browser-nav-btn" id="browser-refresh" onclick="BrowserApp.refresh()" title="Neu laden">↺</button>
|
||||
<input id="browser-address" class="browser-address-input" type="text"
|
||||
placeholder="ic://home oder https://…"
|
||||
onkeydown="if(event.key==='Enter')BrowserApp.navigate(this.value)"/>
|
||||
<button class="browser-nav-btn browser-go-btn"
|
||||
onclick="BrowserApp.navigate(document.getElementById('browser-address').value)"
|
||||
title="Aufrufen">→</button>
|
||||
</div>
|
||||
<div class="browser-viewport fade-in" id="browser-viewport"></div>
|
||||
`,
|
||||
});
|
||||
|
||||
if (!el) return;
|
||||
navigate(startUrl || 'ic://home');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (histIdx >= 0) navigate(history[histIdx]);
|
||||
}
|
||||
|
||||
return { open, navigate, goBack, goForward, refresh };
|
||||
})();
|
||||
26
nui/js/apps/gesetzbuch.js
Normal file
26
nui/js/apps/gesetzbuch.js
Normal file
|
|
@ -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: `<iframe
|
||||
src="${URL}"
|
||||
style="flex:1;width:100%;height:100%;border:none;display:block;background:#0f172a"
|
||||
allowfullscreen
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>`,
|
||||
});
|
||||
}
|
||||
|
||||
return { open };
|
||||
})();
|
||||
74
nui/js/apps/ic_verwaltung.js
Normal file
74
nui/js/apps/ic_verwaltung.js
Normal file
|
|
@ -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 = `
|
||||
<iframe
|
||||
id="${IFRAME_ID}"
|
||||
src="${IC_ORIGIN}/html/index.html"
|
||||
style="width:100%;height:100%;border:none;display:block;background:#1a1a2e;"
|
||||
></iframe>`;
|
||||
|
||||
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 };
|
||||
})();
|
||||
1153
nui/js/apps/mail.js
Normal file
1153
nui/js/apps/mail.js
Normal file
File diff suppressed because it is too large
Load diff
344
nui/js/apps/parkuhr.js
Normal file
344
nui/js/apps/parkuhr.js
Normal file
|
|
@ -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 `
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:12px">
|
||||
<div style="font-size:.63rem;color:var(--text-dim);margin-bottom:4px">${icon} ${label}</div>
|
||||
<div style="font-size:1.05rem;font-weight:700;color:${color}">${value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Overview page ───────────────────────────────────────── */
|
||||
function renderOverview() {
|
||||
if (!_data) {
|
||||
return `<div class="empty-state"><div class="empty-icon">⏳</div><p>Daten werden geladen…</p></div>`;
|
||||
}
|
||||
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 `
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- KPI row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||||
${kpiCard('💰', 'Gesamteinnahmen', fmtMoney(totalRevenue, cur), 'var(--accent)')}
|
||||
${kpiCard('📋', 'Transaktionen', totalSales)}
|
||||
${kpiCard('🏧', 'Automaten', machineCount)}
|
||||
${kpiCard('🅿', 'Parkuhren', meterCount)}
|
||||
</div>
|
||||
|
||||
<!-- Device revenue bar -->
|
||||
${devices.length > 0 ? `
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">Gerät-Einnahmen</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
${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 `
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:.72rem;cursor:pointer"
|
||||
onclick="ParkuhrApp._select(${d.id})">
|
||||
<span style="min-width:60px;color:var(--text-muted)">${meta.icon} #${d.id}</span>
|
||||
<div style="flex:1;background:var(--bg-input);border-radius:99px;height:6px;overflow:hidden">
|
||||
<div style="width:${pct}%;height:100%;background:${meta.color};border-radius:99px"></div>
|
||||
</div>
|
||||
<span style="min-width:70px;text-align:right;color:var(--text)">${fmtMoney(rev, cur)}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Recent transactions -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">Letzte Transaktionen</div>
|
||||
${recent.length === 0
|
||||
? `<div class="empty-state" style="padding:20px"><p>Keine Transaktionen vorhanden</p></div>`
|
||||
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||||
${recent.map(s => {
|
||||
const meta = TYPE_META[s.devType] || { icon: '📍' };
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||||
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||||
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||||
<span class="tag" style="font-size:.58rem">${meta.icon} #${s.devId}</span>
|
||||
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||||
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||||
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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 `
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- Device info card -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:14px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||||
<span style="font-size:2rem">${meta.icon}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:.92rem;font-weight:700;color:#fff">Gerät #${device.id}</div>
|
||||
<div style="font-size:.7rem;color:var(--text-muted)">
|
||||
${meta.label} · <span style="color:var(--text-dim)">${device.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:1.1rem;font-weight:700;color:${meta.color}">${fmtMoney(device.revenue, cur)}</div>
|
||||
<div style="font-size:.62rem;color:var(--text-dim)">Kassenstand</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:.68rem;color:var(--text-dim);
|
||||
border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||||
<span>📍 ${fmtPos(device.pos)}</span>
|
||||
<span>👤 ${device.placed_by || '—'}</span>
|
||||
<span>📋 ${device.sales?.length || 0} Verkäufe (gespeichert)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tariffs -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 8px">
|
||||
Tarife
|
||||
${isMach
|
||||
? '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(gerätespezifisch)</span>'
|
||||
: '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(Typstandard, nicht editierbar)</span>'}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:${isMach ? '10px' : '0'}">
|
||||
${tariffList.length === 0
|
||||
? `<span style="color:var(--text-dim);font-size:.73rem">Keine Tarife definiert</span>`
|
||||
: tariffList.map(t => {
|
||||
const dur = t.duration ?? t.duration_min;
|
||||
return `
|
||||
<div style="background:var(--bg-input);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:8px 10px;text-align:center;min-width:76px">
|
||||
<div style="font-size:.65rem;color:var(--text-dim)">${fmtDuration(dur)}</div>
|
||||
<div style="font-size:.9rem;font-weight:700;color:${meta.color};margin:2px 0">${fmtMoney(t.price, cur)}</div>
|
||||
${isMach ? `
|
||||
<button class="btn-danger btn-sm" style="padding:1px 5px;font-size:.58rem;margin-top:2px"
|
||||
onclick="ParkuhrApp._deleteTariff(${device.id},${dur})">✕</button>` : ''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
${isMach ? `
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<select id="tariff-dur" style="width:88px;height:28px;font-size:.72rem;padding:0 6px">
|
||||
<option value="60">1 Stunde</option>
|
||||
<option value="1440">1 Tag</option>
|
||||
<option value="10080">7 Tage</option>
|
||||
</select>
|
||||
<input id="tariff-price" type="number" min="0" step="0.01" placeholder="Preis (${cur})"
|
||||
style="width:110px;height:28px;padding:0 8px;font-size:.72rem"/>
|
||||
<button class="btn-primary btn-sm" onclick="ParkuhrApp._saveTariff(${device.id})">
|
||||
Tarif speichern
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Sales history -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">
|
||||
Verkaufshistorie (letzte ${sales.length})
|
||||
</div>
|
||||
${sales.length === 0
|
||||
? `<div class="empty-state" style="padding:16px"><p>Keine Einträge</p></div>`
|
||||
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||||
${sales.map(s => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||||
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||||
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||||
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||||
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||||
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||||
</span>
|
||||
</div>`).join('')}
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Full window render ──────────────────────────────────── */
|
||||
function _refresh() {
|
||||
const body = WindowManager.getBody(WIN_ID);
|
||||
if (!body) return;
|
||||
|
||||
const devices = _data?.devices || [];
|
||||
const cur = _data?.currency || '$';
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="app-sidebar" style="width:170px;overflow-y:auto">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 14px 4px">
|
||||
<span class="sidebar-section" style="padding:0;font-size:.6rem">
|
||||
Geräte (${devices.length})
|
||||
</span>
|
||||
<button class="btn-ghost btn-sm" style="height:20px;padding:0 6px;font-size:.65rem"
|
||||
title="Aktualisieren" onclick="ParkuhrApp._reload()">↻</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item ${_selectedDevice === null ? 'active' : ''}"
|
||||
onclick="ParkuhrApp._select(null)">
|
||||
📊 Übersicht
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
${devices.length === 0
|
||||
? `<div style="padding:8px 14px;font-size:.7rem;color:var(--text-dim)">Keine Geräte</div>`
|
||||
: devices.map(d => {
|
||||
const m = TYPE_META[d.type] || { icon: '📍', label: d.type };
|
||||
const sel = _selectedDevice?.id === d.id;
|
||||
return `
|
||||
<div class="sidebar-item ${sel ? 'active' : ''}"
|
||||
onclick="ParkuhrApp._select(${d.id})">
|
||||
<div>
|
||||
<div style="font-size:.72rem;font-weight:600">${m.icon} #${d.id}</div>
|
||||
<div style="font-size:.6rem;color:var(--text-dim)">${fmtMoney(d.revenue, cur)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="app-content">
|
||||
${_selectedDevice === null ? renderOverview() : renderDevice(_selectedDevice)}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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 = `<div class="empty-state"><div class="empty-icon">⏳</div><p>Aktualisiere…</p></div>`;
|
||||
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 };
|
||||
})();
|
||||
30
nui/js/apps/pbs_dashboard.js
Normal file
30
nui/js/apps/pbs_dashboard.js
Normal file
|
|
@ -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: `<iframe
|
||||
src="${url}"
|
||||
style="flex:1;width:100%;height:100%;border:none;display:block;background:#0f172a"
|
||||
allowfullscreen
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>`,
|
||||
});
|
||||
}
|
||||
|
||||
return { open };
|
||||
})();
|
||||
192
nui/js/apps/store.js
Normal file
192
nui/js/apps/store.js
Normal file
|
|
@ -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 `
|
||||
<div class="app-sidebar">
|
||||
<div class="sidebar-section">Categories</div>
|
||||
${cats.map(c => {
|
||||
const m = CATEGORY_META[c] || { icon: '📂', label: c };
|
||||
return `
|
||||
<div class="sidebar-item ${_filter.category === c ? 'active' : ''}"
|
||||
onclick="StoreApp._setCategory('${c}')">
|
||||
${m.icon} ${m.label}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<button class="btn-primary btn-sm" onclick="StoreApp._install('${app.app_id}')">↑ Update</button>`;
|
||||
} else {
|
||||
action = `<button class="btn-ghost btn-sm" style="cursor:default;opacity:.5" disabled>✓ Installed</button>`;
|
||||
}
|
||||
if (!app.default) {
|
||||
action += `<button class="btn-danger btn-sm" onclick="StoreApp._uninstall('${app.app_id}')">✕</button>`;
|
||||
}
|
||||
} else {
|
||||
const locked = missingDeps.length > 0;
|
||||
action = `<button class="btn-primary btn-sm"
|
||||
${locked ? 'style="opacity:.4" title="Missing: ' + missingDeps.join(', ') + '"' : ''}
|
||||
onclick="StoreApp._install('${app.app_id}')">Install</button>`;
|
||||
}
|
||||
|
||||
// Info badges
|
||||
let badges = `<span class="tag">${catMeta.icon} ${app.category || 'other'}</span>`;
|
||||
badges += `<span class="tag">v${app.version || '?'}</span>`;
|
||||
if (app.installed) badges += `<span class="tag tag-accent">✓</span>`;
|
||||
if (app.update_available) badges += `<span class="tag" style="color:var(--warning);border-color:rgba(245,158,11,.4)">↑ Update</span>`;
|
||||
if (app.permissions?.job) badges += `<span class="tag" style="color:var(--text-dim)">🔑 ${app.permissions.job}</span>`;
|
||||
if (missingDeps.length) badges += `<span class="tag" style="color:var(--error);border-color:rgba(239,68,68,.3)">⚠ Needs: ${missingDeps.join(', ')}</span>`;
|
||||
|
||||
return `
|
||||
<div class="store-card ${app.installed ? 'store-card--installed' : ''}">
|
||||
<div class="store-card-icon">${icon}</div>
|
||||
<div class="store-card-name">${app.name || app.app_id}</div>
|
||||
<div class="store-card-desc">${app.description || ''}</div>
|
||||
<div class="store-card-footer"><div style="display:flex;gap:4px;flex-wrap:wrap">${badges}</div></div>
|
||||
<div class="store-card-footer">
|
||||
<span class="store-price ${priceClass}">${priceLabel}</span>
|
||||
<div style="display:flex;gap:4px">${action}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
${renderSidebar()}
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div class="app-content" style="flex:1">
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="Search apps…"
|
||||
value="${(_filter.search || '').replace(/"/g, '"')}"
|
||||
oninput="StoreApp._setSearch(this.value)" id="store-search"/>
|
||||
<button class="btn-sm ${_filter.installedOnly ? 'btn-primary' : 'btn-ghost'}"
|
||||
onclick="StoreApp._toggleInstalled()" title="Show installed only">
|
||||
✓ Installed
|
||||
</button>
|
||||
</div>
|
||||
${list.length === 0
|
||||
? `<div class="empty-state"><div class="empty-icon">📦</div><p>No apps found</p></div>`
|
||||
: `<div class="store-grid">${list.map(renderCard).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="store-statusbar">
|
||||
📦 <strong>${instCount}</strong> installed · ${totalCount} available
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── 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 };
|
||||
})();
|
||||
1489
nui/js/apps/webhosting.js
Normal file
1489
nui/js/apps/webhosting.js
Normal file
File diff suppressed because it is too large
Load diff
452
nui/js/desktop.js
Normal file
452
nui/js/desktop.js
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/**
|
||||
* pc-live | Desktop – boot, icon grid, clock, message router
|
||||
*/
|
||||
|
||||
/* ── NUI fetch helper ───────────────────────────────────── */
|
||||
async function fetchNui(event, data = {}) {
|
||||
try {
|
||||
const resp = await fetch(`https://pc-live/${event}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.ok ? await resp.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State ──────────────────────────────────────────────── */
|
||||
const Desktop = (() => {
|
||||
let _session = null;
|
||||
let _clockTimer = null;
|
||||
let _installedApps = [];
|
||||
|
||||
const APP_META = {
|
||||
'pc.browser': { name: 'Browser', icon: '🌐', open: () => BrowserApp.open() },
|
||||
'pc.mail': { name: 'Mail', icon: '✉', open: () => MailApp.open() },
|
||||
'pc.addressbook': { name: 'Adressbuch', icon: '👤', open: () => AddressBookApp.open() },
|
||||
'pc.store': { name: 'Software Store', icon: '🛒', open: () => StoreApp.open() },
|
||||
'pc.parkuhr': { name: 'Parkuhr Dashboard', icon: '🅿', open: () => ParkuhrApp.open() },
|
||||
'pc.ic_verwaltung': { name: 'IC-Verwaltung', icon: '🏛', open: () => ICVerwaltungApp.open() },
|
||||
'pc.gesetzbuch': { name: 'Gesetzbuch', icon: '📖', open: () => GesetzbuchApp.open() },
|
||||
'pbs.dashboard': { name: 'PBS Dashboard', icon: '📞', open: () => PBSDashboardApp.open() },
|
||||
'pc.webhosting': { name: 'Webhosting', icon: '🌍', open: () => WebhostingApp.open() },
|
||||
'pc.bleeter': { name: 'Bleeter', icon: '🐦', open: () => BleeterApp.open() },
|
||||
'pc.bleeteradmin': { name: 'Bleeter-Verwaltung', icon: '🛠', open: () => BleeterAdminApp.open() },
|
||||
};
|
||||
|
||||
/* ── Boot sequence ────────────────────────────────────── */
|
||||
function boot(session) {
|
||||
_session = session;
|
||||
MailApp.initMailboxes(session.sharedMailboxes || [], session.personalAddress || '',
|
||||
session.personalSignature || '');
|
||||
// Freigeschaltete Postfächer nachladen, damit die ✕-Knöpfe stimmen.
|
||||
MailApp.refreshMailboxes();
|
||||
|
||||
// Apply accent color
|
||||
if (session.accentColor) {
|
||||
document.documentElement.style.setProperty('--accent', session.accentColor);
|
||||
}
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const bootEl = document.getElementById('boot-screen');
|
||||
const fill = document.getElementById('boot-bar-fill');
|
||||
const desktopEl = document.getElementById('desktop');
|
||||
|
||||
// Set labels in titlebar and boot screen
|
||||
const label = session.pcLabel || 'Terminal';
|
||||
const pcLabel = document.getElementById('pc-label');
|
||||
const pcBootLabel = document.getElementById('pc-boot-label');
|
||||
if (pcLabel) pcLabel.textContent = label;
|
||||
if (pcBootLabel) pcBootLabel.textContent = label;
|
||||
|
||||
app.classList.add('visible');
|
||||
|
||||
// Animate progress bar
|
||||
const duration = 2500;
|
||||
fill.style.transition = `width ${duration}ms linear`;
|
||||
requestAnimationFrame(() => { fill.style.width = '100%'; });
|
||||
|
||||
setTimeout(() => {
|
||||
bootEl.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
bootEl.style.display = 'none';
|
||||
desktopEl.classList.add('visible');
|
||||
startClock();
|
||||
renderIcons(session.installedApps || []);
|
||||
// Startseite automatisch öffnen falls konfiguriert
|
||||
if (session.startPage) {
|
||||
BrowserApp.open(session.startPage);
|
||||
}
|
||||
// Auto-Open-App (z.B. mobiler Streifenwagen-Terminal → IC-Verwaltung/Personalakte)
|
||||
if (session.autoOpenApp) {
|
||||
openApp(session.autoOpenApp);
|
||||
}
|
||||
}, 600);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/* ── Clock ────────────────────────────────────────────── */
|
||||
function startClock() {
|
||||
const el = document.getElementById('clock');
|
||||
const tick = () => {
|
||||
if (!el) return;
|
||||
const now = new Date();
|
||||
el.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
_clockTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function stopClock() {
|
||||
clearInterval(_clockTimer);
|
||||
_clockTimer = null;
|
||||
}
|
||||
|
||||
/* ── Desktop Icons ────────────────────────────────────── */
|
||||
function renderIcons(apps) {
|
||||
_installedApps = apps || [];
|
||||
const grid = document.getElementById('icon-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
|
||||
_installedApps.forEach(app => {
|
||||
const meta = APP_META[app.app_id] || {};
|
||||
const icon = meta.icon || app.icon || '📦';
|
||||
const name = meta.name || app.name || app.app_id;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'desktop-icon';
|
||||
div.innerHTML = `
|
||||
<div class="icon-glyph">${icon}</div>
|
||||
<div class="icon-label">${name}</div>`;
|
||||
div.addEventListener('dblclick', () => openApp(app.app_id));
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function openApp(appId) {
|
||||
const meta = APP_META[appId];
|
||||
if (meta && meta.open) {
|
||||
meta.open();
|
||||
} else {
|
||||
showNotification('⚠ App not yet available');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Close ────────────────────────────────────────────── */
|
||||
function close() {
|
||||
WindowManager.closeAll();
|
||||
stopClock();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const bootEl = document.getElementById('boot-screen');
|
||||
const desktopEl = document.getElementById('desktop');
|
||||
const fill = document.getElementById('boot-bar-fill');
|
||||
const grid = document.getElementById('icon-grid');
|
||||
const taskbar = document.getElementById('taskbar-apps');
|
||||
const container = document.getElementById('pc-container');
|
||||
const minBtn = document.getElementById('pc-minimize-btn');
|
||||
|
||||
desktopEl.classList.remove('visible');
|
||||
bootEl.style.display = 'flex';
|
||||
bootEl.classList.remove('fade-out');
|
||||
fill.style.transition = '';
|
||||
fill.style.width = '0%';
|
||||
if (grid) grid.innerHTML = '';
|
||||
if (taskbar) taskbar.innerHTML = '';
|
||||
|
||||
// Reset PC container position and minimized state
|
||||
if (container) {
|
||||
container.classList.remove('minimized');
|
||||
container.style.left = '50%';
|
||||
container.style.top = '50%';
|
||||
container.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
if (minBtn) { minBtn.textContent = '─'; minBtn.title = 'Minimize'; }
|
||||
|
||||
app.classList.remove('visible');
|
||||
app.classList.remove('interact-mode');
|
||||
_session = null;
|
||||
}
|
||||
|
||||
/* ── Message handler ───────────────────────────────────── */
|
||||
window.addEventListener('message', (e) => {
|
||||
const { action, data } = e.data || {};
|
||||
switch (action) {
|
||||
case 'boot':
|
||||
boot(data);
|
||||
break;
|
||||
case 'close':
|
||||
close();
|
||||
break;
|
||||
case 'exitInteract':
|
||||
exitInteractMode();
|
||||
break;
|
||||
case 'setInstalledApps':
|
||||
renderIcons(data);
|
||||
break;
|
||||
case 'setAccentColor':
|
||||
document.documentElement.style.setProperty('--accent', data.color);
|
||||
break;
|
||||
|
||||
// Mail
|
||||
case 'setInbox': MailApp.onInbox(data); break;
|
||||
case 'setMailContent': MailApp.onMailContent(data); break;
|
||||
case 'mailSent': MailApp.onMailSent(); break;
|
||||
case 'mailDeleted': MailApp.onMailDeleted(data.id); break;
|
||||
case 'newMailNotify':
|
||||
// Show unread badge on desktop icon (quick visual cue)
|
||||
showNotification('📧 New mail received');
|
||||
break;
|
||||
|
||||
// Store
|
||||
case 'setStoreList': StoreApp.onStoreData(data); break;
|
||||
|
||||
// Mail extended
|
||||
case 'setSent': MailApp.onSetSent(data); break;
|
||||
case 'folderCreated': MailApp.onFolderCreated(data); break;
|
||||
case 'folderDeleted': MailApp.onFolderDeleted(data.id, data.address); break;
|
||||
case 'foldersData': MailApp.onFoldersData(data.address, data.folders); break;
|
||||
case 'folderMailsData': MailApp.onFolderMailsData(data.folderId, data.rows); break;
|
||||
case 'mailMoved': MailApp.onMailMoved(data.id, data.folderId); break;
|
||||
|
||||
// Geteilte Postfächer
|
||||
case 'sharedMailboxes': MailApp.onSharedMailboxes(data.mailboxes, data.personalAddress,
|
||||
data.personalSignature); break;
|
||||
case 'signatureSaved': MailApp.onSignatureSaved(data); break;
|
||||
|
||||
// Mail-Einrichtung
|
||||
case 'mailAddressStatus': MailApp.onMailAddressStatus(data); break;
|
||||
case 'mailCreateResult': MailApp.onMailCreateResult(data); break;
|
||||
|
||||
// Calendar
|
||||
case 'calendarData': MailApp.onCalendarData(data); break;
|
||||
case 'calendarEventAdded': MailApp.onCalendarEventAdded(data); break;
|
||||
case 'calendarEventUpdated': MailApp.onCalendarEventUpdated(data); break;
|
||||
case 'calendarEventDeleted': MailApp.onCalendarEventDeleted(data.id); break;
|
||||
|
||||
// Contacts
|
||||
case 'contactsData': AddressBookApp.onContactsData(data); break;
|
||||
case 'contactAdded': AddressBookApp.onContactAdded(data); break;
|
||||
case 'contactUpdated': AddressBookApp.onContactUpdated(data); break;
|
||||
case 'contactDeleted': AddressBookApp.onContactDeleted(data.id); break;
|
||||
|
||||
// IC-Verwaltung relay: forward Lua SendNUIMessage into the iframe
|
||||
case 'ic_verwaltung_relay': ICVerwaltungApp.onRelay(data); break;
|
||||
|
||||
// Webhosting (ic-web): Antwort auf eine Anfrage mit Anfrage-Id
|
||||
case 'icweb_response': ICWebNet.onResponse(data); break;
|
||||
// Bleeter schickt alles über einen Kanal: Zustand und Verwaltungsantworten.
|
||||
case 'bleeter_data': BleeterApp.onData(data); break;
|
||||
case 'bleeter_notify': BleeterApp.onNotify(data); break;
|
||||
case 'bleeter_uploaded': BleeterApp.onUploaded(data); break;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Keyboard: Escape closes PC ────────────────────────── */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && document.getElementById('desktop')?.classList.contains('visible')) {
|
||||
doClosePC();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Notification toast ─────────────────────────────────── */
|
||||
function showNotification(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `
|
||||
position:fixed;top:50px;right:16px;z-index:9000;
|
||||
background:var(--bg-window);border:1px solid var(--border);
|
||||
border-left:3px solid var(--accent);
|
||||
border-radius:var(--radius);padding:10px 14px;
|
||||
font-size:.78rem;color:var(--text);
|
||||
box-shadow:var(--shadow);
|
||||
animation:fadeIn .2s ease;
|
||||
`;
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
return { boot, close, openApp, showNotification,
|
||||
get session() { return _session; } };
|
||||
})();
|
||||
|
||||
/* ── Close button ───────────────────────────────────────── */
|
||||
function doClosePC() {
|
||||
fetchNui('closePC', {});
|
||||
}
|
||||
|
||||
/* ── PC Container: Minimize / Maximize ─────────────────── */
|
||||
function toggleMinimizePC() {
|
||||
const container = document.getElementById('pc-container');
|
||||
const btn = document.getElementById('pc-minimize-btn');
|
||||
if (!container) return;
|
||||
const isMin = container.classList.contains('minimized');
|
||||
container.classList.toggle('minimized', !isMin);
|
||||
if (btn) {
|
||||
btn.textContent = isMin ? '─' : '□';
|
||||
btn.title = isMin ? 'Minimize' : 'Restore';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Interaktionsmodus: PC transparent + Fokus zurück ins Spiel ── */
|
||||
function toggleInteractMode() {
|
||||
const app = document.getElementById('app');
|
||||
const btn = document.getElementById('pc-interact-btn');
|
||||
if (!app) return;
|
||||
const on = !app.classList.contains('interact-mode');
|
||||
app.classList.toggle('interact-mode', on);
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
// interact AN -> NUI-Fokus AUS (man interagiert mit dem Spiel); AUS -> Fokus zurück auf den PC
|
||||
fetchNui('setFocus', { focus: !on });
|
||||
}
|
||||
function exitInteractMode() {
|
||||
const app = document.getElementById('app');
|
||||
const btn = document.getElementById('pc-interact-btn');
|
||||
if (app) app.classList.remove('interact-mode');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
|
||||
/* ── PC Size (viewport %) ────────────────────────────────── */
|
||||
const PCScale = (() => {
|
||||
const STEP = 5;
|
||||
const MIN = 40;
|
||||
const MAX = 98;
|
||||
const KEY = 'pc_live_vp';
|
||||
|
||||
let _vp = parseInt(localStorage.getItem(KEY) || '88', 10);
|
||||
|
||||
function apply() {
|
||||
_vp = Math.max(MIN, Math.min(MAX, _vp));
|
||||
document.documentElement.style.setProperty('--pc-vp', _vp);
|
||||
localStorage.setItem(KEY, _vp);
|
||||
const c = document.getElementById('pc-container');
|
||||
if (c) {
|
||||
c.style.left = '50%';
|
||||
c.style.top = '50%';
|
||||
c.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
const disp = document.getElementById('pc-vp-display');
|
||||
const slider = document.getElementById('pc-vp-slider');
|
||||
if (disp) disp.textContent = _vp + '%';
|
||||
if (slider) slider.value = _vp;
|
||||
}
|
||||
|
||||
function step(dir) { _vp += dir * STEP; apply(); }
|
||||
function setVP(v) { _vp = parseInt(v, 10); apply(); }
|
||||
|
||||
document.addEventListener('wheel', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
step(e.deltaY < 0 ? 1 : -1);
|
||||
}, { passive: false });
|
||||
|
||||
apply();
|
||||
return { step, setVP };
|
||||
})();
|
||||
|
||||
/* ── PC Font Size ─────────────────────────────────────────── */
|
||||
const PCFontSize = (() => {
|
||||
const STEP = 1;
|
||||
const MIN = 11;
|
||||
const MAX = 20;
|
||||
const KEY = 'pc_live_fs';
|
||||
|
||||
let _fs = parseInt(localStorage.getItem(KEY) || '13', 10);
|
||||
|
||||
function apply() {
|
||||
_fs = Math.max(MIN, Math.min(MAX, _fs));
|
||||
document.documentElement.style.setProperty('--pc-fs', _fs);
|
||||
localStorage.setItem(KEY, _fs);
|
||||
const disp = document.getElementById('pc-fs-display');
|
||||
const slider = document.getElementById('pc-fs-slider');
|
||||
if (disp) disp.textContent = _fs + 'px';
|
||||
if (slider) slider.value = _fs;
|
||||
}
|
||||
|
||||
function step(dir) { _fs += dir * STEP; apply(); }
|
||||
function set(v) { _fs = parseInt(v, 10); apply(); }
|
||||
|
||||
apply();
|
||||
return { step, set };
|
||||
})();
|
||||
|
||||
/* ── Settings Panel ───────────────────────────────────────── */
|
||||
const PCSettings = (() => {
|
||||
let _open = false;
|
||||
|
||||
function toggle() {
|
||||
_open = !_open;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel) panel.classList.toggle('open', _open);
|
||||
if (btn) btn.classList.toggle('active', _open);
|
||||
// Sync slider values when opening
|
||||
if (_open) {
|
||||
const fsSlider = document.getElementById('pc-fs-slider');
|
||||
const vpSlider = document.getElementById('pc-vp-slider');
|
||||
if (fsSlider) fsSlider.value = parseInt(localStorage.getItem('pc_live_fs') || '13', 10);
|
||||
if (vpSlider) vpSlider.value = parseInt(localStorage.getItem('pc_live_vp') || '88', 10);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
_open = false;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel) panel.classList.remove('open');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
|
||||
// Schließen bei Klick außerhalb
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!_open) return;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel && !panel.contains(e.target) && btn && !btn.contains(e.target)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
return { toggle, close };
|
||||
})();
|
||||
|
||||
/* ── PC Container: Draggable via Titelleiste ────────────── */
|
||||
(function initPCDrag() {
|
||||
const container = document.getElementById('pc-container');
|
||||
const titlebar = document.getElementById('pc-titlebar');
|
||||
if (!container || !titlebar) return;
|
||||
|
||||
let startX, startY, startL, startT;
|
||||
|
||||
titlebar.addEventListener('mousedown', (e) => {
|
||||
// Ignore clicks on buttons
|
||||
if (e.target.closest('#pc-minimize-btn, #close-pc-btn, #pc-settings-btn, #pc-settings-panel')) return;
|
||||
|
||||
// Convert centered transform to explicit pixel position
|
||||
const rect = container.getBoundingClientRect();
|
||||
container.style.left = rect.left + 'px';
|
||||
container.style.top = rect.top + 'px';
|
||||
container.style.transform = 'none';
|
||||
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = rect.left; startT = rect.top;
|
||||
|
||||
const onMove = (e2) => {
|
||||
const newL = startL + e2.clientX - startX;
|
||||
const newT = startT + e2.clientY - startY;
|
||||
const maxL = window.innerWidth - container.offsetWidth;
|
||||
const maxT = window.innerHeight - container.offsetHeight;
|
||||
container.style.left = Math.max(0, Math.min(maxL, newL)) + 'px';
|
||||
container.style.top = Math.max(0, Math.min(maxT, newT)) + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
})();
|
||||
252
nui/js/windowManager.js
Normal file
252
nui/js/windowManager.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* pc-live | Window Manager
|
||||
* Handles: create, focus, drag, resize, maximize, minimize, close, taskbar
|
||||
*/
|
||||
const WindowManager = (() => {
|
||||
const layer = document.getElementById('window-layer');
|
||||
const taskbarApps = document.getElementById('taskbar-apps');
|
||||
let zCounter = 10;
|
||||
const windows = new Map(); // id -> { el, tbBtn, minimized, maximized, preMax, title, icon }
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────── */
|
||||
function nextZ() { return ++zCounter; }
|
||||
|
||||
function clampPos(el) {
|
||||
const lw = layer.clientWidth;
|
||||
const lh = layer.clientHeight;
|
||||
let l = parseInt(el.style.left) || 0;
|
||||
let t = parseInt(el.style.top) || 0;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
el.style.left = Math.min(Math.max(0, l), lw - w) + 'px';
|
||||
el.style.top = Math.min(Math.max(0, t), lh - Math.max(40, h - 300)) + 'px';
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────── */
|
||||
function makeDraggable(el, handle) {
|
||||
let startX, startY, startL, startT;
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (e.target.classList.contains('wc-btn')) return;
|
||||
if (e.target.classList.contains('wc-resize')) return;
|
||||
const win = windows.get(el.dataset.wid);
|
||||
if (win && win.maximized) return;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = parseInt(el.style.left) || 0;
|
||||
startT = parseInt(el.style.top) || 0;
|
||||
focus(el.dataset.wid);
|
||||
const mm = (e2) => {
|
||||
el.style.left = (startL + e2.clientX - startX) + 'px';
|
||||
el.style.top = (startT + e2.clientY - startY) + 'px';
|
||||
};
|
||||
const mu = () => {
|
||||
clampPos(el);
|
||||
document.removeEventListener('mousemove', mm);
|
||||
document.removeEventListener('mouseup', mu);
|
||||
};
|
||||
document.addEventListener('mousemove', mm);
|
||||
document.addEventListener('mouseup', mu);
|
||||
});
|
||||
// Double-click titlebar → toggle maximize
|
||||
handle.addEventListener('dblclick', (e) => {
|
||||
if (e.target.classList.contains('wc-btn')) return;
|
||||
toggleMaximize(el.dataset.wid);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Resize ───────────────────────────────────────────── */
|
||||
function makeResizable(el, id) {
|
||||
const dirs = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
|
||||
dirs.forEach(dir => {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'wc-resize wc-resize-' + dir;
|
||||
el.appendChild(h);
|
||||
|
||||
let startX, startY, startL, startT, startW, startH;
|
||||
const MIN_W = 320, MIN_H = 200;
|
||||
|
||||
h.addEventListener('mousedown', (e) => {
|
||||
const win = windows.get(id);
|
||||
if (!win || win.maximized) return;
|
||||
e.stopPropagation();
|
||||
focus(id);
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = parseInt(el.style.left) || 0;
|
||||
startT = parseInt(el.style.top) || 0;
|
||||
startW = el.offsetWidth;
|
||||
startH = el.offsetHeight;
|
||||
|
||||
const mm = (e2) => {
|
||||
const dx = e2.clientX - startX;
|
||||
const dy = e2.clientY - startY;
|
||||
let newL = startL, newT = startT, newW = startW, newH = startH;
|
||||
|
||||
if (dir.includes('e')) newW = Math.max(MIN_W, startW + dx);
|
||||
if (dir.includes('w')) { newW = Math.max(MIN_W, startW - dx); newL = startL + startW - newW; }
|
||||
if (dir.includes('s')) newH = Math.max(MIN_H, startH + dy);
|
||||
if (dir.includes('n')) { newH = Math.max(MIN_H, startH - dy); newT = startT + startH - newH; }
|
||||
|
||||
el.style.left = newL + 'px';
|
||||
el.style.top = newT + 'px';
|
||||
el.style.width = newW + 'px';
|
||||
el.style.height = newH + 'px';
|
||||
};
|
||||
const mu = () => {
|
||||
document.removeEventListener('mousemove', mm);
|
||||
document.removeEventListener('mouseup', mu);
|
||||
};
|
||||
document.addEventListener('mousemove', mm);
|
||||
document.addEventListener('mouseup', mu);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Focus ────────────────────────────────────────────── */
|
||||
function focus(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
windows.forEach((w, wid) => {
|
||||
w.el.classList.toggle('focused', wid === id);
|
||||
w.tbBtn && w.tbBtn.classList.toggle('active', wid === id);
|
||||
});
|
||||
win.el.style.zIndex = nextZ();
|
||||
}
|
||||
|
||||
/* ── Maximize / Restore ───────────────────────────────── */
|
||||
function maximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win || win.maximized) return;
|
||||
win.preMax = {
|
||||
left: win.el.style.left,
|
||||
top: win.el.style.top,
|
||||
width: win.el.style.width,
|
||||
height: win.el.style.height,
|
||||
};
|
||||
win.maximized = true;
|
||||
win.el.classList.add('maximized');
|
||||
win.el.style.left = '0';
|
||||
win.el.style.top = '0';
|
||||
win.el.style.width = layer.clientWidth + 'px';
|
||||
win.el.style.height = layer.clientHeight + 'px';
|
||||
const btn = win.el.querySelector('.wc-maximize');
|
||||
if (btn) btn.title = 'Restore';
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function unmaximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win || !win.maximized || !win.preMax) return;
|
||||
win.maximized = false;
|
||||
win.el.classList.remove('maximized');
|
||||
win.el.style.left = win.preMax.left;
|
||||
win.el.style.top = win.preMax.top;
|
||||
win.el.style.width = win.preMax.width;
|
||||
win.el.style.height = win.preMax.height;
|
||||
win.preMax = null;
|
||||
const btn = win.el.querySelector('.wc-maximize');
|
||||
if (btn) btn.title = 'Maximize';
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function toggleMaximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
if (win.maximized) unmaximize(id); else maximize(id);
|
||||
}
|
||||
|
||||
/* ── Create ───────────────────────────────────────────── */
|
||||
function create({ id, title, icon = '🖥', width = 700, height = 480, content }) {
|
||||
if (windows.has(id)) {
|
||||
restore(id);
|
||||
return windows.get(id).el;
|
||||
}
|
||||
|
||||
const lw = layer.clientWidth;
|
||||
const lh = layer.clientHeight;
|
||||
const left = Math.max(20, Math.floor(lw / 2 - width / 2));
|
||||
const top = Math.max(20, Math.floor(lh / 2 - height / 2));
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'window fade-in';
|
||||
el.dataset.wid = id;
|
||||
el.style.cssText = `left:${left}px;top:${top}px;width:${width}px;height:${height}px;z-index:${nextZ()}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="window-titlebar">
|
||||
<span class="window-title-icon">${icon}</span>
|
||||
<span class="window-title-text">${title}</span>
|
||||
<div class="window-controls">
|
||||
<button class="wc-btn wc-minimize" title="Minimize">─</button>
|
||||
<button class="wc-btn wc-maximize" title="Maximize">□</button>
|
||||
<button class="wc-btn wc-close" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body">${content || ''}</div>
|
||||
`;
|
||||
|
||||
el.querySelector('.wc-minimize').addEventListener('click', () => minimize(id));
|
||||
el.querySelector('.wc-maximize').addEventListener('click', () => toggleMaximize(id));
|
||||
el.querySelector('.wc-close').addEventListener('click', () => close(id));
|
||||
el.addEventListener('mousedown', () => focus(id));
|
||||
|
||||
makeDraggable(el, el.querySelector('.window-titlebar'));
|
||||
makeResizable(el, id);
|
||||
|
||||
layer.appendChild(el);
|
||||
|
||||
const tbBtn = document.createElement('div');
|
||||
tbBtn.className = 'taskbar-btn';
|
||||
tbBtn.innerHTML = `<span>${icon}</span><span>${title}</span>`;
|
||||
tbBtn.addEventListener('click', () => toggle(id));
|
||||
taskbarApps.appendChild(tbBtn);
|
||||
|
||||
windows.set(id, { el, tbBtn, minimized: false, maximized: false, preMax: null, title, icon });
|
||||
focus(id);
|
||||
return el;
|
||||
}
|
||||
|
||||
/* ── Minimize / restore / toggle ────────────────────────── */
|
||||
function minimize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.minimized = true;
|
||||
win.el.classList.add('minimized');
|
||||
win.tbBtn && win.tbBtn.classList.remove('active');
|
||||
win.tbBtn && win.tbBtn.classList.add('minimized');
|
||||
}
|
||||
|
||||
function restore(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.minimized = false;
|
||||
win.el.classList.remove('minimized');
|
||||
win.tbBtn && win.tbBtn.classList.remove('minimized');
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
if (win.minimized) restore(id); else minimize(id);
|
||||
}
|
||||
|
||||
/* ── Close ───────────────────────────────────────────────── */
|
||||
function close(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.el.remove();
|
||||
win.tbBtn && win.tbBtn.remove();
|
||||
windows.delete(id);
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
windows.forEach((_, id) => close(id));
|
||||
}
|
||||
|
||||
/* ── Get body element ────────────────────────────────────── */
|
||||
function getBody(id) {
|
||||
const win = windows.get(id);
|
||||
return win ? win.el.querySelector('.window-body') : null;
|
||||
}
|
||||
|
||||
return { create, close, closeAll, focus, minimize, restore, toggle, maximize, unmaximize, toggleMaximize, getBody };
|
||||
})();
|
||||
447
server/apps.lua
Normal file
447
server/apps.lua
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- App Registry – manages registered apps, installation & export
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
AppRegistry = {}
|
||||
|
||||
local _registry = {} -- app_id -> manifest
|
||||
|
||||
--- Register an app with the system.
|
||||
---@param manifest table
|
||||
function AppRegistry.Register(manifest)
|
||||
if not manifest or not manifest.app_id then
|
||||
Utils.Warn("AppRegistry.Register: invalid manifest (missing app_id)")
|
||||
return false
|
||||
end
|
||||
if _registry[manifest.app_id] then
|
||||
Utils.Warn(("AppRegistry: app '%s' already registered, overwriting"):format(manifest.app_id))
|
||||
end
|
||||
_registry[manifest.app_id] = manifest
|
||||
Utils.Log(("Registered app: %s v%s"):format(manifest.app_id, manifest.version or "?"))
|
||||
return true
|
||||
end
|
||||
|
||||
--- Get the full registry sorted (default/system first, then alphabetically).
|
||||
---@return table[]
|
||||
function AppRegistry.GetAll()
|
||||
local list = {}
|
||||
for _, m in pairs(_registry) do
|
||||
list[#list + 1] = m
|
||||
end
|
||||
table.sort(list, function(a, b)
|
||||
if (a.default or false) ~= (b.default or false) then
|
||||
return (a.default and true or false)
|
||||
end
|
||||
return (a.name or "") < (b.name or "")
|
||||
end)
|
||||
return list
|
||||
end
|
||||
|
||||
--- Get a single app manifest by id.
|
||||
---@param appId string
|
||||
---@return table?
|
||||
function AppRegistry.Get(appId)
|
||||
return _registry[appId]
|
||||
end
|
||||
|
||||
--- Check if an app is registered.
|
||||
---@param appId string
|
||||
---@return boolean
|
||||
function AppRegistry.Exists(appId)
|
||||
return _registry[appId] ~= nil
|
||||
end
|
||||
|
||||
--- Combine registry with per-user install data (for store listing).
|
||||
---@param installs table[] rows from pc_live_installs
|
||||
---@return table[]
|
||||
function AppRegistry.BuildStoreList(installs)
|
||||
local installedMap = {}
|
||||
for _, row in ipairs(installs) do
|
||||
installedMap[row.app_id] = row.version
|
||||
end
|
||||
|
||||
local list = AppRegistry.GetAll()
|
||||
for _, app in ipairs(list) do
|
||||
app.installed = installedMap[app.app_id] ~= nil
|
||||
app.installed_version = installedMap[app.app_id]
|
||||
app.update_available = app.installed and (app.installed_version ~= app.version)
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
--- Build list of currently installed apps for the desktop.
|
||||
---@param installs table[]
|
||||
---@return table[]
|
||||
function AppRegistry.BuildInstalledList(installs)
|
||||
local list = {}
|
||||
for _, row in ipairs(installs) do
|
||||
local manifest = _registry[row.app_id]
|
||||
if manifest then
|
||||
local entry = {}
|
||||
for k, v in pairs(manifest) do entry[k] = v end
|
||||
entry.installed_version = row.version
|
||||
list[#list + 1] = entry
|
||||
end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
-- ── Export for external resources ──────────────────────────────────────────
|
||||
exports('RegisterApp', function(manifest)
|
||||
return AppRegistry.Register(manifest)
|
||||
end)
|
||||
|
||||
-- ── Register Built-In Apps ─────────────────────────────────────────────────
|
||||
-- NOTE: icon field uses the emoji directly so NUI can display it without a
|
||||
-- separate lookup table. Add dependencies = {} on every app.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CreateThread(function()
|
||||
Wait(0) -- Let other resources load first
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ SYSTEM (pre-installed, cannot remove) │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.browser",
|
||||
name = "Browser",
|
||||
icon = "🌐",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "Browse internal IC websites.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.mail",
|
||||
name = "Mail",
|
||||
icon = "✉",
|
||||
version = "2.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "E-Mail, Ordner, Kalender – alles in einer App.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.addressbook",
|
||||
name = "Adressbuch",
|
||||
icon = "👤",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "Kontakte verwalten – Name, E-Mail, Telefon. Direkt mit Mail verknüpft.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.store",
|
||||
name = "Software Store",
|
||||
icon = "🛒",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "Browse and install software.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- Webhosting (ic-web). Vorinstalliert, weil die Anmeldung ohnehin
|
||||
-- entscheidet, wer hier etwas tun kann – ohne Zugang sieht man nur die
|
||||
-- Anmeldemaske.
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.webhosting",
|
||||
name = "Webhosting",
|
||||
icon = "🌍",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "IC-Domänen und Webseiten verwalten. Anmeldung erforderlich.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- Bleeter selbst, eingebettet als Fenster im PC.
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.bleeter",
|
||||
name = "Bleeter",
|
||||
icon = "🐦",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "Bleeter – Feed, Werbung, Gewerbe und Profil.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- Bleeter-Verwaltung. Ohne Anmeldung sieht man nur die Anmeldemaske,
|
||||
-- deshalb vorinstalliert wie das Webhosting.
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.bleeteradmin",
|
||||
name = "Bleeter-Verwaltung",
|
||||
icon = "🐦",
|
||||
version = "1.0.0",
|
||||
category = "system",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = true,
|
||||
description = "Unternehmensprofile auf Bleeter einrichten und Mitarbeiter freigeben.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ UTILITY │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.terminal",
|
||||
name = "Terminal",
|
||||
icon = "⌨",
|
||||
version = "1.3.0",
|
||||
category = "utility",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Advanced command-line interface for power users.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.notes",
|
||||
name = "Notepad Pro",
|
||||
icon = "📝",
|
||||
version = "1.0.0",
|
||||
category = "utility",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Text editor with rich formatting and local storage.",
|
||||
price = 299,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.calculator",
|
||||
name = "Calculator",
|
||||
icon = "🔢",
|
||||
version = "1.0.0",
|
||||
category = "utility",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Scientific calculator with unit conversion.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ COMMUNICATION │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.chat",
|
||||
name = "IC Chat",
|
||||
icon = "💬",
|
||||
version = "2.0.0",
|
||||
category = "communication",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "In-character instant messaging platform.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.voip",
|
||||
name = "VoIP Client",
|
||||
icon = "📞",
|
||||
version = "1.1.0",
|
||||
category = "communication",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Encrypted voice-over-IP calls via IC network.",
|
||||
price = 499,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ SECURITY │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.vpn",
|
||||
name = "SecureVPN",
|
||||
icon = "🔒",
|
||||
version = "2.0.0",
|
||||
category = "security",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Encrypt your connection and mask your IP on IC networks.",
|
||||
price = 1499,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.firewall",
|
||||
name = "Firewall Pro",
|
||||
icon = "🛡",
|
||||
version = "1.1.0",
|
||||
category = "security",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Block unauthorized access to your system.",
|
||||
price = 999,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ BUSINESS │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.finance",
|
||||
name = "Finance Manager",
|
||||
icon = "💰",
|
||||
version = "1.0.0",
|
||||
category = "business",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Track income, expenses and bank account balances.",
|
||||
price = 4999,
|
||||
})
|
||||
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ GOVERNMENT (job-locked) │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.police.mdw",
|
||||
name = "Police MDW",
|
||||
icon = "🔍",
|
||||
version = "3.0.1",
|
||||
category = "government",
|
||||
permissions = { job = "police" },
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Mobile Data Workstation: criminal records, warrants, BOLO alerts.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.police.dispatch",
|
||||
name = "Dispatch Board",
|
||||
icon = "🚔",
|
||||
version = "2.1.0",
|
||||
category = "government",
|
||||
permissions = { job = "police" },
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Live dispatch board for law enforcement units.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.ems",
|
||||
name = "EMS Dashboard",
|
||||
icon = "🏥",
|
||||
version = "1.2.0",
|
||||
category = "government",
|
||||
permissions = { job = "ambulance" },
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Patient management and ambulance dispatch system.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ ENTERTAINMENT │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.music",
|
||||
name = "TuneStream",
|
||||
icon = "🎵",
|
||||
version = "1.0.0",
|
||||
category = "entertainment",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Stream in-character radio stations and playlists.",
|
||||
price = 999,
|
||||
})
|
||||
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ PARKING JOB │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.parkuhr",
|
||||
name = "Parkuhr Dashboard",
|
||||
icon = "🅿",
|
||||
version = "1.0.0",
|
||||
category = "government",
|
||||
permissions = { job = "parking" },
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Parking management dashboard – device overview, revenue tracking and tariff management.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ IC-VERWALTUNG (Behörden) │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.ic_verwaltung",
|
||||
name = "IC-Verwaltung",
|
||||
icon = "🏛",
|
||||
version = "1.0.0",
|
||||
category = "government",
|
||||
permissions = { jobs = { "police", "ambulance", "dpa", "fire", "doj" } },
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Zugang zum IC-Verwaltungsportal. Nur für Behörden: PD, MD, DPA, FD, DOJ.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ GESETZBUCH (öffentliche Gesetze-Übersicht) │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.gesetzbuch",
|
||||
name = "Gesetzbuch",
|
||||
icon = "📖",
|
||||
version = "1.0.0",
|
||||
category = "government",
|
||||
permissions = {},
|
||||
dependencies = {},
|
||||
default = false,
|
||||
description = "Öffentliche Übersicht aller Gesetzbücher und Paragraphen des Bundesstaates San Andreas – ohne Login einsehbar.",
|
||||
price = 0,
|
||||
})
|
||||
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ BLEETER │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
|
||||
-- ┌─────────────────────────────────────────────┐
|
||||
-- │ DARKNET (requires VPN) │
|
||||
-- └─────────────────────────────────────────────┘
|
||||
AppRegistry.Register({
|
||||
app_id = "pc.darkbrowser",
|
||||
name = "Dark Browser",
|
||||
icon = "🕵",
|
||||
version = "0.9.0",
|
||||
category = "darknet",
|
||||
permissions = {},
|
||||
dependencies = { "pc.vpn" },
|
||||
default = false,
|
||||
description = "Access the hidden IC darknet. Requires SecureVPN. Use at your own risk.",
|
||||
price = 2499,
|
||||
})
|
||||
|
||||
end)
|
||||
183
server/calendar.lua
Normal file
183
server/calendar.lua
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Kalender
|
||||
--
|
||||
-- Drei Arten von Terminen:
|
||||
--
|
||||
-- privat gehoert einer Person, sonst sieht ihn niemand
|
||||
-- geteilt gehoert einem Postfach, sichtbar fuer alle, die es bedienen
|
||||
-- oeffentlich gehoert einem Postfach, sichtbar fuer jeden auf dem Server
|
||||
--
|
||||
-- Geteilt wird ueber dasselbe Mittel wie Postfaecher und Ordner: die Adresse.
|
||||
-- Ein oeffentlicher Termin haengt bewusst auch an einem Postfach – sonst gaebe
|
||||
-- es niemanden, der ihn spaeter aendern oder absagen koennte.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
--- Alle Postfaecher, die diese Person bedienen darf.
|
||||
---@return string[]
|
||||
local function MailboxesOf(identifier)
|
||||
if GetResourceState('ic-mail') ~= 'started' then return {} end
|
||||
|
||||
local out = {}
|
||||
for _, mb in ipairs(exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}) do
|
||||
if mb.address then out[#out + 1] = mb.address end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function CanUseMailbox(identifier, address)
|
||||
if address == '' then return true end
|
||||
for _, addr in ipairs(MailboxesOf(identifier)) do
|
||||
if addr == address then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Darf diese Person den Termin aendern?
|
||||
--- Privat: nur wer ihn angelegt hat. Postfach: wer das Postfach bedient –
|
||||
--- auch wenn jemand anderes ihn eingetragen hat. Genau das macht ihn geteilt.
|
||||
local function CanEditEvent(identifier, event)
|
||||
if not event then return false end
|
||||
if event.address and event.address ~= '' then
|
||||
return CanUseMailbox(identifier, event.address)
|
||||
end
|
||||
return event.identifier == identifier
|
||||
end
|
||||
|
||||
--- Eingaben pruefen und in die Form bringen, die die Datenbank erwartet.
|
||||
--- Gibt (daten, adresse, sichtbarkeit) oder nil zurueck.
|
||||
local function ReadEvent(identifier, data)
|
||||
if type(data) ~= 'table' then return nil end
|
||||
|
||||
local d = {
|
||||
title = Utils.Sanitize(tostring(data.title or "")):sub(1, 120),
|
||||
description = Utils.Sanitize(tostring(data.description or "")):sub(1, 500),
|
||||
start_at = Utils.Sanitize(tostring(data.start_at or "")):sub(1, 20),
|
||||
end_at = data.end_at and Utils.Sanitize(tostring(data.end_at)):sub(1, 20) or nil,
|
||||
color = Utils.Sanitize(tostring(data.color or "#00aaff")):sub(1, 20),
|
||||
}
|
||||
if #d.title < 1 or #d.start_at < 1 then return nil end
|
||||
|
||||
local address = Utils.Sanitize(tostring(data.address or '')):sub(1, 200)
|
||||
if not CanUseMailbox(identifier, address) then
|
||||
-- Kein Zugriff auf das Postfach: der Termin wird privat, statt in einem
|
||||
-- fremden Kalender zu landen.
|
||||
address = ''
|
||||
end
|
||||
|
||||
local visibility = tostring(data.visibility or 'private')
|
||||
if address == '' then
|
||||
-- Ohne Postfach gibt es niemanden, der einen oeffentlichen Termin
|
||||
-- pflegen koennte. Also bleibt er privat.
|
||||
visibility = 'private'
|
||||
elseif visibility ~= 'public' then
|
||||
visibility = 'shared'
|
||||
end
|
||||
|
||||
return d, address, visibility
|
||||
end
|
||||
|
||||
--- Termin so aufbereiten, wie ihn der Client braucht.
|
||||
local function PublicEvent(identifier, row)
|
||||
row.can_edit = CanEditEvent(identifier, row)
|
||||
row.address = row.address or ''
|
||||
return row
|
||||
end
|
||||
|
||||
local function SendCalendar(src, identifier)
|
||||
local rows = DB.GetCalendar(identifier, MailboxesOf(identifier))
|
||||
for _, row in ipairs(rows) do PublicEvent(identifier, row) end
|
||||
TriggerClientEvent('pc-live:client:calendarData', src, rows)
|
||||
end
|
||||
|
||||
--- Alle, die einen Termin sehen, bekommen die Aenderung sofort.
|
||||
--- Ohne das haette jeder eine andere Vorstellung vom Dienstplan, bis er den
|
||||
--- PC neu oeffnet – und genau dafuer ist ein geteilter Kalender da.
|
||||
local function NotifyOthers(src, event)
|
||||
if not event then return end
|
||||
local shared = (event.address and event.address ~= '') or event.visibility == 'public'
|
||||
if not shared then return end
|
||||
|
||||
for playerSrc, session in pairs(ActiveSessions) do
|
||||
if playerSrc ~= src then
|
||||
if event.visibility == 'public'
|
||||
or CanUseMailbox(session.identifier, event.address or '') then
|
||||
SendCalendar(playerSrc, session.identifier)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Lesen ────────────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getCalendar', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
SendCalendar(src, user.identifier)
|
||||
end)
|
||||
|
||||
-- ── Anlegen ──────────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:addCalendarEvent', function(data)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local d, address, visibility = ReadEvent(user.identifier, data)
|
||||
if not d then return end
|
||||
|
||||
local id = DB.AddCalendarEvent(user.identifier, address, visibility, d)
|
||||
|
||||
d.id = id
|
||||
d.address = address
|
||||
d.visibility = visibility
|
||||
d.identifier = user.identifier
|
||||
d.can_edit = true
|
||||
|
||||
TriggerClientEvent('pc-live:client:calendarEventAdded', src, d)
|
||||
NotifyOthers(src, d)
|
||||
end)
|
||||
|
||||
-- ── Aendern ──────────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:updateCalendarEvent', function(id, data)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(id) ~= "number" then return end
|
||||
|
||||
-- Erst der bestehende Termin: sonst koennte man einen fremden aendern,
|
||||
-- indem man einfach eine Adresse mitschickt, auf die man Zugriff hat.
|
||||
local existing = DB.GetCalendarEvent(id)
|
||||
if not CanEditEvent(user.identifier, existing) then return end
|
||||
|
||||
local d, address, visibility = ReadEvent(user.identifier, data)
|
||||
if not d then return end
|
||||
|
||||
DB.UpdateCalendarEvent(id, address, visibility, d)
|
||||
|
||||
d.id = id
|
||||
d.address = address
|
||||
d.visibility = visibility
|
||||
d.can_edit = true
|
||||
|
||||
TriggerClientEvent('pc-live:client:calendarEventUpdated', src, d)
|
||||
|
||||
-- Beide Seiten benachrichtigen: der Termin kann aus einem geteilten
|
||||
-- Kalender herausgenommen worden sein, dann muss er dort verschwinden.
|
||||
NotifyOthers(src, existing)
|
||||
NotifyOthers(src, d)
|
||||
end)
|
||||
|
||||
-- ── Loeschen ─────────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:deleteCalendarEvent', function(id)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(id) ~= "number" then return end
|
||||
|
||||
local existing = DB.GetCalendarEvent(id)
|
||||
if not CanEditEvent(user.identifier, existing) then return end
|
||||
|
||||
DB.DeleteCalendarEvent(id)
|
||||
TriggerClientEvent('pc-live:client:calendarEventDeleted', src, id)
|
||||
NotifyOthers(src, existing)
|
||||
end)
|
||||
60
server/contacts.lua
Normal file
60
server/contacts.lua
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Contacts / Address Book handlers
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
RegisterNetEvent('pc-live:server:getContacts', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local rows = DB.GetContacts(user.identifier)
|
||||
TriggerClientEvent('pc-live:client:contactsData', src, rows)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('pc-live:server:addContact', function(data)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(data) ~= "table" then return end
|
||||
|
||||
local d = {
|
||||
name = Utils.Sanitize(tostring(data.name or "")):sub(1, 100),
|
||||
email = Utils.Sanitize(tostring(data.email or "")):sub(1, 120),
|
||||
phone = Utils.Sanitize(tostring(data.phone or "")):sub(1, 30),
|
||||
notes = Utils.Sanitize(tostring(data.notes or "")):sub(1, 500),
|
||||
}
|
||||
if #d.name < 1 then return end
|
||||
|
||||
local id = DB.AddContact(user.identifier, d)
|
||||
d.id = id
|
||||
TriggerClientEvent('pc-live:client:contactAdded', src, d)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('pc-live:server:updateContact', function(id, data)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(id) ~= "number" or type(data) ~= "table" then return end
|
||||
|
||||
local d = {
|
||||
name = Utils.Sanitize(tostring(data.name or "")):sub(1, 100),
|
||||
email = Utils.Sanitize(tostring(data.email or "")):sub(1, 120),
|
||||
phone = Utils.Sanitize(tostring(data.phone or "")):sub(1, 30),
|
||||
notes = Utils.Sanitize(tostring(data.notes or "")):sub(1, 500),
|
||||
}
|
||||
if #d.name < 1 then return end
|
||||
|
||||
DB.UpdateContact(id, user.identifier, d)
|
||||
d.id = id
|
||||
TriggerClientEvent('pc-live:client:contactUpdated', src, d)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('pc-live:server:deleteContact', function(id)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(id) ~= "number" then return end
|
||||
|
||||
DB.DeleteContact(id, user.identifier)
|
||||
TriggerClientEvent('pc-live:client:contactDeleted', src, id)
|
||||
end)
|
||||
419
server/database.lua
Normal file
419
server/database.lua
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Database layer – all MySQL calls live here
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DB = {}
|
||||
|
||||
-- ── Users ──────────────────────────────────────────────────────────────────
|
||||
|
||||
---@param identifier string
|
||||
---@param displayName string
|
||||
---@return table?
|
||||
function DB.GetOrCreateUser(identifier, displayName)
|
||||
local row = MySQL.single.await(
|
||||
'SELECT * FROM pc_live_users WHERE identifier = ?',
|
||||
{ identifier }
|
||||
)
|
||||
if row then return row end
|
||||
|
||||
MySQL.insert.await(
|
||||
'INSERT INTO pc_live_users (identifier, display_name, settings_json) VALUES (?, ?, ?)',
|
||||
{ identifier, displayName, '{}' }
|
||||
)
|
||||
|
||||
return MySQL.single.await(
|
||||
'SELECT * FROM pc_live_users WHERE identifier = ?',
|
||||
{ identifier }
|
||||
)
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@param settings table
|
||||
function DB.SaveSettings(identifier, settings)
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_users SET settings_json = ? WHERE identifier = ?',
|
||||
{ json.encode(settings), identifier }
|
||||
)
|
||||
end
|
||||
|
||||
-- ── Installs ───────────────────────────────────────────────────────────────
|
||||
|
||||
---@param userId number
|
||||
---@return table[]
|
||||
function DB.GetInstalls(userId)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_installs WHERE user_id = ?',
|
||||
{ userId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param userId number
|
||||
---@param appId string
|
||||
---@param version string
|
||||
function DB.InstallApp(userId, appId, version)
|
||||
local existing = MySQL.single.await(
|
||||
'SELECT id FROM pc_live_installs WHERE user_id = ? AND app_id = ?',
|
||||
{ userId, appId }
|
||||
)
|
||||
if existing then
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_installs SET version = ?, installed_at = NOW() WHERE id = ?',
|
||||
{ version, existing.id }
|
||||
)
|
||||
else
|
||||
MySQL.insert.await(
|
||||
'INSERT INTO pc_live_installs (user_id, app_id, version, installed_at) VALUES (?, ?, ?, NOW())',
|
||||
{ userId, appId, version }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
---@param userId number
|
||||
---@param appId string
|
||||
function DB.UninstallApp(userId, appId)
|
||||
MySQL.update.await(
|
||||
'DELETE FROM pc_live_installs WHERE user_id = ? AND app_id = ?',
|
||||
{ userId, appId }
|
||||
)
|
||||
end
|
||||
|
||||
-- ── Mail ───────────────────────────────────────────────────────────────────
|
||||
|
||||
---@param toId string
|
||||
---@return table[]
|
||||
function DB.GetInbox(toId)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail WHERE to_identifier = ? ORDER BY sent_at DESC LIMIT 100',
|
||||
{ toId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@param toId string
|
||||
---@return table?
|
||||
function DB.GetMail(id, toId)
|
||||
return MySQL.single.await(
|
||||
'SELECT * FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
|
||||
{ id, toId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param toId string
|
||||
---@param fromId string
|
||||
---@param subject string
|
||||
---@param body string
|
||||
---@return number inserted id
|
||||
function DB.SendMail(toId, fromId, subject, body)
|
||||
return MySQL.insert.await(
|
||||
'INSERT INTO pc_live_mail (to_identifier, from_identifier, subject, body, is_read, sent_at) VALUES (?, ?, ?, ?, 0, NOW())',
|
||||
{ toId, fromId, subject, body }
|
||||
)
|
||||
end
|
||||
|
||||
---@param toId string Empfänger-Identifier (aufgelöst)
|
||||
---@param fromDisplay string Absender-Anzeige (E-Mail oder Identifier)
|
||||
---@param senderId string Roher Identifier des Absenders (für Gesendet-Abfrage)
|
||||
---@param toAddress string Originale Empfängeradresse
|
||||
---@param subject string
|
||||
---@param body string
|
||||
---@return number
|
||||
--- deliveryId klammert die Kopien einer Zustellung zusammen: eine Mail an ein
|
||||
--- geteiltes Postfach wird je Empfaenger einmal gespeichert, soll aber fuer
|
||||
--- alle im selben Ordner landen.
|
||||
function DB.SendMailExtended(toId, fromDisplay, senderId, toAddress, subject, body, deliveryId)
|
||||
return MySQL.insert.await(
|
||||
'INSERT INTO pc_live_mail'
|
||||
.. ' (to_identifier, from_identifier, sender_id, to_address, subject, body,'
|
||||
.. ' delivery_id, is_read, sent_at)'
|
||||
.. ' VALUES (?, ?, ?, ?, ?, ?, ?, 0, NOW())',
|
||||
{ toId, fromDisplay, senderId, toAddress, subject, body, deliveryId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@param toId string
|
||||
function DB.MarkRead(id, toId)
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_mail SET is_read = 1 WHERE id = ? AND to_identifier = ?',
|
||||
{ id, toId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@param toId string
|
||||
function DB.DeleteMail(id, toId)
|
||||
MySQL.update.await(
|
||||
'DELETE FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
|
||||
{ id, toId }
|
||||
)
|
||||
end
|
||||
|
||||
---@param toId string
|
||||
---@return number
|
||||
function DB.CountUnread(toId)
|
||||
local row = MySQL.single.await(
|
||||
'SELECT COUNT(*) as cnt FROM pc_live_mail WHERE to_identifier = ? AND is_read = 0',
|
||||
{ toId }
|
||||
)
|
||||
return row and row.cnt or 0
|
||||
end
|
||||
|
||||
---@param fromId string Spieler-Identifier
|
||||
---@return table[]
|
||||
function DB.GetSent(fromId)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail'
|
||||
.. ' WHERE from_identifier = ? OR sender_id = ?'
|
||||
.. ' ORDER BY sent_at DESC LIMIT 100',
|
||||
{ fromId, fromId }
|
||||
)
|
||||
end
|
||||
|
||||
-- ── Mail Folders ───────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Ordner gehoeren zum Postfach, nicht zur Person: ein geteiltes Postfach wird
|
||||
-- von mehreren bedient und braucht dieselbe Struktur fuer alle.
|
||||
--
|
||||
-- address = '' → persoenliche Ordner (Spalte address IS NULL)
|
||||
-- address ~= '' → Ordner dieses Postfachs
|
||||
--
|
||||
-- Der Aufrufer muss vorher pruefen, ob die Person das Postfach benutzen darf.
|
||||
|
||||
---@param identifier string
|
||||
---@param address string|nil
|
||||
---@return table[]
|
||||
function DB.GetFolders(identifier, address)
|
||||
if address and address ~= '' then
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail_folders WHERE address = ? ORDER BY name ASC',
|
||||
{ address }
|
||||
) or {}
|
||||
end
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail_folders WHERE identifier = ? AND address IS NULL ORDER BY name ASC',
|
||||
{ identifier }
|
||||
) or {}
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@param address string|nil
|
||||
---@param name string
|
||||
---@return number
|
||||
function DB.CreateFolder(identifier, address, name)
|
||||
return MySQL.insert.await(
|
||||
'INSERT INTO pc_live_mail_folders (identifier, address, name) VALUES (?, ?, ?)',
|
||||
{ identifier, (address ~= '' and address or nil), name }
|
||||
)
|
||||
end
|
||||
|
||||
--- Gibt den Ordner zurueck, wenn er zu diesem Postfach gehoert – sonst nil.
|
||||
--- Damit haengt jede Ordneraktion an einer Besitzpruefung statt an der Id
|
||||
--- allein, die der Client frei mitschicken kann.
|
||||
---@param folderId number
|
||||
---@param identifier string
|
||||
---@param address string|nil
|
||||
---@return table|nil
|
||||
function DB.GetFolder(folderId, identifier, address)
|
||||
if address and address ~= '' then
|
||||
return MySQL.single.await(
|
||||
'SELECT * FROM pc_live_mail_folders WHERE id = ? AND address = ?',
|
||||
{ folderId, address }
|
||||
)
|
||||
end
|
||||
return MySQL.single.await(
|
||||
'SELECT * FROM pc_live_mail_folders WHERE id = ? AND identifier = ? AND address IS NULL',
|
||||
{ folderId, identifier }
|
||||
)
|
||||
end
|
||||
|
||||
---@param folderId number
|
||||
---@param identifier string
|
||||
---@param address string|nil
|
||||
function DB.DeleteFolder(folderId, identifier, address)
|
||||
-- Mails zurueck in den Posteingang. Bei einem geteilten Postfach betrifft
|
||||
-- das die Kopien aller Beteiligten, sonst blieben deren Mails in einem
|
||||
-- Ordner haengen, den es nicht mehr gibt.
|
||||
if address and address ~= '' then
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_mail SET folder_id = NULL WHERE folder_id = ? AND to_address = ?',
|
||||
{ folderId, address }
|
||||
)
|
||||
else
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_mail SET folder_id = NULL WHERE folder_id = ? AND to_identifier = ?',
|
||||
{ folderId, identifier }
|
||||
)
|
||||
end
|
||||
|
||||
MySQL.update.await('DELETE FROM pc_live_mail_folders WHERE id = ?', { folderId })
|
||||
end
|
||||
|
||||
--- Mail einsortieren.
|
||||
--- Im geteilten Postfach wandern alle Kopien derselben Zustellung mit, damit
|
||||
--- die Ablage fuer alle gleich aussieht.
|
||||
---@param mailId number
|
||||
---@param folderId number|nil nil = Posteingang
|
||||
---@param identifier string
|
||||
---@param address string|nil
|
||||
function DB.MoveMailToFolder(mailId, folderId, identifier, address)
|
||||
local mail = MySQL.single.await(
|
||||
'SELECT id, delivery_id, to_address FROM pc_live_mail WHERE id = ? AND to_identifier = ?',
|
||||
{ mailId, identifier }
|
||||
)
|
||||
if not mail then return end
|
||||
|
||||
local shared = address and address ~= '' and mail.to_address == address
|
||||
|
||||
if shared and mail.delivery_id then
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_mail SET folder_id = ? WHERE delivery_id = ? AND to_address = ?',
|
||||
{ folderId, mail.delivery_id, address }
|
||||
)
|
||||
return
|
||||
end
|
||||
|
||||
-- Aeltere Mails haben keine delivery_id – dann bleibt es bei der Kopie.
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_mail SET folder_id = ? WHERE id = ? AND to_identifier = ?',
|
||||
{ folderId, mailId, identifier }
|
||||
)
|
||||
end
|
||||
|
||||
---@param folderId number
|
||||
---@param identifier string
|
||||
---@return table[]
|
||||
function DB.GetFolderMails(folderId, identifier)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail WHERE folder_id = ? AND to_identifier = ? ORDER BY sent_at DESC',
|
||||
{ folderId, identifier }
|
||||
) or {}
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@return table[]
|
||||
function DB.GetInboxOnly(identifier)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_mail WHERE to_identifier = ? AND folder_id IS NULL ORDER BY sent_at DESC LIMIT 100',
|
||||
{ identifier }
|
||||
)
|
||||
end
|
||||
|
||||
-- ── Calendar ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Ein Termin ist entweder privat (address IS NULL) oder gehoert einem Postfach.
|
||||
-- Postfachtermine sind entweder nur fuer dessen Leute sichtbar ('shared') oder
|
||||
-- fuer alle ('public') – bearbeiten darf sie in beiden Faellen nur, wer das
|
||||
-- Postfach bedienen darf.
|
||||
|
||||
--- Alle Termine, die diese Person sehen darf.
|
||||
---@param identifier string
|
||||
---@param addresses string[] Postfaecher, auf die sie Zugriff hat
|
||||
---@return table[]
|
||||
function DB.GetCalendar(identifier, addresses)
|
||||
addresses = addresses or {}
|
||||
|
||||
local params = { identifier }
|
||||
local clauses = { '(c.identifier = ? AND c.address IS NULL)' }
|
||||
|
||||
if #addresses > 0 then
|
||||
local marks = {}
|
||||
for _, addr in ipairs(addresses) do
|
||||
marks[#marks + 1] = '?'
|
||||
params[#params + 1] = addr
|
||||
end
|
||||
clauses[#clauses + 1] = ('(c.address IN (%s))'):format(table.concat(marks, ','))
|
||||
end
|
||||
|
||||
-- Oeffentliche Termine sieht jeder, auch ohne Zugriff auf das Postfach.
|
||||
clauses[#clauses + 1] = "(c.visibility = 'public')"
|
||||
|
||||
return MySQL.query.await(
|
||||
'SELECT c.* FROM pc_live_calendar c WHERE '
|
||||
.. table.concat(clauses, ' OR ') .. ' ORDER BY c.start_at ASC',
|
||||
params
|
||||
) or {}
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@return table|nil
|
||||
function DB.GetCalendarEvent(id)
|
||||
return MySQL.single.await('SELECT * FROM pc_live_calendar WHERE id = ?', { id })
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@param address string|nil '' oder nil = privat
|
||||
---@param visibility string 'private' | 'shared' | 'public'
|
||||
---@param data table
|
||||
---@return number
|
||||
function DB.AddCalendarEvent(identifier, address, visibility, data)
|
||||
return MySQL.insert.await(
|
||||
'INSERT INTO pc_live_calendar'
|
||||
.. ' (identifier, address, visibility, title, description, start_at, end_at, color)'
|
||||
.. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
{ identifier, (address ~= '' and address or nil), visibility,
|
||||
data.title, data.description or '', data.start_at,
|
||||
data.end_at or nil, data.color or '#00aaff' }
|
||||
)
|
||||
end
|
||||
|
||||
--- Aendern. Die Berechtigung prueft der Aufrufer (server/calendar.lua) –
|
||||
--- hier steht keine Bedingung mehr, weil ein Postfachtermin auch von jemandem
|
||||
--- geaendert werden darf, der ihn nicht angelegt hat.
|
||||
---@param id number
|
||||
---@param address string|nil
|
||||
---@param visibility string
|
||||
---@param data table
|
||||
function DB.UpdateCalendarEvent(id, address, visibility, data)
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_calendar SET address=?, visibility=?, title=?, description=?,'
|
||||
.. ' start_at=?, end_at=?, color=? WHERE id=?',
|
||||
{ (address ~= '' and address or nil), visibility,
|
||||
data.title, data.description or '', data.start_at,
|
||||
data.end_at or nil, data.color or '#00aaff', id }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
function DB.DeleteCalendarEvent(id)
|
||||
MySQL.update.await('DELETE FROM pc_live_calendar WHERE id = ?', { id })
|
||||
end
|
||||
|
||||
-- ── Contacts ───────────────────────────────────────────────────────────────
|
||||
|
||||
---@param identifier string
|
||||
---@return table[]
|
||||
function DB.GetContacts(identifier)
|
||||
return MySQL.query.await(
|
||||
'SELECT * FROM pc_live_contacts WHERE identifier = ? ORDER BY name ASC',
|
||||
{ identifier }
|
||||
)
|
||||
end
|
||||
|
||||
---@param identifier string
|
||||
---@param data table
|
||||
---@return number
|
||||
function DB.AddContact(identifier, data)
|
||||
return MySQL.insert.await(
|
||||
'INSERT INTO pc_live_contacts (identifier, name, email, phone, notes) VALUES (?, ?, ?, ?, ?)',
|
||||
{ identifier, data.name, data.email or '', data.phone or '', data.notes or '' }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@param identifier string
|
||||
---@param data table
|
||||
function DB.UpdateContact(id, identifier, data)
|
||||
MySQL.update.await(
|
||||
'UPDATE pc_live_contacts SET name=?, email=?, phone=?, notes=? WHERE id=? AND identifier=?',
|
||||
{ data.name, data.email or '', data.phone or '', data.notes or '', id, identifier }
|
||||
)
|
||||
end
|
||||
|
||||
---@param id number
|
||||
---@param identifier string
|
||||
function DB.DeleteContact(id, identifier)
|
||||
MySQL.update.await(
|
||||
'DELETE FROM pc_live_contacts WHERE id = ? AND identifier = ?',
|
||||
{ id, identifier }
|
||||
)
|
||||
end
|
||||
373
server/mail.lua
Normal file
373
server/mail.lua
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Mail handlers
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Mail = {}
|
||||
|
||||
-- Rate limiting: identifier -> { count, resetAt }
|
||||
local _rateMap = {}
|
||||
|
||||
--- Darf diese Person das Postfach benutzen?
|
||||
--- '' bedeutet "persoenliches Postfach" und ist immer erlaubt. Ohne diese
|
||||
--- Pruefung koennte der Client jede beliebige Adresse mitschicken und damit in
|
||||
--- fremden Ordnern lesen und schreiben.
|
||||
---@return string die gepruefte Adresse, oder '' bei fehlendem Zugriff
|
||||
local function CheckMailbox(identifier, address)
|
||||
address = tostring(address or '')
|
||||
if address == '' then return '' end
|
||||
if GetResourceState('ic-mail') ~= 'started' then return '' end
|
||||
|
||||
local ok = exports['ic-mail']:CanAccessAddress(identifier, address)
|
||||
return ok and address or ''
|
||||
end
|
||||
|
||||
--- Postfachliste samt Signaturen an einen Spieler schicken.
|
||||
--- Steht hier als Funktion, weil sie aus mehreren Ereignissen gebraucht wird –
|
||||
--- ein TriggerEvent auf das Netzwerkereignis waere kein Ersatz: dort ist
|
||||
--- `source` nicht der Spieler, und der Aufruf liefe ins Leere.
|
||||
local function SendMailboxes(src, identifier)
|
||||
local mailboxes = {}
|
||||
local personalAddr = ''
|
||||
|
||||
if GetResourceState('ic-mail') == 'started' then
|
||||
personalAddr = exports['ic-mail']:GetPrimaryAddress(identifier) or ''
|
||||
local addrs = exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}
|
||||
table.sort(addrs, function(a, b)
|
||||
if a.type == 'personal' and b.type ~= 'personal' then return true end
|
||||
if a.type ~= 'personal' and b.type == 'personal' then return false end
|
||||
return (a.address or '') < (b.address or '')
|
||||
end)
|
||||
mailboxes = addrs
|
||||
end
|
||||
|
||||
local personalSig = ''
|
||||
for _, mb in ipairs(mailboxes) do
|
||||
if mb.address == personalAddr then personalSig = mb.signature or '' end
|
||||
end
|
||||
|
||||
TriggerClientEvent('pc-live:client:sharedMailboxes', src, mailboxes, personalAddr, personalSig)
|
||||
end
|
||||
|
||||
local function CheckRateLimit(identifier)
|
||||
local now = os.time()
|
||||
local entry = _rateMap[identifier]
|
||||
if not entry or now >= entry.resetAt then
|
||||
_rateMap[identifier] = { count = 1, resetAt = now + 60 }
|
||||
return true
|
||||
end
|
||||
if entry.count >= Config.MailRateLimit then
|
||||
return false
|
||||
end
|
||||
entry.count = entry.count + 1
|
||||
return true
|
||||
end
|
||||
|
||||
-- ── Fetch inbox ─────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getInbox', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local rows = DB.GetInboxOnly(user.identifier)
|
||||
local folders = DB.GetFolders(user.identifier, '')
|
||||
TriggerClientEvent('pc-live:client:inboxData', src, rows, folders)
|
||||
end)
|
||||
|
||||
-- ── Fetch sent ───────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getSent', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local rows = DB.GetSent(user.identifier)
|
||||
TriggerClientEvent('pc-live:client:sentData', src, rows)
|
||||
end)
|
||||
|
||||
-- ── Fetch folder mails ───────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getFolderMails', function(folderId, address)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(folderId) ~= "number" then return end
|
||||
|
||||
local box = CheckMailbox(user.identifier, address)
|
||||
if not DB.GetFolder(folderId, user.identifier, box) then return end
|
||||
|
||||
local rows = DB.GetFolderMails(folderId, user.identifier)
|
||||
TriggerClientEvent('pc-live:client:folderMailsData', src, folderId, rows)
|
||||
end)
|
||||
|
||||
-- ── Ordner eines Postfachs holen ─────────────────────────────────────────────
|
||||
-- Wird beim Wechsel des Postfachs gerufen: jedes Postfach hat eigene Ordner.
|
||||
RegisterNetEvent('pc-live:server:getFolders', function(address)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local box = CheckMailbox(user.identifier, address)
|
||||
TriggerClientEvent('pc-live:client:foldersData', src, box,
|
||||
DB.GetFolders(user.identifier, box))
|
||||
end)
|
||||
|
||||
-- ── Create folder ────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:createFolder', function(name, address)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
name = Utils.Sanitize(tostring(name or "")):sub(1, 60)
|
||||
if #name < 1 then return end
|
||||
|
||||
local box = CheckMailbox(user.identifier, address)
|
||||
local id = DB.CreateFolder(user.identifier, box, name)
|
||||
|
||||
TriggerClientEvent('pc-live:client:folderCreated', src, { id = id, name = name, address = box })
|
||||
|
||||
-- Im geteilten Postfach sehen die anderen den Ordner sofort.
|
||||
if box ~= '' then
|
||||
for playerSrc, session in pairs(ActiveSessions) do
|
||||
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
|
||||
TriggerClientEvent('pc-live:client:foldersData', playerSrc, box,
|
||||
DB.GetFolders(session.identifier, box))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- ── Delete folder ────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:deleteFolder', function(folderId, address)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(folderId) ~= "number" then return end
|
||||
|
||||
local box = CheckMailbox(user.identifier, address)
|
||||
if not DB.GetFolder(folderId, user.identifier, box) then return end
|
||||
|
||||
DB.DeleteFolder(folderId, user.identifier, box)
|
||||
TriggerClientEvent('pc-live:client:folderDeleted', src, folderId, box)
|
||||
|
||||
if box ~= '' then
|
||||
for playerSrc, session in pairs(ActiveSessions) do
|
||||
if playerSrc ~= src and CheckMailbox(session.identifier, box) ~= '' then
|
||||
TriggerClientEvent('pc-live:client:folderDeleted', playerSrc, folderId, box)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- ── Move mail to folder ──────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:moveMail', function(mailId, folderId, address)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(mailId) ~= "number" then return end
|
||||
-- folderId may be nil (move to inbox)
|
||||
if folderId ~= nil and type(folderId) ~= "number" then return end
|
||||
|
||||
local box = CheckMailbox(user.identifier, address)
|
||||
if folderId ~= nil and not DB.GetFolder(folderId, user.identifier, box) then return end
|
||||
|
||||
DB.MoveMailToFolder(mailId, folderId, user.identifier, box)
|
||||
TriggerClientEvent('pc-live:client:mailMoved', src, mailId, folderId)
|
||||
end)
|
||||
|
||||
-- ── Read mail ───────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:readMail', function(mailId)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(mailId) ~= "number" then return end
|
||||
|
||||
local mail = DB.GetMail(mailId, user.identifier)
|
||||
if not mail then return end
|
||||
|
||||
DB.MarkRead(mailId, user.identifier)
|
||||
mail.is_read = 1
|
||||
TriggerClientEvent('pc-live:client:mailContent', src, mail)
|
||||
end)
|
||||
|
||||
-- ── Send mail ────────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:sendMail', function(payload)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
-- Validate payload shape
|
||||
if type(payload) ~= "table" then return end
|
||||
|
||||
local toId = Utils.Sanitize(tostring(payload.to or "")):sub(1, 120)
|
||||
local subject = Utils.Sanitize(tostring(payload.subject or "")):sub(1, Config.MailMaxSubject)
|
||||
local body = Utils.Sanitize(tostring(payload.body or "")):sub(1, Config.MailMaxBody)
|
||||
|
||||
if #toId < 3 or #subject < 1 or #body < 1 then
|
||||
Bridge.Notify(src, "Invalid mail data.", "error")
|
||||
return
|
||||
end
|
||||
|
||||
-- Rate limit
|
||||
if not CheckRateLimit(user.identifier) then
|
||||
Bridge.Notify(src, "You are sending mails too fast. Please wait.", "error")
|
||||
return
|
||||
end
|
||||
|
||||
-- Absender-Adresse ermitteln (ic-mail optional)
|
||||
local fromDisplay = user.identifier
|
||||
if GetResourceState and GetResourceState('ic-mail') == 'started' then
|
||||
local addr = exports['ic-mail']:GetPrimaryAddress(user.identifier)
|
||||
if addr then fromDisplay = addr end
|
||||
|
||||
-- Versand aus geteiltem Postfach: Zugriff prüfen
|
||||
local fromMailbox = Utils.Sanitize(tostring(payload.fromMailbox or '')):sub(1, 120)
|
||||
if fromMailbox ~= '' and fromMailbox ~= fromDisplay then
|
||||
local addrs = exports['ic-mail']:GetAddressesForIdentifier(user.identifier) or {}
|
||||
for _, a in ipairs(addrs) do
|
||||
if a.address == fromMailbox then
|
||||
fromDisplay = fromMailbox
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Empfänger auflösen: @-Adresse → Identifier-Liste, sonst direkt
|
||||
local recipients = {}
|
||||
if toId:find('@') and GetResourceState and GetResourceState('ic-mail') == 'started' then
|
||||
local resolved = exports['ic-mail']:ResolveAddress(toId)
|
||||
if resolved and resolved.identifiers and #resolved.identifiers > 0 then
|
||||
recipients = resolved.identifiers
|
||||
else
|
||||
Bridge.Notify(src, "Empfänger-Adresse nicht gefunden.", "error")
|
||||
return
|
||||
end
|
||||
else
|
||||
recipients = { toId }
|
||||
end
|
||||
|
||||
-- Eine Kennung fuer diese Zustellung. Alle Kopien tragen sie, damit das
|
||||
-- Einsortieren in einen Ordner im geteilten Postfach fuer alle gilt.
|
||||
local deliveryId = ('%08x-%04x-%04x-%04x-%012x'):format(
|
||||
os.time(), math.random(0, 0xFFFF), math.random(0, 0xFFFF),
|
||||
math.random(0, 0xFFFF), math.random(0, 0xFFFFFFFFFFFF))
|
||||
|
||||
-- Mail an alle Empfänger senden (pc_live_mail + ic-mail Dual-Write)
|
||||
for _, recipId in ipairs(recipients) do
|
||||
DB.SendMailExtended(recipId, fromDisplay, user.identifier, toId, subject, body, deliveryId)
|
||||
-- ic-mail parallel benachrichtigen (zentrale Mailplattform)
|
||||
pcall(function()
|
||||
if GetResourceState('ic-mail') == 'started' then
|
||||
exports['ic-mail']:SendSystemMail(recipId, subject, body)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
Bridge.Notify(src, "Mail gesendet.", "success")
|
||||
TriggerClientEvent('pc-live:client:mailSent', src)
|
||||
|
||||
-- Online-Empfänger benachrichtigen
|
||||
for playerSrc, session in pairs(ActiveSessions) do
|
||||
for _, recipId in ipairs(recipients) do
|
||||
if session.identifier == recipId then
|
||||
TriggerClientEvent('pc-live:client:newMailNotify', playerSrc)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- ── Delete mail ─────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:deleteMail', function(mailId)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(mailId) ~= "number" then return end
|
||||
|
||||
DB.DeleteMail(mailId, user.identifier)
|
||||
TriggerClientEvent('pc-live:client:mailDeleted', src, mailId)
|
||||
end)
|
||||
|
||||
-- ── Geteilte Postfächer (ic-mail Integration) ────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getSharedMailboxes', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
SendMailboxes(src, user.identifier)
|
||||
end)
|
||||
|
||||
-- ── Mail-Adresse prüfen (Ersteinrichtung) ────────────────────────────────────
|
||||
|
||||
RegisterNetEvent('pc-live:server:checkMailAddress', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
if GetResourceState('ic-mail') ~= 'started' then return end
|
||||
|
||||
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
|
||||
if existing then
|
||||
-- Adresse vorhanden → Status senden, Postfach lädt der Client selbst nach
|
||||
TriggerClientEvent('pc-live:client:mailAddressStatus', src, true, existing, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local realms = exports['ic-mail']:GetRealms() or {}
|
||||
local citizenRealms = {}
|
||||
for _, r in ipairs(realms) do
|
||||
if r.realm_type == 'citizen' and r.active then
|
||||
table.insert(citizenRealms, r)
|
||||
end
|
||||
end
|
||||
TriggerClientEvent('pc-live:client:mailAddressStatus', src, false, nil, citizenRealms)
|
||||
end)
|
||||
|
||||
RegisterNetEvent('pc-live:server:createMailAddress', function(username)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
if type(username) ~= 'string' or #username < 2 or #username > 40 then
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername muss 2-40 Zeichen haben')
|
||||
return
|
||||
end
|
||||
username = username:lower():gsub('[^a-z0-9._%-]', ''):gsub('^%.+', ''):gsub('%.+$', '')
|
||||
if #username < 2 then
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Ungültiger Benutzername')
|
||||
return
|
||||
end
|
||||
if GetResourceState('ic-mail') ~= 'started' then
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Mail-System nicht verfügbar')
|
||||
return
|
||||
end
|
||||
local existing = exports['ic-mail']:GetPrimaryAddress(user.identifier)
|
||||
if existing then
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, existing)
|
||||
return
|
||||
end
|
||||
local addr = exports['ic-mail']:EnsurePersonalAddress(user.identifier, username)
|
||||
if addr then
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, true, nil, addr)
|
||||
else
|
||||
TriggerClientEvent('pc-live:client:mailCreateResult', src, false, 'Benutzername bereits vergeben')
|
||||
end
|
||||
end)
|
||||
|
||||
-- ── Signaturen (ic-mail) ─────────────────────────────────────────────────────
|
||||
-- Die Signatur gehoert zum Postfach: eine Firmenadresse traegt dieselbe
|
||||
-- Fusszeile, egal wer sie gerade bedient. Geprueft wird in ic-mail.
|
||||
|
||||
RegisterNetEvent('pc-live:server:setSignature', function(address, signature)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
if GetResourceState('ic-mail') ~= 'started' then
|
||||
TriggerClientEvent('pc-live:client:signatureSaved', src, false, 'Mailsystem nicht verfügbar.')
|
||||
return
|
||||
end
|
||||
|
||||
local ok, err = exports['ic-mail']:SetSignature(
|
||||
tostring(address or ''), user.identifier, tostring(signature or ''))
|
||||
|
||||
TriggerClientEvent('pc-live:client:signatureSaved', src, ok == true, err)
|
||||
|
||||
-- Postfachliste nachschieben, damit die neue Signatur sofort greift.
|
||||
if ok then SendMailboxes(src, user.identifier) end
|
||||
end)
|
||||
230
server/main.lua
Normal file
230
server/main.lua
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Server Main – session management, boot, init
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Bridge namespace (must be declared before bridge file is loaded;
|
||||
-- bridge file is loaded by fxmanifest before this file)
|
||||
Bridge = Bridge or {}
|
||||
|
||||
-- Active sessions: source -> { identifier, displayName, dbId, pcId }
|
||||
ActiveSessions = {}
|
||||
|
||||
-- ── PC Cache (loaded from DB) ─────────────────────────────────────────────────
|
||||
|
||||
local PCCache = {} -- keyed by id (aus DB-Tabelle pc_live_devices)
|
||||
|
||||
-- Virtuelle/mobile Terminals aus der Config (kein DB-Standort), z.B. Streifenwagen-PC.
|
||||
local MobilePCs = {}
|
||||
for _, pc in ipairs(Config.PCs or {}) do
|
||||
if pc.mobile then MobilePCs[pc.id] = pc end
|
||||
end
|
||||
|
||||
local function LoadPCsFromDB()
|
||||
local rows = MySQL.query.await('SELECT * FROM pc_live_devices WHERE active = 1')
|
||||
PCCache = {}
|
||||
for _, row in ipairs(rows or {}) do
|
||||
PCCache[row.id] = {
|
||||
id = row.id,
|
||||
label = row.label,
|
||||
coords = vector3(row.x, row.y, row.z),
|
||||
heading = row.heading,
|
||||
type = row.type,
|
||||
job = row.job,
|
||||
start_page = row.start_page,
|
||||
}
|
||||
end
|
||||
Utils.Log(("Loaded %d PC devices from DB"):format(#(rows or {})))
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
Wait(500)
|
||||
LoadPCsFromDB()
|
||||
end)
|
||||
|
||||
-- Reload nach Änderungen (z.B. durch Backend oder F6)
|
||||
AddEventHandler('pc-live:reloadDevices', function()
|
||||
LoadPCsFromDB()
|
||||
local list = {}
|
||||
for _, pc in pairs(PCCache) do
|
||||
list[#list + 1] = { id=pc.id, label=pc.label, heading=pc.heading, type=pc.type,
|
||||
job=pc.job, start_page=pc.start_page,
|
||||
coords={ x=pc.coords.x, y=pc.coords.y, z=pc.coords.z } }
|
||||
end
|
||||
TriggerClientEvent('pc-live:client:syncDevices', -1, list)
|
||||
end)
|
||||
|
||||
-- Client fragt nach Device-Liste
|
||||
RegisterNetEvent('pc-live:server:requestDevices', function()
|
||||
local src = source
|
||||
local list = {}
|
||||
for _, pc in pairs(PCCache) do
|
||||
list[#list + 1] = { id=pc.id, label=pc.label, heading=pc.heading, type=pc.type,
|
||||
job=pc.job, start_page=pc.start_page,
|
||||
coords={ x=pc.coords.x, y=pc.coords.y, z=pc.coords.z } }
|
||||
end
|
||||
TriggerClientEvent('pc-live:client:syncDevices', src, list)
|
||||
end)
|
||||
|
||||
local function GetPCConfig(pcId)
|
||||
return PCCache[pcId] or MobilePCs[pcId]
|
||||
end
|
||||
|
||||
local function IsPlayerNearPC(source, pcConfig)
|
||||
-- We trust client position check; server re-validates via ped coords
|
||||
local ped = GetPlayerPed(source)
|
||||
if not ped or ped == 0 then return false end
|
||||
local px, py, pz = table.unpack(GetEntityCoords(ped))
|
||||
local cx, cy, cz = table.unpack({ pcConfig.coords.x, pcConfig.coords.y, pcConfig.coords.z })
|
||||
local dist = #(vector3(px, py, pz) - vector3(cx, cy, cz))
|
||||
return dist <= (Config.InteractRange + 2.0) -- small server-side tolerance
|
||||
end
|
||||
|
||||
-- ── Open PC session ──────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:openPC', function(pcId)
|
||||
local src = source
|
||||
if ActiveSessions[src] then return end -- already in session
|
||||
|
||||
if type(pcId) ~= "string" then return end
|
||||
pcId = pcId:sub(1, 64)
|
||||
|
||||
local pcConfig = GetPCConfig(pcId)
|
||||
if not pcConfig then
|
||||
Utils.Warn(("Player %d tried unknown PC id: %s"):format(src, pcId))
|
||||
return
|
||||
end
|
||||
|
||||
-- Distance check (mobile Terminals wie der Streifenwagen-PC haben keinen Standort)
|
||||
if not pcConfig.mobile and not IsPlayerNearPC(src, pcConfig) then
|
||||
Utils.Warn(("Player %d too far from PC %s"):format(src, pcId))
|
||||
return
|
||||
end
|
||||
|
||||
-- Job lock check – pcConfig.job kann eine Liste aus Behörden-Jobs und
|
||||
-- Firmen-Tokens ("company:<id>") sein, z.B. "dpa, police, company:3".
|
||||
if pcConfig.type == "job" and pcConfig.job and pcConfig.job ~= "" then
|
||||
local job = Bridge.GetJob(src)
|
||||
local allowed = false
|
||||
for token in tostring(pcConfig.job):gmatch("[^,%s]+") do
|
||||
local companyId = token:match("^company:(%d+)$")
|
||||
if companyId then
|
||||
if Bridge.IsCompanyEmployee and Bridge.IsCompanyEmployee(src, tonumber(companyId)) then
|
||||
allowed = true; break
|
||||
end
|
||||
elseif job == token then
|
||||
allowed = true; break
|
||||
end
|
||||
end
|
||||
if not allowed then
|
||||
Bridge.Notify(src, "You don't have access to this terminal.", "error")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local identifier = Bridge.GetIdentifier(src)
|
||||
local displayName = Bridge.GetName(src)
|
||||
local dbUser = DB.GetOrCreateUser(identifier, displayName)
|
||||
|
||||
if not dbUser then
|
||||
Utils.Error(("Failed to get/create user for %s"):format(identifier))
|
||||
return
|
||||
end
|
||||
|
||||
-- Install default apps if first time
|
||||
local installs = DB.GetInstalls(dbUser.id)
|
||||
local installedIds = {}
|
||||
for _, row in ipairs(installs) do installedIds[row.app_id] = true end
|
||||
|
||||
for _, app in ipairs(AppRegistry.GetAll()) do
|
||||
if app.default and not installedIds[app.app_id] then
|
||||
DB.InstallApp(dbUser.id, app.app_id, app.version)
|
||||
end
|
||||
end
|
||||
|
||||
-- Apps eines (mobilen) Terminals sicher installieren, damit sie verfügbar sind.
|
||||
local ensureApps = pcConfig.apps or (pcConfig.auto_open and { pcConfig.auto_open }) or {}
|
||||
if #ensureApps > 0 then
|
||||
local byId = {}
|
||||
for _, app in ipairs(AppRegistry.GetAll()) do byId[app.app_id] = app end
|
||||
for _, appId in ipairs(ensureApps) do
|
||||
if not installedIds[appId] and byId[appId] then
|
||||
DB.InstallApp(dbUser.id, appId, byId[appId].version)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Reload installs after defaults
|
||||
installs = DB.GetInstalls(dbUser.id)
|
||||
|
||||
ActiveSessions[src] = {
|
||||
identifier = identifier,
|
||||
displayName = displayName,
|
||||
dbId = dbUser.id,
|
||||
pcId = pcId,
|
||||
}
|
||||
|
||||
-- Adresse wird über den Tablet-Assistenten erstellt, nicht automatisch
|
||||
|
||||
-- Postfächer für diesen Spieler laden (ic-mail optional)
|
||||
local sharedMailboxes = {}
|
||||
local personalAddress = ''
|
||||
if GetResourceState and GetResourceState('ic-mail') == 'started' then
|
||||
personalAddress = exports['ic-mail']:GetPrimaryAddress(identifier) or ''
|
||||
local addrs = exports['ic-mail']:GetAddressesForIdentifier(identifier) or {}
|
||||
table.sort(addrs, function(a, b)
|
||||
if a.type == 'personal' and b.type ~= 'personal' then return true end
|
||||
if a.type ~= 'personal' and b.type == 'personal' then return false end
|
||||
return (a.address or '') < (b.address or '')
|
||||
end)
|
||||
sharedMailboxes = addrs
|
||||
end
|
||||
|
||||
local installed = AppRegistry.BuildInstalledList(installs)
|
||||
-- Mobiles Terminal: nur die freigegebenen Apps anzeigen (Whitelist aus der PC-Config).
|
||||
if pcConfig.apps then
|
||||
local allow = {}
|
||||
for _, a in ipairs(pcConfig.apps) do allow[a] = true end
|
||||
local filtered = {}
|
||||
for _, app in ipairs(installed) do
|
||||
if allow[app.app_id] then filtered[#filtered + 1] = app end
|
||||
end
|
||||
installed = filtered
|
||||
end
|
||||
local unread = DB.CountUnread(identifier)
|
||||
local settings = json.decode(dbUser.settings_json or "{}") or {}
|
||||
|
||||
TriggerClientEvent('pc-live:client:sessionStart', src, {
|
||||
pcId = pcId,
|
||||
pcLabel = pcConfig.label,
|
||||
startPage = pcConfig.start_page,
|
||||
autoOpenApp = pcConfig.auto_open,
|
||||
identifier = identifier,
|
||||
displayName = displayName,
|
||||
installedApps = installed,
|
||||
unread = unread,
|
||||
settings = settings,
|
||||
sharedMailboxes = sharedMailboxes,
|
||||
personalAddress = personalAddress,
|
||||
-- Signatur des persoenlichen Postfachs. Steckt zwar auch in
|
||||
-- sharedMailboxes, aber der Client kennt es dort nur ueber die Adresse.
|
||||
personalSignature = (function()
|
||||
for _, mb in ipairs(sharedMailboxes) do
|
||||
if mb.address == personalAddress then return mb.signature or '' end
|
||||
end
|
||||
return ''
|
||||
end)(),
|
||||
})
|
||||
end)
|
||||
|
||||
-- ── Close PC session ──────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:closePC', function()
|
||||
local src = source
|
||||
ActiveSessions[src] = nil
|
||||
Utils.Log(("Session closed for player %d"):format(src))
|
||||
end)
|
||||
|
||||
-- ── Player disconnect cleanup ─────────────────────────────────────────────────
|
||||
AddEventHandler('playerDropped', function()
|
||||
ActiveSessions[source] = nil
|
||||
end)
|
||||
|
||||
Utils.Log("Server main loaded.")
|
||||
124
server/store.lua
Normal file
124
server/store.lua
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Store handlers – install / uninstall / list
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- ── List store ───────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:getStore', function()
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
local installs = DB.GetInstalls(user.dbId)
|
||||
local storeList = AppRegistry.BuildStoreList(installs)
|
||||
TriggerClientEvent('pc-live:client:storeData', src, storeList)
|
||||
end)
|
||||
|
||||
-- ── Install app ──────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:installApp', function(appId)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
if type(appId) ~= "string" then return end
|
||||
appId = appId:sub(1, 64)
|
||||
|
||||
local manifest = AppRegistry.Get(appId)
|
||||
if not manifest then
|
||||
Bridge.Notify(src, "App not found.", "error")
|
||||
return
|
||||
end
|
||||
|
||||
-- Fetch installs once – used for all checks below
|
||||
local installs = DB.GetInstalls(user.dbId)
|
||||
|
||||
-- Already installed and up to date?
|
||||
local installedVersion = nil
|
||||
for _, row in ipairs(installs) do
|
||||
if row.app_id == appId then
|
||||
installedVersion = row.version
|
||||
break
|
||||
end
|
||||
end
|
||||
if installedVersion and installedVersion == manifest.version then
|
||||
Bridge.Notify(src, "Already installed.", "primary")
|
||||
return
|
||||
end
|
||||
|
||||
-- Job permission check (single job OR list of jobs)
|
||||
local perms = manifest.permissions or {}
|
||||
if perms.job or perms.jobs then
|
||||
local playerJob = Bridge.GetJob and Bridge.GetJob(src) or nil
|
||||
local allowed = false
|
||||
if perms.jobs and type(perms.jobs) == "table" then
|
||||
for _, j in ipairs(perms.jobs) do
|
||||
if j == playerJob then allowed = true; break end
|
||||
end
|
||||
elseif perms.job then
|
||||
allowed = (playerJob == perms.job)
|
||||
end
|
||||
if not allowed then
|
||||
local jobList = perms.jobs and table.concat(perms.jobs, ", ") or perms.job
|
||||
Bridge.Notify(src, ("Zugriff verweigert. Benötigt: %s"):format(jobList), "error")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Dependency check
|
||||
if manifest.dependencies and #manifest.dependencies > 0 then
|
||||
local installedMap = {}
|
||||
for _, row in ipairs(installs) do
|
||||
installedMap[row.app_id] = true
|
||||
end
|
||||
for _, depId in ipairs(manifest.dependencies) do
|
||||
if not installedMap[depId] then
|
||||
local dep = AppRegistry.Get(depId)
|
||||
Bridge.Notify(src, ("Missing dependency: %s"):format(dep and dep.name or depId), "error")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Price check
|
||||
local price = manifest.price or 0
|
||||
if price > 0 then
|
||||
local balance = Bridge.GetMoney(src, "bank")
|
||||
if balance < price then
|
||||
Bridge.Notify(src, ("Insufficient funds. Required: $%d"):format(price), "error")
|
||||
return
|
||||
end
|
||||
Bridge.RemoveMoney(src, "bank", price)
|
||||
end
|
||||
|
||||
DB.InstallApp(user.dbId, appId, manifest.version)
|
||||
|
||||
-- Refresh desktop icons AND store view
|
||||
local updated = DB.GetInstalls(user.dbId)
|
||||
TriggerClientEvent('pc-live:client:installedApps', src, AppRegistry.BuildInstalledList(updated))
|
||||
TriggerClientEvent('pc-live:client:storeData', src, AppRegistry.BuildStoreList(updated))
|
||||
Bridge.Notify(src, ("%s installed."):format(manifest.name), "success")
|
||||
end)
|
||||
|
||||
-- ── Uninstall app ────────────────────────────────────────────────────────────
|
||||
RegisterNetEvent('pc-live:server:uninstallApp', function(appId)
|
||||
local src = source
|
||||
local user = ActiveSessions[src]
|
||||
if not user then return end
|
||||
|
||||
if type(appId) ~= "string" then return end
|
||||
appId = appId:sub(1, 64)
|
||||
|
||||
-- Cannot uninstall default system apps
|
||||
local manifest = AppRegistry.Get(appId)
|
||||
if manifest and manifest.default then
|
||||
Bridge.Notify(src, "System apps cannot be uninstalled.", "error")
|
||||
return
|
||||
end
|
||||
|
||||
DB.UninstallApp(user.dbId, appId)
|
||||
|
||||
-- Refresh desktop icons AND store view
|
||||
local updated = DB.GetInstalls(user.dbId)
|
||||
TriggerClientEvent('pc-live:client:installedApps', src, AppRegistry.BuildInstalledList(updated))
|
||||
TriggerClientEvent('pc-live:client:storeData', src, AppRegistry.BuildStoreList(updated))
|
||||
Bridge.Notify(src, "App uninstalled.", "primary")
|
||||
end)
|
||||
58
shared/utils.lua
Normal file
58
shared/utils.lua
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---@class Utils
|
||||
Utils = {}
|
||||
|
||||
--- Trim whitespace from both ends of a string.
|
||||
---@param s string
|
||||
---@return string
|
||||
function Utils.Trim(s)
|
||||
return s:match("^%s*(.-)%s*$")
|
||||
end
|
||||
|
||||
--- Clamp a value between min and max.
|
||||
---@param val number
|
||||
---@param min number
|
||||
---@param max number
|
||||
---@return number
|
||||
function Utils.Clamp(val, min, max)
|
||||
if val < min then return min end
|
||||
if val > max then return max end
|
||||
return val
|
||||
end
|
||||
|
||||
--- Sanitize a string for safe DB storage (basic, not a replacement for prepared statements).
|
||||
---@param s string
|
||||
---@return string
|
||||
function Utils.Sanitize(s)
|
||||
return tostring(s):gsub("[<>\"'%;]", "")
|
||||
end
|
||||
|
||||
--- Check if a value exists in a plain table.
|
||||
---@param tbl table
|
||||
---@param val any
|
||||
---@return boolean
|
||||
function Utils.TableContains(tbl, val)
|
||||
for _, v in ipairs(tbl) do
|
||||
if v == val then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Generate a simple UUID-like string (server-side only, uses os.time).
|
||||
---@return string
|
||||
function Utils.UUID()
|
||||
local t = os.time()
|
||||
return string.format("%x-%x", t, math.random(0xFFFF))
|
||||
end
|
||||
|
||||
-- Logging helpers
|
||||
function Utils.Log(msg)
|
||||
print(("[pc-live] %s"):format(msg))
|
||||
end
|
||||
|
||||
function Utils.Warn(msg)
|
||||
print(("[pc-live] [WARN] %s"):format(msg))
|
||||
end
|
||||
|
||||
function Utils.Error(msg)
|
||||
print(("[pc-live] [ERROR] %s"):format(msg))
|
||||
end
|
||||
93
sql/install.sql
Normal file
93
sql/install.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- pc-live | Database Schema
|
||||
-- Run this once against your game database.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`display_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`settings_json` TEXT NOT NULL DEFAULT '{}',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_installs` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`app_id` VARCHAR(80) NOT NULL,
|
||||
`version` VARCHAR(20) NOT NULL DEFAULT '1.0.0',
|
||||
`installed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_user_app` (`user_id`, `app_id`),
|
||||
CONSTRAINT `fk_installs_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `pc_live_users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_mail` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`to_identifier` VARCHAR(120) NOT NULL,
|
||||
`from_identifier` VARCHAR(120) NOT NULL,
|
||||
`subject` VARCHAR(80) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`folder_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`sent_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_to_identifier` (`to_identifier`),
|
||||
KEY `idx_sent_at` (`sent_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_mail_folders` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_calendar` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`description` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`start_at` DATETIME NOT NULL,
|
||||
`end_at` DATETIME NULL DEFAULT NULL,
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT '#00aaff',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`),
|
||||
KEY `idx_start_at` (`start_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_contacts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`email` VARCHAR(120) NOT NULL DEFAULT '',
|
||||
`phone` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`notes` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Migration: add folder_id to pc_live_mail if it doesn't exist yet
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD COLUMN IF NOT EXISTS `folder_id` INT UNSIGNED NULL DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD COLUMN IF NOT EXISTS `sender_id` VARCHAR(120) NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `to_address` VARCHAR(120) NULL DEFAULT NULL;
|
||||
34
sql/migration_kalender.sql
Normal file
34
sql/migration_kalender.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- pc-live | Private, geteilte und oeffentliche Kalender
|
||||
--
|
||||
-- Bisher gehoerte jeder Termin genau einer Person. Kalender sollen aber auch
|
||||
-- gemeinsam gefuehrt werden – Dienstplaene, Gerichtstermine, Veranstaltungen.
|
||||
--
|
||||
-- Geteilt wird ueber dasselbe Mittel wie Postfaecher und Ordner: die Adresse.
|
||||
-- Wer das Postfach bedienen darf, fuehrt auch dessen Kalender.
|
||||
--
|
||||
-- visibility address sieht bearbeitet
|
||||
-- ────────── ───────── ──────────────────────── ───────────────────────
|
||||
-- private NULL nur die Person nur die Person
|
||||
-- shared Postfach wer das Postfach hat wer das Postfach hat
|
||||
-- public Postfach jeder auf dem Server wer das Postfach hat
|
||||
--
|
||||
-- Ein oeffentlicher Termin haengt bewusst auch an einem Postfach: sonst gaebe
|
||||
-- es niemanden, der ihn spaeter noch aendern oder absagen koennte.
|
||||
--
|
||||
-- Wiederholbar ausfuehrbar.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE `pc_live_calendar`
|
||||
ADD COLUMN IF NOT EXISTS `address` VARCHAR(200) NULL DEFAULT NULL
|
||||
COMMENT 'Postfach, dessen Kalender das ist. NULL = privat';
|
||||
|
||||
ALTER TABLE `pc_live_calendar`
|
||||
ADD COLUMN IF NOT EXISTS `visibility` ENUM('private','shared','public')
|
||||
NOT NULL DEFAULT 'private';
|
||||
|
||||
ALTER TABLE `pc_live_calendar`
|
||||
ADD INDEX IF NOT EXISTS `idx_address` (`address`);
|
||||
|
||||
ALTER TABLE `pc_live_calendar`
|
||||
ADD INDEX IF NOT EXISTS `idx_visibility` (`visibility`);
|
||||
34
sql/migration_postfach_ordner.sql
Normal file
34
sql/migration_postfach_ordner.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- pc-live | Ordner je Postfach, Zustellungen zusammenhalten
|
||||
--
|
||||
-- Bisher gehoerten Ordner der Person (identifier). Geteilte Postfaecher werden
|
||||
-- aber von mehreren bedient – dort muss die Ordnerstruktur dieselbe sein,
|
||||
-- sonst sortiert jeder in seine eigenen Faecher.
|
||||
--
|
||||
-- address = NULL → persoenliche Ordner dieser Person (wie bisher)
|
||||
-- address gesetzt → Ordner des Postfachs, fuer alle gleich
|
||||
--
|
||||
-- Eine Mail an ein geteiltes Postfach wird je Empfaenger einmal gespeichert
|
||||
-- (siehe DB.SendMailExtended). Damit das Einsortieren fuer alle gilt, bekommen
|
||||
-- alle Kopien einer Zustellung dieselbe delivery_id: wird eine Kopie in einen
|
||||
-- Ordner geschoben, wandern die anderen mit.
|
||||
--
|
||||
-- Gelesen/geloescht bleibt bewusst persoenlich – das ist keine Eigenschaft der
|
||||
-- Nachricht, sondern der Person.
|
||||
--
|
||||
-- Wiederholbar ausfuehrbar.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE `pc_live_mail_folders`
|
||||
ADD COLUMN IF NOT EXISTS `address` VARCHAR(200) NULL DEFAULT NULL
|
||||
COMMENT 'Postfach, zu dem der Ordner gehoert. NULL = persoenlich';
|
||||
|
||||
ALTER TABLE `pc_live_mail_folders`
|
||||
ADD INDEX IF NOT EXISTS `idx_address` (`address`);
|
||||
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD COLUMN IF NOT EXISTS `delivery_id` CHAR(36) NULL DEFAULT NULL
|
||||
COMMENT 'Alle Kopien einer Zustellung teilen sich diesen Wert';
|
||||
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD INDEX IF NOT EXISTS `idx_delivery` (`delivery_id`);
|
||||
93
sql/pc_live.sql
Normal file
93
sql/pc_live.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- pc-live | Database Schema
|
||||
-- Run this once against your game database.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`display_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`settings_json` TEXT NOT NULL DEFAULT '{}',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_installs` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INT UNSIGNED NOT NULL,
|
||||
`app_id` VARCHAR(80) NOT NULL,
|
||||
`version` VARCHAR(20) NOT NULL DEFAULT '1.0.0',
|
||||
`installed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_user_app` (`user_id`, `app_id`),
|
||||
CONSTRAINT `fk_installs_user` FOREIGN KEY (`user_id`)
|
||||
REFERENCES `pc_live_users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_mail` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`to_identifier` VARCHAR(120) NOT NULL,
|
||||
`from_identifier` VARCHAR(120) NOT NULL,
|
||||
`subject` VARCHAR(80) NOT NULL,
|
||||
`body` TEXT NOT NULL,
|
||||
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`folder_id` INT UNSIGNED NULL DEFAULT NULL,
|
||||
`sent_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_to_identifier` (`to_identifier`),
|
||||
KEY `idx_sent_at` (`sent_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_mail_folders` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`name` VARCHAR(80) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_calendar` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`title` VARCHAR(120) NOT NULL,
|
||||
`description` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`start_at` DATETIME NOT NULL,
|
||||
`end_at` DATETIME NULL DEFAULT NULL,
|
||||
`color` VARCHAR(20) NOT NULL DEFAULT '#00aaff',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`),
|
||||
KEY `idx_start_at` (`start_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_contacts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`identifier` VARCHAR(120) NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`email` VARCHAR(120) NOT NULL DEFAULT '',
|
||||
`phone` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`notes` VARCHAR(500) NOT NULL DEFAULT '',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_identifier` (`identifier`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Migration: add folder_id to pc_live_mail if it doesn't exist yet
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD COLUMN IF NOT EXISTS `folder_id` INT UNSIGNED NULL DEFAULT NULL;
|
||||
|
||||
ALTER TABLE `pc_live_mail`
|
||||
ADD COLUMN IF NOT EXISTS `sender_id` VARCHAR(120) NULL DEFAULT NULL,
|
||||
ADD COLUMN IF NOT EXISTS `to_address` VARCHAR(120) NULL DEFAULT NULL;
|
||||
37
sql/pc_live_devices.sql
Normal file
37
sql/pc_live_devices.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- pc-live | Feste PC-Standorte
|
||||
--
|
||||
-- server/main.lua liest diese Tabelle (LoadPCsFromDB), aber sie fehlt in
|
||||
-- install.sql und pc_live.sql. Ohne sie liefert die Abfrage nichts, PCCache
|
||||
-- bleibt leer und KEIN fester PC ist ansprechbar – nur mobile Terminals aus
|
||||
-- Config.PCs funktionieren.
|
||||
--
|
||||
-- ACHTUNG, doppelte Pflege: Standort, Label und Jobsperre kommen aus dieser
|
||||
-- Tabelle, die Kamerafahrt beim Öffnen aber aus Config.PCs (client/
|
||||
-- interaction.lua sucht dort nach der id). Ein PC braucht deshalb beides:
|
||||
-- eine Zeile hier UND einen Eintrag mit derselben id in Config.PCs. Fehlt der
|
||||
-- Config-Eintrag, lässt sich der PC benutzen, aber ohne Kamera und ohne
|
||||
-- Einfrieren.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pc_live_devices` (
|
||||
`id` VARCHAR(60) NOT NULL, -- muss zu Config.PCs passen
|
||||
`label` VARCHAR(80) NOT NULL DEFAULT 'Terminal',
|
||||
`x` DOUBLE NOT NULL,
|
||||
`y` DOUBLE NOT NULL,
|
||||
`z` DOUBLE NOT NULL,
|
||||
`heading` DOUBLE NOT NULL DEFAULT 0, -- Blickrichtung AUF den Schirm
|
||||
`type` ENUM('public','job') NOT NULL DEFAULT 'public',
|
||||
`job` VARCHAR(120) NULL DEFAULT NULL, -- bei type='job': ein Job oder Liste "police, ambulance"
|
||||
`start_page` VARCHAR(120) NULL DEFAULT NULL, -- Browser-Startseite, z. B. 'ic://weazel-news.ls'
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Erster Standort: Monitor im Mission Row PD (prop_monitor_01a, Interior 137473).
|
||||
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)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
label = VALUES(label), x = VALUES(x), y = VALUES(y), z = VALUES(z),
|
||||
heading = VALUES(heading), type = VALUES(type), job = VALUES(job), active = VALUES(active);
|
||||
Loading…
Add table
Add a link
Reference in a new issue