aether-framework/resources/[aether]/aether-phone/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

2655 lines
138 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 light">
</head>
<body>
<title>AetherOS — RP Handy</title>
<style>
/* ===================== Design-Tokens ===================== */
:root {
/* Farbwelt der Seite (um das Handy herum) */
--page-bg: #0b0c12;
--page-fg: #e7e8f2;
--page-muted: #8a8da3;
--page-card: #14151d;
--page-border: rgba(255,255,255,0.08);
--accent: #7b6cf6;
}
/* Helles Seiten-Theme, wenn der Betrachter Light bevorzugt */
@media (prefers-color-scheme: light) {
:root {
--page-bg: #eceef4;
--page-fg: #1a1c24;
--page-muted: #5c6070;
--page-card: #ffffff;
--page-border: rgba(0,0,0,0.09);
}
}
:root[data-theme="dark"] {
--page-bg: #0b0c12; --page-fg: #e7e8f2; --page-muted: #8a8da3;
--page-card: #14151d; --page-border: rgba(255,255,255,0.08);
}
:root[data-theme="light"] {
--page-bg: #eceef4; --page-fg: #1a1c24; --page-muted: #5c6070;
--page-card: #ffffff; --page-border: rgba(0,0,0,0.09);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background:
radial-gradient(1200px 700px at 80% -10%, rgba(123,108,246,0.18), transparent 60%),
radial-gradient(1000px 700px at 0% 110%, rgba(52,211,153,0.10), transparent 55%),
var(--page-bg);
color: var(--page-fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 40px;
align-items: center;
padding: 48px 24px;
}
@media (max-width: 900px) {
body { grid-template-columns: 1fr; justify-items: center; gap: 28px; }
.side-panel { max-width: 460px; }
}
/* ===================== Info-Panels neben dem Handy ===================== */
.side-panel { max-width: 300px; }
.side-panel.left { justify-self: end; }
.side-panel.right { justify-self: start; }
.brand-mark {
display: inline-flex; align-items: center; gap: 10px;
font-weight: 700; font-size: 20px; letter-spacing: -0.02em; margin-bottom: 6px;
}
.brand-mark .dot {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(145deg, #7b6cf6, #34d399);
box-shadow: 0 4px 14px rgba(123,108,246,0.5);
}
.side-panel h2 { font-size: 15px; margin: 22px 0 10px; letter-spacing: -0.01em; }
.side-panel p { color: var(--page-muted); font-size: 13.5px; line-height: 1.55; margin: 0 0 12px; }
.hint-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.hint-list li {
background: var(--page-card); border: 1px solid var(--page-border);
border-radius: 12px; padding: 10px 12px; font-size: 12.5px; color: var(--page-muted);
display: flex; gap: 9px; align-items: flex-start;
}
.hint-list li b { color: var(--page-fg); font-weight: 600; }
.kbd {
display: inline-block; min-width: 20px; text-align: center;
background: rgba(123,108,246,0.16); color: var(--accent);
border-radius: 6px; padding: 1px 6px; font-size: 11px; font-weight: 700;
}
.feature-tag {
display: inline-block; font-size: 11px; color: var(--page-muted);
border: 1px solid var(--page-border); border-radius: 999px;
padding: 4px 10px; margin: 0 6px 6px 0;
}
/* ===================== Handy-Gehäuse ===================== */
.device {
--w: 384px;
width: min(var(--w), 94vw);
aspect-ratio: 384 / 700;
background: linear-gradient(160deg, #2a2c38, #101118 60%);
border-radius: 54px;
padding: 13px;
box-shadow:
0 2px 3px rgba(255,255,255,0.14) inset,
0 0 0 2px #050509,
0 40px 80px -20px rgba(0,0,0,0.7),
0 20px 40px -25px rgba(123,108,246,0.5);
position: relative;
flex-shrink: 0;
}
/* Seitentasten */
.device::before {
content: ""; position: absolute; left: -3px; top: 150px; width: 3px; height: 92px;
background: #1a1b22; border-radius: 3px 0 0 3px;
}
.device::after {
content: ""; position: absolute; right: -3px; top: 180px; width: 3px; height: 64px;
background: #1a1b22; border-radius: 0 3px 3px 0;
}
/* Der eigentliche Bildschirm */
.screen {
position: relative;
width: 100%; height: 100%;
border-radius: 42px;
overflow: hidden;
background: #000;
/* Handy-interne Theme-Tokens (Standard: Dark / AetherOS Nacht) */
--s-fg: #f4f5fb;
--s-muted: #9a9db4;
--s-surface: rgba(30,32,44,0.82);
--s-surface-2: rgba(44,47,63,0.9);
--s-border: rgba(255,255,255,0.10);
--s-accent: #7b6cf6;
--s-green: #34d399;
--s-red: #f0506e;
--s-amber: #f5b23d;
--s-blue: #4aa8ff;
color: var(--s-fg);
font-size: 15px;
}
/* Handy-internes Light-Mode (über Einstellungen umschaltbar) */
.screen.light {
--s-fg: #14161d;
--s-muted: #6b6f82;
--s-surface: rgba(255,255,255,0.78);
--s-surface-2: rgba(240,241,246,0.92);
--s-border: rgba(0,0,0,0.10);
}
/* Hintergrundbild / Wallpaper */
.wallpaper {
position: absolute; inset: 0; z-index: 0;
background-size: cover; background-position: center;
transition: opacity .4s ease;
}
.wp-aurora {
background:
radial-gradient(120% 90% at 20% 0%, #3a2f7d 0%, transparent 55%),
radial-gradient(120% 90% at 90% 20%, #1c6d63 0%, transparent 50%),
radial-gradient(140% 100% at 50% 100%, #10233f 0%, transparent 60%),
linear-gradient(160deg, #0d0f1a, #05060b);
}
.wp-sunset {
background:
radial-gradient(120% 80% at 50% 110%, #ff8c42 0%, transparent 55%),
radial-gradient(100% 90% at 80% 0%, #7b2f8f 0%, transparent 55%),
linear-gradient(180deg, #241436, #4a1f4d 55%, #1a0e1f);
}
.wp-carbon {
background:
radial-gradient(100% 60% at 50% 0%, #23262f 0%, transparent 60%),
repeating-linear-gradient(45deg, #0c0d12 0 8px, #101219 8px 16px);
}
.wp-mono { background: linear-gradient(180deg, #0a0b10, #16181f); }
.screen.light .wallpaper.wp-aurora {
background:
radial-gradient(120% 90% at 15% 0%, #c3b9ff 0%, transparent 55%),
radial-gradient(120% 90% at 90% 15%, #9fe6da 0%, transparent 50%),
linear-gradient(160deg, #eef0fb, #dfe3f2);
}
/* ===================== Statusleiste ===================== */
.statusbar {
position: absolute; top: 0; left: 0; right: 0; height: 52px; z-index: 40;
display: flex; align-items: center; justify-content: space-between;
padding: 0 30px; font-size: 14px; font-weight: 600;
color: var(--s-fg); pointer-events: none;
font-variant-numeric: tabular-nums;
}
.statusbar .sb-right { display: flex; align-items: center; gap: 6px; }
.statusbar svg { display: block; }
/* Dynamic Island */
.island {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
width: 118px; height: 32px; background: #000; border-radius: 20px; z-index: 45;
display: flex; align-items: center; justify-content: flex-end; padding-right: 12px; gap: 8px;
}
.island .cam { width: 9px; height: 9px; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #34506b, #05070c); }
/* Home-Indikator */
.home-indicator {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
width: 130px; height: 5px; border-radius: 3px; background: var(--s-fg);
opacity: 0.55; z-index: 60; cursor: pointer; border: none;
}
.home-indicator:hover { opacity: 0.9; }
.screen.locked .home-indicator { display: none; }
/* ===================== Sperrbildschirm ===================== */
.lockscreen {
position: absolute; inset: 0; z-index: 55;
display: flex; flex-direction: column; align-items: center;
padding-top: 84px; text-align: center;
transition: transform .5s cubic-bezier(.6,.05,.2,1), opacity .4s ease;
}
.screen:not(.locked) .lockscreen { transform: translateY(-100%); opacity: 0; pointer-events: none; }
.lock-date { font-size: 17px; font-weight: 600; opacity: 0.9; }
.lock-time { font-size: 82px; font-weight: 700; letter-spacing: -0.04em; line-height: 1; margin-top: 2px;
font-variant-numeric: tabular-nums; text-shadow: 0 2px 30px rgba(0,0,0,0.4); }
.lock-notes { margin-top: 26px; width: calc(100% - 40px); display: grid; gap: 10px;
position: relative; z-index: 3; }
.lock-note {
background: rgba(20,22,32,0.55); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 18px; padding: 12px 14px; text-align: left;
display: flex; gap: 11px; align-items: center;
}
.lock-note .ic { width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
display: grid; place-items: center; }
.lock-note .txt { min-width: 0; }
.lock-note .txt .t { font-size: 13px; font-weight: 700; }
.lock-note .txt .d { font-size: 12.5px; color: rgba(244,245,251,0.75); overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.lock-unlock {
position: absolute; bottom: 40px; left: 0; right: 0; text-align: center;
font-size: 13px; color: rgba(244,245,251,0.7); font-weight: 500;
animation: floaty 2.2s ease-in-out infinite;
}
@keyframes floaty { 0%,100%{ transform: translateY(0);} 50%{ transform: translateY(-5px);} }
@media (prefers-reduced-motion: reduce) { .lock-unlock { animation: none; } }
.lock-btn { position:absolute; inset:0; background:transparent; border:none; cursor:pointer; z-index: 2; }
/* ===================== Homescreen ===================== */
.homescreen {
position: absolute; inset: 0; z-index: 10;
padding: 56px 20px 18px; display: flex; flex-direction: column; gap: 14px;
transition: opacity .35s ease, transform .35s ease;
}
.screen.locked .homescreen { opacity: 0; pointer-events: none; }
.app-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px 16px;
align-content: start; flex: 1;
}
.app {
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: none; border: none; cursor: pointer; padding: 0; color: var(--s-fg);
font-family: inherit;
}
.app .icon {
width: 58px; height: 58px; border-radius: 15px; display: grid; place-items: center;
box-shadow: 0 6px 14px rgba(0,0,0,0.35); position: relative;
transition: transform .12s ease;
}
.app:hover .icon { transform: translateY(-3px) scale(1.03); }
.app:active .icon { transform: scale(0.94); }
.app .icon svg { width: 30px; height: 30px; }
.app .label { font-size: 11.5px; font-weight: 500; text-shadow: 0 1px 4px rgba(0,0,0,0.5); }
.app .badge {
position: absolute; top: -5px; right: -5px; min-width: 19px; height: 19px;
background: var(--s-red); border-radius: 999px; color: #fff; font-size: 11px; font-weight: 700;
display: grid; place-items: center; padding: 0 5px; border: 2px solid rgba(0,0,0,0.2);
}
/* Dock */
.dock {
margin: 0 -6px; padding: 14px 10px; border-radius: 30px;
background: rgba(255,255,255,0.10); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: grid; grid-template-columns: repeat(4,1fr); gap: 16px;
}
.screen.light .dock { background: rgba(255,255,255,0.45); }
/* Icon-Farbverläufe */
.g-phone { background: linear-gradient(160deg,#4ade80,#16a34a); }
.g-msg { background: linear-gradient(160deg,#5ad0ff,#2563eb); }
.g-bank { background: linear-gradient(160deg,#34d399,#0f766e); }
.g-cam { background: linear-gradient(160deg,#40424f,#191a22); }
.g-birdy { background: linear-gradient(160deg,#7b6cf6,#4f46e5); }
.g-insta { background: linear-gradient(140deg,#feda75,#fa7e1e 35%,#d62976 65%,#962fbf); }
.g-set { background: linear-gradient(160deg,#9aa0b5,#4b5165); }
.g-music { background: linear-gradient(160deg,#fb7185,#e11d48); }
.g-weather{ background: linear-gradient(160deg,#4aa8ff,#1e6fd6); }
.g-clock { background: linear-gradient(160deg,#1a1c24,#0a0b10); }
.g-calc { background: linear-gradient(160deg,#2a2d38,#14151b); }
.g-garage{ background: linear-gradient(160deg,#f5b23d,#d97706); }
.g-mdt { background: linear-gradient(160deg,#1e5eff,#0a2f8f); }
.g-dispatch { background: linear-gradient(160deg,#f0506e,#1e5eff); }
.g-pages { background: linear-gradient(160deg,#fbbf24,#f59e0b); }
.g-map { background: linear-gradient(160deg,#34d399,#4aa8ff); }
.g-dark { background: linear-gradient(160deg,#374151,#111318); }
.g-staat { background: linear-gradient(160deg,#4a5578,#232a3f); }
.g-notes { background: linear-gradient(160deg,#ffe082,#f6c500); }
/* ===================== App-Fenster ===================== */
.app-view {
position: absolute; inset: 0; z-index: 30; background: transparent;
display: none; flex-direction: column;
animation: appIn .3s cubic-bezier(.2,.7,.2,1);
}
.app-view.open { display: flex; }
@keyframes appIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .app-view { animation: none; } }
.app-bg { position: absolute; inset: 0; background: #0d0e15; z-index: -1; }
.screen.light .app-bg { background: #f4f5fa; }
.app-header {
padding: 58px 18px 12px; display: flex; align-items: center; gap: 10px;
border-bottom: 1px solid var(--s-border); position: relative;
background: var(--s-surface); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
}
.app-header h1 { font-size: 20px; margin: 0; font-weight: 700; letter-spacing: -0.02em; flex: 1; }
.app-header .sub { font-size: 12px; color: var(--s-muted); font-weight: 500; }
/* Apple-artiger runder Zurück-/Aktions-Button */
.icon-btn {
width: 34px; height: 34px; border-radius: 50%; padding: 0;
background: var(--s-surface-2); border: 1px solid var(--s-border);
color: var(--s-accent); cursor: pointer; font-family: inherit;
display: grid; place-items: center; flex-shrink: 0;
transition: transform .1s ease, filter .15s ease;
}
.icon-btn:hover { filter: brightness(1.12); }
.icon-btn:active { transform: scale(0.9); }
.icon-btn svg { width: 18px; height: 18px; display: block; }
.app-body { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 14px 16px 40px; -webkit-overflow-scrolling: touch; }
.app-body::-webkit-scrollbar { width: 0; }
/* Wiederverwendbare Bausteine */
.row {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
background: var(--s-surface); border-radius: 14px; margin-bottom: 8px;
border: 1px solid var(--s-border);
}
.row .avatar {
width: 44px; height: 44px; border-radius: 50%; flex-shrink: 0; display: grid; place-items: center;
font-weight: 700; font-size: 16px; color: #fff;
}
.row .grow { flex: 1; min-width: 0; }
.row .title { font-weight: 600; font-size: 15px; }
.row .meta { font-size: 12.5px; color: var(--s-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .side { font-size: 12.5px; color: var(--s-muted); text-align: right; flex-shrink: 0; }
.list-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--s-muted); font-weight: 700; margin: 14px 4px 8px; }
.pill { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; display: inline-block; }
.pill.green { background: rgba(52,211,153,0.16); color: var(--s-green); }
.pill.red { background: rgba(240,80,110,0.16); color: var(--s-red); }
.pill.amber { background: rgba(245,178,61,0.16); color: var(--s-amber); }
.pill.blue { background: rgba(74,168,255,0.16); color: var(--s-blue); }
.pill.violet { background: rgba(123,108,246,0.18); color: var(--s-accent); }
.btn {
background: var(--s-accent); color: #fff; border: none; border-radius: 13px;
padding: 13px 18px; font-size: 15px; font-weight: 600; cursor: pointer; font-family: inherit;
width: 100%; transition: filter .15s ease, transform .1s ease;
}
.btn:hover { filter: brightness(1.08); }
.btn:active { transform: scale(0.98); }
.btn.green { background: var(--s-green); }
.btn.ghost { background: var(--s-surface-2); color: var(--s-fg); }
.btn.red { background: var(--s-red); }
.input {
width: 100%; background: var(--s-surface-2); border: 1px solid var(--s-border);
border-radius: 12px; padding: 13px 14px; color: var(--s-fg); font-size: 15px;
font-family: inherit; outline: none;
}
.input:focus { border-color: var(--s-accent); }
/* Dropdown (Auswahlfeld) mit eigenem Pfeil */
select.input {
-webkit-appearance: none; appearance: none; cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%238a8da3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l5 5 5-5'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 14px center; padding-right: 38px;
}
select.input option { background: #1e2029; color: var(--s-fg); }
.field-label { font-size: 12px; color: var(--s-muted); font-weight: 600; margin: 12px 4px 6px; display: block; }
/* ---- Telefon-App ---- */
.dial-display { text-align: center; font-size: 34px; font-weight: 500; padding: 16px 0 8px; min-height: 44px;
font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.dialpad { display: grid; grid-template-columns: repeat(3,1fr); gap: 16px; padding: 6px 30px 4px; }
.dial-key {
aspect-ratio: 1; border-radius: 50%; background: var(--s-surface-2); border: 1px solid var(--s-border);
color: var(--s-fg); font-size: 27px; font-weight: 500; cursor: pointer; font-family: inherit;
display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1;
}
.dial-key small { font-size: 9px; letter-spacing: 0.14em; color: var(--s-muted); font-weight: 700; margin-top: 2px; }
.dial-key:active { background: var(--s-accent); color:#fff; }
.call-bar { display: flex; align-items: center; justify-content: center; gap: 30px; padding: 14px 0 6px; }
.call-btn { width: 66px; height: 66px; border-radius: 50%; border: none; cursor: pointer; display: grid; place-items: center; }
.call-btn.call { background: var(--s-green); }
.call-btn.del { background: transparent; }
.seg { display: flex; background: var(--s-surface-2); border-radius: 11px; padding: 3px; margin-bottom: 12px; }
.seg button { flex: 1; border: none; background: none; color: var(--s-muted); font-weight: 600;
padding: 8px; border-radius: 8px; cursor: pointer; font-family: inherit; font-size: 13px; }
.seg button.active { background: var(--s-accent); color: #fff; }
/* aktiver Anruf */
.in-call {
position: absolute; inset: 0; z-index: 70; display: none; flex-direction: column;
align-items: center; padding-top: 100px; text-align: center;
background: radial-gradient(120% 80% at 50% 0%, #1a3a2f, #07110c);
color: #fff;
}
.in-call.show { display: flex; }
.in-call .who { font-size: 30px; font-weight: 700; }
.in-call .status { color: rgba(255,255,255,0.7); margin-top: 6px; font-variant-numeric: tabular-nums; }
.in-call .big-avatar { width: 120px; height: 120px; border-radius: 50%; margin: 30px 0;
display: grid; place-items: center; font-size: 44px; font-weight: 700;
background: linear-gradient(160deg,#4ade80,#16a34a); }
.in-call .call-buttons { margin-top: auto; margin-bottom: 70px; display: flex; gap: 40px; }
.in-call .end, .in-call .accept { width: 72px; height: 72px; border-radius: 50%;
border: none; cursor: pointer; display: grid; place-items: center; }
.in-call .end { background: var(--s-red); }
.in-call .accept { background: #34d39a; display: none; }
/* Annehmen erscheint nur bei einem eingehenden Anruf */
.in-call[data-eingehend] .accept { display: grid; }
.in-call .accept svg { animation: klingelPuls 1s ease-in-out infinite; }
@keyframes klingelPuls { 50% { transform: scale(1.12); } }
/* ---- Nachrichten ---- */
.chat-view { display: none; flex-direction: column; height: 100%; }
.chat-view.open { display: flex; }
.bubbles { flex: 1; overflow-y: auto; padding: 14px 14px 8px; display: flex; flex-direction: column; gap: 7px; }
.bubble { max-width: 76%; padding: 9px 13px; border-radius: 18px; font-size: 14.5px; line-height: 1.35; word-wrap: break-word; }
.bubble.them { background: var(--s-surface-2); align-self: flex-start; border-bottom-left-radius: 5px; }
.bubble.me { background: var(--s-accent); color: #fff; align-self: flex-end; border-bottom-right-radius: 5px; }
.bubble .time { display: block; font-size: 10px; opacity: 0.6; margin-top: 3px; }
.composer { display: flex; gap: 8px; padding: 10px 12px 14px; border-top: 1px solid var(--s-border);
background: var(--s-surface); }
.composer input { flex: 1; }
.send-btn { width: 44px; height: 44px; border-radius: 50%; background: var(--s-accent); border: none;
cursor: pointer; display: grid; place-items: center; flex-shrink: 0; }
/* ---- Bank ---- */
.bank-card {
border-radius: 22px; padding: 22px; color: #fff; position: relative; overflow: hidden;
background: linear-gradient(135deg, #1b3b34, #0c221d);
border: 1px solid rgba(52,211,153,0.3); margin-bottom: 6px;
}
.bank-card::after { content:""; position:absolute; width:200px; height:200px; border-radius:50%;
background: radial-gradient(circle, rgba(52,211,153,0.35), transparent 70%); top:-80px; right:-40px; }
.bank-card .lbl { font-size: 12px; opacity: 0.75; letter-spacing: 0.04em; }
.bank-card .bal { font-size: 40px; font-weight: 700; margin: 6px 0 2px; letter-spacing: -0.02em;
font-variant-numeric: tabular-nums; }
.bank-card .no { font-family: ui-monospace, monospace; letter-spacing: 0.14em; opacity: 0.8; margin-top: 18px; font-size: 14px; }
.bank-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; }
.tx { display: flex; align-items: center; gap: 12px; padding: 11px 4px; border-bottom: 1px solid var(--s-border); }
.tx .ti { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; flex-shrink: 0;
background: var(--s-surface-2); }
.tx .amt { font-weight: 700; font-variant-numeric: tabular-nums; }
.tx .amt.plus { color: var(--s-green); }
/* ---- Social (Birdy) ---- */
.post { background: var(--s-surface); border: 1px solid var(--s-border); border-radius: 16px;
padding: 14px; margin-bottom: 10px; }
.post .ph { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.post .ph .avatar { width: 40px; height: 40px; }
.post .name { font-weight: 700; font-size: 14px; }
.post .handle { color: var(--s-muted); font-size: 12.5px; }
.post .txt { font-size: 14.5px; line-height: 1.4; }
.post .actions { display: flex; gap: 24px; margin-top: 10px; color: var(--s-muted); font-size: 12.5px; }
.post .actions button { background: none; border: none; color: inherit; cursor: pointer; font-family: inherit;
display: flex; align-items: center; gap: 5px; font-size: 12.5px; padding: 0; }
.post .actions button.liked { color: var(--s-red); }
.composer-box { background: var(--s-surface); border: 1px solid var(--s-border); border-radius: 16px;
padding: 12px; margin-bottom: 14px; }
.composer-box textarea { width: 100%; background: transparent; border: none; color: var(--s-fg);
resize: none; font-family: inherit; font-size: 15px; outline: none; min-height: 48px; }
/* ---- Kamera / Galerie ---- */
.gallery { display: grid; grid-template-columns: repeat(3,1fr); gap: 4px; }
.gallery .ph { aspect-ratio: 1; border-radius: 6px; overflow: hidden; position: relative; }
.cam-view { position: relative; height: 100%; display: flex; flex-direction: column; }
.cam-feed { flex: 1; position: relative; overflow: hidden;
background: radial-gradient(80% 60% at 50% 40%, #2a3b52, #0a0f18); }
.cam-controls { padding: 20px 0 40px; display: flex; align-items: center; justify-content: center; gap: 40px;
background: #000; }
.shutter { width: 68px; height: 68px; border-radius: 50%; background: #fff; border: 4px solid #000;
box-shadow: 0 0 0 3px #fff; cursor: pointer; }
.cam-thumb { width: 44px; height: 44px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.3); }
/* ---- Rechner ---- */
.calc-display { text-align: right; padding: 8px 6px 4px; font-size: 40px; font-weight: 300;
font-variant-numeric: tabular-nums; overflow: hidden; white-space: nowrap;
max-width: 296px; margin: 0 auto; width: 100%; }
.calc-pad { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; padding: 0 0 8px;
max-width: 296px; margin: 0 auto; width: 100%; }
.calc-key { aspect-ratio: 1; border-radius: 50%; border: none; font-size: 21px; cursor: pointer;
background: var(--s-surface-2); color: var(--s-fg); font-family: inherit; font-weight: 500; }
.calc-key.op { background: var(--s-amber); color: #fff; }
.calc-key.fn { background: #6b7280; color: #fff; }
.calc-key.eq { background: var(--s-accent); color: #fff; }
.calc-key.zero { aspect-ratio: auto; grid-column: span 2; border-radius: 40px; text-align: left; padding-left: 28px; }
/* ---- Wetter ---- */
.weather-hero { text-align: center; padding: 20px 0 10px; }
.weather-hero .city { font-size: 26px; font-weight: 600; }
.weather-hero .temp { font-size: 82px; font-weight: 200; line-height: 1; }
.weather-hero .cond { font-size: 15px; color: var(--s-muted); }
.weather-hour { display: flex; gap: 14px; overflow-x: auto; padding: 14px 4px; }
.weather-hour .h { display: flex; flex-direction: column; align-items: center; gap: 8px; flex-shrink: 0;
font-size: 13px; }
.weather-hour .h .t { font-weight: 700; }
/* ---- Einstellungen ---- */
.setting-row { display: flex; align-items: center; gap: 12px; padding: 13px 14px;
background: var(--s-surface); border-bottom: 1px solid var(--s-border); }
.setting-row:first-child { border-radius: 14px 14px 0 0; }
.setting-row:last-child { border-radius: 0 0 14px 14px; border-bottom: none; }
.setting-row .si { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex-shrink: 0; }
.switch { width: 50px; height: 30px; border-radius: 999px; background: var(--s-surface-2); position: relative;
cursor: pointer; border: none; flex-shrink: 0; transition: background .2s; }
.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 24px; height: 24px;
border-radius: 50%; background: #fff; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
.switch.on { background: var(--s-green); }
.switch.on::after { transform: translateX(20px); }
.wp-picker { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-top: 8px; }
.wp-opt { aspect-ratio: 9/16; border-radius: 12px; cursor: pointer; border: 2px solid transparent; }
.wp-opt.sel { border-color: var(--s-accent); }
/* ---- MDT (Polizei / Dienst) ---- */
.mdt-stat { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
.mdt-box { background: var(--s-surface); border: 1px solid var(--s-border); border-radius: 14px; padding: 14px; }
.mdt-box .n { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
.mdt-box .l { font-size: 11.5px; color: var(--s-muted); text-transform: uppercase; letter-spacing: 0.04em; }
/* Generischer Empty-State */
.empty { text-align: center; color: var(--s-muted); padding: 50px 20px; }
.empty .big { font-size: 40px; margin-bottom: 10px; }
/* Sperrbildschirm-Benachrichtigung: nach links wegwischbar */
.lock-note { touch-action: pan-y; user-select: none; }
.lock-note.swiping { cursor: grabbing; }
/* Push-Banner (oben eingeblendet) */
.notif-banner {
position: absolute; top: 8px; left: 10px; right: 10px; z-index: 80;
background: rgba(30,32,44,0.86); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 20px; padding: 12px 14px;
display: none; gap: 11px; align-items: center; cursor: pointer;
box-shadow: 0 14px 34px -10px rgba(0,0,0,0.7);
transform: translateY(-140%); transition: transform .38s cubic-bezier(.2,.8,.2,1);
}
.notif-banner.show { display: flex; transform: translateY(0); }
.notif-banner .ic { width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center; }
.notif-banner .ic svg { width: 18px; height: 18px; }
.notif-banner .txt { min-width: 0; flex: 1; }
.notif-banner .txt .t { font-size: 13px; font-weight: 700; color: #fff; }
.notif-banner .txt .d { font-size: 12.5px; color: rgba(255,255,255,0.78); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Staatsapp: einfaches Balkendiagramm (Einnahmen vs. Ausgaben) */
.staat-card { border-radius: 22px; padding: 20px; color: #fff; margin-bottom: 12px;
background: linear-gradient(135deg,#3a466b,#20263a);
border: 1px solid rgba(255,255,255,0.12); position: relative; overflow: hidden; }
.staat-card .lbl { font-size: 12px; opacity: 0.75; letter-spacing: 0.04em; }
.staat-card .bal { font-size: 34px; font-weight: 700; margin: 4px 0; font-variant-numeric: tabular-nums; }
.chart { display: flex; align-items: flex-end; gap: 10px; height: 130px; padding: 8px 4px 0; }
.chart .col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; height: 100%; justify-content: flex-end; }
.chart .bars { display: flex; gap: 3px; align-items: flex-end; flex: 1; min-height: 0; width: 100%; justify-content: center; }
.chart .bar { width: 11px; border-radius: 3px 3px 0 0; }
.chart .bar.ein { background: var(--s-green); }
.chart .bar.aus { background: var(--s-red); }
.chart .m { font-size: 11px; color: var(--s-muted); }
.legende { display: flex; gap: 16px; justify-content: center; font-size: 12px; color: var(--s-muted); margin-top: 6px; }
.legende span { display: inline-flex; align-items: center; gap: 6px; }
.legende i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
.vote-bar { height: 8px; border-radius: 4px; background: var(--s-surface-2); overflow: hidden; display: flex; margin: 8px 0 4px; }
.vote-bar .ja { background: var(--s-green); height: 100%; }
.vote-bar .nein { background: var(--s-red); height: 100%; }
.vote-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
/* Vollbild-Overlays: Ersteinrichtung & Entsperren (immer dunkel gehalten) */
.fullscreen-overlay {
position: absolute; inset: 0; z-index: 250; display: none; flex-direction: column;
color: #f4f5fb;
background:
radial-gradient(120% 80% at 50% 0%, rgba(123,108,246,0.35), transparent 55%),
linear-gradient(160deg, #0e1020, #05060b);
}
.fullscreen-overlay.show { display: flex; }
/* Onboarding-Schritte */
.setup-inner { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 44px 30px; gap: 14px; }
.setup-logo { width: 66px; height: 66px; border-radius: 18px; display: grid; place-items: center; font-size: 32px;
background: linear-gradient(145deg,#7b6cf6,#34d399); box-shadow: 0 12px 30px rgba(123,108,246,0.5); margin-bottom: 6px; }
.setup-h1 { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; text-wrap: balance; }
.setup-p { font-size: 14px; color: #a9acc2; line-height: 1.55; max-width: 280px; }
.setup-inner .btn { max-width: 220px; }
/* PIN-Eingabe (Setup & Entsperren) */
.pin-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px 24px; }
.pin-icon { font-size: 44px; margin-bottom: 8px; }
.pin-title { font-size: 20px; font-weight: 700; }
.pin-sub { font-size: 13.5px; color: #a9acc2; margin-top: 4px; margin-bottom: 20px; text-align: center; }
.pin-dots { display: flex; gap: 16px; margin-bottom: 30px; }
.pin-dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.55); transition: background .12s; }
.pin-dot.on { background: #fff; border-color: #fff; }
.pin-pad { display: grid; grid-template-columns: repeat(3, 70px); gap: 16px; justify-content: center; }
.pin-key { width: 70px; height: 70px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.12);
background: rgba(255,255,255,0.09); color: #fff; font-size: 27px; font-weight: 400; cursor: pointer; font-family: inherit; }
.pin-key:active { background: rgba(255,255,255,0.25); }
.pin-key.pin-del { background: none; border: none; font-size: 22px; }
.pin-cancel { margin-top: 22px; background: none; border: none; color: #9a8cff; font-size: 15px; font-weight: 600;
cursor: pointer; font-family: inherit; }
/* Face-ID-Scan (Apple-artiges Glyph mit Scan-Strahl, löst sich in einen Haken auf) */
.faceid { position: relative; width: 118px; height: 118px; margin: 0 auto 10px; overflow: hidden; }
.faceid-glyph, .faceid-check { position: absolute; inset: 0; width: 100%; height: 100%;
transition: opacity .3s ease, transform .3s ease; }
.faceid-glyph { animation: fidGlow 1.6s ease-in-out infinite; }
@keyframes fidGlow { 0%,100% { opacity: .82; } 50% { opacity: 1; } }
/* wandernder Scan-Strahl */
.faceid-scanline { position: absolute; left: 10px; right: 10px; height: 10px; top: 50%; margin-top: -5px;
border-radius: 6px; pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(52,211,153,0.95), transparent);
box-shadow: 0 0 18px 5px rgba(52,211,153,0.45);
animation: fidScan 1.15s ease-in-out infinite alternate; }
@keyframes fidScan { from { transform: translateY(-42px); } to { transform: translateY(42px); } }
/* Erfolgs-Haken (anfangs verborgen) */
.faceid-check { opacity: 0; transform: scale(.6); }
.faceid-check .ring { stroke-dasharray: 214; stroke-dashoffset: 214; }
.faceid-check .tick { stroke-dasharray: 52; stroke-dashoffset: 52; }
/* Übergang bei Erfolg: Gesicht ausblenden, Haken zeichnen */
.faceid.done .faceid-glyph { animation: none; opacity: 0; transform: scale(.82); }
.faceid.done .faceid-scanline { opacity: 0; }
.faceid.done .faceid-check { opacity: 1; animation: fidPop .45s cubic-bezier(.2,.9,.3,1.2) forwards; }
.faceid.done .faceid-check .ring { animation: fidDraw .5s ease forwards; }
.faceid.done .faceid-check .tick { animation: fidDraw .35s .28s ease forwards; }
@keyframes fidDraw { to { stroke-dashoffset: 0; } }
@keyframes fidPop { 0% { transform: scale(.6); } 60% { transform: scale(1.06); } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) {
.faceid-glyph, .faceid-scanline { animation: none; }
.faceid.done .faceid-check { animation: none; }
.faceid.done .faceid-check .ring, .faceid.done .faceid-check .tick { animation: none; stroke-dashoffset: 0; }
}
/* Overlay bei leerem Akku / ausgeschaltetem Handy */
.power-off {
position: absolute; inset: 0; z-index: 300; background: #000; color: #d0d2dc;
display: none; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 0 40px;
}
.power-off.show { display: flex; }
.power-off .po-icon { font-size: 60px; margin-bottom: 14px; filter: grayscale(0.2); }
.power-off .po-title { font-size: 22px; font-weight: 700; color: #fff; }
.power-off .po-sub { font-size: 13.5px; margin-top: 8px; line-height: 1.5; color: #8a8da3; }
.toast {
position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(20px);
background: rgba(20,22,32,0.95); color: #fff; padding: 12px 20px; border-radius: 14px;
font-size: 13.5px; font-weight: 600; z-index: 200; opacity: 0; pointer-events: none;
transition: all .3s ease; border: 1px solid rgba(255,255,255,0.12); white-space: nowrap;
max-width: 90%; text-align: center;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
<!-- ==================== Linkes Info-Panel ==================== -->
<aside class="side-panel left">
<div class="brand-mark"><span class="dot"></span> AetherOS</div>
<p>Ein voll interaktiver Web-Prototyp eines RP-Smartphones für deinen deutschsprachigen FiveM GTA-Server. Alles läuft ohne Server — reines HTML/CSS/JS zum Ansehen.</p>
<h2>So bedienst du es</h2>
<ul class="hint-list">
<li><span class="kbd">↑</span> <span><b>Wischen/Tippen</b> auf den Sperrbildschirm entsperrt das Handy.</span></li>
<li><span class="kbd">←</span> <span>Mitteilungen am Sperrbildschirm <b>nach links wegwischen</b>.</span></li>
<li><span class="kbd">▬</span> <span>Die <b>Leiste unten</b> bringt dich zurück zum Homescreen.</span></li>
<li><span class="kbd">🔐</span> <span>Beim <b>Erststart Code festlegen</b>. Entsperren per <b>Face-ID</b> oder Code (Einstellungen → Sicherheit).</span></li>
</ul>
<h2>Push-Mitteilung auslösen</h2>
<p>Im Server kommen diese Events vom Backend. Hier zum Testen manuell:</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<button class="demo-btn" data-push="messages">💬 Nachricht</button>
<button class="demo-btn" data-push="bank">🏦 Bank</button>
<button class="demo-btn" data-push="dispatch">📡 Dispatch</button>
<button class="demo-btn" data-push="birdy">🐦 Birdy</button>
</div>
<h2>Gesperrte Apps</h2>
<p>Manche Apps (z. B. Darkchat) sind nicht für jeden verfügbar — im Spiel per Gamemechanik freizuschalten. Hier zum Testen:</p>
<button class="demo-btn" id="unlock-darkweb" style="width:100%">🔓 Darkchat freischalten</button>
<h2>Hardware &amp; Items</h2>
<p>Handy, SIM-Karte und Akku sind im Spiel eigene <b>Items</b>, die kombiniert werden. Akkustand: <b id="demo-akku">78%</b></p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<button class="demo-btn" data-hw="drain">🪫 Akku entladen</button>
<button class="demo-btn" data-hw="newbatt">🔋 Neuen Akku einsetzen</button>
<button class="demo-btn" data-hw="sim">📶 SIM entfernen / einsetzen</button>
<button class="demo-btn" data-hw="handy">📱 Handy aus / an</button>
</div>
</aside>
<style>
.demo-btn {
background: var(--page-card); border: 1px solid var(--page-border); color: var(--page-fg);
border-radius: 10px; padding: 9px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer;
font-family: inherit; transition: filter .15s ease, transform .1s ease;
}
.demo-btn:hover { filter: brightness(1.06); border-color: var(--accent); }
.demo-btn:active { transform: scale(0.96); }
</style>
<!-- ==================== Das Handy ==================== -->
<div class="device">
<div class="screen locked" id="screen">
<div class="wallpaper wp-aurora" id="wallpaper"></div>
<!-- Dynamic Island + Statusleiste -->
<div class="island"><span class="cam"></span></div>
<div class="statusbar">
<span id="sb-time">14:32</span>
<span class="sb-right">
<span id="sb-net" style="font-size:11px;font-weight:700;display:none">Kein Netz</span>
<span id="sb-signal" title="Netz">
<svg width="18" height="12" viewBox="0 0 18 12" fill="currentColor"><rect x="0" y="8" width="3" height="4" rx="1"/><rect x="5" y="5" width="3" height="7" rx="1"/><rect x="10" y="2" width="3" height="10" rx="1"/><rect x="15" y="0" width="3" height="12" rx="1" opacity="0.4"/></svg>
</span>
<span style="display:flex;align-items:center;gap:4px;font-size:12px;">
<span id="sb-batt-pct" style="font-variant-numeric:tabular-nums">78%</span>
<svg width="26" height="13" viewBox="0 0 26 13"><rect x="0.5" y="0.5" width="22" height="12" rx="3.5" fill="none" stroke="currentColor" opacity="0.5"/><rect id="sb-batt-fill" x="2" y="2" width="17" height="9" rx="2" fill="currentColor"/><rect x="23.5" y="4" width="1.8" height="5" rx="1" fill="currentColor" opacity="0.5"/></svg>
</span>
</span>
</div>
<!-- Sperrbildschirm -->
<div class="lockscreen" id="lockscreen">
<div class="lock-date" id="lock-date">Dienstag, 21. Juli</div>
<div class="lock-time" id="lock-time">14:32</div>
<div class="lock-notes" id="lock-notes"><!-- Benachrichtigungen per JS --></div>
<div class="lock-unlock">Tippen zum Entsperren · ← Mitteilung wegwischen</div>
<button class="lock-btn" id="lock-btn" aria-label="Entsperren"></button>
</div>
<!-- Homescreen -->
<div class="homescreen" id="homescreen">
<div class="app-grid" id="app-grid"><!-- Apps per JS --></div>
<div class="dock" id="dock"><!-- Dock-Apps per JS --></div>
</div>
<!-- App-Container (wird per JS befüllt) -->
<div class="app-view" id="app-view">
<div class="app-bg"></div>
<div id="app-content" style="display:flex;flex-direction:column;height:100%;"></div>
</div>
<!-- Overlay: aktiver Anruf -->
<div class="in-call" id="in-call">
<div class="who" id="call-who">Unbekannt</div>
<div class="status" id="call-status">Anruf läuft…</div>
<div class="big-avatar" id="call-avatar">?</div>
<div class="call-buttons">
<!-- Nur bei eingehenden Anrufen sichtbar (siehe CSS) -->
<button class="accept" id="call-accept" aria-label="Annehmen">
<svg width="30" height="30" viewBox="0 0 24 24" fill="#fff"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.996.996 0 0 1-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-1.48 1.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1C15.15 9.25 13.6 9 12 9Z"/></svg>
</button>
<button class="end" id="call-end" aria-label="Auflegen">
<svg width="30" height="30" viewBox="0 0 24 24" fill="#fff"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.996.996 0 0 1-.29-.7c0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-1.48 1.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1C15.15 9.25 13.6 9 12 9Z" transform="rotate(135 12 12)"/></svg>
</button>
</div>
</div>
<!-- Push-Banner (oben eingeblendet) -->
<div class="notif-banner" id="notif-banner">
<span class="ic" id="notif-ic"></span>
<div class="txt"><div class="t" id="notif-t"></div><div class="d" id="notif-d"></div></div>
</div>
<!-- Overlay: Ersteinrichtung (Code festlegen) -->
<div class="fullscreen-overlay" id="setup-screen"></div>
<!-- Overlay: Entsperren per Face-ID oder Code -->
<div class="fullscreen-overlay" id="unlock-screen"></div>
<!-- Overlay: Akku leer / Handy aus -->
<div class="power-off" id="power-off">
<div class="po-icon" id="po-icon">🪫</div>
<div class="po-title" id="po-title">Akku leer</div>
<div class="po-sub" id="po-sub">Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.</div>
</div>
<button class="home-indicator" id="home-indicator" aria-label="Startbildschirm"></button>
<div class="toast" id="toast"></div>
</div>
</div>
<!-- ==================== Rechtes Info-Panel ==================== -->
<aside class="side-panel right">
<h2>Enthaltene Apps</h2>
<div>
<span class="feature-tag">📞 Telefon &amp; Wähler</span>
<span class="feature-tag">💬 Nachrichten</span>
<span class="feature-tag">🏦 Bank / Fleeca</span>
<span class="feature-tag">🐦 Birdy (Social)</span>
<span class="feature-tag">📸 Kamera &amp; Galerie</span>
<span class="feature-tag">🎵 Musik</span>
<span class="feature-tag">📡 Dispatch / Leitstelle</span>
<span class="feature-tag">🏛️ Staat / Staatskasse</span>
<span class="feature-tag">🚗 Garage</span>
<span class="feature-tag">📒 Gelbe Seiten</span>
<span class="feature-tag">🔒 Darkchat (freischaltbar)</span>
<span class="feature-tag">🌤️ Wetter</span>
<span class="feature-tag">🧮 Rechner</span>
<span class="feature-tag">⚙️ Einstellungen</span>
</div>
<h2>Für FiveM gedacht</h2>
<p>Datenmodelle (Kontakte, Konten, Chats, Posts) sind bewusst getrennt vom UI gehalten — später einfach an <code>NUI</code>-Callbacks &amp; deine Datenbank anbinden.</p>
</aside>
<script>
"use strict";
/* =====================================================================
ZENTRALER ZUSTAND — alle "Server-Daten" gebündelt an einer Stelle,
damit später eine Anbindung an FiveM/NUI leichtfällt.
===================================================================== */
let spielerZustand = {
name: "Max Weber", // Name des Spielercharakters
nummer: "555-0182", // Telefonnummer des Spielers
kontostand: 48320, // Bankguthaben in Euro
kontonummer: "DE 4021 5589", // angezeigte Kontonummer
theme: "dark", // aktuelles Handy-Theme (dark/light)
wallpaper: "wp-aurora", // aktuell gewähltes Hintergrundbild
dockApps: ["phone", "messages", "bank", "camera"], // anpassbare Dock-Leiste (4 Apps)
freigeschaltet: { darkweb: false }, // per Gamemechanik freischaltbare Apps
// Hardware — im Spiel jeweils eigene Items, die kombiniert werden:
handy: true, // besitzt der Spieler ein Handy (Item)?
sim: true, // ist eine SIM-Karte (Item) eingelegt?
akku: 78, // Akkustand in Prozent (Akku = Item, geht leer, austauschbar)
// Sicherheit — schützt das Handy, falls es im Spiel gestohlen wird:
pin: null, // festgelegter Entsperrcode (null = noch nicht eingerichtet -> Setup)
faceId: true // bequemes Entsperren ohne Code-Eingabe
};
// Kontaktliste des Spielers
let kontakteListe = [
{ name: "Polizei (Notruf)", nummer: "110", farbe: "#1e5eff", initialen: "🚓", notruf: true },
{ name: "Rettungsdienst", nummer: "112", farbe: "#f0506e", initialen: "🚑", notruf: true },
{ name: "Familie Schmidt", nummer: "555-0147", farbe: "#7b6cf6", initialen: "FS" },
{ name: "Benny's Werkstatt", nummer: "555-0199", farbe: "#f5b23d", initialen: "BW" },
{ name: "Taxi Zentrale", nummer: "555-0100", farbe: "#facc15", initialen: "TX" },
{ name: "Dr. Weber (Anwalt)", nummer: "555-0163", farbe: "#34d399", initialen: "AW" },
{ name: "Lena Berger", nummer: "555-0134", farbe: "#e11d48", initialen: "LB" }
];
// Anrufliste (Verlauf)
let anrufListe = [
{ name: "Lena Berger", zeit: "vor 12 Min", art: "eingehend", verpasst: false },
{ name: "Benny's Werkstatt", zeit: "vor 1 Std", art: "ausgehend", verpasst: false },
{ name: "Unbekannt", zeit: "vor 2 Std", art: "eingehend", verpasst: true },
{ name: "Familie Schmidt", zeit: "Gestern", art: "ausgehend", verpasst: false }
];
// Chatverläufe je Kontakt
let chatVerlaeufe = {
"Familie Schmidt": [
{ von: "them", text: "Hey Max! Wie läuft dein Tag?", zeit: "13:40" },
{ von: "me", text: "Alles gut, bin gerade beim Tanken", zeit: "13:42" },
{ von: "them", text: "Kommst du heute zum Grillen vorbei? 🔥", zeit: "14:15" }
],
"Lena Berger": [
{ von: "them", text: "Treffen wir uns nachher am Pier?", zeit: "12:10" },
{ von: "me", text: "Klar, so gegen 18 Uhr passt", zeit: "12:12" }
],
"Benny's Werkstatt": [
{ von: "them", text: "Dein Wagen ist fertig, kannst ihn abholen!", zeit: "11:05" }
],
"Dr. Weber (Anwalt)": [
{ von: "them", text: "Die Kaution wurde hinterlegt. Alles geregelt.", zeit: "Gestern" }
]
};
// Banktransaktionen (neueste zuerst)
let transaktionsListe = [
{ titel: "Gehalt — Polizei LSPD", betrag: 2400, zeit: "Heute, 14:00", icon: "💼" },
{ titel: "Tankstelle LTD", betrag: -85, zeit: "Heute, 13:41", icon: "⛽" },
{ titel: "Benny's Werkstatt", betrag: -1250, zeit: "Heute, 11:20", icon: "🔧" },
{ titel: "Überweisung Lena Berger", betrag: -300, zeit: "Gestern", icon: "↗" },
{ titel: "Miete Apartment 4B", betrag: -1200, zeit: "20. Juli", icon: "🏠" },
{ titel: "Bargeld-Einzahlung", betrag: 5000, zeit: "19. Juli", icon: "💵" }
];
// Social-Media-Beiträge (Birdy)
const birdyBeitraege = [
{ name: "Los Santos News", handle: "@lsnews", initialen: "LS", farbe: "#4aa8ff", text: "🚨 Verkehrschaos an der Autobahn nahe Sandy Shores. Bitte weiträumig umfahren!", likes: 142, retweets: 38, gemocht: false },
{ name: "Benny's Motorworks", handle: "@bennys", initialen: "BW", farbe: "#f5b23d", text: "Neue Felgen eingetroffen! Kommt vorbei und pimpt euren Wagen. 🔧🚗", likes: 89, retweets: 12, gemocht: false },
{ name: "Lena Berger", handle: "@lena_b", initialen: "LB", farbe: "#e11d48", text: "Sonnenuntergang am Vespucci Beach heute war einfach traumhaft 🌅", likes: 231, retweets: 45, gemocht: true }
];
/* =====================================================================
APP-DEFINITIONEN — Name, Icon-Farbe, SVG-Glyph, Badge
===================================================================== */
const svgGlyphen = {
phone: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.6.1.4 0 .8-.3 1l-2.2 2.2Z"/></svg>',
msg: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 3C6.5 3 2 6.6 2 11c0 2.4 1.3 4.5 3.4 6-.2 1-.8 2.2-1.7 3.2 1.6-.2 3.2-.8 4.4-1.6 1.2.4 2.5.6 3.9.6 5.5 0 10-3.6 10-8s-4.5-8-10-8Z"/></svg>',
bank: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 2 2 7v2h20V7L12 2ZM4 10v7H2v3h20v-3h-2v-7h-3v7h-3v-7h-3v7H7v-7H4Z"/></svg>',
cam: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M9 4 7.2 6H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-3.2L15 4H9Zm3 5a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9Z"/></svg>',
birdy: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M22 5.9c-.7.3-1.5.5-2.3.6.8-.5 1.4-1.3 1.7-2.2-.8.4-1.7.8-2.6.9A4 4 0 0 0 12 8.5c0 .3 0 .6.1.9A11.5 11.5 0 0 1 3.8 5a4 4 0 0 0 1.2 5.4c-.6 0-1.2-.2-1.7-.5v.1a4 4 0 0 0 3.2 4 4 4 0 0 1-1.8.1 4 4 0 0 0 3.7 2.8A8 8 0 0 1 2 18.6a11.4 11.4 0 0 0 6.3 1.8c7.5 0 11.6-6.2 11.6-11.6v-.5c.8-.6 1.5-1.3 2.1-2.1Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm8.9 4c0-.5 0-1-.1-1.5l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2.6-1.5L15.5 2h-4l-.3 2.6A7 7 0 0 0 8.6 6l-2.3-1-2 3.4 2 1.5c-.1.5-.1 1-.1 1.5s0 1 .1 1.5l-2 1.5 2 3.4 2.3-1c.8.7 1.6 1.2 2.6 1.5l.3 2.6h4l.3-2.6c1-.3 1.8-.8 2.6-1.5l2.3 1 2-3.4-2-1.5c.1-.5.1-1 .1-1.5Z"/></svg>',
music: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M9 18V6l10-2v11.5A3.5 3.5 0 1 1 17 12V7.3L11 8.6V19a3.5 3.5 0 1 1-2-3.16"/></svg>',
weather: '<svg viewBox="0 0 24 24" fill="#fff"><circle cx="8" cy="8" r="4" opacity="0.9"/><path d="M17.5 12a4.5 4.5 0 0 0-8.6-1.8A4 4 0 1 0 8 18h9a3 3 0 0 0 .5-6Z"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2" stroke-linecap="round"/></svg>',
calc: '<svg viewBox="0 0 24 24" fill="#fff"><rect x="4" y="2" width="16" height="20" rx="2"/><rect x="6.5" y="4.5" width="11" height="4" rx="1" fill="#14151b"/><g fill="#14151b"><circle cx="8" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="16" cy="12" r="1.2"/><circle cx="8" cy="16" r="1.2"/><circle cx="12" cy="16" r="1.2"/><circle cx="16" cy="16" r="1.2"/></g></svg>',
garage: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M5 11l1.5-4.5A2 2 0 0 1 8.4 5h7.2a2 2 0 0 1 1.9 1.5L19 11v7h-2v-2H7v2H5v-7Zm2.5 3a1.3 1.3 0 1 0 0-2.6 1.3 1.3 0 0 0 0 2.6Zm9 0a1.3 1.3 0 1 0 0-2.6 1.3 1.3 0 0 0 0 2.6ZM6.8 10h10.4l-1-3H7.8l-1 3Z"/></svg>',
mdt: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3Zm-1 13-3-3 1.4-1.4L11 12.2l4.6-4.6L17 9l-6 6Z"/></svg>',
dispatch: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 2a8 8 0 0 0-8 8v6a3 3 0 0 0 3 3h1v-8H6v-1a6 6 0 0 1 12 0v1h-2v8h1a3 3 0 0 0 3-3v-6a8 8 0 0 0-8-8Z"/><rect x="10.5" y="6.5" width="3" height="6" rx="1.5" fill="#fff"/></svg>',
pages: '<svg viewBox="0 0 24 24" fill="#14151b"><path d="M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm2 4v2h10V7H7Zm0 4v2h10v-2H7Zm0 4v2h7v-2H7Z"/></svg>',
dark: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 3a9 9 0 1 0 9 9c0-.5 0-1-.1-1.4A6 6 0 0 1 12 3Z"/></svg>',
staat: '<svg viewBox="0 0 24 24" fill="#fff"><path d="M12 2 3 6.5V8h18V6.5L12 2Z"/><path d="M5 9v8H4v2.5h16V17h-1V9h-2v8h-3V9h-2v8H9V9H5Z"/></svg>',
notes: '<svg viewBox="0 0 24 24" fill="#3a3320"><path d="M6 2h9l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm8 1.6V8h4.4L14 3.6ZM7 12h10v1.5H7V12Zm0 3.5h10V17H7v-1.5ZM7 8.5h5V10H7V8.5Z"/></svg>'
};
// Zentrale Registry aller Apps (id -> Anzeigedaten). gated = nur nach Freischaltung sichtbar.
const alleApps = {
phone: { label: "Telefon", g: "g-phone", glyph: "phone", badge: 1 },
messages: { label: "Nachrichten", g: "g-msg", glyph: "msg", badge: 3 },
bank: { label: "Bank", g: "g-bank", glyph: "bank" },
camera: { label: "Kamera", g: "g-cam", glyph: "cam" },
birdy: { label: "Birdy", g: "g-birdy", glyph: "birdy" },
insta: { label: "InstaPic", g: "g-insta", glyph: "cam" },
weather: { label: "Wetter", g: "g-weather", glyph: "weather" },
clock: { label: "Uhr", g: "g-clock", glyph: "clock" },
calc: { label: "Rechner", g: "g-calc", glyph: "calc" },
garage: { label: "Garage", g: "g-garage", glyph: "garage" },
dispatch: { label: "Dispatch", g: "g-dispatch", glyph: "dispatch" },
pages: { label: "Gelbe Seiten", g: "g-pages", glyph: "pages" },
music: { label: "Musik", g: "g-music", glyph: "music" },
contacts: { label: "Kontakte", g: "g-phone", glyph: "phone" },
staat: { label: "Staat", g: "g-staat", glyph: "staat" },
notizen: { label: "Notizen", g: "g-notes", glyph: "notes" },
settings: { label: "Einstellungen", g: "g-set", glyph: "settings" },
darkweb: { label: "Darkchat", g: "g-dark", glyph: "dark", gated: true }
};
// Master-Reihenfolge aller Apps im Grid (Dock-Apps werden beim Aufbau herausgefiltert)
const appReihenfolge = [
"phone", "messages", "bank", "camera", "birdy", "insta", "weather", "clock",
"calc", "garage", "dispatch", "pages", "music", "contacts", "staat",
"notizen", "settings", "darkweb"
];
/* =====================================================================
DOM-REFERENZEN
===================================================================== */
const screenEl = document.getElementById("screen");
const appViewEl = document.getElementById("app-view");
const appContentEl = document.getElementById("app-content");
const toastEl = document.getElementById("toast");
/* Hilfsfunktion: kurze Einblend-Nachricht ("Toast") anzeigen */
let toastTimer = null;
function zeigeToast(nachricht) {
toastEl.textContent = nachricht;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2000);
}
/* Hilfsfunktion: Euro-Beträge deutsch formatieren */
function formatiereEuro(betrag) {
return betrag.toLocaleString("de-DE", { minimumFractionDigits: 0 }) + " €";
}
/* Hilfsfunktion: HTML gegen einfache Injektion absichern */
function sicher(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/* =====================================================================
UHR — Statusleiste und Sperrbildschirm aktualisieren
===================================================================== */
function aktualisiereUhr() {
const jetzt = new Date();
const stunden = String(jetzt.getHours()).padStart(2, "0");
const minuten = String(jetzt.getMinutes()).padStart(2, "0");
const zeitText = stunden + ":" + minuten;
document.getElementById("sb-time").textContent = zeitText;
document.getElementById("lock-time").textContent = zeitText;
// Deutsches Datum auf dem Sperrbildschirm
const wochentage = ["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"];
const monate = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"];
document.getElementById("lock-date").textContent =
wochentage[jetzt.getDay()] + ", " + jetzt.getDate() + ". " + monate[jetzt.getMonth()];
}
aktualisiereUhr();
setInterval(aktualisiereUhr, 1000);
/* =====================================================================
SPERRBILDSCHIRM & HOMESCREEN-NAVIGATION
===================================================================== */
// Handy entsperren (Sperrbildschirm ausblenden)
function entsperreHandy() { screenEl.classList.remove("locked"); }
// Klick auf den Sperrbildschirm -> je nach Einstellung Face-ID oder Code
document.getElementById("lock-btn").addEventListener("click", versucheEntsperren);
/* =====================================================================
SICHERHEIT: Ersteinrichtung, Code-Eingabe & Face-ID
===================================================================== */
// Wiederverwendbares PIN-Eingabefeld in einen Container rendern.
// opts: { titel, unterzeile, icon, stellen=4, onFertig(code, reset), abbrechen:{label,cb} }
function renderePinFeld(container, opts) {
const stellen = opts.stellen || 4;
let code = ""; // aktuell eingegebene Ziffern
function zeichne() {
container.innerHTML =
'<div class="pin-wrap">' +
(opts.icon ? '<div class="pin-icon">' + opts.icon + '</div>' : '') +
'<div class="pin-title">' + sicher(opts.titel) + '</div>' +
(opts.unterzeile ? '<div class="pin-sub">' + sicher(opts.unterzeile) + '</div>' : '') +
'<div class="pin-dots">' + Array.from({ length: stellen }).map((_, i) =>
'<span class="pin-dot' + (i < code.length ? ' on' : '') + '"></span>').join("") + '</div>' +
'<div class="pin-pad">' +
[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => '<button class="pin-key" data-k="' + n + '">' + n + '</button>').join("") +
'<span></span>' +
'<button class="pin-key" data-k="0">0</button>' +
'<button class="pin-key pin-del" data-k="del" aria-label="Löschen">⌫</button>' +
'</div>' +
(opts.abbrechen ? '<button class="pin-cancel" id="pin-cancel">' + sicher(opts.abbrechen.label) + '</button>' : '') +
'</div>';
// Zifferntasten
container.querySelectorAll(".pin-key").forEach(b =>
b.addEventListener("click", () => {
const k = b.dataset.k;
if (k === "del") { code = code.slice(0, -1); zeichne(); return; }
if (code.length >= stellen) return;
code += k;
zeichne();
// Bei voller Länge auswerten
if (code.length === stellen) {
const fertig = code;
setTimeout(() => opts.onFertig(fertig, () => { code = ""; zeichne(); }), 160);
}
}));
const abbr = container.querySelector("#pin-cancel");
if (abbr && opts.abbrechen) abbr.addEventListener("click", opts.abbrechen.cb);
}
zeichne();
}
// Entsperr-Versuch: Face-ID (bequem) oder Code-Eingabe
function versucheEntsperren() {
if (!spielerZustand.pin) return; // noch nicht eingerichtet -> Setup läuft
if (spielerZustand.faceId) entsperreMitFaceId();
else entsperreMitCode();
}
// Apple-artige Face-ID-Grafik: Gesichts-Glyph + Scan-Strahl + Erfolgs-Haken
const faceIdGrafik =
'<svg class="faceid-glyph" viewBox="0 0 100 100" fill="none" stroke="#f4f5fb" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M8 30 V22 a14 14 0 0 1 14 -14 H30"/>' +
'<path d="M70 8 H78 a14 14 0 0 1 14 14 V30"/>' +
'<path d="M92 70 V78 a14 14 0 0 1 -14 14 H70"/>' +
'<path d="M30 92 H22 a14 14 0 0 1 -14 -14 V70"/>' +
'<path d="M38 40 V50"/><path d="M62 40 V50"/>' + // Augen
'<path d="M50 44 V57 h-7"/>' + // Nase
'<path d="M38 67 q12 10 24 0"/>' + // Mund
'</svg>' +
'<div class="faceid-scanline"></div>' +
'<svg class="faceid-check" viewBox="0 0 100 100" fill="none" stroke="#34d399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">' +
'<circle class="ring" cx="50" cy="50" r="34"/>' +
'<path class="tick" d="M34 51 l11 11 l21 -23"/>' +
'</svg>';
// Face-ID: Scan zeigen, dann in den Haken auflösen und entsperren
function entsperreMitFaceId() {
const u = document.getElementById("unlock-screen");
u.classList.add("show");
u.innerHTML = '<div class="setup-inner"><div class="faceid" id="faceid-box">' + faceIdGrafik + '</div>' +
'<div class="pin-sub" id="faceid-text">Face-ID</div></div>';
const box = document.getElementById("faceid-box");
// Nach kurzer Scanzeit auf "erkannt" umschalten (Haken zeichnen)
setTimeout(() => {
box.classList.add("done");
const txt = document.getElementById("faceid-text");
if (txt) txt.textContent = "Entsperrt";
setTimeout(() => { u.classList.remove("show"); entsperreHandy(); }, 620);
}, 1150);
}
// Code-Eingabe zum Entsperren
function entsperreMitCode() {
const u = document.getElementById("unlock-screen");
u.classList.add("show");
renderePinFeld(u, {
titel: "Code eingeben", unterzeile: "Handy entsperren", icon: "🔒",
onFertig: (code, reset) => {
if (!IST_FIVEM) {
// Browser-Ansicht: lokal prüfen
if (code === spielerZustand.pin) { u.classList.remove("show"); entsperreHandy(); }
else { zeigeToast("Falscher Code"); reset(); }
return;
}
// Der Code wird ausschließlich auf dem Server geprüft
pinZuruecksetzen = reset;
nui("entsperren", { pin: code });
}
});
}
// Wird gesetzt, damit die Antwort des Servers das Feld leeren kann
let pinZuruecksetzen = null;
/* ---- Ersteinrichtung (First-Start-Setup) ---- */
function starteEinrichtung() {
document.getElementById("setup-screen").classList.add("show");
setupWillkommen();
}
function setupWillkommen() {
document.getElementById("setup-screen").innerHTML =
'<div class="setup-inner"><div class="setup-logo">📱</div>' +
'<div class="setup-h1">Willkommen bei AetherOS</div>' +
'<div class="setup-p">Lege einen Code fest, damit niemand sonst dein Handy nutzen kann – falls es dir im Spiel gestohlen wird.</div>' +
'<button class="btn" id="setup-start">Los geht\'s</button></div>';
document.getElementById("setup-start").addEventListener("click", setupCodeFestlegen);
}
function setupCodeFestlegen() {
renderePinFeld(document.getElementById("setup-screen"), {
titel: "Code festlegen", unterzeile: "Wähle einen 4-stelligen Code", icon: "🔐",
onFertig: (code) => setupCodeBestaetigen(code)
});
}
function setupCodeBestaetigen(ersterCode) {
renderePinFeld(document.getElementById("setup-screen"), {
titel: "Code bestätigen", unterzeile: "Gib den Code erneut ein", icon: "🔐",
onFertig: (code, reset) => {
if (code === ersterCode) { spielerZustand.pin = code; setupFaceId(); }
else { zeigeToast("Codes stimmen nicht überein"); reset(); }
},
abbrechen: { label: "Zurück", cb: setupCodeFestlegen }
});
}
function setupFaceId() {
document.getElementById("setup-screen").innerHTML =
'<div class="setup-inner"><div class="setup-logo" style="background:linear-gradient(145deg,#4aa8ff,#7b6cf6);font-size:34px">🙂</div>' +
'<div class="setup-h1">Face-ID einrichten</div>' +
'<div class="setup-p">Mit Face-ID entsperrst du dein Handy, ohne jedes Mal den Code einzugeben. Später jederzeit in den Einstellungen änderbar.</div>' +
'<button class="btn" id="fid-on">Face-ID aktivieren</button>' +
'<button class="btn ghost" id="fid-skip" style="margin-top:8px">Später, mit Code</button></div>';
document.getElementById("fid-on").addEventListener("click", () => {
spielerZustand.faceId = true;
if (IST_FIVEM) nui("pinEinrichten", { pin: spielerZustand.pin, faceId: true });
setupFertig();
});
document.getElementById("fid-skip").addEventListener("click", () => {
spielerZustand.faceId = false;
if (IST_FIVEM) nui("pinEinrichten", { pin: spielerZustand.pin, faceId: false });
setupFertig();
});
}
function setupFertig() {
document.getElementById("setup-screen").classList.remove("show");
screenEl.classList.add("locked");
zeigeToast("Einrichtung abgeschlossen ✅");
}
// In FiveM startet die Einrichtung erst, wenn der Server den Zustand
// geschickt hat (siehe NUI-Brücke am Ende der Datei).
if (typeof GetParentResourceName !== "function" && !spielerZustand.pin) starteEinrichtung();
/* =====================================================================
BENACHRICHTIGUNGEN (Sperrbildschirm-Mitteilungen + Push-Banner)
===================================================================== */
// Aktuelle Mitteilungen auf dem Sperrbildschirm (neueste zuerst)
const benachrichtigungen = [
{ app: "messages", titel: "Familie Schmidt", text: "Kommst du heute zum Grillen vorbei? 🔥" },
{ app: "bank", titel: "Fleeca Bank", text: "Gehalt gutgeschrieben: +2.400 €" }
];
// Sperrbildschirm-Mitteilungen zeichnen (inkl. Wisch-zum-Entfernen)
function zeichneLockNotes() {
const box = document.getElementById("lock-notes");
box.innerHTML = benachrichtigungen.map((n, i) => {
const def = alleApps[n.app] || alleApps.settings;
return '<div class="lock-note" data-index="' + i + '">' +
'<span class="ic ' + def.g + '">' + miniGlyph(def.glyph) + '</span>' +
'<div class="txt"><div class="t">' + sicher(n.titel) + '</div>' +
'<div class="d">' + sicher(n.text) + '</div></div></div>';
}).join("");
// Jede Mitteilung nach links wegwischbar machen
box.querySelectorAll(".lock-note").forEach(el =>
macheWischbar(el, () => {
benachrichtigungen.splice(+el.dataset.index, 1);
zeichneLockNotes();
}));
}
// Kleines Icon (18px) für Mitteilungen/Banner erzeugen
function miniGlyph(glyph) {
return '<span style="display:grid;place-items:center;width:18px;height:18px">' + svgGlyphen[glyph] + '</span>';
}
// Element nach links wegwischbar machen; onEntfernen wird bei genügend Wischweg ausgelöst
function macheWischbar(el, onEntfernen) {
let startX = 0, dx = 0, aktiv = false;
el.addEventListener("pointerdown", e => {
aktiv = true; startX = e.clientX; dx = 0;
el.classList.add("swiping"); el.style.transition = "none";
el.setPointerCapture(e.pointerId);
});
el.addEventListener("pointermove", e => {
if (!aktiv) return;
dx = Math.min(0, e.clientX - startX); // nur nach links
el.style.transform = "translateX(" + dx + "px)";
el.style.opacity = String(1 - Math.min(1, -dx / 180));
});
const beenden = () => {
if (!aktiv) return;
aktiv = false; el.classList.remove("swiping");
el.style.transition = "transform .25s ease, opacity .25s ease";
if (dx < -80) { // Schwelle überschritten -> entfernen
el.style.transform = "translateX(-120%)"; el.style.opacity = "0";
setTimeout(onEntfernen, 220);
} else { // zurückschnappen
el.style.transform = "translateX(0)"; el.style.opacity = "1";
}
};
el.addEventListener("pointerup", beenden);
el.addEventListener("pointercancel", beenden);
}
// Push-Benachrichtigung auslösen (im Server käme dies vom Backend)
let bannerTimer = null;
function loeseBenachrichtigungAus(spec) {
// spec: { app, titel, text }
benachrichtigungen.unshift({ app: spec.app, titel: spec.titel, text: spec.text });
if (screenEl.classList.contains("locked")) {
zeichneLockNotes(); // gesperrt: erscheint in der Mitteilungsliste
return;
}
// entsperrt: Banner oben einblenden
const def = alleApps[spec.app] || alleApps.settings;
const banner = document.getElementById("notif-banner");
document.getElementById("notif-ic").className = "ic " + def.g;
document.getElementById("notif-ic").innerHTML = miniGlyph(def.glyph);
document.getElementById("notif-t").textContent = spec.titel;
document.getElementById("notif-d").textContent = spec.text;
banner.classList.add("show");
// Tippen öffnet die zugehörige App
banner.onclick = () => { banner.classList.remove("show"); oeffneApp(spec.app, def.label); };
clearTimeout(bannerTimer);
bannerTimer = setTimeout(() => banner.classList.remove("show"), 4200);
}
zeichneLockNotes(); // Erstaufbau der Sperrbildschirm-Mitteilungen
// Demo-Buttons im linken Panel: Push-Mitteilungen auslösen
const pushBeispiele = {
messages: { app: "messages", titel: "Lena Berger", text: "Bist du noch am Pier? 🌊" },
bank: { app: "bank", titel: "Fleeca Bank", text: "Zahlung erhalten: +750 €" },
dispatch: { app: "dispatch", titel: "Leitstelle", text: "10-90 Raubüberfall · Vinewood" },
birdy: { app: "birdy", titel: "Birdy", text: "@lsnews hat einen neuen Beitrag gepostet" }
};
document.querySelectorAll("[data-push]").forEach(btn =>
btn.addEventListener("click", () => loeseBenachrichtigungAus(pushBeispiele[btn.dataset.push])));
// Demo-Button: Darkchat freischalten
document.getElementById("unlock-darkweb").addEventListener("click", () => {
spielerZustand.freigeschaltet.darkweb = !spielerZustand.freigeschaltet.darkweb;
baueHomescreen();
zeigeToast(spielerZustand.freigeschaltet.darkweb
? "Darkchat freigeschaltet 🔓" : "Darkchat wieder gesperrt 🔒");
});
/* =====================================================================
HARDWARE (Akku, SIM, Handy) — im Spiel jeweils kombinierbare Items
===================================================================== */
// Statusleiste (Akkustand, Netz) an den aktuellen Hardware-Zustand anpassen
function aktualisiereHardwareUI() {
const stand = Math.max(0, Math.min(100, Math.round(spielerZustand.akku)));
const fuellung = document.getElementById("sb-batt-fill");
const prozentText = document.getElementById("sb-batt-pct");
// Akkubalken füllen und je nach Stand einfärben
if (fuellung) {
fuellung.setAttribute("width", Math.max(0.5, 17 * stand / 100).toFixed(1));
fuellung.setAttribute("fill", stand <= 15 ? "#f0506e" : (stand <= 30 ? "#f5b23d" : "currentColor"));
}
if (prozentText) { prozentText.textContent = stand + "%"; prozentText.style.color = stand <= 15 ? "#f0506e" : ""; }
const demoAnzeige = document.getElementById("demo-akku");
if (demoAnzeige) demoAnzeige.textContent = stand + "%";
// Netz-Anzeige abhängig von der SIM-Karte
const netzText = document.getElementById("sb-net");
const signal = document.getElementById("sb-signal");
if (netzText) netzText.style.display = spielerZustand.sim ? "none" : "inline";
if (signal) signal.style.opacity = spielerZustand.sim ? "1" : "0.25";
}
// Handy ausschalten / Akku leer: Overlay einblenden
function handyAusschalten(titel, unterzeile, icon) {
document.getElementById("po-icon").textContent = icon;
document.getElementById("po-title").textContent = titel;
document.getElementById("po-sub").textContent = unterzeile;
document.getElementById("power-off").classList.add("show");
geheZumHomescreen(); // eventuell offene App schließen
}
// Handy wieder einschalten (startet gesperrt, wie ein frischer Boot)
function handyEinschalten() {
document.getElementById("power-off").classList.remove("show");
screenEl.classList.add("locked");
aktualisiereHardwareUI();
}
// Automatische Akku-Entladung (~1 % alle 12 s)
setInterval(() => {
if (!spielerZustand.handy || spielerZustand.akku <= 0) return;
spielerZustand.akku = Math.max(0, spielerZustand.akku - 1);
aktualisiereHardwareUI();
if (spielerZustand.akku <= 0) {
handyAusschalten("Akku leer", "Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.", "🪫");
}
}, 12000);
// Demo-Buttons: Hardware/Items steuern
document.querySelectorAll("[data-hw]").forEach(btn =>
btn.addEventListener("click", () => {
const aktion = btn.dataset.hw;
if (aktion === "drain") { // Akku schnell entladen (zum Testen)
spielerZustand.akku = Math.max(0, spielerZustand.akku - 20);
aktualisiereHardwareUI();
if (spielerZustand.akku <= 0) handyAusschalten("Akku leer", "Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.", "🪫");
else zeigeToast("Akku: " + spielerZustand.akku + " %");
} else if (aktion === "newbatt") { // frischen Akku einsetzen
spielerZustand.akku = 100;
if (!spielerZustand.handy) { aktualisiereHardwareUI(); return; } // ohne Handy nichts tun
// Nur neu starten, wenn das Handy vorher wegen leerem Akku aus war
const warAus = document.getElementById("power-off").classList.contains("show");
if (warAus) handyEinschalten(); else aktualisiereHardwareUI();
zeigeToast("Neuer Akku eingesetzt 🔋 (100 %)");
} else if (aktion === "sim") { // SIM-Karte entfernen/einsetzen
spielerZustand.sim = !spielerZustand.sim;
aktualisiereHardwareUI();
zeigeToast(spielerZustand.sim ? "SIM-Karte eingesetzt 📶" : "SIM-Karte entfernt – kein Netz");
} else if (aktion === "handy") { // Handy als Item aus/an
spielerZustand.handy = !spielerZustand.handy;
if (!spielerZustand.handy) handyAusschalten("Kein Handy", "Im Spiel muss zuerst ein Handy (Item) gekauft werden.", "📱");
else if (spielerZustand.akku > 0) handyEinschalten();
else handyAusschalten("Akku leer", "Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.", "🪫");
}
}));
aktualisiereHardwareUI(); // Erstanzeige der Hardware
// Zurück zum Homescreen (schließt offene App und leert die Historie)
function geheZumHomescreen() {
appViewEl.classList.remove("open");
appContentEl.innerHTML = "";
navStack = [];
}
document.getElementById("home-indicator").addEventListener("click", () => {
if (appViewEl.classList.contains("open")) geheZumHomescreen();
else screenEl.classList.add("locked"); // Auf dem Homescreen: sperren
});
/* Baut eine einzelne App-Kachel (für Grid oder Dock) anhand ihrer id */
function baueAppKachel(appId) {
const def = alleApps[appId];
const button = document.createElement("button");
button.className = "app";
button.innerHTML =
'<span class="icon ' + def.g + '">' + svgGlyphen[def.glyph] +
(def.badge ? '<span class="badge">' + def.badge + '</span>' : '') +
'</span><span class="label">' + def.label + '</span>';
button.addEventListener("click", () => oeffneApp(appId, def.label));
return button;
}
// Homescreen-Grid und Dock aus dem Zustand neu aufbauen (nach Änderungen erneut aufrufen)
function baueHomescreen() {
const gridEl = document.getElementById("app-grid");
const dockEl = document.getElementById("dock");
gridEl.innerHTML = "";
dockEl.innerHTML = "";
// Grid: alle Apps außer den Dock-Apps; gesperrte Apps nur wenn freigeschaltet
appReihenfolge.forEach(appId => {
if (spielerZustand.dockApps.includes(appId)) return;
if (alleApps[appId].gated && !spielerZustand.freigeschaltet[appId]) return;
gridEl.appendChild(baueAppKachel(appId));
});
// Dock: genau die konfigurierten Apps
spielerZustand.dockApps.forEach(appId => dockEl.appendChild(baueAppKachel(appId)));
}
baueHomescreen();
/* =====================================================================
APP-ÖFFNEN — ruft die passende Render-Funktion auf
===================================================================== */
/* Navigations-Historie: jede Seite ist eine Render-Funktion auf dem Stapel.
So gelangt der Zurück-Button immer eine Ebene zurück statt direkt nach Hause. */
let navStack = [];
// App vom Homescreen öffnen (startet eine frische Historie)
function oeffneApp(appId, titel) {
navStack = [];
const renderFunktion = appRenderer[appId] || (() => { appContentEl.innerHTML = platzhalterApp(titel); });
appViewEl.classList.add("open");
navigiere(renderFunktion);
}
// Neue Unterseite anzeigen und auf den Stapel legen
function navigiere(renderFunktion) {
navStack.push(renderFunktion);
renderFunktion();
verdrahteZurueck();
}
// Eine Ebene zurück (oder nach Hause, wenn keine Historie mehr da ist)
function zurueck() {
navStack.pop(); // aktuelle Seite entfernen
const vorherigeSeite = navStack[navStack.length - 1];
if (vorherigeSeite) {
vorherigeSeite();
verdrahteZurueck();
} else {
geheZumHomescreen();
}
}
// Alle Zurück-Buttons der aktuell gerenderten Seite mit "zurück" verbinden
function verdrahteZurueck() {
appContentEl.querySelectorAll("[data-back]").forEach(btn => { btn.onclick = zurueck; });
}
/* SVG-Symbole für die Kopfzeile */
const chevronLinks = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>';
const telefonSymbol = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.6 10.8c1.4 2.8 3.8 5.1 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1C10.6 21 3 13.4 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.2.2 2.4.6 3.6.1.4 0 .8-.3 1l-2.2 2.2Z"/></svg>';
/* Standard-Kopfzeile für Apps */
function appKopf(titel, unterzeile) {
return '<div class="app-header">' +
'<button class="icon-btn" data-back aria-label="Zurück">' + chevronLinks + '</button>' +
'<div style="flex:1;text-align:center;">' +
'<h1>' + sicher(titel) + '</h1>' +
(unterzeile ? '<div class="sub">' + sicher(unterzeile) + '</div>' : '') +
'</div>' +
'<span style="width:34px;"></span>' +
'</div>';
}
/* Platzhalter für noch nicht ausgebaute Apps */
function platzhalterApp(titel) {
return appKopf(titel) +
'<div class="app-body"><div class="empty"><div class="big">🚧</div>' +
'<div style="font-weight:600;color:var(--s-fg);">In Entwicklung</div>' +
'<div style="margin-top:6px;">Diese App wird für den finalen Server noch gebaut.</div></div></div>';
}
/* =====================================================================
APP: TELEFON (Wähler, Verlauf, Kontakte)
===================================================================== */
let telefonNummer = ""; // aktuell eingegebene Nummer im Wähler
const appRenderer = {}; // Register aller App-Render-Funktionen
appRenderer.phone = function () {
appContentEl.innerHTML =
appKopf("Telefon") +
'<div class="app-body">' +
'<div class="seg" id="phone-seg">' +
'<button data-tab="dial" class="active">Ziffern</button>' +
'<button data-tab="recents">Verlauf</button>' +
'<button data-tab="contacts">Kontakte</button>' +
'</div>' +
'<div id="phone-panel"></div>' +
'</div>';
telefonNummer = "";
zeigePhoneTab("dial");
// Tab-Umschaltung
appContentEl.querySelectorAll("#phone-seg button").forEach(b =>
b.addEventListener("click", () => {
appContentEl.querySelectorAll("#phone-seg button").forEach(x => x.classList.remove("active"));
b.classList.add("active");
zeigePhoneTab(b.dataset.tab);
}));
};
// Inhalt des jeweiligen Telefon-Tabs rendern
function zeigePhoneTab(tab) {
const panel = document.getElementById("phone-panel");
if (tab === "dial") {
const tasten = [
["1",""],["2","ABC"],["3","DEF"],["4","GHI"],["5","JKL"],["6","MNO"],
["7","PQRS"],["8","TUV"],["9","WXYZ"],["*",""],["0","+"],["#",""]
];
panel.innerHTML =
'<div class="dial-display" id="dial-display">' + (telefonNummer || '<span style="color:var(--s-muted)">Nummer eingeben</span>') + '</div>' +
'<div class="dialpad">' + tasten.map(t =>
'<button class="dial-key" data-key="' + t[0] + '">' + t[0] +
(t[1] ? '<small>' + t[1] + '</small>' : '') + '</button>').join("") + '</div>' +
'<div class="call-bar">' +
'<span style="width:66px"></span>' +
'<button class="call-btn call" id="dial-call" aria-label="Anrufen">' + svgGlyphen.phone + '</button>' +
'<button class="call-btn del" id="dial-del" aria-label="Löschen">' +
'<svg width="30" height="24" viewBox="0 0 24 24" fill="var(--s-muted)"><path d="M22 3H7c-.6 0-1.1.3-1.5.8L0 12l5.5 8.2c.4.5.9.8 1.5.8h15a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-3.7 12.9-1.4 1.4L14 14.4l-2.9 2.9-1.4-1.4L12.6 13l-2.9-2.9 1.4-1.4L14 11.6l2.9-2.9 1.4 1.4L15.4 13l2.9 2.9Z"/></svg>' +
'</button>' +
'</div>';
// Ziffern-Tasten
panel.querySelectorAll(".dial-key").forEach(k =>
k.addEventListener("click", () => { telefonNummer += k.dataset.key; zeigePhoneTab("dial"); }));
// Löschen (letzte Ziffer)
document.getElementById("dial-del").addEventListener("click", () => {
telefonNummer = telefonNummer.slice(0, -1); zeigePhoneTab("dial");
});
// Anrufen
document.getElementById("dial-call").addEventListener("click", () => {
if (!telefonNummer) { zeigeToast("Bitte zuerst eine Nummer eingeben"); return; }
starteAnruf(telefonNummer, telefonNummer);
});
} else if (tab === "recents") {
panel.innerHTML = anrufListe.map(a => {
const pfeil = a.verpasst ? "↙" : (a.art === "eingehend" ? "↙" : "↗");
const farbe = a.verpasst ? "var(--s-red)" : "var(--s-muted)";
return '<div class="row" style="cursor:pointer" data-call="' + sicher(a.name) + '">' +
'<span style="color:' + farbe + ';font-size:18px">' + pfeil + '</span>' +
'<div class="grow"><div class="title" style="color:' + (a.verpasst ? "var(--s-red)" : "var(--s-fg)") + '">' + sicher(a.name) + '</div>' +
'<div class="meta">' + (a.art === "eingehend" ? "Eingehend" : "Ausgehend") + (a.verpasst ? " · Verpasst" : "") + '</div></div>' +
'<div class="side">' + a.zeit + '</div></div>';
}).join("");
panel.querySelectorAll("[data-call]").forEach(r =>
r.addEventListener("click", () => starteAnruf(r.dataset.call, r.dataset.call)));
} else {
panel.innerHTML = kontakteHtml();
panel.querySelectorAll("[data-call]").forEach(r =>
r.addEventListener("click", () => starteAnruf(r.dataset.call, r.dataset.call)));
}
}
// Wiederverwendbare Kontaktliste
function kontakteHtml() {
return kontakteListe.map(k =>
'<div class="row" style="cursor:pointer" data-call="' + sicher(k.name) + '">' +
'<span class="avatar" style="background:' + k.farbe + '">' + sicher(k.initialen) + '</span>' +
'<div class="grow"><div class="title">' + sicher(k.name) + '</div>' +
'<div class="meta">' + sicher(k.nummer) + '</div></div>' +
(k.notruf ? '<span class="pill red">Notruf</span>' : '<span class="icon-btn" style="pointer-events:none">' + telefonSymbol + '</span>') +
'</div>').join("");
}
// Kontakte-App (eigene Kachel)
appRenderer.contacts = function () {
appContentEl.innerHTML = appKopf("Kontakte", spielerZustand.name + " · " + spielerZustand.nummer) +
'<div class="app-body">' + kontakteHtml() + '</div>';
appContentEl.querySelectorAll("[data-call]").forEach(r =>
r.addEventListener("click", () => starteAnruf(r.dataset.call, r.dataset.call)));
};
/* Anruf starten — Overlay einblenden und Gesprächszeit hochzählen */
let anrufTimer = null;
function starteAnruf(anzeigeName, initialenQuelle, nummer) {
// Ohne SIM-Karte kein Netz -> kein Anruf möglich
if (!spielerZustand.sim) { zeigeToast("Keine SIM-Karte – kein Netz"); return; }
// In FiveM baut der Server die Verbindung auf; das Overlay zeigt den Status
if (IST_FIVEM) {
const ziel = nummer || nummerZuName(anzeigeName) || anzeigeName;
nui("anrufen", { nummer: ziel });
}
const overlay = document.getElementById("in-call");
document.getElementById("call-who").textContent = anzeigeName;
document.getElementById("call-avatar").textContent =
/^\d/.test(initialenQuelle) ? "☎" : initialenQuelle.substring(0, 2).toUpperCase();
overlay.classList.add("show");
let sekunden = 0;
const statusEl = document.getElementById("call-status");
statusEl.textContent = "Verbinde…";
clearInterval(anrufTimer);
// Nach kurzer "Verbindungszeit" Gesprächsdauer hochzählen
setTimeout(() => {
anrufTimer = setInterval(() => {
sekunden++;
const m = String(Math.floor(sekunden / 60)).padStart(2, "0");
const s = String(sekunden % 60).padStart(2, "0");
statusEl.textContent = m + ":" + s;
}, 1000);
}, 1200);
}
// Auflegen — bei einem eingehenden Anruf zuerst annehmen
document.getElementById("call-end").addEventListener("click", () => {
const overlay = document.getElementById("in-call");
if (IST_FIVEM) nui("auflegen", {});
overlay.classList.remove("show");
delete overlay.dataset.eingehend;
clearInterval(anrufTimer);
});
// Anruf annehmen (nur sichtbar bei eingehendem Anruf)
const annehmenBtn = document.getElementById("call-accept");
if (annehmenBtn) {
annehmenBtn.addEventListener("click", () => {
if (IST_FIVEM) nui("annehmen", {});
});
}
/* =====================================================================
APP: NACHRICHTEN (Chat-Liste + Konversation)
===================================================================== */
let aktiverChat = null; // Name des aktuell geöffneten Chats
appRenderer.messages = function () {
zeigeChatListe();
};
// Übersicht aller Chats
function zeigeChatListe() {
aktiverChat = null;
const eintraege = Object.keys(chatVerlaeufe).map(name => {
const verlauf = chatVerlaeufe[name];
const letzte = verlauf[verlauf.length - 1];
const kontakt = kontakteListe.find(k => k.name === name);
const farbe = kontakt ? kontakt.farbe : "#7b6cf6";
const initialen = kontakt ? kontakt.initialen : name.substring(0, 2);
return '<div class="row" style="cursor:pointer" data-chat="' + sicher(name) + '">' +
'<span class="avatar" style="background:' + farbe + '">' + sicher(initialen) + '</span>' +
'<div class="grow"><div class="title">' + sicher(name) + '</div>' +
'<div class="meta">' + (letzte.von === "me" ? "Du: " : "") + sicher(letzte.text) + '</div></div>' +
'<div class="side">' + letzte.zeit + '</div></div>';
}).join("");
appContentEl.innerHTML = appKopf("Nachrichten") + '<div class="app-body">' + eintraege + '</div>';
appContentEl.querySelectorAll("[data-chat]").forEach(r =>
r.addEventListener("click", () => navigiere(() => oeffneChat(r.dataset.chat))));
}
// Einzelne Konversation öffnen
function oeffneChat(name) {
aktiverChat = name;
appContentEl.innerHTML =
'<div class="app-header">' +
'<button class="icon-btn" data-back aria-label="Zurück">' + chevronLinks + '</button>' +
'<div style="flex:1;text-align:center"><h1 style="font-size:17px">' + sicher(name) + '</h1></div>' +
'<button class="icon-btn" data-call2="' + sicher(name) + '" aria-label="Anrufen">' + telefonSymbol + '</button>' +
'</div>' +
'<div class="bubbles" id="bubbles"></div>' +
'<div class="composer">' +
'<input class="input" id="chat-input" placeholder="Nachricht…" autocomplete="off">' +
'<button class="send-btn" id="chat-send" aria-label="Senden">' +
'<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff"><path d="M3 20l18-8L3 4v6l12 2-12 2v6Z"/></svg>' +
'</button>' +
'</div>';
zeichneBubbles();
appContentEl.querySelector("[data-call2]").addEventListener("click", () => starteAnruf(name, name));
const input = document.getElementById("chat-input");
const sende = () => sendeNachricht(input);
document.getElementById("chat-send").addEventListener("click", sende);
input.addEventListener("keydown", e => { if (e.key === "Enter") sende(); });
input.focus();
}
// Nachrichtenblasen zeichnen
function zeichneBubbles() {
const box = document.getElementById("bubbles");
box.innerHTML = chatVerlaeufe[aktiverChat].map(m =>
'<div class="bubble ' + m.von + '">' + sicher(m.text) +
'<span class="time">' + m.zeit + '</span></div>').join("");
box.scrollTop = box.scrollHeight;
}
// Nachricht senden
function sendeNachricht(input) {
const text = input.value.trim();
if (!text) return;
const jetzt = new Date();
const zeit = String(jetzt.getHours()).padStart(2, "0") + ":" + String(jetzt.getMinutes()).padStart(2, "0");
if (IST_FIVEM) {
// Der Server stellt zu und schickt den aktualisierten Verlauf zurück.
// Die eigene Nachricht wird sofort angezeigt, damit es sich flüssig anfühlt.
chatVerlaeufe[aktiverChat] = chatVerlaeufe[aktiverChat] || [];
chatVerlaeufe[aktiverChat].push({ von: "me", text: text, zeit: zeit });
input.value = "";
zeichneBubbles();
nui("senden", { nummer: aktiverChat, text: text });
return;
}
// Browser-Ansicht: lokal mit simulierter Antwort
chatVerlaeufe[aktiverChat].push({ von: "me", text: text, zeit: zeit });
input.value = "";
zeichneBubbles();
setTimeout(() => {
const antworten = ["Alles klar! 👍", "Verstanden.", "Bin gleich da.", "Ok, bis später!", "Klingt gut."];
chatVerlaeufe[aktiverChat].push({
von: "them",
text: antworten[Math.floor(Math.random() * antworten.length)],
zeit: zeit
});
if (aktiverChat) zeichneBubbles();
}, 1400);
}
/* =====================================================================
APP: BANK (Konto, Aktionen, Transaktionen)
===================================================================== */
appRenderer.bank = function () {
zeigeBankUebersicht();
};
function zeigeBankUebersicht() {
appContentEl.innerHTML = appKopf("Fleeca Bank") +
'<div class="app-body">' +
'<div class="bank-card">' +
'<div class="lbl">GIROKONTO</div>' +
'<div class="bal" id="bank-balance">' + formatiereEuro(spielerZustand.kontostand) + '</div>' +
'<div class="lbl">' + sicher(spielerZustand.name) + '</div>' +
'<div class="no">' + sicher(spielerZustand.kontonummer) + '</div>' +
'</div>' +
'<button class="btn" id="bank-transfer" style="margin:14px 0">Überweisen</button>' +
'<div class="list-title">Transaktionen</div>' +
'<div id="tx-list">' + transaktionenHtml() + '</div>' +
'</div>';
document.getElementById("bank-transfer").addEventListener("click", () => navigiere(zeigeUeberweisung));
}
// Transaktionsliste als HTML
function transaktionenHtml() {
return transaktionsListe.map(t =>
'<div class="tx"><span class="ti">' + t.icon + '</span>' +
'<div class="grow"><div class="title">' + sicher(t.titel) + '</div>' +
'<div class="meta">' + t.zeit + '</div></div>' +
'<div class="amt ' + (t.betrag > 0 ? "plus" : "") + '">' +
(t.betrag > 0 ? "+" : "") + formatiereEuro(t.betrag) + '</div></div>').join("");
}
// Überweisungs-Formular
function zeigeUeberweisung() {
appContentEl.innerHTML = appKopf("Überweisen") +
'<div class="app-body">' +
'<div style="text-align:center;margin:6px 0 18px;color:var(--s-muted);font-size:13px">Verfügbar: <b style="color:var(--s-green)">' + formatiereEuro(spielerZustand.kontostand) + '</b></div>' +
'<label class="field-label">Empfänger (Name oder Kontonummer)</label>' +
'<input class="input" id="tr-to" placeholder="z. B. Lena Berger" autocomplete="off">' +
'<label class="field-label">Betrag (€)</label>' +
'<input class="input" id="tr-amount" type="number" placeholder="0" inputmode="numeric">' +
'<label class="field-label">Verwendungszweck</label>' +
'<input class="input" id="tr-note" placeholder="optional" autocomplete="off">' +
'<div style="height:16px"></div>' +
'<button class="btn green" id="tr-send">Jetzt überweisen</button>' +
'<div style="height:8px"></div>' +
'<button class="btn ghost" id="tr-cancel">Abbrechen</button>' +
'</div>';
document.getElementById("tr-cancel").addEventListener("click", zurueck);
document.getElementById("tr-send").addEventListener("click", fuehreUeberweisungAus);
}
// Überweisung durchführen (mit Validierung)
function fuehreUeberweisungAus() {
const empfaenger = document.getElementById("tr-to").value.trim();
const betrag = parseInt(document.getElementById("tr-amount").value, 10);
const zweck = document.getElementById("tr-note").value.trim();
if (!empfaenger) { zeigeToast("Bitte einen Empfänger angeben"); return; }
if (!betrag || betrag <= 0) { zeigeToast("Bitte einen gültigen Betrag angeben"); return; }
if (betrag > spielerZustand.kontostand) { zeigeToast("Nicht genügend Guthaben"); return; }
// Kontostand anpassen und Transaktion vormerken
spielerZustand.kontostand -= betrag;
transaktionsListe.unshift({
titel: "Überweisung an " + empfaenger + (zweck ? " (" + zweck + ")" : ""),
betrag: -betrag, zeit: "Gerade eben", icon: "↗"
});
zeigeToast(formatiereEuro(betrag) + " an " + empfaenger + " gesendet");
zurueck(); // zurück zur Kontoübersicht (mit aktualisiertem Guthaben)
}
/* =====================================================================
APP: BIRDY (Social Media)
===================================================================== */
appRenderer.birdy = function () {
appContentEl.innerHTML = appKopf("Birdy", "Was passiert in Los Santos?") +
'<div class="app-body">' +
'<div class="composer-box">' +
'<textarea id="birdy-input" placeholder="Was gibt\'s Neues, ' + sicher(spielerZustand.name.split(" ")[0]) + '?" maxlength="180"></textarea>' +
'<div style="display:flex;justify-content:flex-end"><button class="btn" style="width:auto;padding:8px 20px" id="birdy-post">Birden</button></div>' +
'</div>' +
'<div id="birdy-feed">' + birdyFeedHtml() + '</div>' +
'</div>';
document.getElementById("birdy-post").addEventListener("click", sendeBirdyBeitrag);
verdrahteBirdyAktionen();
};
// Feed als HTML
function birdyFeedHtml() {
return birdyBeitraege.map((p, i) =>
'<div class="post">' +
'<div class="ph"><span class="avatar" style="background:' + p.farbe + '">' + sicher(p.initialen) + '</span>' +
'<div><div class="name">' + sicher(p.name) + '</div><div class="handle">' + sicher(p.handle) + '</div></div></div>' +
'<div class="txt">' + sicher(p.text) + '</div>' +
'<div class="actions">' +
'<button data-like="' + i + '" class="' + (p.gemocht ? "liked" : "") + '">♥ <span>' + p.likes + '</span></button>' +
'<button data-rt="' + i + '">↻ <span>' + p.retweets + '</span></button>' +
'<button>💬 <span>Antworten</span></button>' +
'</div>' +
'</div>').join("");
}
// Like/Retweet-Buttons verdrahten
function verdrahteBirdyAktionen() {
appContentEl.querySelectorAll("[data-like]").forEach(b =>
b.addEventListener("click", () => {
const p = birdyBeitraege[+b.dataset.like];
p.gemocht = !p.gemocht;
p.likes += p.gemocht ? 1 : -1;
document.getElementById("birdy-feed").innerHTML = birdyFeedHtml();
verdrahteBirdyAktionen();
}));
appContentEl.querySelectorAll("[data-rt]").forEach(b =>
b.addEventListener("click", () => {
birdyBeitraege[+b.dataset.rt].retweets++;
document.getElementById("birdy-feed").innerHTML = birdyFeedHtml();
verdrahteBirdyAktionen();
zeigeToast("Beitrag geteilt");
}));
}
// Eigenen Beitrag posten
function sendeBirdyBeitrag() {
const input = document.getElementById("birdy-input");
const text = input.value.trim();
if (!text) { zeigeToast("Bitte etwas schreiben"); return; }
birdyBeitraege.unshift({
name: spielerZustand.name, handle: "@" + spielerZustand.name.toLowerCase().replace(/\s/g, "_"),
initialen: spielerZustand.name.substring(0, 2).toUpperCase(), farbe: "#7b6cf6",
text: text, likes: 0, retweets: 0, gemocht: false
});
input.value = "";
document.getElementById("birdy-feed").innerHTML = birdyFeedHtml();
verdrahteBirdyAktionen();
zeigeToast("Beitrag veröffentlicht");
}
/* =====================================================================
APP: KAMERA (Sucher + Auslöser) & GALERIE
===================================================================== */
// Vordefinierte "Fotos" als Farbverläufe für die Galerie
const galerieFotos = [
"linear-gradient(135deg,#ff8c42,#7b2f8f)", "linear-gradient(135deg,#4aa8ff,#0a2f8f)",
"linear-gradient(135deg,#34d399,#0f766e)", "linear-gradient(135deg,#f0506e,#7b2f3f)",
"linear-gradient(135deg,#f5b23d,#b45309)", "linear-gradient(135deg,#7b6cf6,#312e81)"
];
appRenderer.camera = function () {
appContentEl.innerHTML = appKopf("Kamera") +
'<div class="cam-view">' +
'<div class="cam-feed" id="cam-feed">' +
'<div style="position:absolute;top:14px;left:0;right:0;text-align:center;color:rgba(255,255,255,0.6);font-size:12px">📍 Vespucci Beach, Los Santos</div>' +
'<div style="position:absolute;inset:0;border:1px solid rgba(255,255,255,0.15);margin:40px 20px;border-radius:8px"></div>' +
'</div>' +
'<div class="cam-controls">' +
'<div class="cam-thumb" style="background:' + galerieFotos[0] + '"></div>' +
'<button class="shutter" id="cam-shutter" aria-label="Foto aufnehmen"></button>' +
'<button style="background:none;border:none;color:#fff;font-size:22px;cursor:pointer" id="cam-gallery" aria-label="Galerie">🖼️</button>' +
'</div>' +
'</div>';
// Auslöser: kurzer Weißblitz + Galerie-Eintrag
document.getElementById("cam-shutter").addEventListener("click", () => {
const feed = document.getElementById("cam-feed");
const blitz = document.createElement("div");
blitz.style.cssText = "position:absolute;inset:0;background:#fff;z-index:5";
feed.appendChild(blitz);
setTimeout(() => blitz.remove(), 150);
galerieFotos.unshift("linear-gradient(135deg,#2a3b52,#0a0f18)");
zeigeToast("Foto gespeichert 📸");
});
document.getElementById("cam-gallery").addEventListener("click", () => navigiere(zeigeGalerie));
};
// Galerie-Ansicht
function zeigeGalerie() {
appContentEl.innerHTML = appKopf("Galerie", galerieFotos.length + " Fotos") +
'<div class="app-body"><div class="gallery">' +
galerieFotos.map(f => '<div class="ph" style="background:' + f + '"></div>').join("") +
'</div></div>';
}
appRenderer.insta = zeigeGalerie; // InstaPic zeigt vereinfacht die Galerie
/* =====================================================================
APP: MUSIK (Player-Mock)
===================================================================== */
appRenderer.music = function () {
appContentEl.innerHTML = appKopf("Musik") +
'<div class="app-body" style="text-align:center">' +
'<div style="width:210px;height:210px;border-radius:20px;margin:20px auto;background:linear-gradient(145deg,#7b6cf6,#e11d48);box-shadow:0 20px 40px -12px rgba(123,108,246,0.5);display:grid;place-items:center;font-size:60px">🎧</div>' +
'<div style="font-size:20px;font-weight:700">Midnight Drive</div>' +
'<div style="color:var(--s-muted);margin-bottom:18px">Los Santos Radio · Non-Stop</div>' +
'<div style="height:4px;background:var(--s-surface-2);border-radius:2px;margin:0 10px"><div style="width:38%;height:100%;background:var(--s-accent);border-radius:2px"></div></div>' +
'<div style="display:flex;justify-content:space-between;color:var(--s-muted);font-size:11px;margin:6px 10px 20px"><span>1:24</span><span>3:42</span></div>' +
'<div style="display:flex;align-items:center;justify-content:center;gap:34px;font-size:30px">' +
'<button style="background:none;border:none;color:var(--s-fg);cursor:pointer;font-size:26px">⏮</button>' +
'<button id="music-play" style="width:70px;height:70px;border-radius:50%;background:var(--s-accent);border:none;color:#fff;cursor:pointer;font-size:28px">⏸</button>' +
'<button style="background:none;border:none;color:var(--s-fg);cursor:pointer;font-size:26px">⏭</button>' +
'</div>' +
'</div>';
let spielt = true;
document.getElementById("music-play").addEventListener("click", (e) => {
spielt = !spielt;
e.currentTarget.textContent = spielt ? "⏸" : "▶";
});
};
/* =====================================================================
APP: WETTER
===================================================================== */
appRenderer.weather = function () {
const stunden = [["Jetzt","24°","☀️"],["15h","25°","☀️"],["16h","24°","⛅"],["17h","22°","⛅"],["18h","20°","🌤️"],["19h","18°","🌙"],["20h","16°","🌙"]];
appContentEl.innerHTML = appKopf("Wetter") +
'<div class="app-body">' +
'<div class="weather-hero">' +
'<div class="city">Los Santos</div>' +
'<div class="temp">24°</div>' +
'<div class="cond">Sonnig · H:26° T:15°</div>' +
'</div>' +
'<div class="row" style="display:block">' +
'<div class="weather-hour">' + stunden.map(h =>
'<div class="h"><span>' + h[0] + '</span><span style="font-size:22px">' + h[2] + '</span><span class="t">' + h[1] + '</span></div>').join("") + '</div>' +
'</div>' +
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">' +
wetterKachel("Wind", "12 km/h", "💨") + wetterKachel("Luftfeuchte", "48 %", "💧") +
wetterKachel("Sicht", "16 km", "👁️") + wetterKachel("UV-Index", "6 · Hoch", "☀️") +
'</div>' +
'</div>';
};
function wetterKachel(titel, wert, icon) {
return '<div class="row" style="flex-direction:column;align-items:flex-start;gap:4px;margin:0">' +
'<div class="meta">' + icon + ' ' + titel + '</div><div style="font-size:22px;font-weight:700">' + wert + '</div></div>';
}
/* =====================================================================
APP: RECHNER (voll funktionsfähig)
===================================================================== */
appRenderer.calc = function () {
appContentEl.innerHTML = appKopf("Rechner") +
'<div class="app-body" style="padding:0 0 24px;display:flex;flex-direction:column;justify-content:flex-end;overflow:hidden">' +
'<div class="calc-display" id="calc-display">0</div>' +
'<div class="calc-pad" id="calc-pad">' +
calcTaste("C","fn") + calcTaste("±","fn") + calcTaste("%","fn") + calcTaste("÷","op") +
calcTaste("7") + calcTaste("8") + calcTaste("9") + calcTaste("×","op") +
calcTaste("4") + calcTaste("5") + calcTaste("6") + calcTaste("−","op") +
calcTaste("1") + calcTaste("2") + calcTaste("3") + calcTaste("+","op") +
calcTaste("0","zero") + calcTaste(",") + calcTaste("=","eq") +
'</div>' +
'</div>';
document.getElementById("calc-pad").querySelectorAll("button").forEach(b =>
b.addEventListener("click", () => rechnerEingabe(b.textContent)));
};
function calcTaste(zeichen, klasse) {
return '<button class="calc-key ' + (klasse || "") + '">' + zeichen + '</button>';
}
// Rechner-Logik
let calcEingabe = "0"; // aktuelle Anzeige
let calcVorher = null; // gespeicherter Operand
let calcOperator = null;// aktive Rechenoperation
let calcNeu = true; // ob die nächste Ziffer neu beginnt
function rechnerEingabe(taste) {
const display = document.getElementById("calc-display");
const operatoren = { "÷": "/", "×": "*", "−": "-", "+": "+" };
if (taste >= "0" && taste <= "9") {
calcEingabe = calcNeu ? taste : (calcEingabe === "0" ? taste : calcEingabe + taste);
calcNeu = false;
} else if (taste === ",") {
if (!calcEingabe.includes(",")) calcEingabe += ",";
calcNeu = false;
} else if (taste === "C") {
calcEingabe = "0"; calcVorher = null; calcOperator = null; calcNeu = true;
} else if (taste === "±") {
calcEingabe = calcEingabe.startsWith("-") ? calcEingabe.slice(1) : "-" + calcEingabe;
} else if (taste === "%") {
calcEingabe = String(parseFloat(calcEingabe.replace(",", ".")) / 100).replace(".", ",");
} else if (operatoren[taste]) {
calcVorher = parseFloat(calcEingabe.replace(",", "."));
calcOperator = operatoren[taste];
calcNeu = true;
} else if (taste === "=") {
if (calcOperator !== null && calcVorher !== null) {
const jetzt = parseFloat(calcEingabe.replace(",", "."));
let ergebnis = 0;
if (calcOperator === "/") ergebnis = calcVorher / jetzt;
else if (calcOperator === "*") ergebnis = calcVorher * jetzt;
else if (calcOperator === "-") ergebnis = calcVorher - jetzt;
else if (calcOperator === "+") ergebnis = calcVorher + jetzt;
// Ergebnis runden und deutsch anzeigen
calcEingabe = String(Math.round(ergebnis * 1e8) / 1e8).replace(".", ",");
calcOperator = null; calcVorher = null; calcNeu = true;
}
}
display.textContent = calcEingabe;
}
/* =====================================================================
APP: GARAGE (Fahrzeuge des Spielers)
===================================================================== */
const fahrzeugListe = [
{ name: "Sultan RS", kennzeichen: "LS-MW 182", status: "Ausgeparkt", zustand: 92, farbe: "#f0506e" },
{ name: "Bati 801 (Motorrad)", kennzeichen: "LS-MW 044", status: "In Garage", zustand: 100, farbe: "#4aa8ff" },
{ name: "Bison (Pickup)", kennzeichen: "LS-MW 210", status: "In Garage", zustand: 76, farbe: "#34d399" }
];
appRenderer.garage = function () {
appContentEl.innerHTML = appKopf("Garage", fahrzeugListe.length + " Fahrzeuge") +
'<div class="app-body">' + fahrzeugListe.map(f =>
'<div class="row" style="flex-direction:column;align-items:stretch;gap:10px">' +
'<div style="display:flex;align-items:center;gap:12px">' +
'<span class="avatar" style="background:' + f.farbe + '">🚗</span>' +
'<div class="grow"><div class="title">' + sicher(f.name) + '</div>' +
'<div class="meta">' + sicher(f.kennzeichen) + '</div></div>' +
'<span class="pill ' + (f.status === "In Garage" ? "green" : "amber") + '">' + f.status + '</span>' +
'</div>' +
'<div><div class="meta" style="margin-bottom:4px">Zustand: ' + f.zustand + ' %</div>' +
'<div style="height:6px;background:var(--s-surface-2);border-radius:3px"><div style="width:' + f.zustand + '%;height:100%;background:' + (f.zustand > 80 ? "var(--s-green)" : "var(--s-amber)") + ';border-radius:3px"></div></div></div>' +
'</div>').join("") +
'</div>';
};
/* =====================================================================
APP: DISPATCH (Notfall an die Leitstelle MELDEN — kein Annehmen)
===================================================================== */
// Vordefinierte Meldegründe für das Dropdown (Code, Bezeichnung, Priorität)
const dispatchGruende = [
{ code: "10-90", label: "Raubüberfall", prio: "red" },
{ code: "10-71", label: "Schusswaffengebrauch", prio: "red" },
{ code: "10-70", label: "Brand", prio: "red" },
{ code: "10-52", label: "Medizinischer Notfall", prio: "amber" },
{ code: "10-50", label: "Verkehrsunfall", prio: "amber" },
{ code: "10-55", label: "Fahrzeugdiebstahl", prio: "amber" },
{ code: "10-66", label: "Verdächtige Person", prio: "blue" },
{ code: "10-16", label: "Ruhestörung", prio: "blue" },
{ code: "10-00", label: "Sonstiges", prio: "blue" }
];
// Session-Historie der eigenen Meldungen (neueste zuerst)
const dispatchMeldungen = [];
appRenderer.dispatch = function () {
appContentEl.innerHTML = appKopf("Dispatch", "Notfall an die Leitstelle melden") +
'<div class="app-body">' +
'<label class="field-label">Grund der Meldung</label>' +
'<select class="input" id="disp-grund">' +
dispatchGruende.map((g, i) =>
'<option value="' + i + '">' + sicher(g.code) + ' · ' + sicher(g.label) + '</option>').join("") +
'</select>' +
'<label class="field-label">Standort</label>' +
'<input class="input" id="disp-ort" placeholder="z. B. Legion Square" value="Aktueller Standort">' +
'<label class="field-label">Beschreibung (optional)</label>' +
'<input class="input" id="disp-info" placeholder="Kurze Zusatzinfo für die Leitstelle" autocomplete="off">' +
'<div style="height:16px"></div>' +
'<button class="btn red" id="disp-send">📡 Notruf absetzen</button>' +
'<div class="list-title">Deine Meldungen</div>' +
'<div id="disp-log"></div>' +
'</div>';
zeichneMeldungen();
document.getElementById("disp-send").addEventListener("click", sendeMeldung);
};
// Eine Meldung an die Leitstelle absenden
function sendeMeldung() {
const grund = dispatchGruende[+document.getElementById("disp-grund").value];
const ort = document.getElementById("disp-ort").value.trim() || "Unbekannter Standort";
const info = document.getElementById("disp-info").value.trim();
const jetzt = new Date();
const zeit = String(jetzt.getHours()).padStart(2, "0") + ":" + String(jetzt.getMinutes()).padStart(2, "0");
// Meldung vorne in die Historie einfügen
dispatchMeldungen.unshift({ code: grund.code, label: grund.label, prio: grund.prio, ort: ort, info: info, zeit: zeit });
document.getElementById("disp-info").value = "";
zeichneMeldungen();
zeigeToast("Notruf " + grund.code + " abgesetzt 📡");
}
// Historie der eigenen Meldungen zeichnen
function zeichneMeldungen() {
const container = document.getElementById("disp-log");
if (dispatchMeldungen.length === 0) {
container.innerHTML = '<div class="empty"><div class="big">📻</div><div>Noch keine Meldungen abgesetzt.</div></div>';
return;
}
container.innerHTML = dispatchMeldungen.map(m =>
'<div class="row" style="flex-direction:column;align-items:stretch;gap:6px">' +
'<div style="display:flex;align-items:center;gap:10px">' +
'<div class="grow"><div class="title">' + sicher(m.code) + ' · ' + sicher(m.label) + '</div>' +
'<div class="meta">📍 ' + sicher(m.ort) + (m.info ? ' · ' + sicher(m.info) : '') + '</div></div>' +
'<span class="pill ' + m.prio + '">' + prioText(m.prio) + '</span>' +
'</div>' +
'<div class="meta">Abgesetzt um ' + m.zeit + ' Uhr · Status: <span style="color:var(--s-green)">übermittelt</span></div>' +
'</div>').join("");
}
// Prioritätsstufe als Text
function prioText(prio) {
if (prio === "red") return "Priorität 1";
if (prio === "amber") return "Priorität 2";
return "Priorität 3";
}
/* =====================================================================
APP: GELBE SEITEN (Anzeigen / Unternehmen)
===================================================================== */
appRenderer.pages = function () {
const anzeigen = [
{ name: "Benny's Motorworks", kat: "Werkstatt & Tuning", tel: "555-0199", icon: "🔧", farbe: "#f5b23d" },
{ name: "Taxi Zentrale LS", kat: "Personenbeförderung", tel: "555-0100", icon: "🚕", farbe: "#facc15" },
{ name: "Kanzlei Dr. Weber", kat: "Rechtsanwalt", tel: "555-0163", icon: "⚖️", farbe: "#34d399" },
{ name: "Vanilla Unicorn", kat: "Nachtclub & Bar", tel: "555-0170", icon: "🍸", farbe: "#e11d48" },
{ name: "Los Santos Customs", kat: "Fahrzeugveredelung", tel: "555-0188", icon: "🎨", farbe: "#4aa8ff" }
];
appContentEl.innerHTML = appKopf("Gelbe Seiten", "Unternehmen in Los Santos") +
'<div class="app-body">' + anzeigen.map(a =>
'<div class="row" style="cursor:pointer" data-call="' + sicher(a.name) + '">' +
'<span class="avatar" style="background:' + a.farbe + '">' + a.icon + '</span>' +
'<div class="grow"><div class="title">' + sicher(a.name) + '</div>' +
'<div class="meta">' + sicher(a.kat) + ' · ' + sicher(a.tel) + '</div></div>' +
'<span class="icon-btn" style="pointer-events:none">' + telefonSymbol + '</span></div>').join("") +
'</div>';
appContentEl.querySelectorAll("[data-call]").forEach(r =>
r.addEventListener("click", () => starteAnruf(r.dataset.call, r.dataset.call)));
};
/* =====================================================================
APP: EINSTELLUNGEN (Theme, Wallpaper, Schalter)
===================================================================== */
appRenderer.settings = function () {
const istHell = screenEl.classList.contains("light");
appContentEl.innerHTML = appKopf("Einstellungen") +
'<div class="app-body">' +
'<div class="row"><span class="avatar" style="background:linear-gradient(145deg,#7b6cf6,#34d399)">' + spielerZustand.name.substring(0,2).toUpperCase() + '</span>' +
'<div class="grow"><div class="title">' + sicher(spielerZustand.name) + '</div><div class="meta">' + sicher(spielerZustand.nummer) + ' · AetherOS 3.1</div></div></div>' +
'<div class="list-title">Startbildschirm</div>' +
'<div class="setting-row" id="set-dock" style="border-radius:14px;cursor:pointer">' +
'<span class="si g-set" style="font-size:16px">▦</span>' +
'<div class="grow"><div class="title">Dock anpassen</div><div class="meta">Untere App-Leiste ändern</div></div>' +
'<span style="color:var(--s-muted);font-size:20px">›</span></div>' +
'<div class="list-title">Anzeige</div>' +
'<div class="setting-row"><span class="si g-set">🌙</span><div class="grow"><div class="title">Helles Design</div></div>' +
'<button class="switch ' + (istHell ? "on" : "") + '" id="sw-theme" aria-label="Design umschalten"></button></div>' +
'<div class="setting-row"><span class="si g-weather">✈️</span><div class="grow"><div class="title">Flugmodus</div></div>' +
'<button class="switch" id="sw-airplane" aria-label="Flugmodus"></button></div>' +
'<div class="setting-row"><span class="si g-music">🔔</span><div class="grow"><div class="title">Nicht stören</div></div>' +
'<button class="switch" id="sw-dnd" aria-label="Nicht stören"></button></div>' +
'<div class="list-title">Sicherheit</div>' +
'<div class="setting-row" style="border-radius:14px 14px 0 0"><span class="si g-birdy" style="font-size:15px">🙂</span>' +
'<div class="grow"><div class="title">Face-ID</div><div class="meta">Ohne Code-Eingabe entsperren</div></div>' +
'<button class="switch ' + (spielerZustand.faceId ? "on" : "") + '" id="sw-faceid" aria-label="Face-ID"></button></div>' +
'<div class="setting-row" id="set-pin" style="border-radius:0 0 14px 14px;cursor:pointer"><span class="si g-set" style="font-size:15px">🔒</span>' +
'<div class="grow"><div class="title">Code ändern</div></div>' +
'<span style="color:var(--s-muted);font-size:20px">›</span></div>' +
'<div class="list-title">Hintergrundbild</div>' +
'<div class="wp-picker" id="wp-picker">' +
wpOption("wp-aurora","Aurora") + wpOption("wp-sunset","Sonnenuntergang") +
wpOption("wp-carbon","Carbon") + wpOption("wp-mono","Schlicht") +
'</div>' +
'<div class="list-title">Info</div>' +
'<div class="row"><div class="grow"><div class="title">Modell</div></div><div class="side">Aether X · 128 GB</div></div>' +
'<div class="row"><div class="grow"><div class="title">Netzbetreiber</div></div><div class="side">LS-Mobile</div></div>' +
'</div>';
// Dock anpassen öffnen
document.getElementById("set-dock").addEventListener("click", () => navigiere(zeigeDockEditor));
// Face-ID an/aus
document.getElementById("sw-faceid").addEventListener("click", (e) => {
e.currentTarget.classList.toggle("on");
spielerZustand.faceId = e.currentTarget.classList.contains("on");
zeigeToast(spielerZustand.faceId ? "Face-ID aktiviert" : "Face-ID aus – Code nötig");
});
// Code ändern öffnen
document.getElementById("set-pin").addEventListener("click", () => navigiere(zeigeCodeAendern));
// Theme umschalten
document.getElementById("sw-theme").addEventListener("click", (e) => {
screenEl.classList.toggle("light");
spielerZustand.theme = screenEl.classList.contains("light") ? "light" : "dark";
e.currentTarget.classList.toggle("on");
});
// Flugmodus schaltet die Netz-Anzeige
document.getElementById("sw-airplane").addEventListener("click", (e) => {
e.currentTarget.classList.toggle("on");
const an = e.currentTarget.classList.contains("on");
document.getElementById("sb-signal").style.opacity = an ? "0.25" : "1";
zeigeToast(an ? "Flugmodus aktiviert ✈️" : "Flugmodus deaktiviert");
});
// Nicht stören (nur Rückmeldung)
document.getElementById("sw-dnd").addEventListener("click", (e) => {
e.currentTarget.classList.toggle("on");
zeigeToast(e.currentTarget.classList.contains("on") ? "Nicht stören aktiv 🔕" : "Nicht stören aus");
});
// Wallpaper auswählen
appContentEl.querySelectorAll("[data-wp]").forEach(opt =>
opt.addEventListener("click", () => {
const klasse = opt.dataset.wp;
document.getElementById("wallpaper").className = "wallpaper " + klasse;
spielerZustand.wallpaper = klasse;
appContentEl.querySelectorAll("[data-wp]").forEach(o => o.classList.remove("sel"));
opt.classList.add("sel");
}));
};
// Einzelne Wallpaper-Vorschau
function wpOption(klasse, name) {
const gewaehlt = spielerZustand.wallpaper === klasse ? " sel" : "";
return '<div class="wp-opt ' + klasse + gewaehlt + '" data-wp="' + klasse + '" title="' + name + '"></div>';
}
/* Dock-Editor: die 4 Plätze der unteren Leiste per Dropdown belegen */
function zeigeDockEditor() {
// Nur echte, nicht gesperrte Apps zur Auswahl anbieten
const auswahl = appReihenfolge.filter(id => !(alleApps[id].gated && !spielerZustand.freigeschaltet[id]));
appContentEl.innerHTML = appKopf("Dock anpassen", "Wähle die 4 Apps der unteren Leiste") +
'<div class="app-body">' +
'<div class="dock" style="position:relative;margin-bottom:18px;background:var(--s-surface)">' + '</div>' +
spielerZustand.dockApps.map((id, platz) =>
'<label class="field-label">Platz ' + (platz + 1) + '</label>' +
'<select class="input dock-slot" data-slot="' + platz + '">' +
auswahl.map(oid => '<option value="' + oid + '"' + (oid === id ? " selected" : "") + '>' +
sicher(alleApps[oid].label) + '</option>').join("") +
'</select>').join("") +
'<div style="height:12px"></div>' +
'<div class="meta" style="text-align:center">Änderungen werden sofort übernommen.</div>' +
'</div>';
zeichneDockVorschau();
// Auswahl ändern -> Dock aktualisieren
appContentEl.querySelectorAll(".dock-slot").forEach(sel =>
sel.addEventListener("change", () => {
spielerZustand.dockApps[+sel.dataset.slot] = sel.value;
baueHomescreen();
zeichneDockVorschau();
}));
}
// Kleine Live-Vorschau des Docks im Editor
function zeichneDockVorschau() {
const vorschau = appContentEl.querySelector(".dock");
if (!vorschau) return;
vorschau.innerHTML = "";
spielerZustand.dockApps.forEach(id => vorschau.appendChild(baueAppKachel(id)));
}
/* Code ändern (neuen Code eingeben und bestätigen) */
function zeigeCodeAendern() {
// Dunkler Hintergrund, damit das PIN-Feld in jedem Theme gut lesbar ist
appContentEl.innerHTML = appKopf("Code ändern") +
'<div class="app-body" id="code-body" style="padding:0;background:#0b0d16"></div>';
const body = document.getElementById("code-body");
renderePinFeld(body, {
titel: "Neuer Code", unterzeile: "4-stelligen Code eingeben", icon: "🔐",
onFertig: (neuerCode) => {
renderePinFeld(body, {
titel: "Code bestätigen", unterzeile: "Neuen Code erneut eingeben", icon: "🔐",
onFertig: (bestaetigung, reset) => {
if (bestaetigung === neuerCode) { spielerZustand.pin = neuerCode; zeigeToast("Code geändert ✅"); zurueck(); }
else { zeigeToast("Codes stimmen nicht überein"); reset(); }
}
});
}
});
}
/* =====================================================================
APP: DARKCHAT (anonymer Marktplatz — RP-Flair)
===================================================================== */
appRenderer.darkweb = function () {
const angebote = [
{ user: "@ghost_44", text: "Biete Dienstleistungen. Diskret. Nur Bargeld. 💼", zeit: "vor 5 Min" },
{ user: "@nachtschatten", text: "Suche zuverlässigen Fahrer für heute Nacht. Gute Bezahlung.", zeit: "vor 22 Min" },
{ user: "@anon_ls", text: "Verkaufe elektronische Bauteile. DM für Details.", zeit: "vor 1 Std" }
];
appContentEl.innerHTML = appKopf("Darkchat", "🔒 Verbindung anonymisiert") +
'<div class="app-body">' +
'<div class="row" style="background:rgba(240,80,110,0.1);border-color:rgba(240,80,110,0.3)"><span>⚠️</span><div class="meta" style="white-space:normal">Reines Rollenspiel-Feature. Alle Inhalte sind fiktiv.</div></div>' +
angebote.map(a =>
'<div class="post"><div class="ph"><span class="avatar" style="background:#374151">?</span>' +
'<div><div class="name">' + sicher(a.user) + '</div><div class="handle">' + a.zeit + '</div></div></div>' +
'<div class="txt">' + sicher(a.text) + '</div></div>').join("") +
'</div>';
};
/* =====================================================================
APP: UHR (Weltzeit / Timer-Mock)
===================================================================== */
// Gestellte Wecker des Spielers
const weckerListe = [
{ zeit: "07:00", label: "Dienstbeginn · Werktags", an: true },
{ zeit: "22:30", label: "Feierabend", an: false }
];
let uhrAppTimer = null; // Timer der Live-Uhr (läuft nur während die App offen ist)
appRenderer.clock = function () {
appContentEl.innerHTML = appKopf("Uhr", "Los Santos · Ortszeit") +
'<div class="app-body">' +
'<div style="text-align:center;font-size:58px;font-weight:300;font-variant-numeric:tabular-nums;margin:12px 0" id="clock-big">--:--:--</div>' +
'<div class="list-title">Neuer Wecker</div>' +
'<div style="display:flex;gap:8px;margin-bottom:8px">' +
'<input class="input" id="al-time" type="time" value="08:00" style="max-width:130px">' +
'<input class="input" id="al-label" placeholder="Bezeichnung" autocomplete="off">' +
'</div>' +
'<button class="btn" id="al-add">Wecker hinzufügen</button>' +
'<div class="list-title">Wecker</div>' +
'<div id="al-list"></div>' +
'</div>';
// Live-Uhr: sekündlich aktualisieren, stoppt automatisch beim Verlassen der App
clearInterval(uhrAppTimer);
const tick = () => {
const el = document.getElementById("clock-big");
if (!el) { clearInterval(uhrAppTimer); return; }
const j = new Date();
el.textContent = String(j.getHours()).padStart(2, "0") + ":" +
String(j.getMinutes()).padStart(2, "0") + ":" + String(j.getSeconds()).padStart(2, "0");
};
tick();
uhrAppTimer = setInterval(tick, 1000);
zeichneWecker();
// Neuen Wecker hinzufügen
document.getElementById("al-add").addEventListener("click", () => {
const zeit = document.getElementById("al-time").value;
const label = document.getElementById("al-label").value.trim() || "Wecker";
if (!zeit) { zeigeToast("Bitte eine Uhrzeit wählen"); return; }
weckerListe.push({ zeit: zeit, label: label, an: true });
weckerListe.sort((a, b) => a.zeit.localeCompare(b.zeit));
document.getElementById("al-label").value = "";
zeichneWecker();
zeigeToast("Wecker " + zeit + " gestellt ⏰");
});
};
// Weckerliste zeichnen (an/aus schalten, löschen)
function zeichneWecker() {
const box = document.getElementById("al-list");
if (!box) return;
if (weckerListe.length === 0) {
box.innerHTML = '<div class="empty"><div class="big">⏰</div><div>Keine Wecker gestellt.</div></div>';
return;
}
box.innerHTML = weckerListe.map((w, i) =>
'<div class="setting-row" style="border-radius:14px;margin-bottom:8px">' +
'<div class="grow"><div style="font-size:26px;font-weight:300;color:' + (w.an ? "var(--s-fg)" : "var(--s-muted)") + '">' + w.zeit + '</div>' +
'<div class="meta">' + sicher(w.label) + '</div></div>' +
'<button class="switch ' + (w.an ? "on" : "") + '" data-al-toggle="' + i + '" aria-label="Wecker an/aus"></button>' +
'<button class="icon-btn" data-al-del="' + i + '" style="color:var(--s-red);font-size:15px" aria-label="Wecker löschen">🗑</button>' +
'</div>').join("");
// An/Aus umschalten
box.querySelectorAll("[data-al-toggle]").forEach(b =>
b.addEventListener("click", () => {
weckerListe[+b.dataset.alToggle].an = !weckerListe[+b.dataset.alToggle].an;
zeichneWecker();
}));
// Löschen
box.querySelectorAll("[data-al-del]").forEach(b =>
b.addEventListener("click", () => {
weckerListe.splice(+b.dataset.alDel, 1);
zeichneWecker();
zeigeToast("Wecker gelöscht");
}));
}
/* =====================================================================
APP: STAAT (Staatskasse mit Graph, Einnahmen/Ausgaben, Abstimmungen)
===================================================================== */
const staatDaten = {
kontostand: 12480000, // aktueller Kontostand der Staatskasse in Euro
// Verlauf der letzten 6 Monate (Werte in Mio € für den Graphen)
verlauf: [
{ monat: "Feb", ein: 2.1, aus: 1.8 }, { monat: "Mär", ein: 2.4, aus: 2.0 },
{ monat: "Apr", ein: 2.0, aus: 2.3 }, { monat: "Mai", ein: 2.6, aus: 2.1 },
{ monat: "Jun", ein: 2.9, aus: 2.4 }, { monat: "Jul", ein: 3.1, aus: 2.2 }
],
// Aufschlüsselung der Einnahmen
einnahmen: [
{ q: "Steuern", v: 1250000 }, { q: "Bußgelder & Strafen", v: 340000 },
{ q: "Gewerbelizenzen", v: 210000 }, { q: "Zoll & Gebühren", v: 95000 }
],
// Aufschlüsselung der Ausgaben
ausgaben: [
{ q: "Gehälter Beamte", v: 820000 }, { q: "Infrastruktur", v: 540000 },
{ q: "Gesundheitswesen", v: 310000 }, { q: "Subventionen", v: 150000 }
],
// Umsetzungsprojekte, über die abgestimmt wird
projekte: [
{ titel: "Neue Feuerwache Sandy Shores", kosten: 450000, ja: 128, nein: 34, gestimmt: null },
{ titel: "Ausbau Autobahn N1", kosten: 1200000, ja: 87, nein: 96, gestimmt: null },
{ titel: "Steuersenkung für Kleingewerbe", kosten: 300000, ja: 203, nein: 41, gestimmt: null }
]
};
appRenderer.staat = function () {
// Höchsten Monatswert für die Skalierung des Graphen bestimmen
const maxWert = Math.max(...staatDaten.verlauf.flatMap(m => [m.ein, m.aus]));
const graphHtml = '<div class="chart">' + staatDaten.verlauf.map(m =>
'<div class="col"><div class="bars">' +
'<div class="bar ein" style="height:' + (m.ein / maxWert * 100).toFixed(0) + '%"></div>' +
'<div class="bar aus" style="height:' + (m.aus / maxWert * 100).toFixed(0) + '%"></div>' +
'</div><div class="m">' + m.monat + '</div></div>').join("") + '</div>';
appContentEl.innerHTML = appKopf("Staat", "Staatskasse Los Santos") +
'<div class="app-body">' +
'<div class="staat-card">' +
'<div class="lbl">STAATSKONTO · GESAMTBESTAND</div>' +
'<div class="bal">' + formatiereEuro(staatDaten.kontostand) + '</div>' +
'<div class="lbl">Haushaltsjahr 2026 · öffentlich einsehbar</div>' +
'</div>' +
'<div class="list-title">Einnahmen &amp; Ausgaben (Mio €)</div>' +
graphHtml +
'<div class="legende"><span><i style="background:var(--s-green)"></i>Einnahmen</span>' +
'<span><i style="background:var(--s-red)"></i>Ausgaben</span></div>' +
'<div class="list-title">Einnahmen (Monat)</div>' +
staatDaten.einnahmen.map(e =>
'<div class="tx"><div class="grow"><div class="title">' + sicher(e.q) + '</div></div>' +
'<div class="amt plus">+' + formatiereEuro(e.v) + '</div></div>').join("") +
'<div class="list-title">Ausgaben (Monat)</div>' +
staatDaten.ausgaben.map(a =>
'<div class="tx"><div class="grow"><div class="title">' + sicher(a.q) + '</div></div>' +
'<div class="amt" style="color:var(--s-red)">-' + formatiereEuro(a.v) + '</div></div>').join("") +
'<div class="list-title">Umsetzungsprojekte · Abstimmung</div>' +
'<div id="staat-projekte"></div>' +
'</div>';
zeichneProjekte();
};
// Projekte inkl. Abstimmungsbalken zeichnen
function zeichneProjekte() {
const box = document.getElementById("staat-projekte");
box.innerHTML = staatDaten.projekte.map((p, i) => {
const gesamt = p.ja + p.nein;
const jaProzent = gesamt ? Math.round(p.ja / gesamt * 100) : 50;
return '<div class="row" style="flex-direction:column;align-items:stretch;gap:4px">' +
'<div style="display:flex;justify-content:space-between;gap:8px;align-items:flex-start">' +
'<div class="title">' + sicher(p.titel) + '</div>' +
'<span class="pill blue" style="flex-shrink:0">' + formatiereEuro(p.kosten) + '</span></div>' +
'<div class="vote-bar"><div class="ja" style="width:' + jaProzent + '%"></div>' +
'<div class="nein" style="width:' + (100 - jaProzent) + '%"></div></div>' +
'<div class="meta">' + p.ja + ' dafür · ' + p.nein + ' dagegen' +
(p.gestimmt ? ' · <b style="color:var(--s-fg)">deine Stimme: ' + (p.gestimmt === "ja" ? "Dafür" : "Dagegen") + '</b>' : '') + '</div>' +
(p.gestimmt ? '' :
'<div class="vote-actions">' +
'<button class="btn green" style="padding:9px;font-size:14px" data-vote="ja" data-p="' + i + '">Dafür</button>' +
'<button class="btn red" style="padding:9px;font-size:14px" data-vote="nein" data-p="' + i + '">Dagegen</button></div>') +
'</div>';
}).join("");
// Abstimmungs-Buttons verdrahten (nur eine Stimme pro Projekt)
box.querySelectorAll("[data-vote]").forEach(b =>
b.addEventListener("click", () => {
const projekt = staatDaten.projekte[+b.dataset.p];
if (projekt.gestimmt) return;
projekt.gestimmt = b.dataset.vote;
if (b.dataset.vote === "ja") projekt.ja++; else projekt.nein++;
zeichneProjekte();
zeigeToast("Deine Stimme wurde gezählt 🗳️");
}));
}
/* =====================================================================
APP: NOTIZEN (einfache, funktionierende Notizen)
===================================================================== */
const notizenListe = [
{ titel: "Einkaufsliste", text: "Verband, Munition, Snacks für die Tour" },
{ titel: "Wichtig", text: "Treffen mit Anwalt Dr. Weber am Donnerstag, 18 Uhr" }
];
appRenderer.notizen = function () {
const liste = notizenListe.length
? notizenListe.map((n, i) =>
'<div class="row" style="cursor:pointer;flex-direction:column;align-items:stretch;gap:3px" data-note="' + i + '">' +
'<div class="title">' + sicher(n.titel) + '</div>' +
'<div class="meta" style="white-space:normal">' + sicher(n.text) + '</div></div>').join("")
: '<div class="empty"><div class="big">📝</div><div>Noch keine Notizen.</div></div>';
appContentEl.innerHTML = appKopf("Notizen", notizenListe.length + " Notizen") +
'<div class="app-body">' +
'<button class="btn" id="note-new" style="margin-bottom:14px">+ Neue Notiz</button>' +
liste +
'</div>';
document.getElementById("note-new").addEventListener("click", () => navigiere(() => zeigeNotizEditor(null)));
// Tippen auf eine Notiz öffnet den Editor
appContentEl.querySelectorAll("[data-note]").forEach(r =>
r.addEventListener("click", () => navigiere(() => zeigeNotizEditor(+r.dataset.note))));
};
// Notiz-Editor (index === null -> neue Notiz, sonst bestehende bearbeiten)
function zeigeNotizEditor(index) {
const vorhanden = index !== null;
const notiz = vorhanden ? notizenListe[index] : { titel: "", text: "" };
appContentEl.innerHTML = appKopf(vorhanden ? "Notiz bearbeiten" : "Neue Notiz") +
'<div class="app-body">' +
'<label class="field-label">Titel</label>' +
'<input class="input" id="note-titel" placeholder="Titel" value="' + sicher(notiz.titel) + '">' +
'<label class="field-label">Inhalt</label>' +
'<textarea class="input" id="note-text" style="min-height:140px;resize:none">' + sicher(notiz.text) + '</textarea>' +
'<div style="height:16px"></div>' +
'<button class="btn green" id="note-save">Speichern</button>' +
(vorhanden ? '<div style="height:8px"></div><button class="btn red" id="note-del">Notiz löschen</button>' : '') +
'</div>';
// Speichern (neu anlegen oder aktualisieren)
document.getElementById("note-save").addEventListener("click", () => {
const titel = document.getElementById("note-titel").value.trim();
const text = document.getElementById("note-text").value.trim();
if (!titel && !text) { zeigeToast("Bitte etwas eingeben"); return; }
const eintrag = { titel: titel || "Ohne Titel", text: text || "—" };
if (vorhanden) notizenListe[index] = eintrag; else notizenListe.unshift(eintrag);
zeigeToast("Notiz gespeichert");
zurueck();
});
// Löschen
if (vorhanden) {
document.getElementById("note-del").addEventListener("click", () => {
notizenListe.splice(index, 1);
zeigeToast("Notiz gelöscht");
zurueck();
});
}
}
/* =====================================================================
NUI-BRÜCKE — Verbindung zum FiveM-Client
===================================================================== */
const IST_FIVEM = (typeof GetParentResourceName === "function");
const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-phone";
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(() => {});
}
/* Sucht die Nummer zu einem Kontaktnamen (für Anrufe aus der Kontaktliste) */
function nummerZuName(name) {
const treffer = kontakteListe.find(k => k.name === name);
return treffer ? treffer.nummer : null;
}
/* Sucht den Anzeigenamen zu einer Nummer (unbekannt = Nummer selbst) */
function nameZuNummer(nummer) {
const treffer = kontakteListe.find(k => k.nummer === nummer);
return treffer ? treffer.name : nummer;
}
/* Übernimmt den kompletten Zustand vom Server */
function setzeZustand(z) {
spielerZustand.name = z.name || "—";
spielerZustand.nummer = z.nummer || "—";
spielerZustand.kontostand = z.kontostand || 0;
spielerZustand.kontonummer = z.kontonummer || "—";
spielerZustand.theme = z.theme || "dark";
spielerZustand.wallpaper = z.wallpaper || "wp-aurora";
spielerZustand.dockApps = z.dockApps || spielerZustand.dockApps;
spielerZustand.freigeschaltet = z.freigeschaltet || {};
spielerZustand.handy = z.handy;
spielerZustand.sim = z.sim;
spielerZustand.akku = z.akku;
spielerZustand.faceId = z.faceId;
/* Wichtig: Die echte PIN kommt nie an. Hier steht nur ein Platzhalter,
damit die Oberfläche weiß, dass bereits eine eingerichtet ist. */
spielerZustand.pin = z.pinGesetzt ? "****" : null;
kontakteListe = (z.kontakte || []).map(k => ({
id: k.id, name: k.name, nummer: k.nummer, notruf: k.notruf || false,
farbe: k.notruf ? "#f0506e" : "#7b6cf6",
initialen: k.notruf ? "🚨" : (k.name || "?").substring(0, 2).toUpperCase(),
}));
/* Chats sind nach Nummer geschlüsselt; für die Anzeige den Kontaktnamen
verwenden, sofern die Nummer gespeichert ist. */
chatVerlaeufe = z.chats || {};
anrufListe = (z.anrufe || []).map(a => ({
name: nameZuNummer(a.nummer), zeit: a.zeit,
art: a.art, verpasst: a.verpasst,
}));
if (typeof zeichneHome === "function") { try { zeichneHome(); } catch (e) {} }
if (z.hinweis) zeigeToast(z.hinweis);
}
window.addEventListener("message", (ereignis) => {
const n = ereignis.data || {};
if (n.aktion === "oeffnen") {
document.body.style.display = "";
setzeZustand(n);
// Ersteinrichtung, falls noch keine PIN gesetzt ist
if (!spielerZustand.pin) starteEinrichtung();
}
else if (n.aktion === "zustand") {
setzeZustand(n);
}
else if (n.aktion === "entsperrt") {
if (n.erfolg) {
const u = document.getElementById("unlock-screen");
if (u) u.classList.remove("show");
entsperreHandy();
} else {
zeigeToast("Falscher Code");
if (pinZuruecksetzen) pinZuruecksetzen();
}
}
else if (n.aktion === "neueNachricht") {
const von = n.nachricht.von;
chatVerlaeufe[von] = chatVerlaeufe[von] || [];
chatVerlaeufe[von].push({ von: "them", text: n.nachricht.text, zeit: n.nachricht.zeit });
if (typeof aktiverChat !== "undefined" && aktiverChat === von) zeichneBubbles();
zeigeToast("Neue Nachricht von " + nameZuNummer(von));
}
else if (n.aktion === "eingehenderAnruf") {
document.body.style.display = "";
const overlay = document.getElementById("in-call");
if (overlay) {
document.getElementById("call-who").textContent = nameZuNummer(n.nummer);
document.getElementById("call-avatar").textContent = "☎";
document.getElementById("call-status").textContent = "Eingehender Anruf …";
overlay.classList.add("show");
overlay.dataset.eingehend = "1";
}
}
else if (n.aktion === "anrufStatus") {
const statusEl = document.getElementById("call-status");
const overlay = document.getElementById("in-call");
if (n.status === "klingelt" && statusEl) statusEl.textContent = "Klingelt …";
else if (n.status === "verbunden" && statusEl) statusEl.textContent = "Verbunden";
else if (n.status === "besetzt") { zeigeToast("Besetzt"); if (overlay) overlay.classList.remove("show"); }
else if (n.status === "nichtErreichbar") { zeigeToast("Nicht erreichbar"); if (overlay) overlay.classList.remove("show"); }
else if (n.status === "notruf") { zeigeToast("Notruf abgesetzt: " + (n.zusatz || "")); if (overlay) overlay.classList.remove("show"); }
else if (n.status === "beendet") { if (overlay) overlay.classList.remove("show"); }
}
else if (n.aktion === "akku") {
spielerZustand.akku = n.stand;
}
else if (n.aktion === "fehler") {
zeigeToast(n.text || "Nicht möglich");
}
else if (n.aktion === "schliessen") {
document.body.style.display = "none";
}
});
/* ESC schließt das Handy */
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && IST_FIVEM) nui("schliessen", {});
});
/* In FiveM zunächst verstecken — die Seite wartet auf "oeffnen" */
if (IST_FIVEM) document.body.style.display = "none";
</script>
</body>
</html>