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:
Bjoern Flessing 2026-08-09 12:02:11 +00:00
commit be502d2834
46 changed files with 13119 additions and 0 deletions

307
nui/js/apps/addressbook.js Normal file
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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

File diff suppressed because it is too large Load diff

481
nui/js/apps/bleeteradmin.js Normal file
View 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
View 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 &nbsp;·&nbsp; 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 &amp; Ö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 &nbsp;·&nbsp; 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 &amp; 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 &nbsp;·&nbsp; 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 &amp; 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 &amp; 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 &nbsp;·&nbsp; 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 &amp; 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 &nbsp;·&nbsp; 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 &amp; Infrastruktur</div>
</div>
</div>
<div class="article"><div class="article-date">Verwaltung</div>
<h2>Staatliche Verwaltung &amp; Ö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 &nbsp;·&nbsp; 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 &amp; Anfragen</div>
</div>
</div>
<div class="article"><div class="article-date">Information</div>
<h2>Knöllchen &amp; 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 &nbsp;·&nbsp; <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 &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 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.
&nbsp;·&nbsp; 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, '&quot;')}"
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
View 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 };
})();

View 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

File diff suppressed because it is too large Load diff

344
nui/js/apps/parkuhr.js Normal file
View 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} &nbsp;·&nbsp; <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 };
})();

View 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
View 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, '&quot;')}"
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 &nbsp;·&nbsp; ${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

File diff suppressed because it is too large Load diff