aether-framework/resources/[aether]/aether-documents/html/index.html
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

710 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
</head>
<body>
<title>Aether — Börse &amp; Dokumente</title>
<style>
/* ===================== Tokens ===================== */
:root {
--page-bg: #0a0b10;
--fg: #e9ecf4;
--muted: #868da2;
--surface: rgba(24,26,36,0.7);
--surface2: rgba(38,41,55,0.8);
--border: rgba(255,255,255,0.09);
--accent: #c79a5a; /* Leder-Gold */
--red: #f0506e;
--green: #34d39a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
min-height: 100%;
background:
radial-gradient(1100px 700px at 15% 8%, rgba(199,154,90,0.08), transparent 55%),
radial-gradient(1000px 800px at 85% 92%, rgba(74,168,255,0.06), transparent 55%),
var(--page-bg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: var(--fg);
display: flex; align-items: center; justify-content: center; padding: 32px 18px; overflow-x: hidden;
}
/* ===================== Börse (Wallet) ===================== */
.wallet {
position: relative; width: min(1080px, 96vw); aspect-ratio: 16 / 10;
border-radius: 22px; overflow: hidden; display: flex; flex-direction: column;
background:
linear-gradient(150deg, #3a2c1f, #241a12 60%, #1a130d),
var(--page-bg);
border: 1px solid rgba(199,154,90,0.28);
box-shadow: 0 50px 100px -30px rgba(0,0,0,0.85),
0 0 0 1px rgba(0,0,0,0.4), 0 2px 0 rgba(255,255,255,0.05) inset;
}
/* Ledernaht */
.wallet::before {
content: ""; position: absolute; inset: 10px; border-radius: 16px; pointer-events: none; z-index: 1;
border: 1.5px dashed rgba(199,154,90,0.35);
}
.wallet-head { position: relative; z-index: 2; display: flex; align-items: center; gap: 14px;
padding: 18px 26px; border-bottom: 1px solid rgba(199,154,90,0.2); }
.wallet-head .logo { width: 38px; height: 38px; border-radius: 10px; display: grid; place-items: center; font-size: 20px;
background: linear-gradient(150deg, #c79a5a, #8a6a34); box-shadow: 0 6px 16px rgba(199,154,90,0.4); }
.wallet-head h1 { font-size: 19px; margin: 0; font-weight: 800; letter-spacing: -0.02em; }
.wallet-head h1 small { display: block; font-size: 11.5px; font-weight: 500; color: #b79b74; letter-spacing: 0; }
.wallet-head .count { margin-left: auto; font-size: 12.5px; color: #b79b74; font-weight: 600; }
/* Börsen-Wechsel */
.wtabs { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.wtab { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 7px 14px; border-radius: 11px;
background: rgba(0,0,0,0.25); border: 1px solid rgba(199,154,90,0.25); color: #cbb794; cursor: pointer; font-family: inherit; transition: .12s; }
.wtab:hover { border-color: var(--accent); }
.wtab.active { background: linear-gradient(150deg,#c79a5a,#8a6a34); color: #241a0c; border-color: transparent; }
.wtab .wn { font-size: 13px; font-weight: 800; }
.wtab .wc { font-size: 10.5px; font-weight: 600; opacity: 0.85; font-variant-numeric: tabular-nums; }
.wtab.buy { justify-content: center; align-items: center; color: var(--accent); border-style: dashed; font-weight: 800; font-size: 13px; }
.wtab.buy:hover { background: rgba(199,154,90,0.12); }
.wallet-body { position: relative; z-index: 2; flex: 1; display: grid; grid-template-columns: 268px 1fr; min-height: 0; }
.doclist { padding: 18px; overflow-y: auto; border-right: 1px solid rgba(199,154,90,0.18); display: flex; flex-direction: column; gap: 9px; }
.doclist::-webkit-scrollbar { width: 6px; } .doclist::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
.doctab { display: flex; align-items: center; gap: 12px; padding: 12px 13px; border-radius: 13px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border); position: relative; overflow: hidden; transition: .14s; }
.doctab::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--dc, #888); }
.doctab:hover { background: var(--surface2); transform: translateX(2px); }
.doctab.active { border-color: var(--dc); box-shadow: 0 0 0 1px var(--dc) inset; }
.doctab .di { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; font-size: 17px; flex-shrink: 0;
background: rgba(255,255,255,0.06); }
.doctab .dn { font-size: 14px; font-weight: 700; }
.doctab .dm { font-size: 11.5px; color: var(--muted); }
.stage { padding: 26px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; min-height: 0; overflow-y: auto; }
.stage-actions { display: flex; gap: 10px; }
.btn { border: none; border-radius: 12px; padding: 12px 22px; font-size: 14.5px; font-weight: 700; cursor: pointer; font-family: inherit; transition: .12s; }
.btn.primary { background: var(--accent); color: #241a0c; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { background: var(--surface2); color: var(--red); border: 1px solid var(--border); }
.btn.danger:hover { background: rgba(240,80,110,0.14); }
.btn.ghost { background: var(--surface2); color: var(--fg); border: 1px solid var(--border); }
.btn:active { transform: scale(0.97); }
.empty { text-align: center; color: var(--muted); }
.empty .big { font-size: 52px; margin-bottom: 10px; }
/* ===================== Dokument-Basis (ID-1 Format) ===================== */
.doccard {
width: 100%; max-width: 480px; aspect-ratio: 1.586 / 1; border-radius: 16px; position: relative;
overflow: hidden; color: #10141a; box-shadow: 0 30px 60px -20px rgba(0,0,0,0.7), 0 0 0 1px rgba(0,0,0,0.3);
font-size: 13px; user-select: none;
}
.doccard .foto { border-radius: 8px; background: linear-gradient(160deg,#c9d2dc,#8b97a6); overflow: hidden;
display: grid; place-items: end center; }
.doccard .foto svg { width: 100%; height: 100%; }
.lbl { font-size: 8px; letter-spacing: 0.05em; text-transform: uppercase; opacity: 0.7; font-weight: 700; }
.val { font-size: 12.5px; font-weight: 700; margin-top: 1px; }
.mono { font-family: ui-monospace, "SF Mono", monospace; letter-spacing: 0.06em; }
/* Hologramm-Schimmer */
.holo { position: absolute; inset: 0; pointer-events: none; mix-blend-mode: overlay; opacity: 0.5;
background: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.5) 45%, transparent 60%);
background-size: 300% 300%; animation: holo 5s linear infinite; }
@keyframes holo { 0% { background-position: 120% 0; } 100% { background-position: -120% 0; } }
@media (prefers-reduced-motion: reduce) { .holo { animation: none; opacity: 0.25; } }
/* ---- Personalausweis ---- */
.doc-perso {
color: #16241d;
background:
repeating-linear-gradient(60deg, rgba(255,255,255,0.05) 0 2px, transparent 2px 6px),
repeating-linear-gradient(-60deg, rgba(0,80,50,0.05) 0 2px, transparent 2px 7px),
linear-gradient(150deg, #e6efe6, #cfe3d6 55%, #bcd9c8);
}
.doc-perso .top { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 16px 0; }
.doc-perso .title { font-size: 15px; font-weight: 800; letter-spacing: 0.02em; color: #0f4d36; }
.doc-perso .sub { font-size: 8.5px; letter-spacing: 0.18em; color: #2a6b52; font-weight: 700; }
.doc-perso .body { display: grid; grid-template-columns: 92px 1fr; gap: 14px; padding: 10px 16px; }
.doc-perso .foto { height: 116px; }
.doc-perso .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px 12px; align-content: start; }
.doc-perso .mrz { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(255,255,255,0.55);
font-family: ui-monospace, monospace; font-size: 10px; letter-spacing: 0.14em; padding: 4px 16px; color: #223; border-top: 1px solid rgba(0,0,0,0.1); }
.doc-perso .emblem { width: 34px; height: 34px; }
/* ---- Führerschein ---- */
.doc-fs { color: #2a1420; background: linear-gradient(150deg, #f7dfe6, #efc3d2 55%, #e7a9bf); }
.doc-fs .eu { position: absolute; top: 12px; right: 14px; width: 44px; height: 44px; border-radius: 50%;
background: radial-gradient(circle, #1a3a8a, #0f245e); display: grid; place-items: center; }
.doc-fs .top { padding: 12px 16px 0; }
.doc-fs .title { font-size: 15px; font-weight: 800; color: #9a1b46; letter-spacing: 0.03em; }
.doc-fs .sub { font-size: 8.5px; letter-spacing: 0.16em; color: #b24a6c; font-weight: 700; }
.doc-fs .body { display: grid; grid-template-columns: 88px 1fr; gap: 14px; padding: 10px 16px 6px; }
.doc-fs .foto { height: 104px; background: linear-gradient(160deg,#e7c7d2,#c99cad); }
.doc-fs .fields { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; align-content: start; }
.doc-fs .klassen { position: absolute; bottom: 10px; left: 16px; right: 16px; display: flex; gap: 6px; align-items: center; }
.doc-fs .kl { background: #9a1b46; color: #fff; font-weight: 800; font-size: 11px; padding: 3px 9px; border-radius: 6px; }
/* ---- Waffenschein ---- */
.doc-ws { color: #f0e6d8; background: linear-gradient(155deg, #241a1a, #17110f 60%, #0f0b0a); display: flex; flex-direction: column; }
.doc-ws .band { position: absolute; top: 0; left: 0; right: 0; height: 5px; background: linear-gradient(90deg,#8a1f1f,#e0503f,#8a1f1f); }
.doc-ws .top { padding: 14px 18px 2px; display: flex; justify-content: space-between; align-items: flex-start; }
.doc-ws .title { font-size: 16px; font-weight: 800; letter-spacing: 0.05em; color: #e0503f; }
.doc-ws .sub { font-size: 8.5px; letter-spacing: 0.14em; color: #b98f6a; font-weight: 700; }
.doc-ws .ws-info { display: flex; gap: 16px; flex-wrap: wrap; padding: 4px 18px 8px; font-size: 11px; }
.doc-ws .ws-info b { font-size: 12px; }
.doc-ws .wtable { margin: 0 18px; }
.doc-ws .wrow { display: grid; grid-template-columns: 1.5fr 0.7fr 1.2fr; gap: 8px; font-size: 10.5px; padding: 3.5px 0; border-top: 1px solid rgba(255,255,255,0.12); }
.doc-ws .wrow.h { color: #b98f6a; font-weight: 800; text-transform: uppercase; font-size: 8px; letter-spacing: 0.06em; border: none; }
.doc-ws .stamp { position: absolute; right: 14px; bottom: 12px; width: 66px; height: 66px; border-radius: 50%;
border: 2px solid rgba(224,80,63,0.5); color: rgba(224,80,63,0.65); display: grid; place-items: center; text-align: center;
font-size: 8px; font-weight: 800; transform: rotate(-14deg); line-height: 1.3; letter-spacing: 0.04em; opacity: 0.75; }
/* Visitenkarte: nur Handykontakt (Name + Nummer) */
.doc-vk .vk-tag { font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--muted); font-weight: 700; }
.doc-vk .vk-name { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; margin-top: 6px; }
.doc-vk .vk-phone { color: #f5a623; font-weight: 800; font-size: 23px; margin-top: 10px; font-variant-numeric: tabular-nums; letter-spacing: 0.03em; }
/* ---- Gesundheitskarte ---- */
.doc-gk { color: #08343f; background: linear-gradient(150deg, #d6f0f2, #a9dfe6 55%, #7fcdd8); }
.doc-gk .top { padding: 14px 18px 0; display: flex; justify-content: space-between; align-items: center; }
.doc-gk .title { font-size: 14px; font-weight: 800; color: #0b6b7a; letter-spacing: 0.02em; }
.doc-gk .cross { width: 30px; height: 30px; border-radius: 7px; background: #e2413f; display: grid; place-items: center; color: #fff; font-weight: 900; font-size: 20px; line-height: 1; }
.doc-gk .chip { width: 44px; height: 34px; border-radius: 6px; margin: 14px 0 0 18px;
background: linear-gradient(135deg,#e9d9a0,#b89b53); position: relative; }
.doc-gk .chip::after { content:""; position:absolute; inset:6px; border:1px solid rgba(0,0,0,0.25); border-radius:3px; }
.doc-gk .body { padding: 8px 18px; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 14px; }
.doc-gk .insn { position: absolute; bottom: 12px; left: 18px; font-size: 15px; font-weight: 800; letter-spacing: 0.02em; color: #0b6b7a; }
/* ---- Visitenkarte ---- */
.doc-vk { color: #eef1f6; background: linear-gradient(150deg, #1c2333, #12161f 70%); border: 1px solid rgba(245,166,35,0.3); }
.doc-vk .accentbar { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; background: linear-gradient(180deg,#f5a623,#c9791a); }
.doc-vk .vk-in { padding: 22px 22px 22px 30px; height: 100%; display: flex; flex-direction: column; justify-content: center; }
.doc-vk .vk-name { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
.doc-vk .vk-role { color: #f5a623; font-weight: 700; font-size: 13px; margin-top: 2px; letter-spacing: 0.04em; }
.doc-vk .vk-firma { color: var(--muted); font-size: 12px; margin-top: 2px; }
.doc-vk .vk-lines { margin-top: 16px; display: grid; gap: 5px; font-size: 12.5px; }
.doc-vk .vk-lines div { display: flex; align-items: center; gap: 9px; }
.doc-vk .vk-lines .ic { opacity: 0.8; }
.doc-vk .vk-logo { position: absolute; top: 20px; right: 22px; width: 44px; height: 44px; border-radius: 11px;
background: linear-gradient(150deg,#f5a623,#c9791a); display: grid; place-items: center; color: #241a06; font-weight: 900; font-size: 20px; }
/* ===================== Overlays ===================== */
.overlay { position: absolute; inset: 0; z-index: 30; background: rgba(6,7,11,0.72); backdrop-filter: blur(6px);
display: none; align-items: center; justify-content: center; flex-direction: column; gap: 20px; padding: 30px; }
.overlay.show { display: flex; }
.present-caption { text-align: center; color: #dfe4ee; }
.present-caption .pc-h { font-size: 17px; font-weight: 800; }
.present-caption .pc-s { font-size: 13px; color: var(--muted); margin-top: 3px; }
.present-card { transform: scale(1.18); filter: drop-shadow(0 30px 60px rgba(0,0,0,0.6)); animation: raise .4s ease; }
@keyframes raise { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1.18); opacity: 1; } }
.show-to { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; max-width: 520px; }
.show-to button { display: flex; align-items: center; gap: 8px; background: var(--surface2); border: 1px solid var(--border);
color: var(--fg); border-radius: 999px; padding: 8px 14px; font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.show-to button:hover { border-color: var(--accent); }
.show-to .pa { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 800; color: #fff; }
.modal { width: 380px; max-width: 92%; background: rgba(18,20,28,0.98); border: 1px solid var(--border);
border-radius: 18px; padding: 24px; text-align: center; box-shadow: 0 30px 70px -18px rgba(0,0,0,0.8); }
.modal .mic { font-size: 40px; }
.modal h3 { margin: 8px 0 4px; font-size: 19px; }
.modal p { margin: 0 0 20px; color: var(--muted); font-size: 13.5px; line-height: 1.5; }
.modal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.toast { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(18px);
background: rgba(12,14,20,0.97); border: 1px solid var(--border); color: var(--fg); padding: 11px 20px;
border-radius: 13px; font-size: 13.5px; font-weight: 600; z-index: 40; opacity: 0; pointer-events: none; transition: .3s; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.hint-bar { position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 5; font-size: 12px; color: #6f7789; }
.hint-bar b { color: #9aa2b4; }
</style>
<div class="wallet" id="wallet">
<div class="wallet-head">
<span class="logo">👛</span>
<h1>Börse<small>Max Weber · Dokumente &amp; Karten</small></h1>
<div class="wtabs" id="wtabs"></div>
</div>
<div class="wallet-body">
<div class="doclist" id="doclist"></div>
<div class="stage" id="stage"></div>
</div>
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
</div>
<div class="hint-bar"><b>Karte wählen</b> · <b>Vorzeigen</b> präsentiert das Dokument · <b>Verlieren</b> entfernt es aus der Börse</div>
<script>
"use strict";
/* =====================================================================
PERSON — Stammdaten des Charakters (später aus dem Charakter-Datensatz)
===================================================================== */
/* Personendaten. In FiveM vom Server befüllt (Name/Geburtsdatum kommen
live aus dem Charakter, die Dokumentnummern aus der Datenbank). */
let person = {
vorname: "Max", nachname: "Weber", name: "Max Weber",
geb: "14.03.1996", gebOrt: "Los Santos", nationalitaet: "San Andreas",
geschlecht: "männlich", groesse: "182 cm", augen: "braun",
adresse: "Vespucci Blvd 4B",
persoNr: "LSA-2847-1196", persoGueltig: "14.03.2034",
fsNr: "DL-77 4821 SA", fsKlassen: ["A", "B", "C1"], fsAusstellung: "2016", fsGueltig: "2032",
wsNr: "WA-1102-LS", wsGueltig: "31.12.2027",
wsWaffen: [
{ art: "Pistole P99", kaliber: "9 mm", serie: "P99-8842-LS" },
{ art: "Glock 19", kaliber: "9 mm", serie: "GL19-3391-LS" },
{ art: "MP5 (MPX)", kaliber: "9 mm", serie: "MP5-0075-LS" },
{ art: "Schrotflinte 870", kaliber: "12/70", serie: "SG12-2210-LS" }
],
gkVersicherung: "San Andreas Gesundheit", gkNr: "A123 456 789", gkGueltig: "12/28",
telefon: "555-0182"
};
/* Personen in der Nähe (zum "Vorzeigen").
Der Server schickt bewusst keine Namen mit — im Rollenspiel kennt man
eine Person erst nach Ausweis oder Visitenkarte. */
let spielerNah = [];
/* =====================================================================
DOKUMENTE — jedes ist ein einzelnes Item mit eigenem Kartendesign
===================================================================== */
/* Dokumente des Spielers — in FiveM vom Server geladen.
Jedes trägt seine Datenbank-ID mit, damit Aktionen zuordenbar sind. */
let dokumente = [
{ id: "perso", name: "Personalausweis", icon: "🪪", accent: "#2f9e73", info: "gültig bis " + person.persoGueltig },
{ id: "fs", name: "Führerschein", icon: "🚗", accent: "#d5638a", info: "Klassen " + person.fsKlassen.join(", ") },
{ id: "ws", name: "Waffenschein", icon: "🔫", accent: "#e0503f", info: "gültig bis " + person.wsGueltig },
{ id: "gk", name: "Gesundheitskarte", icon: "➕", accent: "#2fb0c4", info: person.gkVersicherung },
{ id: "vk", name: "Visitenkarte", icon: "💼", accent: "#f5a623", info: person.telefon }
];
/* Mehrere Börsen möglich (jede Börse ist ein Item im Inventar).
Jede fasst max. 10 Dokumente; ein Dokument liegt in genau einer Börse. */
let MAX_SLOTS = 10;
let boersen = [
{ name: "Börse 1", docs: dokumente.map(d => d.id) },
{ name: "Börse 2", docs: [] }
];
let aktiveBoerse = 0; // aktuell geöffnete Börse
let aktiv = boersen[0].docs[0] || null; // aktuell ausgewähltes Dokument
const akt = () => boersen[aktiveBoerse]; // aktuelle Börse
/* =====================================================================
HILFSFUNKTIONEN
===================================================================== */
const $ = id => document.getElementById(id);
function sicher(t) { const d = document.createElement("div"); d.textContent = t; return d.innerHTML; }
let toastTimer = null;
function toast(text) { const t = $("toast"); t.textContent = text; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2200); }
// wert darf HTML enthalten (z. B. <span class="mono">) — Werte stammen aus vertrauenswürdigen Stammdaten
const feld = (label, wert) => '<div><div class="lbl">' + label + '</div><div class="val">' + wert + '</div></div>';
// Personen-Silhouette (Passfoto)
const fotoSvg = '<svg viewBox="0 0 100 120" preserveAspectRatio="xMidYMax meet"><circle cx="50" cy="42" r="24" fill="#5a6472"/><path d="M12 120 q0 -34 38 -34 q38 0 38 34 Z" fill="#5a6472"/></svg>';
// Sternenkreis (EU-Stil) für den Führerschein
function sternRing() {
let s = ""; for (let i = 0; i < 12; i++) { const a = i / 12 * 2 * Math.PI - Math.PI / 2; s += '<text x="' + (22 + 15 * Math.cos(a)).toFixed(1) + '" y="' + (26 + 15 * Math.sin(a)).toFixed(1) + '" font-size="6" fill="#f5c542" text-anchor="middle">★</text>'; }
return '<svg width="44" height="44" viewBox="0 0 44 44">' + s + '<text x="22" y="25" font-size="9" fill="#f5c542" text-anchor="middle" font-weight="800">SA</text></svg>';
}
/* =====================================================================
KARTEN-RENDERER — jedes Dokument sieht anders aus
===================================================================== */
const kartenRenderer = {
perso: () =>
'<div class="doccard doc-perso">' +
'<div class="holo"></div>' +
'<div class="top"><div><div class="title">PERSONALAUSWEIS</div><div class="sub">REPUBLIK SAN ANDREAS · IDENTITY CARD</div></div>' +
'<svg class="emblem" viewBox="0 0 34 34"><circle cx="17" cy="17" r="16" fill="none" stroke="#0f4d36" stroke-width="1.5"/><path d="M17 5 l3 8 h8 l-6 5 2 9 -7 -5 -7 5 2 -9 -6 -5 h8 Z" fill="#0f4d36"/></svg></div>' +
'<div class="body">' +
'<div class="foto">' + fotoSvg + '</div>' +
'<div class="fields">' +
feld("Name", person.nachname) + feld("Vornamen", person.vorname) +
feld("Geburtsdatum", person.geb) + feld("Geburtsort", person.gebOrt) +
feld("Staatsangeh.", person.nationalitaet) + feld("Größe", person.groesse) +
'<div style="grid-column:span 2">' + feld("Ausweisnr.", '<span class="mono">' + person.persoNr + '</span> · gültig bis ' + person.persoGueltig) + '</div>' +
'</div>' +
'</div>' +
'<div class="mrz">IDSAA' + person.persoNr.replace(/[^0-9]/g, "") + '&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;<br>9603140M3403147SAA&lt;&lt;WEBER&lt;&lt;MAX&lt;&lt;&lt;&lt;&lt;&lt;&lt;&lt;</div>' +
'</div>',
fs: () =>
'<div class="doccard doc-fs">' +
'<div class="holo"></div>' +
'<div class="eu">' + sternRing() + '</div>' +
'<div class="top"><div class="title">FÜHRERSCHEIN</div><div class="sub">REPUBLIK SAN ANDREAS · DRIVING LICENCE</div></div>' +
'<div class="body">' +
'<div class="foto">' + fotoSvg + '</div>' +
'<div class="fields">' +
feld("1. Name", person.nachname) + feld("2. Vorname", person.vorname) +
feld("3. Geburtsdatum", person.geb) + feld("4a. Ausstellung", person.fsAusstellung) +
feld("4b. Gültig bis", person.fsGueltig) + feld("5. Nr.", '<span class="mono">' + person.fsNr + '</span>') +
'</div>' +
'</div>' +
'<div class="klassen"><span class="lbl" style="color:#9a1b46">9. Klassen:</span>' +
person.fsKlassen.map(k => '<span class="kl">' + k + '</span>').join("") + '</div>' +
'</div>',
ws: () =>
'<div class="doccard doc-ws">' +
'<div class="band"></div>' +
'<div class="stamp">GEPRÜFT<br>LSPD<br>★ SA ★</div>' +
'<div class="top"><div><div class="title">WAFFENSCHEIN</div><div class="sub">INNENMINISTERIUM SAN ANDREAS</div></div>' +
'<div style="text-align:right"><div class="lbl" style="color:#b98f6a">Schein-Nr.</div><div class="val mono">' + person.wsNr + '</div></div></div>' +
'<div class="ws-info">' +
'<span><span class="lbl">Inhaber</span> <b>' + sicher(person.name) + '</b></span>' +
'<span><span class="lbl">Geboren</span> <b>' + sicher(person.geb) + '</b></span>' +
'<span><span class="lbl">Gültig bis</span> <b>' + sicher(person.wsGueltig) + '</b></span>' +
'</div>' +
'<div class="wtable">' +
'<div class="wrow h"><span>Registrierte Waffe (' + person.wsWaffen.length + ')</span><span>Kaliber</span><span>Seriennummer</span></div>' +
person.wsWaffen.map(w => '<div class="wrow"><span>' + sicher(w.art) + '</span><span>' + sicher(w.kaliber) + '</span><span class="mono">' + sicher(w.serie) + '</span></div>').join("") +
'</div>' +
'</div>',
gk: () =>
'<div class="doccard doc-gk">' +
'<div class="holo"></div>' +
'<div class="top"><div class="title">GESUNDHEITSKARTE</div><div class="cross">+</div></div>' +
'<div class="chip"></div>' +
'<div class="body" style="margin-top:6px">' +
feld("Versicherte/r", person.name) + feld("Vers.-Nr.", '<span class="mono">' + person.gkNr + '</span>') +
feld("Geboren", person.geb) + feld("Gültig", person.gkGueltig) +
'</div>' +
'<div class="insn">' + sicher(person.gkVersicherung) + '</div>' +
'</div>',
vk: () =>
'<div class="doccard doc-vk">' +
'<div class="accentbar"></div>' +
'<div class="vk-logo">📱</div>' +
'<div class="vk-in">' +
'<div class="vk-tag">Handykontakt</div>' +
'<div class="vk-name">' + sicher(person.name) + '</div>' +
'<div class="vk-phone">' + sicher(person.telefon) + '</div>' +
'</div>' +
'</div>'
};
function renderKarte(id) { return kartenRenderer[id] ? kartenRenderer[id]() : ""; }
/* =====================================================================
BÖRSE RENDERN
===================================================================== */
// Börsen-Wechsel-Leiste (Tabs) inkl. Shop-Kauf
function renderBoersenTabs() {
const wt = $("wtabs");
wt.innerHTML = boersen.map((b, i) =>
'<button class="wtab' + (i === aktiveBoerse ? " active" : "") + '" data-b="' + i + '">' +
'<span class="wn">' + sicher(b.name) + '</span><span class="wc">' + b.docs.length + " / " + MAX_SLOTS + '</span></button>').join("") +
'<button class="wtab buy" id="buy-boerse" title="Weitere Börse im Shop kaufen">+ Börse</button>';
wt.querySelectorAll("[data-b]").forEach(t => t.addEventListener("click", () => {
aktiveBoerse = +t.dataset.b; aktiv = akt().docs[0] || null; render();
}));
$("buy-boerse").addEventListener("click", kaufeBoerse);
}
// Neue Börse "kaufen" (Shop-Demo)
function kaufeBoerse() {
if (IST_FIVEM) {
// Börsen sind Items — sie werden im 24/7 gekauft, nicht hier erzeugt
toast("Börsen bekommst du im 24/7-Laden");
return;
}
if (boersen.length >= 4) { toast("Maximale Anzahl Börsen erreicht"); return; }
boersen.push({ name: "Börse " + (boersen.length + 1), docs: [] });
aktiveBoerse = boersen.length - 1; aktiv = null;
toast("Neue Börse gekauft");
render();
}
function render() {
renderBoersenTabs();
const besitz = akt().docs;
const liste = $("doclist");
if (besitz.length === 0) {
liste.innerHTML = '<div class="empty" style="margin:auto"><div class="big">👛</div><div>' + sicher(akt().name) + ' ist leer.</div>' +
'<div style="font-size:12px;margin-top:6px">Dokumente aus einer<br>anderen Börse hierher verschieben.</div></div>';
$("stage").innerHTML = '<div class="empty"><div class="big">📭</div><div style="font-weight:700;color:var(--fg)">Keine Dokumente in ' + sicher(akt().name) + '</div></div>';
return;
}
liste.innerHTML = besitz.map(id => {
const d = dokumente.find(x => x.id === id);
return '<div class="doctab' + (id === aktiv ? " active" : "") + '" data-doc="' + id + '" style="--dc:' + d.accent + '">' +
'<span class="di" style="color:' + d.accent + '">' + d.icon + '</span>' +
'<div><div class="dn">' + sicher(d.name) + '</div><div class="dm">' + sicher(d.info) + '</div></div></div>';
}).join("");
liste.querySelectorAll("[data-doc]").forEach(t => t.addEventListener("click", () => { aktiv = t.dataset.doc; render(); }));
// Bühne rechts: aktive Karte + Aktionen
if (!besitz.includes(aktiv)) aktiv = besitz[0];
$("stage").innerHTML = renderKarte(aktiv) +
'<div class="stage-actions">' +
'<button class="btn primary" id="act-show">Vorzeigen</button>' +
(boersen.length > 1 ? '<button class="btn ghost" id="act-move">Verschieben</button>' : "") +
'<button class="btn danger" id="act-lose">Verlieren</button>' +
'</div>';
$("act-show").addEventListener("click", vorzeigen);
if ($("act-move")) $("act-move").addEventListener("click", verschiebenFrage);
$("act-lose").addEventListener("click", verlierenFrage);
}
/* Dokument in eine andere Börse verschieben (Ziel muss freien Platz haben) */
function verschiebenFrage() {
const d = dokumente.find(x => x.id === aktiv);
const ziele = boersen.map((b, i) => ({ b, i })).filter(o => o.i !== aktiveBoerse && o.b.docs.length < MAX_SLOTS);
if (ziele.length === 0) { toast("Keine andere Börse mit freiem Platz"); return; }
$("overlay").innerHTML =
'<div class="modal"><div class="mic">📂</div><h3>' + sicher(d.name) + ' verschieben</h3>' +
'<p>In welche Börse soll das Dokument?</p>' +
'<div style="display:grid;gap:8px">' + ziele.map(o =>
'<button class="btn ghost" data-t="' + o.i + '" style="width:100%;display:flex;justify-content:space-between">' +
sicher(o.b.name) + '<span style="opacity:0.7">' + o.b.docs.length + " / " + MAX_SLOTS + '</span></button>').join("") +
'</div><div style="height:10px"></div><button class="btn ghost" id="m-cancel" style="width:100%">Abbrechen</button></div>';
$("overlay").classList.add("show");
$("m-cancel").addEventListener("click", schliesseOverlay);
$("overlay").querySelectorAll("[data-t]").forEach(b => b.addEventListener("click", () => {
const zi = +b.dataset.t;
schliesseOverlay();
if (IST_FIVEM) { nui("verschieben", { dokumentId: d.dbId, zielBoerse: zi }); return; }
// Browser-Ansicht: lokal verschieben
akt().docs = akt().docs.filter(id => id !== aktiv);
boersen[zi].docs.push(aktiv);
toast(d.name + " → " + boersen[zi].name);
aktiv = akt().docs[0] || null;
render();
}));
}
/* =====================================================================
VORZEIGEN — Dokument präsentieren (optional gezielt an Spieler)
===================================================================== */
function vorzeigen() {
const d = dokumente.find(x => x.id === aktiv);
$("overlay").innerHTML =
'<div class="present-caption"><div class="pc-h">Du zeigst deinen ' + sicher(d.name) + ' vor</div>' +
'<div class="pc-s">Halte ihn hoch — oder wähle gezielt eine Person</div></div>' +
'<div class="present-card">' + renderKarte(aktiv) + '</div>' +
'<div class="show-to" id="show-to">' +
spielerNah.map((sp, i) => '<button data-p="' + i + '"><span class="pa" style="background:' + sp.farbe + '">' + (sp.bekannt ? sp.name.substring(0, 1) : "?") + '</span>' +
(sp.bekannt ? sicher(sp.name) : "Unbekannt #" + sp.id) + '</button>').join("") +
'</div>' +
'<button class="btn ghost" id="present-close">Einstecken</button>';
$("overlay").classList.add("show");
$("present-close").addEventListener("click", schliesseOverlay);
$("show-to").querySelectorAll("[data-p]").forEach(b => b.addEventListener("click", () => {
const sp = spielerNah[+b.dataset.p];
schliesseOverlay();
if (IST_FIVEM) { nui("vorzeigen", { dokumentId: d.dbId, zielId: sp.id }); return; }
toast(d.name + " vorgezeigt an Unbekannt #" + sp.id);
}));
}
/* =====================================================================
VERLIEREN — Dokument aus der Börse entfernen
===================================================================== */
function verlierenFrage() {
const d = dokumente.find(x => x.id === aktiv);
$("overlay").innerHTML =
'<div class="modal"><div class="mic">⚠️</div><h3>' + sicher(d.name) + ' verlieren?</h3>' +
'<p>Das Dokument wird aus deiner Börse entfernt. Im Spiel müsstest du es neu beantragen.</p>' +
'<div class="modal-actions"><button class="btn ghost" id="v-cancel">Abbrechen</button>' +
'<button class="btn danger" id="v-ok">Verlieren</button></div></div>';
$("overlay").classList.add("show");
$("v-cancel").addEventListener("click", schliesseOverlay);
$("v-ok").addEventListener("click", () => {
schliesseOverlay();
if (IST_FIVEM) { nui("verlieren", { dokumentId: d.dbId }); return; }
akt().docs = akt().docs.filter(id => id !== aktiv);
toast("Du hast deinen " + d.name + " verloren");
aktiv = akt().docs[0] || null;
render();
});
}
function schliesseOverlay() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; }
$("overlay").addEventListener("click", e => { if (e.target === $("overlay")) schliesseOverlay(); });
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-documents";
function nui(aktion, daten) {
if (!IST_FIVEM) return Promise.resolve();
return fetch(`https://${RESSOURCE}/${aktion}`, {
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify(daten || {}),
}).catch(() => {});
}
/* Überführt die Serverdaten in die Felder, die die Kartendesigns erwarten.
Name und Geburtsdatum kommen aus dem Charakter, der Rest aus dem
jeweiligen Dokument — so bleiben die vorhandenen Designs unverändert. */
function setzeZustand(z) {
MAX_SLOTS = z.maxSlots || 10;
// Personendaten aus dem Charakter
person.vorname = z.person.vorname || "";
person.nachname = z.person.nachname || "";
person.name = (person.vorname + " " + person.nachname).trim();
person.geb = z.person.geburt || "—";
person.geschlecht = z.person.geschlecht || "—";
// Dokumentspezifische Felder aus den einzelnen Dokumenten übernehmen
dokumente = (z.dokumente || []).map(d => {
const w = d.daten || {};
if (d.typ === "perso") {
person.persoNr = w.nummer || "—";
person.persoGueltig = d.gueltigBis || "—";
person.gebOrt = w.gebOrt || "Los Santos";
person.groesse = w.groesse || "—";
person.nationalitaet = w.nationalitaet || "San Andreas";
person.adresse = w.adresse || "—";
person.augen = w.augen || "—";
} else if (d.typ === "fs") {
person.fsNr = w.nummer || "—";
person.fsKlassen = w.klassen || ["B"];
person.fsGueltig = d.gueltigBis || "—";
person.fsAusstellung = w.ausstellung || "—";
} else if (d.typ === "ws") {
person.wsNr = w.nummer || "—";
person.wsGueltig = d.gueltigBis || "—";
person.wsWaffen = w.waffen || [];
} else if (d.typ === "gk") {
person.gkVersicherung = w.versicherung || "—";
person.gkNr = w.nummer || "—";
person.gkGueltig = d.gueltigBis || "—";
} else if (d.typ === "vk") {
person.telefon = w.telefon || "—";
}
// Kurzinfo für die Listenansicht
let info = d.gueltigBis ? ("gültig bis " + d.gueltigBis) : "";
if (d.typ === "fs" && w.klassen) info = "Klassen " + w.klassen.join(", ");
if (d.typ === "gk") info = w.versicherung || info;
if (d.typ === "vk") info = w.telefon || "keine Nummer";
return {
id: d.typ, // für die Kartendesigns (kartenRenderer)
dbId: d.id, // für Aktionen am Server
boerse: d.boerse || 0,
name: d.name, icon: d.icon, accent: d.accent, info: info,
};
});
// Börsen aufbauen: so viele, wie der Spieler Börsen-Items besitzt
const anzahl = Math.max(1, z.boersen || 1);
boersen = [];
for (let i = 0; i < anzahl; i++) {
boersen.push({
name: "Börse " + (i + 1),
docs: dokumente.filter(d => d.boerse === i).map(d => d.id),
});
}
if (aktiveBoerse >= boersen.length) aktiveBoerse = 0;
if (!akt().docs.includes(aktiv)) aktiv = akt().docs[0] || null;
spielerNah = (z.spieler || []).map(sp => ({
id: sp.id, bekannt: false, name: "Unbekannt", farbe: "#3a3f4d",
}));
render();
if (z.hinweis) toast(z.hinweis);
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
istEigeneBoerseOffen = true;
setzeZustand(n);
}
else if (n.aktion === "zustand") {
setzeZustand(n);
}
else if (n.aktion === "hinweis") {
toast(n.text || "");
}
else if (n.aktion === "fehler") {
toast(n.text || "Nicht möglich");
}
else if (n.aktion === "vorgezeigt") {
// Jemand zeigt uns ein Dokument — als Vollbild einblenden
zeigeVorgelegtes(n.dokument);
}
else if (n.aktion === "schliessen") {
istEigeneBoerseOffen = false;
document.body.style.display = "none";
schliesseOverlay();
}
});
// Merkt sich, ob die eigene Börse offen ist (für das Schließen nach dem Vorzeigen)
let istEigeneBoerseOffen = false;
/* Zeigt ein Dokument, das eine ANDERE Person vorlegt.
Wichtig: Die Kartendesigns lesen aus `person`. Für die Anzeige wird
daher kurzzeitig auf die Daten des Zeigenden umgeschaltet und danach
wieder zurück — sonst stünden die eigenen Daten auf fremden Karten. */
function zeigeVorgelegtes(d) {
if (!d) return;
document.body.style.display = "";
const eigene = person; // eigene Daten merken
const w = d.daten || {};
person = {
name: d.inhaber || "Unbekannt",
vorname: (d.inhaber || "").split(" ")[0] || "",
nachname: (d.inhaber || "").split(" ").slice(1).join(" "),
geb: d.geburt || "—",
geschlecht: "—",
gebOrt: w.gebOrt || "Los Santos", nationalitaet: w.nationalitaet || "San Andreas",
groesse: w.groesse || "—", augen: w.augen || "—", adresse: w.adresse || "—",
persoNr: w.nummer || "—", persoGueltig: d.gueltigBis || "—",
fsNr: w.nummer || "—", fsKlassen: w.klassen || ["B"],
fsAusstellung: w.ausstellung || "—", fsGueltig: d.gueltigBis || "—",
wsNr: w.nummer || "—", wsGueltig: d.gueltigBis || "—", wsWaffen: w.waffen || [],
gkVersicherung: w.versicherung || "—", gkNr: w.nummer || "—", gkGueltig: d.gueltigBis || "—",
telefon: w.telefon || "—",
};
const karte = renderKarte(d.typ); // mit fremden Daten rendern
person = eigene; // sofort zurückschalten
$("overlay").innerHTML =
'<div class="present-caption"><div class="pc-h">' + sicher(d.inhaber || "Unbekannt") + ' zeigt: ' + sicher(d.name) + '</div>' +
'<div class="pc-s">Geboren am ' + sicher(d.geburt || "—") + '</div></div>' +
'<div class="present-card">' + karte + '</div>' +
'<button class="btn ghost" id="present-close">Schließen</button>';
$("overlay").classList.add("show");
$("present-close").addEventListener("click", () => {
schliesseOverlay();
if (!istEigeneBoerseOffen) document.body.style.display = "none";
});
}
/* ESC schließt die Börse */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* =====================================================================
START
===================================================================== */
if (IST_FIVEM) {
document.body.style.display = "none"; // wartet auf "oeffnen"
} else {
render(); // Browser-Ansicht mit den Beispieldaten oben
}
</script>
</body>
</html>