IC-Computer: Schreibtisch, Fenster und Apps
Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine
Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen.
Andere Resources haengen sich als App ein.
Auf ESX umgestellt:
- Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt.
Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die
Resource waere nie hochgekommen.
- Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung
und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen.
- pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die
Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei
Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in
der config.lua.
Mail ueberarbeitet:
- Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute
koennen dasselbe Firmenpostfach gleichzeitig offen haben.
- Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes
Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren
trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe
Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine
Eigenschaft der Nachricht, sondern der Person.
- Signaturen gehoeren ebenfalls zum Postfach.
- Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher
Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter
niemand mehr aendern oder absagen.
Neue Apps: Webhosting, Bleeter und dessen Verwaltung.
Behoben:
- prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen
Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und
kehrt nie zurueck. Ersetzt durch eigene Dialoge.
- Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht,
schreibt ins Leere und das Fenster bleibt leer.
Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine
eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis
zum Schreibtischsymbol.
This commit is contained in:
commit
be502d2834
46 changed files with 13119 additions and 0 deletions
694
nui/css/bleeter-embedded.css
Normal file
694
nui/css/bleeter-embedded.css
Normal file
|
|
@ -0,0 +1,694 @@
|
|||
/* Erzeugt aus bleeter/html/style.css – nicht von Hand aendern.
|
||||
Jeder Selektor ist auf .bl-root gekapselt, damit Bleeters Farben und
|
||||
Schrift nicht den uebrigen PC einfaerben. */
|
||||
.bl-root{--bg: #020414;--panel: #061325;--panel-2: #091a31;--panel-3: #0d203a;--line: rgba(255, 255, 255, 0.08);--text: #f5f8ff;--muted: #71809b;--green: #19d889;--green-2: #00b978;--red: #ff4f5f;--gold: #f2b866;--shadow: 0 24px 80px rgba(0, 0, 0, 0.58)}
|
||||
.bl-root *{box-sizing: border-box;}
|
||||
.bl-root{color: var(--text);font-family: Inter, Arial, Helvetica, sans-serif}
|
||||
.bl-root button,.bl-root input,.bl-root textarea{font: inherit;}
|
||||
.bl-root button{cursor: pointer;}
|
||||
.bl-root .hidden{display: none !important;}
|
||||
.bl-root .tablet{position: relative;
|
||||
width: min(1680px, 96vw);
|
||||
aspect-ratio: 16 / 7.45;
|
||||
min-height: 700px;
|
||||
border: 13px solid #313943;
|
||||
border-radius: 72px;
|
||||
background: #020414;
|
||||
box-shadow: var(--shadow), inset 0 0 0 5px #77818b;
|
||||
overflow: hidden;}
|
||||
.bl-root .tablet-glass{position: absolute;
|
||||
inset: 7px;
|
||||
border-radius: 58px;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 2px 12px rgba(255, 255, 255, 0.22), inset 0 -24px 60px rgba(255, 255, 255, 0.04);
|
||||
z-index: 5;}
|
||||
.bl-root .screen{position: absolute;
|
||||
inset: 22px;
|
||||
border-radius: 48px;
|
||||
background: var(--bg);
|
||||
overflow: hidden;}
|
||||
.bl-root .topbar{height: 92px;
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 212px minmax(0, 1fr) 320px 58px 44px;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 0 54px;}
|
||||
.bl-root .top-close{width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s, border-color .15s;}
|
||||
.bl-root .top-close:hover{background: rgba(229, 57, 53, 0.16);
|
||||
border-color: rgba(229, 57, 53, 0.5);
|
||||
color: #ff8a80;}
|
||||
.bl-root .brand{height: 58px;
|
||||
border: 0;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;}
|
||||
.bl-root .brand img{width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;}
|
||||
.bl-root .search-wrap{position: relative;}
|
||||
.bl-root .global-search{width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(25, 216, 137, 0.38);
|
||||
border-radius: 999px;
|
||||
outline: 0;
|
||||
padding: 0 18px;
|
||||
color: var(--text);
|
||||
background: rgba(5, 20, 38, 0.94);
|
||||
font-size: 14px;
|
||||
font-weight: 800;}
|
||||
.bl-root .global-search::placeholder{color: #53627a;}
|
||||
.bl-root .search-results{position: absolute;
|
||||
top: 52px;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #051426;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);}
|
||||
.bl-root .search-result{display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);}
|
||||
.bl-root .search-result img{width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;}
|
||||
.bl-root .top-profile{width: 54px;
|
||||
height: 54px;
|
||||
border: 2px solid var(--green);
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
background: #0b1a2c;
|
||||
overflow: hidden;}
|
||||
.bl-root .top-profile img{width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;}
|
||||
.bl-root .app-grid{height: calc(100% - 92px);
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr) 300px;
|
||||
gap: 22px;
|
||||
padding: 0 54px 38px;}
|
||||
.bl-root .side-nav{display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 8px;
|
||||
overflow: hidden;}
|
||||
.bl-root .nav-button{width: 64px;
|
||||
height: 58px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #52617a;
|
||||
background: #071b34;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
transition: color 0.15s ease, background 0.15s ease;}
|
||||
.bl-root .nav-button.active{color: var(--green);
|
||||
background: #092541;
|
||||
box-shadow: inset 3px 0 0 var(--green);}
|
||||
.bl-root .nav-icon{display: grid;
|
||||
place-items: center;
|
||||
min-height: 22px;
|
||||
font-size: 20px;
|
||||
line-height: 1;}
|
||||
.bl-root .nav-icon svg{width: 20px;
|
||||
height: 20px;
|
||||
display: block;}
|
||||
.bl-root .nav-label{font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
max-width: 58px;
|
||||
overflow-wrap: anywhere;}
|
||||
.bl-root .content,.bl-root .context{min-width: 0;
|
||||
overflow: auto;
|
||||
scrollbar-color: var(--green) transparent;
|
||||
scrollbar-width: thin;}
|
||||
.bl-root .content{padding: 36px 0 8px;}
|
||||
.bl-root .context{padding: 36px 0 8px;}
|
||||
.bl-root .section-title{display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;}
|
||||
.bl-root .section-title h1{margin: 0;
|
||||
font-size: 25px;
|
||||
line-height: 1.1;}
|
||||
.bl-root .section-title p{margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;}
|
||||
.bl-root .compose{display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);}
|
||||
.bl-root .avatar{width: 52px;
|
||||
height: 52px;
|
||||
border: 2px solid var(--green);
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: #12243a;}
|
||||
.bl-root .compose textarea{width: 100%;
|
||||
min-height: 74px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text);
|
||||
background: transparent;}
|
||||
.bl-root .compose-actions{display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;}
|
||||
.bl-root .upload-row{display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;}
|
||||
.bl-root .upload-button{border: 1px solid rgba(25, 216, 137, 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 8px 13px;
|
||||
color: var(--green);
|
||||
background: rgba(25, 216, 137, 0.08);
|
||||
font-size: 12px;
|
||||
font-weight: 900;}
|
||||
.bl-root .media-url-input{width: min(360px, 42vw);
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
outline: 0;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 12px;}
|
||||
.bl-root .url-upload-row{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;}
|
||||
.bl-root .toast-layer{position: absolute;
|
||||
right: 34px;
|
||||
bottom: 32px;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(360px, 30vw);
|
||||
pointer-events: none;}
|
||||
.bl-root .toast{padding: 13px 15px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
background: rgba(6, 19, 37, 0.96);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.32);
|
||||
font-size: 13px;
|
||||
font-weight: 800;}
|
||||
.bl-root .toast-error{border-color: rgba(255, 79, 95, 0.55);
|
||||
color: #ffd7dc;}
|
||||
.bl-root .toast-success{border-color: rgba(25, 216, 137, 0.55);
|
||||
color: #b8ffdf;}
|
||||
.bl-root .hint{color: var(--muted);
|
||||
font-size: 12px;}
|
||||
.bl-root .primary-button,.bl-root .ghost-button,.bl-root .danger-button{border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 900;}
|
||||
.bl-root .primary-button{border: 0;
|
||||
color: #001527;
|
||||
background: var(--green);}
|
||||
.bl-root .ghost-button{border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);}
|
||||
.bl-root .danger-text{border-color: rgba(255, 79, 95, 0.42);
|
||||
color: #ff6d7b;}
|
||||
.bl-root .danger-button{border: 1px solid rgba(255, 79, 95, 0.6);
|
||||
color: var(--red);
|
||||
background: rgba(255, 79, 95, 0.08);}
|
||||
.bl-root .feed-list{display: grid;
|
||||
gap: 18px;}
|
||||
.bl-root .post-card{overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);}
|
||||
.bl-root .post-head{display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px 18px;}
|
||||
.bl-root .author-link{display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: left;}
|
||||
.bl-root .author-name{display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 900;}
|
||||
.bl-root .handle{color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;}
|
||||
.bl-root .verified,.bl-root .staff-badge{display: inline-grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
color: #001527;
|
||||
background: var(--green);
|
||||
font-size: 13px;
|
||||
font-weight: 1000;}
|
||||
.bl-root .verified svg,.bl-root .staff-badge svg{width: 18px;
|
||||
height: 18px;}
|
||||
.bl-root .staff-badge{color: #fff;
|
||||
background: transparent;}
|
||||
.bl-root .verified + .staff-badge{margin-left: 4px;}
|
||||
.bl-root .post-time{color: var(--muted);
|
||||
font-size: 12px;}
|
||||
.bl-root .post-body{padding: 0 18px 16px 86px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;}
|
||||
.bl-root .post-image{display: block;
|
||||
width: 100%;
|
||||
max-height: 480px;
|
||||
object-fit: cover;
|
||||
background: #101a29;}
|
||||
.bl-root .post-actions{display: flex;
|
||||
gap: 22px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
color: var(--text);
|
||||
border-top: 1px solid var(--line);}
|
||||
.bl-root .action-link{border: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 400;}
|
||||
.bl-root .danger-text{color: var(--red);}
|
||||
.bl-root .icon-action{width: 28px;
|
||||
height: 28px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: transparent;}
|
||||
.bl-root .icon-action svg{width: 16px;
|
||||
height: 16px;
|
||||
display: block;}
|
||||
.bl-root .danger-icon{border: 1px solid rgba(255, 79, 95, 0.35);
|
||||
color: var(--red);}
|
||||
.bl-root .danger-icon:hover{background: rgba(255, 79, 95, 0.1);}
|
||||
.bl-root .comment-actions{display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;}
|
||||
.bl-root .comments-panel{display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 18px 86px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: rgba(0, 0, 0, 0.1);}
|
||||
.bl-root .comment-form{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;}
|
||||
.bl-root .comment-form input,.bl-root .market-search,.bl-root .market-sort,.bl-root .event-input{min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
outline: 0;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);}
|
||||
.bl-root .comment-item{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);}
|
||||
.bl-root .comment-item strong{display: block;
|
||||
margin-bottom: 3px;}
|
||||
.bl-root .market-tools{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 210px;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;}
|
||||
.bl-root .market-post{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 110px;
|
||||
gap: 12px;
|
||||
align-items: start;}
|
||||
.bl-root .market-compose-grid{display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 220px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;}
|
||||
.bl-root .price-input{border-color: rgba(25, 216, 137, 0.36);}
|
||||
.bl-root .market-author{display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;}
|
||||
.bl-root .market-avatar-link{width: 42px;
|
||||
height: 42px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
background: transparent;}
|
||||
.bl-root .market-avatar-link img,.bl-root .market-avatar-link span,.bl-root .market-author > img,.bl-root .market-author > span{width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid var(--green);
|
||||
background: #102036;
|
||||
font-weight: 900;}
|
||||
.bl-root .market-author p{margin: 2px 0 0;}
|
||||
.bl-root .market-author-name{display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;}
|
||||
.bl-root .mail-icon-button{width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(25, 216, 137, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: var(--green);
|
||||
background: rgba(25, 216, 137, 0.08);}
|
||||
.bl-root .mail-icon-button svg{width: 15px;
|
||||
height: 15px;}
|
||||
.bl-root .market-image{width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;}
|
||||
.bl-root .market-side{display: grid;
|
||||
justify-items: end;
|
||||
align-content: start;
|
||||
gap: 12px;}
|
||||
.bl-root .price-tag{justify-self: end;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
color: #001527;
|
||||
background: var(--green);
|
||||
font-weight: 900;}
|
||||
.bl-root .calendar-list{display: grid;
|
||||
gap: 10px;}
|
||||
.bl-root .day-card{border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
text-align: left;
|
||||
cursor: pointer;}
|
||||
.bl-root .day-card.active{border-color: rgba(25, 216, 137, 0.72);
|
||||
box-shadow: inset 3px 0 0 var(--green);}
|
||||
.bl-root .day-title{font-size: 18px;
|
||||
font-weight: 900;}
|
||||
.bl-root .event-line{margin-top: 5px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;}
|
||||
.bl-root .event-editor{display: grid;
|
||||
gap: 12px;}
|
||||
.bl-root .event-editor-head{display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-weight: 900;}
|
||||
.bl-root .legal-text{max-width: 980px;
|
||||
white-space: pre-line;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;}
|
||||
.bl-root .profile-editor{max-width: 820px;}
|
||||
.bl-root .edit-form{display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);}
|
||||
.bl-root .edit-form label,.bl-root .event-editor label{display: grid;
|
||||
gap: 7px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 900;}
|
||||
.bl-root .edit-textarea{min-height: 120px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
outline: 0;
|
||||
padding: 11px 12px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.04);}
|
||||
.bl-root .edit-actions{display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;}
|
||||
.bl-root .page-grid{display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 22px;}
|
||||
.bl-root .business-card{position: relative;
|
||||
width: 100%;
|
||||
min-height: 114px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
text-align: left;}
|
||||
.bl-root .business-card.closed{filter: grayscale(0.8);
|
||||
opacity: 0.62;}
|
||||
.bl-root .business-banner{position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;}
|
||||
.bl-root .business-overlay{position: relative;
|
||||
min-height: 114px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(90deg, rgba(2, 4, 20, 0.16), rgba(2, 4, 20, 0.48));}
|
||||
.bl-root .business-avatar{width: 78px;
|
||||
height: 78px;
|
||||
border: 4px solid var(--green);
|
||||
border-radius: 999px;
|
||||
object-fit: cover;}
|
||||
.bl-root .business-label{max-width: 74%;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
background: rgba(3, 12, 28, 0.94);}
|
||||
.bl-root .business-label strong{display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 23px;
|
||||
line-height: 1.05;}
|
||||
.bl-root .status-strip{height: 14px;
|
||||
margin-bottom: 22px;
|
||||
border-radius: 0 0 999px 999px;
|
||||
background: var(--green);}
|
||||
.bl-root .status-strip.closed{background: #586477;}
|
||||
.bl-root .profile-hero{position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 310px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);}
|
||||
.bl-root .comment-author-link{border: 0;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
font-weight: 900;}
|
||||
.bl-root .profile-banner{width: 100%;
|
||||
height: 210px;
|
||||
object-fit: cover;
|
||||
display: block;}
|
||||
.bl-root .profile-banner-empty{background: linear-gradient(135deg, #082443, #0e332f);}
|
||||
.bl-root .profile-meta{display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
padding: 0 24px 24px;
|
||||
margin-top: -58px;}
|
||||
.bl-root .profile-avatar{width: 128px;
|
||||
height: 128px;
|
||||
border: 4px solid var(--green);
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
background: #102036;}
|
||||
.bl-root .profile-avatar-empty{display: grid;
|
||||
place-items: center;
|
||||
color: var(--green);
|
||||
font-size: 42px;
|
||||
font-weight: 900;}
|
||||
.bl-root .profile-actions{display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;}
|
||||
.bl-root .moderation-actions{display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;}
|
||||
.bl-root .moderation-icon-button{width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(25, 216, 137, 0.35);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: var(--green);
|
||||
background: rgba(25, 216, 137, 0.06);}
|
||||
.bl-root .moderation-icon-button svg{width: 18px;
|
||||
height: 18px;}
|
||||
.bl-root .moderation-icon-button.active{color: #001527;
|
||||
background: var(--green);}
|
||||
.bl-root .moderation-icon-button.danger{border-color: rgba(255, 79, 95, 0.42);
|
||||
color: var(--red);
|
||||
background: rgba(255, 79, 95, 0.08);}
|
||||
.bl-root .moderation-icon-button.danger.active{color: #fff;
|
||||
background: rgba(255, 79, 95, 0.92);}
|
||||
.bl-root .profile-mail-button{width: 40px;
|
||||
height: 40px;}
|
||||
.bl-root .profile-mail-button svg{width: 18px;
|
||||
height: 18px;}
|
||||
.bl-root .profile-meta h1{display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 32px;}
|
||||
.bl-root .profile-meta p{margin: 8px 0 0;
|
||||
color: var(--muted);}
|
||||
.bl-root .profile-stats{display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;}
|
||||
.bl-root .profile-stats button{position: relative;
|
||||
min-width: 48px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
color: var(--green);
|
||||
background: rgba(255, 255, 255, 0.035);}
|
||||
.bl-root .profile-stats button::after{content: attr(title);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 7px);
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
color: var(--text);
|
||||
background: #051426;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.12s ease;}
|
||||
.bl-root .profile-stats button:hover::after{opacity: 1;}
|
||||
.bl-root .profile-stats svg{width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;}
|
||||
.bl-root .profile-stats strong{color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;}
|
||||
.bl-root .profile-content-section{scroll-margin-top: 18px;}
|
||||
.bl-root .profile-content-section{display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;}
|
||||
.bl-root .profile-content-section h2{margin: 0;
|
||||
font-size: 18px;}
|
||||
.bl-root .profile-event-card h3{margin-bottom: 8px;}
|
||||
.bl-root .simple-list{display: grid;
|
||||
gap: 12px;}
|
||||
.bl-root .list-card,.bl-root .context-card{padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);}
|
||||
.bl-root .list-card h3,.bl-root .context-card h3{margin: 0 0 6px;
|
||||
font-size: 15px;}
|
||||
.bl-root .list-card p,.bl-root .context-card p{margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;}
|
||||
.bl-root .context-title{display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;}
|
||||
.bl-root .context-stack{display: grid;
|
||||
gap: 12px;}
|
||||
.bl-root .suggestion{display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;}
|
||||
.bl-root .suggestion-avatar{width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
background: #8b97a8;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;}
|
||||
.bl-root .mini-button{border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 9px 15px;
|
||||
color: #001527;
|
||||
background: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 900;}
|
||||
.bl-root .social-section{display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;}
|
||||
.bl-root .social-section h2{margin: 0;
|
||||
font-size: 16px;}
|
||||
.bl-root .social-row{display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;}
|
||||
.bl-root .social-actions{display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;}
|
||||
.bl-root .protected-pill{border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;}
|
||||
@media (max-width: 1280px) {.bl-root .tablet{min-height: 640px;}
|
||||
.bl-root .app-grid{grid-template-columns: 78px minmax(0, 1fr) 260px;
|
||||
gap: 16px;
|
||||
padding-inline: 36px;}
|
||||
.bl-root .topbar{padding-inline: 36px;}}
|
||||
116
nui/css/erzeuge-bleeter-css.py
Normal file
116
nui/css/erzeuge-bleeter-css.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Kapselt bleeter/html/style.css fuer die Einbettung in pc-live.
|
||||
|
||||
Warum nicht einfach einbinden: die Datei setzt Regeln auf *, html und body.
|
||||
Die wuerden den gesamten PC einfaerben – Schrift, Hintergrund, alles. Also
|
||||
bekommt jeder Selektor ein .bl-root davor, und die globalen Regeln werden
|
||||
umgeschrieben statt uebernommen.
|
||||
|
||||
Rein mechanisch, damit es sich wiederholen laesst, wenn bleeter sein Aussehen
|
||||
aendert: python3 nui/css/erzeuge-bleeter-css.py
|
||||
"""
|
||||
import io, re
|
||||
|
||||
import os
|
||||
# Aus dem Verzeichnis dieser Datei heraus aufrufbar:
|
||||
# python3 nui/css/erzeuge-bleeter-css.py
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(HERE, '..', '..', '..', 'bleeter', 'html', 'style.css')
|
||||
DST = os.path.join(HERE, 'bleeter-embedded.css')
|
||||
SCOPE = '.bl-root'
|
||||
|
||||
|
||||
# Vom Wurzelelement uebernehmen wir Farben, Schrift und Variablen – aber kein
|
||||
# Layout. Im Original ist .bleeter-root ein bildschirmfuellendes Overlay
|
||||
# (position:fixed; inset:0). Uebernaehme man das, laege die App ueber dem
|
||||
# gesamten PC, inklusive Titelleiste – man kaeme nicht mehr heraus.
|
||||
ROOT_SELECTORS = (':root', 'html', 'body')
|
||||
ROOT_KEEP = ('color', 'font-family', 'font-size', 'font-weight',
|
||||
'letter-spacing', 'line-height')
|
||||
|
||||
|
||||
def is_root_selector(sel):
|
||||
return sel in ROOT_SELECTORS or sel.startswith('.bleeter-root')
|
||||
|
||||
|
||||
def filter_root_declarations(body):
|
||||
"""Nur Variablen und Schrift behalten, alles Raeumliche verwerfen."""
|
||||
kept = []
|
||||
for decl in body.split(';'):
|
||||
decl = decl.strip()
|
||||
if not decl:
|
||||
continue
|
||||
prop = decl.split(':', 1)[0].strip().lower()
|
||||
if prop.startswith('--') or prop in ROOT_KEEP:
|
||||
kept.append(decl)
|
||||
return ';'.join(kept)
|
||||
|
||||
|
||||
def scope_selector(sel):
|
||||
sel = sel.strip()
|
||||
if not sel:
|
||||
return sel
|
||||
if is_root_selector(sel):
|
||||
return SCOPE
|
||||
if sel == '*':
|
||||
return SCOPE + ' *'
|
||||
return SCOPE + ' ' + sel
|
||||
|
||||
|
||||
def rule(selector_list, body):
|
||||
sels_raw = [s.strip() for s in selector_list.split(',')]
|
||||
if any(is_root_selector(s) for s in sels_raw):
|
||||
body = filter_root_declarations(body)
|
||||
if not body:
|
||||
return None
|
||||
sels = ','.join(scope_selector(s) for s in sels_raw)
|
||||
return sels + '{' + body.strip() + '}'
|
||||
|
||||
|
||||
def scope_block(block):
|
||||
parts = []
|
||||
for m in re.finditer(r'([^{}]+)\{([^{}]*)\}', block, flags=re.S):
|
||||
r = rule(m.group(1), m.group(2))
|
||||
if r:
|
||||
parts.append(r)
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
css = re.sub(r'/\*.*?\*/', '', io.open(SRC, encoding='utf-8').read(), flags=re.S)
|
||||
|
||||
out, i, n = [], 0, len(css)
|
||||
while i < n:
|
||||
m = re.match(r'\s*@(media|supports|keyframes|-webkit-keyframes)([^{]*)\{', css[i:])
|
||||
if m:
|
||||
depth, j = 1, i + m.end()
|
||||
while j < n and depth:
|
||||
if css[j] == '{':
|
||||
depth += 1
|
||||
elif css[j] == '}':
|
||||
depth -= 1
|
||||
j += 1
|
||||
inner = css[i + m.end(): j - 1]
|
||||
head = '@' + m.group(1) + m.group(2) + '{'
|
||||
# Prozentschritte in keyframes sind keine Selektoren
|
||||
out.append(head + (inner if 'keyframes' in m.group(1) else scope_block(inner)) + '}')
|
||||
i = j
|
||||
continue
|
||||
|
||||
m = re.match(r'([^{}]+)\{([^{}]*)\}', css[i:], flags=re.S)
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
r = rule(m.group(1), m.group(2))
|
||||
if r:
|
||||
out.append(r)
|
||||
i += m.end()
|
||||
|
||||
header = (
|
||||
'/* Erzeugt aus bleeter/html/style.css – nicht von Hand aendern.\n'
|
||||
' Jeder Selektor ist auf .bl-root gekapselt, damit Bleeters Farben und\n'
|
||||
' Schrift nicht den uebrigen PC einfaerben. */\n'
|
||||
)
|
||||
|
||||
io.open(DST, 'w', encoding='utf-8').write(header + '\n'.join(out) + '\n')
|
||||
print(f'{len(out)} Regeln -> {DST}')
|
||||
463
nui/css/rp-ds.css
Normal file
463
nui/css/rp-ds.css
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
RP Design System – Unified Component Library
|
||||
Additive: existierende Stile werden nicht überschrieben.
|
||||
Alle Klassen tragen das Präfix .ds- um Konflikte zu vermeiden.
|
||||
═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Design Tokens (Canonical Variables) ────────────────────────────────── */
|
||||
:root {
|
||||
/* Brand */
|
||||
--ds-accent: #1a3d6e;
|
||||
--ds-accent-light: #2461b0;
|
||||
--ds-accent-hover: #0f2847;
|
||||
--ds-accent-pale: #e8f0fc;
|
||||
|
||||
/* Status */
|
||||
--ds-danger: #b22222;
|
||||
--ds-danger-hover: #8b0000;
|
||||
--ds-danger-pale: #fdecea;
|
||||
--ds-success: #2e7d32;
|
||||
--ds-success-pale: #e8f5e9;
|
||||
--ds-warning: #c47800;
|
||||
--ds-warning-pale: #fff8e1;
|
||||
--ds-info: #0277bd;
|
||||
--ds-info-pale: #e1f0fa;
|
||||
|
||||
/* Surfaces */
|
||||
--ds-bg-app: #eaecef;
|
||||
--ds-bg-sidebar: #1e2a3a;
|
||||
--ds-bg-header: #162032;
|
||||
--ds-bg-card: #ffffff;
|
||||
--ds-bg-card-alt: #f4f5f7;
|
||||
--ds-bg-input: #ffffff;
|
||||
--ds-bg-modal: #ffffff;
|
||||
--ds-bg-overlay: rgba(0,0,0,0.55);
|
||||
--ds-bg-tbl-hd: #2c3e55;
|
||||
--ds-bg-tbl-alt: #f8f9fb;
|
||||
|
||||
/* Text */
|
||||
--ds-text: #1a1f2e;
|
||||
--ds-text-sub: #4a5568;
|
||||
--ds-text-muted: #7a8494;
|
||||
--ds-text-light: #f0f2f5;
|
||||
|
||||
/* Borders */
|
||||
--ds-border: #c8cdd6;
|
||||
--ds-border-dark: #9aa3b0;
|
||||
|
||||
/* Radii */
|
||||
--ds-r-sm: 3px;
|
||||
--ds-r: 4px;
|
||||
--ds-r-md: 6px;
|
||||
--ds-r-lg: 10px;
|
||||
|
||||
/* Shadows */
|
||||
--ds-shadow-sm: 0 1px 3px rgba(0,0,0,.12);
|
||||
--ds-shadow: 0 2px 8px rgba(0,0,0,.15);
|
||||
--ds-shadow-lg: 0 4px 20px rgba(0,0,0,.2);
|
||||
|
||||
/* Typography */
|
||||
--ds-font: 'Segoe UI', Arial, sans-serif;
|
||||
--ds-font-mono: 'Consolas', 'Courier New', monospace;
|
||||
--ds-text-xs: 11px;
|
||||
--ds-text-sm: 12px;
|
||||
--ds-text-base: 13px;
|
||||
--ds-text-lg: 15px;
|
||||
}
|
||||
|
||||
/* ── Utility: Text ──────────────────────────────────────────────────────── */
|
||||
.ds-text-muted { color: var(--ds-text-muted); }
|
||||
.ds-text-sub { color: var(--ds-text-sub); }
|
||||
.ds-text-accent { color: var(--ds-accent-light); }
|
||||
.ds-text-danger { color: var(--ds-danger); }
|
||||
.ds-text-success { color: var(--ds-success); }
|
||||
.ds-text-sm { font-size: var(--ds-text-sm); }
|
||||
.ds-text-xs { font-size: var(--ds-text-xs); }
|
||||
.ds-mono { font-family: var(--ds-font-mono); }
|
||||
.ds-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────────────────── */
|
||||
.ds-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: var(--ds-r);
|
||||
font-family: var(--ds-font);
|
||||
font-size: var(--ds-text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, opacity .15s;
|
||||
text-decoration: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.ds-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
.ds-btn-primary {
|
||||
background: var(--ds-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.ds-btn-primary:hover:not(:disabled) { background: var(--ds-accent-hover); }
|
||||
|
||||
.ds-btn-secondary {
|
||||
background: var(--ds-bg-card-alt);
|
||||
color: var(--ds-text);
|
||||
border: 1px solid var(--ds-border);
|
||||
}
|
||||
.ds-btn-secondary:hover:not(:disabled) { background: var(--ds-border); }
|
||||
|
||||
.ds-btn-danger {
|
||||
background: var(--ds-danger);
|
||||
color: #fff;
|
||||
}
|
||||
.ds-btn-danger:hover:not(:disabled) { background: var(--ds-danger-hover); }
|
||||
|
||||
.ds-btn-success {
|
||||
background: var(--ds-success);
|
||||
color: #fff;
|
||||
}
|
||||
.ds-btn-success:hover:not(:disabled) { background: #1b5e20; }
|
||||
|
||||
.ds-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ds-accent-light);
|
||||
border: 1px solid var(--ds-accent-light);
|
||||
}
|
||||
.ds-btn-ghost:hover:not(:disabled) { background: var(--ds-accent-pale); }
|
||||
|
||||
.ds-btn-sm { padding: 3px 8px; font-size: var(--ds-text-xs); }
|
||||
.ds-btn-lg { padding: 8px 18px; font-size: var(--ds-text-base); }
|
||||
.ds-btn-icon { padding: 5px 7px; }
|
||||
|
||||
/* ── Inputs ─────────────────────────────────────────────────────────────── */
|
||||
.ds-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 5px 9px;
|
||||
background: var(--ds-bg-input);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--ds-r);
|
||||
font-family: var(--ds-font);
|
||||
font-size: var(--ds-text-sm);
|
||||
color: var(--ds-text);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
outline: none;
|
||||
}
|
||||
.ds-input:focus {
|
||||
border-color: var(--ds-accent-light);
|
||||
box-shadow: 0 0 0 2px rgba(36,97,176,.2);
|
||||
}
|
||||
.ds-input::placeholder { color: var(--ds-text-muted); }
|
||||
.ds-input-sm { padding: 3px 7px; font-size: var(--ds-text-xs); }
|
||||
|
||||
.ds-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='%237a8494'%3E%3Cpath d='M6 8 0 0h12z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
padding-right: 26px;
|
||||
}
|
||||
|
||||
.ds-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* ── Form Group ─────────────────────────────────────────────────────────── */
|
||||
.ds-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ds-label {
|
||||
font-size: var(--ds-text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--ds-text-sub);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
/* ── Search Bar ─────────────────────────────────────────────────────────── */
|
||||
.ds-search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.ds-search-wrap i {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--ds-text-muted);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ds-search-wrap .ds-input {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
/* ── Table ──────────────────────────────────────────────────────────────── */
|
||||
.ds-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--ds-text-sm);
|
||||
color: var(--ds-text);
|
||||
}
|
||||
.ds-table th {
|
||||
background: var(--ds-bg-tbl-hd);
|
||||
color: #fff;
|
||||
font-size: var(--ds-text-xs);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
.ds-table td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--ds-border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ds-table tbody tr:nth-child(even) { background: var(--ds-bg-tbl-alt); }
|
||||
.ds-table tbody tr:hover { background: var(--ds-accent-pale); }
|
||||
.ds-table-sm th, .ds-table-sm td { padding: 4px 8px; }
|
||||
|
||||
/* ── Badge / Chip ───────────────────────────────────────────────────────── */
|
||||
.ds-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 20px;
|
||||
font-size: var(--ds-text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ds-badge-primary { background: var(--ds-accent-pale); color: var(--ds-accent); }
|
||||
.ds-badge-danger { background: var(--ds-danger-pale); color: var(--ds-danger); }
|
||||
.ds-badge-success { background: var(--ds-success-pale); color: var(--ds-success); }
|
||||
.ds-badge-warning { background: var(--ds-warning-pale); color: var(--ds-warning); }
|
||||
.ds-badge-info { background: var(--ds-info-pale); color: var(--ds-info); }
|
||||
.ds-badge-neutral { background: var(--ds-bg-card-alt); color: var(--ds-text-sub); border: 1px solid var(--ds-border); }
|
||||
|
||||
/* ── Alert / Notice ─────────────────────────────────────────────────────── */
|
||||
.ds-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--ds-r-md);
|
||||
font-size: var(--ds-text-sm);
|
||||
border-left: 3px solid currentColor;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ds-alert i { margin-top: 1px; flex-shrink: 0; }
|
||||
.ds-alert-danger { background: var(--ds-danger-pale); color: var(--ds-danger); }
|
||||
.ds-alert-success { background: var(--ds-success-pale); color: var(--ds-success); }
|
||||
.ds-alert-warning { background: var(--ds-warning-pale); color: var(--ds-warning); }
|
||||
.ds-alert-info { background: var(--ds-info-pale); color: var(--ds-info); }
|
||||
|
||||
/* ── Modal ──────────────────────────────────────────────────────────────── */
|
||||
.ds-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--ds-bg-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.ds-modal-overlay.hidden { display: none; }
|
||||
.ds-modal {
|
||||
background: var(--ds-bg-modal);
|
||||
border-radius: var(--ds-r-lg);
|
||||
box-shadow: var(--ds-shadow-lg);
|
||||
min-width: 320px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ds-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--ds-bg-sidebar);
|
||||
color: var(--ds-text-light);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ds-modal-title { font-size: var(--ds-text-base); font-weight: 600; }
|
||||
.ds-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ds-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--ds-r);
|
||||
}
|
||||
.ds-modal-close:hover { color: #fff; }
|
||||
.ds-modal-body { padding: 16px; overflow-y: auto; flex: 1; }
|
||||
.ds-modal-footer { padding: 10px 16px; border-top: 1px solid var(--ds-border); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* ── Confirm Dialog ─────────────────────────────────────────────────────── */
|
||||
.ds-confirm {
|
||||
max-width: 380px;
|
||||
text-align: center;
|
||||
}
|
||||
.ds-confirm .ds-modal-body { padding: 24px 20px 12px; }
|
||||
.ds-confirm p { color: var(--ds-text-sub); margin-top: 8px; font-size: var(--ds-text-sm); }
|
||||
|
||||
/* ── Loading ────────────────────────────────────────────────────────────── */
|
||||
.ds-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
gap: 10px;
|
||||
color: var(--ds-text-muted);
|
||||
font-size: var(--ds-text-sm);
|
||||
}
|
||||
.ds-spinner {
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid var(--ds-border);
|
||||
border-top-color: var(--ds-accent-light);
|
||||
border-radius: 50%;
|
||||
animation: ds-spin .7s linear infinite;
|
||||
}
|
||||
@keyframes ds-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Empty State ────────────────────────────────────────────────────────── */
|
||||
.ds-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 8px;
|
||||
color: var(--ds-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.ds-empty i { font-size: 32px; opacity: .4; }
|
||||
.ds-empty-title { font-size: var(--ds-text-base); font-weight: 600; }
|
||||
.ds-empty-sub { font-size: var(--ds-text-sm); }
|
||||
|
||||
/* ── Audit Hinweis ──────────────────────────────────────────────────────── */
|
||||
.ds-audit-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(194,120,0,.08);
|
||||
border: 1px solid rgba(194,120,0,.25);
|
||||
border-radius: var(--ds-r);
|
||||
font-size: var(--ds-text-xs);
|
||||
color: var(--ds-warning);
|
||||
}
|
||||
.ds-audit-notice i { flex-shrink: 0; }
|
||||
|
||||
/* ── Filter / Toolbar ───────────────────────────────────────────────────── */
|
||||
.ds-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--ds-bg-card-alt);
|
||||
border-bottom: 1px solid var(--ds-border);
|
||||
}
|
||||
.ds-toolbar-spacer { flex: 1; }
|
||||
|
||||
/* ── Pagination ─────────────────────────────────────────────────────────── */
|
||||
.ds-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
font-size: var(--ds-text-sm);
|
||||
color: var(--ds-text-sub);
|
||||
}
|
||||
.ds-page-btn {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--ds-r);
|
||||
background: var(--ds-bg-card);
|
||||
color: var(--ds-text);
|
||||
cursor: pointer;
|
||||
font-size: var(--ds-text-xs);
|
||||
transition: background .1s;
|
||||
}
|
||||
.ds-page-btn:hover:not(:disabled) { background: var(--ds-accent-pale); border-color: var(--ds-accent-light); }
|
||||
.ds-page-btn:disabled { opacity: .4; cursor: default; }
|
||||
.ds-page-btn.active { background: var(--ds-accent); color: #fff; border-color: var(--ds-accent); }
|
||||
|
||||
/* ── Section Divider ────────────────────────────────────────────────────── */
|
||||
.ds-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--ds-border);
|
||||
margin: 10px 0;
|
||||
}
|
||||
.ds-section-title {
|
||||
font-size: var(--ds-text-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .6px;
|
||||
color: var(--ds-text-muted);
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
/* ── Status Dot ─────────────────────────────────────────────────────────── */
|
||||
.ds-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ds-status-dot.online { background: var(--ds-success); }
|
||||
.ds-status-dot.offline { background: var(--ds-text-muted); }
|
||||
.ds-status-dot.busy { background: var(--ds-warning); }
|
||||
|
||||
/* ── Card ───────────────────────────────────────────────────────────────── */
|
||||
.ds-card {
|
||||
background: var(--ds-bg-card);
|
||||
border: 1px solid var(--ds-border);
|
||||
border-radius: var(--ds-r-md);
|
||||
box-shadow: var(--ds-shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ds-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--ds-border);
|
||||
background: var(--ds-bg-card-alt);
|
||||
}
|
||||
.ds-card-title { font-size: var(--ds-text-base); font-weight: 600; }
|
||||
.ds-card-body { padding: 14px; }
|
||||
|
||||
/* ── Flex Utilities ─────────────────────────────────────────────────────── */
|
||||
.ds-flex { display: flex; }
|
||||
.ds-flex-col { display: flex; flex-direction: column; }
|
||||
.ds-items-center { align-items: center; }
|
||||
.ds-justify-between { justify-content: space-between; }
|
||||
.ds-gap-1 { gap: 4px; }
|
||||
.ds-gap-2 { gap: 8px; }
|
||||
.ds-gap-3 { gap: 12px; }
|
||||
.ds-gap-4 { gap: 16px; }
|
||||
.ds-flex-1 { flex: 1; }
|
||||
.ds-mt-1 { margin-top: 4px; }
|
||||
.ds-mt-2 { margin-top: 8px; }
|
||||
.ds-mb-1 { margin-bottom: 4px; }
|
||||
.ds-mb-2 { margin-bottom: 8px; }
|
||||
.ds-p-2 { padding: 8px; }
|
||||
.ds-hidden { display: none; }
|
||||
.ds-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
856
nui/css/style.css
Normal file
856
nui/css/style.css
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
/* ─────────────────────────────────────────────────────────
|
||||
pc-live | Dark Modern Desktop Theme
|
||||
───────────────────────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--pc-vp: 88; /* viewport-% used for width & height */
|
||||
--pc-fs: 13; /* base font size in px (controlled by settings) */
|
||||
--accent: #00aaff;
|
||||
--bg-desktop: #0d0f14;
|
||||
--bg-window: #13161e;
|
||||
--bg-sidebar: #0f1117;
|
||||
--bg-input: #1a1d27;
|
||||
--bg-hover: #1e2230;
|
||||
--titlebar: #0a0c12;
|
||||
--border: #222637;
|
||||
--text: #d4d8e8;
|
||||
--text-muted: #6b7394;
|
||||
--text-dim: #3d4260;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 8px 32px rgba(0,0,0,0.6);
|
||||
--font: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: calc(var(--pc-fs) * 1px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── SETTINGS PANEL ─────────────────────────────────────── */
|
||||
#pc-settings-panel {
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
right: 8px;
|
||||
z-index: 9998;
|
||||
background: var(--titlebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 14px 16px;
|
||||
min-width: 220px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
#pc-settings-panel.open { display: flex; }
|
||||
.settings-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.settings-group-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-value {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
min-width: 46px;
|
||||
}
|
||||
.settings-btn {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.settings-btn:hover { background: var(--border); opacity: 1; }
|
||||
.settings-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.settings-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
#pc-settings-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#pc-settings-btn:hover { color: var(--text); background: var(--bg-hover); opacity: 1; }
|
||||
#pc-settings-btn.active { color: var(--accent); background: rgba(0,170,255,0.1); }
|
||||
|
||||
/* ── APP ROOT ────────────────────────────────────────────── */
|
||||
#app { display: none; width: 100%; height: 100%; position: relative; }
|
||||
#app.visible { display: block; }
|
||||
|
||||
/* ── DIM OVERLAY (Spielwelt bleibt sichtbar) ─────────────── */
|
||||
#dim-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.52);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── PC CONTAINER (schwebendes Panel) ────────────────────── */
|
||||
#pc-container {
|
||||
position: fixed;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* width/height driven by --pc-vp (set via JS, default 88%) */
|
||||
width: calc(var(--pc-vp, 88) * 1vw);
|
||||
height: calc(var(--pc-vp, 88) * 1vh);
|
||||
background: var(--bg-desktop);
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 30%, rgba(0,170,255,0.05) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 80%, rgba(0,170,255,0.03) 0%, transparent 50%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* minimized: nur Titelleiste sichtbar */
|
||||
#pc-container.minimized { height: auto; min-height: 0; }
|
||||
#pc-container.minimized #desktop { display: none !important; }
|
||||
#pc-container.minimized #pc-titlebar { border-radius: 10px; border-bottom-color: transparent; }
|
||||
|
||||
/* ── PC TITELLEISTE ──────────────────────────────────────── */
|
||||
#pc-titlebar {
|
||||
height: 38px;
|
||||
background: var(--titlebar);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
.pc-title-icon { font-size: 14px; pointer-events: none; }
|
||||
#pc-label {
|
||||
flex: 1;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pc-title-controls { display: flex; gap: 5px; }
|
||||
|
||||
#pc-minimize-btn,
|
||||
#close-pc-btn {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
transition: opacity 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#pc-minimize-btn:hover, #close-pc-btn:hover { opacity: 0.75; }
|
||||
#pc-minimize-btn { background: #f59e0b; color: #000; }
|
||||
#close-pc-btn { background: #ef4444; color: #fff; }
|
||||
|
||||
/* ── BOOT SCREEN ────────────────────────────────────────── */
|
||||
#boot-screen {
|
||||
position: absolute; inset: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
#boot-screen.fade-out { opacity: 0; pointer-events: none; }
|
||||
|
||||
.boot-logo {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.boot-bar {
|
||||
width: 280px; height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boot-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 99px;
|
||||
width: 0%;
|
||||
transition: width linear;
|
||||
}
|
||||
.boot-version {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.15em;
|
||||
}
|
||||
|
||||
/* ── DESKTOP ────────────────────────────────────────────── */
|
||||
#desktop {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
#desktop.visible { display: block; }
|
||||
|
||||
/* ── DESKTOP ICONS ─────────────────────────────────────── */
|
||||
#icon-grid {
|
||||
position: absolute;
|
||||
top: 20px; left: 20px; right: 20px; bottom: 64px; /* Platz für Taskbar */
|
||||
display: flex;
|
||||
/* Icons stapeln sich in Spalten und brechen in neue Spalten um,
|
||||
sobald die Desktop-Höhe nicht mehr reicht (responsiv bei kleinem Desktop). */
|
||||
flex-flow: column wrap;
|
||||
align-content: flex-start;
|
||||
gap: 18px 12px;
|
||||
pointer-events: none; /* nur die Icons selbst klickbar, nicht die Lücken */
|
||||
}
|
||||
#icon-grid > .desktop-icon { pointer-events: auto; }
|
||||
.desktop-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 72px;
|
||||
cursor: pointer;
|
||||
padding: 8px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.desktop-icon:hover { background: rgba(255,255,255,0.06); }
|
||||
.desktop-icon:active { background: rgba(255,255,255,0.1); }
|
||||
.icon-glyph {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-window);
|
||||
border: 1px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
}
|
||||
.icon-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── TASKBAR ────────────────────────────────────────────── */
|
||||
#taskbar {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 40px;
|
||||
background: rgba(10,12,18,0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
#taskbar-apps {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.taskbar-btn {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
transition: background 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.taskbar-btn:hover { background: var(--border); }
|
||||
.taskbar-btn.active { border-color: var(--accent); color: var(--accent); }
|
||||
.taskbar-btn.minimized { opacity: 0.55; }
|
||||
|
||||
#taskbar-right {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
#clock { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── WINDOW MANAGER ─────────────────────────────────────── */
|
||||
#window-layer {
|
||||
position: absolute;
|
||||
inset: 0; bottom: 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.window {
|
||||
position: absolute;
|
||||
min-width: 400px;
|
||||
min-height: 300px;
|
||||
background: var(--bg-window);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
}
|
||||
.window.minimized { display: none; }
|
||||
.window.focused { border-color: rgba(0,170,255,0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(0,170,255,0.15); }
|
||||
|
||||
.window-titlebar {
|
||||
height: 34px;
|
||||
background: var(--titlebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
cursor: move;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.window-title-icon { font-size: 14px; }
|
||||
.window-title-text { flex: 1; font-size: 0.75rem; font-weight: 600; color: var(--text-muted); }
|
||||
.window-controls { display: flex; gap: 5px; }
|
||||
.wc-btn {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.wc-btn:hover { opacity: 0.8; }
|
||||
.wc-minimize { background: #f59e0b; color: #000; }
|
||||
.wc-maximize { background: #22c55e; color: #000; font-size: 11px; }
|
||||
.wc-close { background: #ef4444; color: #fff; }
|
||||
|
||||
/* ── Resize handles ─────────────────────────────────────── */
|
||||
.wc-resize { position: absolute; z-index: 10; }
|
||||
.wc-resize-n { top: 0; left: 6px; right: 6px; height: 5px; cursor: n-resize; }
|
||||
.wc-resize-s { bottom: 0; left: 6px; right: 6px; height: 5px; cursor: s-resize; }
|
||||
.wc-resize-e { right: 0; top: 6px; bottom: 6px; width: 5px; cursor: e-resize; }
|
||||
.wc-resize-w { left: 0; top: 6px; bottom: 6px; width: 5px; cursor: w-resize; }
|
||||
.wc-resize-nw { top: 0; left: 0; width: 10px; height: 10px; cursor: nw-resize; }
|
||||
.wc-resize-ne { top: 0; right: 0; width: 10px; height: 10px; cursor: ne-resize; }
|
||||
.wc-resize-sw { bottom: 0; left: 0; width: 10px; height: 10px; cursor: sw-resize; }
|
||||
.wc-resize-se { bottom: 0; right: 0; width: 10px; height: 10px; cursor: se-resize; }
|
||||
|
||||
/* maximized: no border-radius, fills window-layer */
|
||||
.window.maximized { border-radius: 0; }
|
||||
|
||||
.window-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── SCROLLBAR ──────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 99px; }
|
||||
|
||||
/* ── COMMON LAYOUT ─────────────────────────────────────── */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.app-sidebar {
|
||||
width: 200px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-item {
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
border-radius: 0;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.sidebar-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.sidebar-item.active { background: rgba(0,170,255,0.12); color: var(--accent); }
|
||||
.sidebar-section {
|
||||
padding: 12px 14px 4px;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── FORM ELEMENTS ─────────────────────────────────────── */
|
||||
input, textarea, select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.82rem;
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 3px;
|
||||
display: block;
|
||||
}
|
||||
.form-group { display: flex; flex-direction: column; gap: 3px; }
|
||||
.form-row { display: flex; gap: 8px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
padding: 7px 14px;
|
||||
transition: opacity 0.12s, background 0.12s;
|
||||
}
|
||||
button:hover { opacity: 0.85; }
|
||||
.btn-primary { background: var(--accent); color: #000; font-weight: 600; }
|
||||
.btn-ghost { background: var(--bg-hover); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-danger { background: rgba(239,68,68,0.2); color: var(--error); border: 1px solid rgba(239,68,68,0.3); }
|
||||
.btn-success { background: rgba(34,197,94,0.2); color: var(--success); border: 1px solid rgba(34,197,94,0.3); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 0.7rem; }
|
||||
|
||||
/* ── TAGS / BADGES ─────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 18px; height: 18px;
|
||||
border-radius: 99px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
.badge-accent { background: var(--accent); color: #000; }
|
||||
.badge-success { background: var(--success); color: #000; }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.tag-accent { background: rgba(0,170,255,0.12); color: var(--accent); border-color: rgba(0,170,255,0.25); }
|
||||
|
||||
/* ── DIVIDER ─────────────────────────────────────────────── */
|
||||
.divider { height: 1px; background: var(--border); margin: 4px 0; }
|
||||
|
||||
/* ── EMPTY STATE ─────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state .empty-icon { font-size: 2.5rem; opacity: 0.4; }
|
||||
|
||||
/* ── MAIL SPECIFIC ─────────────────────────────────────── */
|
||||
.mail-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.mail-item {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.mail-item:hover { background: var(--bg-hover); }
|
||||
.mail-item.unread { border-left: 2px solid var(--accent); }
|
||||
.mail-item.active { background: rgba(0,170,255,0.08); }
|
||||
.mail-from { font-size: 0.72rem; color: var(--text-muted); }
|
||||
.mail-subject {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mail-item.unread .mail-subject { color: #fff; font-weight: 700; }
|
||||
.mail-date { font-size: 0.65rem; color: var(--text-dim); }
|
||||
|
||||
.mail-detail { padding: 16px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||
.mail-detail-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.mail-detail-subject { font-size: 1rem; font-weight: 600; color: #fff; margin-bottom: 6px; }
|
||||
.mail-detail-meta { font-size: 0.72rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 2px; }
|
||||
.mail-detail-body { font-size: 0.82rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* ── STORE SPECIFIC ─────────────────────────────────────── */
|
||||
.store-statusbar {
|
||||
padding: 7px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-sidebar);
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.store-statusbar strong { color: var(--text-muted); }
|
||||
.store-card--installed { border-color: rgba(0,170,255,0.2); }
|
||||
.store-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.store-card {
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.store-card:hover { border-color: var(--border); }
|
||||
.store-card-icon { font-size: 1.8rem; }
|
||||
.store-card-name { font-size: 0.85rem; font-weight: 600; color: #fff; }
|
||||
.store-card-desc { font-size: 0.72rem; color: var(--text-muted); line-height: 1.4; flex: 1; }
|
||||
.store-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.store-price { font-size: 0.72rem; color: var(--text-muted); }
|
||||
.store-price.free { color: var(--success); }
|
||||
|
||||
/* ── BROWSER SPECIFIC ─────────────────────────────────────── */
|
||||
.browser-chrome {
|
||||
padding: 8px 10px;
|
||||
background: var(--titlebar);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.browser-chrome input {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.browser-nav-btn {
|
||||
width: 26px; height: 26px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.browser-nav-btn:hover { color: var(--text); }
|
||||
.browser-viewport {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
.browser-viewport.browser-viewport-external {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.browser-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
.browser-address-input {
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.browser-go-btn {
|
||||
font-weight: bold;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
.browser-404 {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.browser-404 h2 { font-size: 2rem; color: var(--error); }
|
||||
.browser-404 p { font-size: 0.82rem; }
|
||||
|
||||
/* IC Page styles */
|
||||
.ic-page h1 { font-size: 1.5rem; margin-bottom: 6px; color: #fff; }
|
||||
.ic-page h2 { font-size: 1.1rem; margin: 16px 0 6px; color: var(--text); }
|
||||
.ic-page p { font-size: 0.82rem; line-height: 1.7; color: var(--text-muted); margin-bottom: 8px; }
|
||||
.ic-page .article {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.ic-page .article-date { font-size: 0.65rem; color: var(--text-dim); margin-bottom: 4px; }
|
||||
.ic-home-hero {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
.ic-home-hero h1 { font-size: 2rem; color: var(--accent); margin-bottom: 8px; }
|
||||
.ic-home-links { display: flex; gap: 12px; justify-content: center; margin-top: 20px; flex-wrap: wrap; }
|
||||
.ic-link-card {
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ic-link-card:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
|
||||
/* ── SEARCH ──────────────────────────────────────────────── */
|
||||
.search-bar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.search-bar input { flex: 1; }
|
||||
|
||||
/* ── ANIMATIONS ─────────────────────────────────────────── */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in { animation: fadeIn 0.2s ease forwards; }
|
||||
|
||||
/* ── MAIL: folder sidebar items ─────────────────────────── */
|
||||
.sidebar-folder-item { justify-content: space-between; }
|
||||
.sidebar-folder-del {
|
||||
background: none; border: none; color: var(--text-dim);
|
||||
cursor: pointer; font-size: 10px; padding: 0 2px; line-height:1;
|
||||
opacity: 0;
|
||||
}
|
||||
.sidebar-folder-item:hover .sidebar-folder-del { opacity: 1; }
|
||||
|
||||
.mail-move-select {
|
||||
font-size: .65rem; padding: 1px 4px; cursor: pointer;
|
||||
background: var(--bg-input); border: 1px solid var(--border);
|
||||
color: var(--text-muted); border-radius: var(--radius-sm);
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
/* ── CALENDAR ────────────────────────────────────────────── */
|
||||
.cal-month-header {
|
||||
padding: 10px 14px;
|
||||
font-weight: 700; font-size: .82rem; color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cal-header-cell {
|
||||
text-align: center; font-size: .65rem; font-weight: 600;
|
||||
color: var(--text-muted); padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.cal-cell {
|
||||
min-height: 54px; padding: 3px 4px;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer; transition: background .1s;
|
||||
}
|
||||
.cal-cell:hover { background: var(--bg-hover); }
|
||||
.cal-cell-empty { background: var(--bg-sidebar); cursor: default; }
|
||||
.cal-today { background: rgba(0,170,255,.08); }
|
||||
.cal-today .cal-day-num { color: var(--accent); font-weight: 700; }
|
||||
.cal-day-num { font-size: .68rem; color: var(--text-muted); }
|
||||
.cal-dots { display: flex; flex-wrap: wrap; gap: 2px; margin-top: 2px; }
|
||||
.cal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.cal-event-item {
|
||||
padding: 6px 8px; border-radius: var(--radius-sm);
|
||||
background: var(--bg-input);
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
/* Schild am Termin: privat, Postfach oder öffentlich */
|
||||
.cal-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-edit-form {
|
||||
background: var(--bg-input); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 12px;
|
||||
}
|
||||
|
||||
/* ── ADDRESSBOOK ─────────────────────────────────────────── */
|
||||
.contact-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: .78rem; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.contact-avatar-lg {
|
||||
width: 56px; height: 56px; font-size: 1.2rem; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.contact-field-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.contact-field {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--bg-input); border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.contact-field-label { font-size: .68rem; color: var(--text-dim); }
|
||||
.contact-field-value { font-size: .8rem; color: var(--text); word-break: break-all; }
|
||||
|
||||
/* ── Interaktionsmodus (PC transparent, Fokus im Spiel) ─────────── */
|
||||
#pc-interact-hint {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 14px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
background: rgba(10,14,22,0.85);
|
||||
color: #e8eef7;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.85rem;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.5);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#pc-interact-hint b { color: #4aa3ff; }
|
||||
|
||||
#app.interact-mode #pc-container {
|
||||
opacity: 0.18;
|
||||
pointer-events: none; /* Klicks/Interaktion gehen an das Spiel */
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
#app.interact-mode #dim-overlay { opacity: 0 !important; } /* Spielwelt voll sichtbar */
|
||||
#app.interact-mode #pc-interact-hint { display: block; }
|
||||
|
||||
/* 👁-Button im selben Stil wie das ⚙-Settings-Icon oben */
|
||||
#pc-interact-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
width: 22px; height: 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
transition: color 0.12s, background 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#pc-interact-btn:hover { color: var(--text); background: var(--bg-hover); opacity: 1; }
|
||||
#pc-interact-btn.active { color: var(--accent); background: rgba(0,170,255,0.1); }
|
||||
107
nui/index.html
Normal file
107
nui/index.html
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>pc-live</title>
|
||||
<link rel="stylesheet" href="css/style.css"/>
|
||||
<link rel="stylesheet" href="css/rp-ds.css">
|
||||
<link rel="stylesheet" href="css/bleeter-embedded.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app">
|
||||
|
||||
<!-- DIM OVERLAY (Spielwelt sichtbar, aber leicht abgedunkelt) -->
|
||||
<div id="dim-overlay"></div>
|
||||
|
||||
<!-- PC CONTAINER (schwebendes Panel) -->
|
||||
<div id="pc-container">
|
||||
|
||||
<!-- BOOT SCREEN (absolut positioniert, überdeckt Container während Boot) -->
|
||||
<div id="boot-screen">
|
||||
<div class="boot-logo">⚡ PC-LIVE</div>
|
||||
<div class="boot-bar">
|
||||
<div class="boot-bar-fill" id="boot-bar-fill"></div>
|
||||
</div>
|
||||
<div class="boot-version">OS 1.0.0 · <span id="pc-boot-label">Terminal</span></div>
|
||||
</div>
|
||||
|
||||
<!-- PC TITELLEISTE (Drag, Minimize, Close) -->
|
||||
<div id="pc-titlebar">
|
||||
<span class="pc-title-icon">⚡</span>
|
||||
<span id="pc-label">Terminal</span>
|
||||
<div class="pc-title-controls">
|
||||
<button id="pc-settings-btn" title="Einstellungen" onclick="PCSettings.toggle()">⚙</button>
|
||||
<button id="pc-interact-btn" title="Durchsichtig / im Spiel interagieren" onclick="toggleInteractMode()">👁</button>
|
||||
<button id="pc-minimize-btn" title="Minimize" onclick="toggleMinimizePC()">─</button>
|
||||
<button id="close-pc-btn" title="Close PC" onclick="doClosePC()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EINSTELLUNGEN PANEL -->
|
||||
<div id="pc-settings-panel">
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-label">Schriftgröße</div>
|
||||
<div class="settings-row">
|
||||
<button class="settings-btn" onclick="PCFontSize.step(-1)" title="Kleiner">A−</button>
|
||||
<input class="settings-slider" type="range" min="11" max="20" step="1"
|
||||
id="pc-fs-slider" oninput="PCFontSize.set(this.value)"/>
|
||||
<button class="settings-btn" onclick="PCFontSize.step(+1)" title="Größer">A+</button>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:0.7rem;color:var(--text-muted)">
|
||||
<span id="pc-fs-display">13px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-label">Fenstergröße</div>
|
||||
<div class="settings-row">
|
||||
<button class="settings-btn" onclick="PCScale.step(-1)" title="Kleiner">−</button>
|
||||
<input class="settings-slider" type="range" min="40" max="98" step="5"
|
||||
id="pc-vp-slider" oninput="PCScale.setVP(this.value)"/>
|
||||
<button class="settings-btn" onclick="PCScale.step(+1)" title="Größer">+</button>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:0.7rem;color:var(--text-muted)">
|
||||
<span id="pc-vp-display">88%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DESKTOP -->
|
||||
<div id="desktop">
|
||||
<div id="icon-grid"></div>
|
||||
<div id="window-layer"></div>
|
||||
<div id="taskbar">
|
||||
<div id="taskbar-apps"></div>
|
||||
<div id="taskbar-right">
|
||||
<span id="clock">00:00:00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /pc-container -->
|
||||
|
||||
<!-- Interaktionsmodus-Hinweis (sichtbar wenn PC transparent/Fokus frei) -->
|
||||
<div id="pc-interact-hint">
|
||||
🎮 Interaktionsmodus aktiv — <b>[END]</b> drücken, um zum PC zurückzukehren
|
||||
</div>
|
||||
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Scripts – order matters -->
|
||||
<script src="js/windowManager.js"></script>
|
||||
<script src="js/apps/browser.js"></script>
|
||||
<script src="js/apps/mail.js"></script>
|
||||
<script src="js/apps/addressbook.js"></script>
|
||||
<script src="js/apps/store.js"></script>
|
||||
<script src="js/apps/parkuhr.js"></script>
|
||||
<script src="js/apps/ic_verwaltung.js"></script>
|
||||
<script src="js/apps/gesetzbuch.js"></script>
|
||||
<script src="js/apps/pbs_dashboard.js"></script>
|
||||
<script src="js/apps/webhosting.js"></script>
|
||||
<script src="js/apps/bleeter.js"></script>
|
||||
<script src="js/apps/bleeteradmin.js"></script>
|
||||
<script src="js/desktop.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
307
nui/js/apps/addressbook.js
Normal file
307
nui/js/apps/addressbook.js
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
/**
|
||||
* pc-live | Adressbuch App
|
||||
* Kontakte verwalten, an Mail-App weitergeben, Kontaktkarte per Mail senden.
|
||||
*/
|
||||
const AddressBookApp = (() => {
|
||||
const WIN_ID = 'app-addressbook';
|
||||
|
||||
let _contacts = [];
|
||||
let _active = null; // contact id
|
||||
let _editing = false;
|
||||
let _editData = null;
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────── */
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt) ? '' : dt.toLocaleDateString('de-DE');
|
||||
}
|
||||
function initials(name) {
|
||||
return (name || '?').split(' ').map(p => p[0] || '').slice(0,2).join('').toUpperCase();
|
||||
}
|
||||
function avatarColor(name) {
|
||||
const colors = ['#3b82f6','#8b5cf6','#ec4899','#f59e0b','#10b981','#06b6d4','#ef4444'];
|
||||
let h = 0;
|
||||
for (const c of (name||'?')) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
|
||||
return colors[Math.abs(h) % colors.length];
|
||||
}
|
||||
|
||||
/* ── Sidebar list ─────────────────────────────────────── */
|
||||
function renderList() {
|
||||
if (!_contacts.length)
|
||||
return `<div class="empty-state" style="padding:24px"><div class="empty-icon">👤</div><p>Keine Kontakte</p></div>`;
|
||||
|
||||
return `<div class="mail-list">` + _contacts.map(c => `
|
||||
<div class="mail-item ${_active === c.id ? 'active' : ''}" onclick="AddressBookApp._select(${c.id})">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<div class="contact-avatar" style="background:${avatarColor(c.name)}">${esc(initials(c.name))}</div>
|
||||
<div style="flex:1;overflow:hidden">
|
||||
<div class="mail-from" style="font-weight:600">${esc(c.name)}</div>
|
||||
${c.email ? `<div class="mail-date">${esc(c.email)}</div>` : ''}
|
||||
${c.phone ? `<div class="mail-date">${esc(c.phone)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('') + `</div>`;
|
||||
}
|
||||
|
||||
/* ── Detail / Edit ────────────────────────────────────── */
|
||||
function renderDetail() {
|
||||
if (_editing) {
|
||||
const d = _editData || {};
|
||||
return `
|
||||
<div class="app-content fade-in" style="padding:20px;max-width:480px">
|
||||
<h3 style="color:#fff;font-size:.9rem;margin-bottom:14px">
|
||||
${d.id ? '✏ Kontakt bearbeiten' : '➕ Neuer Kontakt'}
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input id="ab-name" type="text" value="${esc(d.name||'')}" maxlength="100" placeholder="Max Mustermann"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>E-Mail / Identifier</label>
|
||||
<input id="ab-email" type="text" value="${esc(d.email||'')}" maxlength="120" placeholder="license:abc… oder email@…"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Telefon</label>
|
||||
<input id="ab-phone" type="text" value="${esc(d.phone||'')}" maxlength="30" placeholder="+49 123 456789"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Notizen</label>
|
||||
<textarea id="ab-notes" rows="4" maxlength="500" placeholder="Notizen…">${esc(d.notes||'')}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-primary" onclick="AddressBookApp._save()">💾 Speichern</button>
|
||||
<button class="btn-ghost" onclick="AddressBookApp._cancelEdit()">Abbrechen</button>
|
||||
</div>
|
||||
<div id="ab-status" style="font-size:.72rem;margin-top:6px;color:var(--error)"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const c = _contacts.find(x => x.id === _active);
|
||||
if (!c)
|
||||
return `<div class="empty-state"><div class="empty-icon">👤</div><p>Kontakt auswählen</p></div>`;
|
||||
|
||||
const bg = avatarColor(c.name);
|
||||
return `
|
||||
<div class="mail-detail fade-in" style="padding:24px">
|
||||
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
|
||||
<div class="contact-avatar contact-avatar-lg" style="background:${bg}">${esc(initials(c.name))}</div>
|
||||
<div>
|
||||
<div style="font-size:1.1rem;font-weight:700;color:#fff">${esc(c.name)}</div>
|
||||
<div style="font-size:.75rem;color:var(--text-muted)">Hinzugefügt: ${fmtDate(c.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contact-field-group">
|
||||
${c.email ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">✉ E-Mail / ID</span>
|
||||
<span class="contact-field-value">${esc(c.email)}</span>
|
||||
</div>` : ''}
|
||||
${c.phone ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">📞 Telefon</span>
|
||||
<span class="contact-field-value">${esc(c.phone)}</span>
|
||||
</div>` : ''}
|
||||
${c.notes ? `
|
||||
<div class="contact-field">
|
||||
<span class="contact-field-label">📝 Notizen</span>
|
||||
<span class="contact-field-value" style="white-space:pre-wrap">${esc(c.notes)}</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:20px;flex-wrap:wrap">
|
||||
${c.email ? `
|
||||
<button class="btn-primary" onclick="AddressBookApp._mailTo('${esc(c.email)}')">
|
||||
✉ Mail schreiben
|
||||
</button>
|
||||
<button class="btn-ghost" onclick="AddressBookApp._forwardContact(${c.id})">
|
||||
📤 Kontakt weiterleiten
|
||||
</button>` : ''}
|
||||
<button class="btn-ghost" onclick="AddressBookApp._startEdit(${c.id})">✏ Bearbeiten</button>
|
||||
<button class="btn-danger" onclick="AddressBookApp._delete(${c.id})">🗑 Löschen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Full refresh ─────────────────────────────────────── */
|
||||
function _refresh() {
|
||||
const body = WindowManager.getBody(WIN_ID);
|
||||
if (!body) return;
|
||||
|
||||
// Push contacts into mail app for autocomplete
|
||||
MailApp.setContacts(_contacts);
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
<div class="app-sidebar" style="min-width:220px;max-width:250px">
|
||||
<div class="sidebar-section">Kontakte (${_contacts.length})</div>
|
||||
<div style="padding:6px 10px">
|
||||
<button class="btn-primary" style="width:100%;font-size:.75rem"
|
||||
onclick="AddressBookApp._startEdit(null)">➕ Neuer Kontakt</button>
|
||||
</div>
|
||||
<div style="overflow-y:auto;flex:1">
|
||||
${renderList()}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto">
|
||||
${renderDetail()}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────── */
|
||||
function _select(id) {
|
||||
_active = id;
|
||||
_editing = false;
|
||||
_editData = null;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function _startEdit(id) {
|
||||
_editing = true;
|
||||
_editData = id ? { ...(_contacts.find(c => c.id === id) || {}) } : {};
|
||||
_active = id;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function _cancelEdit() {
|
||||
_editing = false;
|
||||
_editData = null;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function _save() {
|
||||
const name = document.getElementById('ab-name')?.value.trim();
|
||||
const email = document.getElementById('ab-email')?.value.trim();
|
||||
const phone = document.getElementById('ab-phone')?.value.trim();
|
||||
const notes = document.getElementById('ab-notes')?.value.trim();
|
||||
const status = document.getElementById('ab-status');
|
||||
if (!name) { if (status) status.textContent = 'Name ist Pflichtfeld.'; return; }
|
||||
|
||||
const payload = { name, email, phone, notes };
|
||||
if (_editData && _editData.id) {
|
||||
fetchNui('updateContact', { id: _editData.id, ...payload });
|
||||
} else {
|
||||
fetchNui('addContact', payload);
|
||||
}
|
||||
_editing = false;
|
||||
_editData = null;
|
||||
}
|
||||
|
||||
function _delete(id) {
|
||||
// Kein confirm(): FiveMs CEF hat keinen Handler für die eingebauten
|
||||
// JS-Dialoge – der Aufruf öffnet nichts und lässt das NUI stehen.
|
||||
ICWebRender.confirmBox('Kontakt löschen', 'Kontakt wirklich löschen?',
|
||||
() => fetchNui('deleteContact', { id }),
|
||||
{ danger: true, okLabel: 'Löschen' });
|
||||
}
|
||||
|
||||
function _mailTo(emailOrId) {
|
||||
// Open mail app in compose mode with prefilled To
|
||||
MailApp.open();
|
||||
setTimeout(() => {
|
||||
MailApp._setView('compose');
|
||||
setTimeout(() => {
|
||||
const t = document.getElementById('mail-to');
|
||||
if (t) { t.value = emailOrId; t.focus(); }
|
||||
}, 50);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function _forwardContact(id) {
|
||||
const c = _contacts.find(x => x.id === id);
|
||||
if (!c) return;
|
||||
const body = [
|
||||
`── Kontaktkarte ──`,
|
||||
`Name: ${c.name}`,
|
||||
c.email ? `E-Mail: ${c.email}` : null,
|
||||
c.phone ? `Telefon: ${c.phone}` : null,
|
||||
c.notes ? `Notizen: ${c.notes}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
MailApp.open();
|
||||
setTimeout(() => {
|
||||
MailApp._setView('compose');
|
||||
setTimeout(() => {
|
||||
const s = document.getElementById('mail-subject');
|
||||
const b = document.getElementById('mail-body');
|
||||
if (s) s.value = `Kontakt: ${c.name}`;
|
||||
if (b) b.value = body;
|
||||
document.getElementById('mail-to')?.focus();
|
||||
}, 50);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/* ── Called from MailApp ("Als Kontakt speichern") ─────── */
|
||||
function openAddNew(prefill) {
|
||||
open();
|
||||
setTimeout(() => {
|
||||
_editing = true;
|
||||
_editData = prefill || {};
|
||||
_active = null;
|
||||
_refresh();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/* ── NUI events ───────────────────────────────────────── */
|
||||
function onContactsData(rows) {
|
||||
_contacts = rows || [];
|
||||
MailApp.setContacts(_contacts);
|
||||
if (WindowManager.getBody(WIN_ID)) _refresh();
|
||||
}
|
||||
|
||||
function onContactAdded(c) {
|
||||
_contacts.push(c);
|
||||
_contacts.sort((a,b) => a.name.localeCompare(b.name));
|
||||
_active = c.id;
|
||||
_editing = false;
|
||||
MailApp.setContacts(_contacts);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function onContactUpdated(c) {
|
||||
const idx = _contacts.findIndex(x => x.id === c.id);
|
||||
if (idx !== -1) _contacts[idx] = c; else _contacts.push(c);
|
||||
_contacts.sort((a,b) => a.name.localeCompare(b.name));
|
||||
_active = c.id;
|
||||
_editing = false;
|
||||
MailApp.setContacts(_contacts);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function onContactDeleted(id) {
|
||||
_contacts = _contacts.filter(c => c.id !== id);
|
||||
if (_active === id) _active = null;
|
||||
MailApp.setContacts(_contacts);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
/* ── Open window ──────────────────────────────────────── */
|
||||
function open() {
|
||||
const created = WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'Adressbuch',
|
||||
icon: '👤',
|
||||
width: 720,
|
||||
height: 500,
|
||||
content: '',
|
||||
});
|
||||
if (created) {
|
||||
_active = null;
|
||||
_editing = false;
|
||||
fetchNui('getContacts', {});
|
||||
_refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open, openAddNew,
|
||||
_select, _startEdit, _cancelEdit, _save, _delete,
|
||||
_mailTo, _forwardContact,
|
||||
onContactsData, onContactAdded, onContactUpdated, onContactDeleted,
|
||||
};
|
||||
})();
|
||||
1420
nui/js/apps/bleeter.js
Normal file
1420
nui/js/apps/bleeter.js
Normal file
File diff suppressed because it is too large
Load diff
481
nui/js/apps/bleeteradmin.js
Normal file
481
nui/js/apps/bleeteradmin.js
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* pc-live | Bleeter-Verwaltung
|
||||
*
|
||||
* Was das Bleeter-Fenster selbst nicht kann: Unternehmensprofile einrichten und
|
||||
* Mitarbeiter freigeben. Es hängt an derselben Anmeldung wie das Webhosting –
|
||||
* wer als admin@liveinvader.ls angemeldet ist, ist auch hier der Anbieter.
|
||||
*
|
||||
* Die Anmeldung sitzt unten links in der Seitenleiste, wie in der
|
||||
* Webhosting-App. Beide teilen sich die Sitzung: einmal anmelden genügt.
|
||||
*
|
||||
* Inhalte kommen von Spielern (Handles, Anzeigenamen). Sie werden über
|
||||
* textContent gesetzt, nie über innerHTML.
|
||||
*/
|
||||
const BleeterAdminApp = (() => {
|
||||
const WIN_ID = 'app-bleeteradmin';
|
||||
const el = ICWebRender.el;
|
||||
|
||||
/* Muss zu BUSINESS_TYPES in bleeter/server/management.lua passen. */
|
||||
const PROFILE_TYPES = [
|
||||
['company', 'Unternehmen'],
|
||||
['small_business', 'Kleingewerbe'],
|
||||
['authority', 'Behörde'],
|
||||
['lifeinvader', 'Lifeinvader'],
|
||||
];
|
||||
|
||||
const typeLabel = (t) => (PROFILE_TYPES.find(p => p[0] === t) || [t, t])[1];
|
||||
|
||||
let session = null; // ic-web-Anmeldung
|
||||
let profiles = [];
|
||||
let provider = false;
|
||||
let winEl = null;
|
||||
let openId = null; // Profil, dessen Mitarbeiter gerade angezeigt werden
|
||||
|
||||
/* ── Transport ────────────────────────────────────────── */
|
||||
/* Bleeter antwortet über ein gemeinsames Ereignis, nicht über Anfrage-Ids.
|
||||
Deshalb wird je Aktion auf die nächste Antwort gewartet. */
|
||||
const waiting = new Map();
|
||||
|
||||
function call(op, payload) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (waiting.get(op) === resolve) waiting.delete(op);
|
||||
resolve({ ok: false, error: 'Keine Antwort vom Server.' });
|
||||
}, 10000);
|
||||
|
||||
waiting.set(op, (data) => { clearTimeout(timer); resolve(data); });
|
||||
fetchNui('bleeter', { op, payload: payload || {} });
|
||||
});
|
||||
}
|
||||
|
||||
function onResponse(data) {
|
||||
if (!data || !data.action) return;
|
||||
const fn = waiting.get(data.action);
|
||||
if (!fn) return;
|
||||
waiting.delete(data.action);
|
||||
fn(data);
|
||||
}
|
||||
|
||||
/* ── Gerüst ───────────────────────────────────────────── */
|
||||
async function open() {
|
||||
const win = WindowManager.create({
|
||||
id: WIN_ID, title: 'Bleeter-Verwaltung', icon: '🐦', width: 940, height: 640,
|
||||
content: `
|
||||
<div class="wh-root">
|
||||
<div class="wh-side" id="ba-side"></div>
|
||||
<div class="wh-main" id="ba-main"></div>
|
||||
</div>`,
|
||||
});
|
||||
if (!win) return;
|
||||
|
||||
winEl = win;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function root(id) {
|
||||
if (!winEl || !winEl.isConnected) {
|
||||
winEl = document.querySelector('[data-wid="' + WIN_ID + '"]');
|
||||
}
|
||||
return winEl ? winEl.querySelector('#' + id) : null;
|
||||
}
|
||||
function setMain(node) {
|
||||
const m = root('ba-main');
|
||||
if (m) m.replaceChildren(node);
|
||||
}
|
||||
function info(icon, text) {
|
||||
const d = el('div', 'wh-empty');
|
||||
d.appendChild(el('div', 'wh-empty-icon', icon));
|
||||
d.appendChild(el('div', null, text));
|
||||
return d;
|
||||
}
|
||||
function notify(res, okText) {
|
||||
Desktop.showNotification(res && res.ok ? '✔ ' + okText
|
||||
: '⚠ ' + ((res && res.error) || 'Fehlgeschlagen.'));
|
||||
return res && res.ok;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setMain(info('⏳', 'Lade…'));
|
||||
|
||||
const s = await ICWebNet.call('session');
|
||||
session = (s.ok && s.data && s.data.session) || null;
|
||||
|
||||
const res = await call('listBusinessProfiles');
|
||||
profiles = (res.ok && res.payload && res.payload.profiles) || [];
|
||||
provider = !!(res.ok && res.payload && res.payload.provider);
|
||||
|
||||
renderSide();
|
||||
showProfiles();
|
||||
}
|
||||
|
||||
/* ── Seitenleiste mit Anmeldung unten links ───────────── */
|
||||
function renderSide() {
|
||||
const side = root('ba-side');
|
||||
if (!side) return;
|
||||
side.replaceChildren();
|
||||
|
||||
side.appendChild(el('div', 'wh-side-title', 'Bleeter'));
|
||||
|
||||
const nav = el('div', 'wh-nav active');
|
||||
nav.appendChild(el('span', 'wh-nav-icon', '🏢'));
|
||||
nav.appendChild(el('span', null, provider ? 'Alle Profile' : 'Meine Profile'));
|
||||
nav.addEventListener('click', () => showProfiles());
|
||||
side.appendChild(nav);
|
||||
|
||||
if (provider) {
|
||||
const add = el('div', 'wh-nav');
|
||||
add.appendChild(el('span', 'wh-nav-icon', '+'));
|
||||
add.appendChild(el('span', null, 'Profil einrichten'));
|
||||
add.addEventListener('click', () => showCreate());
|
||||
side.appendChild(add);
|
||||
}
|
||||
|
||||
/* Anmeldung – unten links, wie im Webhosting. */
|
||||
const foot = el('div', 'wh-side-user');
|
||||
|
||||
if (session) {
|
||||
foot.appendChild(el('div', 'wh-side-username', session.username));
|
||||
foot.appendChild(el('div', 'wh-side-role',
|
||||
session.role === 'superadmin' ? 'Anbieter (Lifeinvader)'
|
||||
: { admin: 'Domänenadmin', editor: 'Redakteur' }[session.role] || session.role));
|
||||
|
||||
const out = el('a', 'wh-side-logout', 'Abmelden');
|
||||
out.addEventListener('click', async () => {
|
||||
await ICWebNet.call('logout');
|
||||
Desktop.showNotification('Abgemeldet.');
|
||||
await refresh();
|
||||
});
|
||||
foot.appendChild(out);
|
||||
} else {
|
||||
foot.appendChild(el('div', 'wh-side-role', 'Nicht angemeldet'));
|
||||
const go = el('a', 'wh-side-logout', '🔑 Als Anbieter anmelden');
|
||||
go.style.color = 'var(--accent)';
|
||||
go.addEventListener('click', showLogin);
|
||||
foot.appendChild(go);
|
||||
}
|
||||
|
||||
side.appendChild(foot);
|
||||
}
|
||||
|
||||
/* ── Anmeldung ────────────────────────────────────────── */
|
||||
function showLogin() {
|
||||
ICWebRender.modal('Anmelden', (box, close) => {
|
||||
box.appendChild(el('div', 'icweb-modal-sub',
|
||||
'Zugangsdaten der Lifeinvader-Verwaltung. Es ist dieselbe Anmeldung wie '
|
||||
+ 'beim Webhosting – einmal anmelden genügt für beides.'));
|
||||
|
||||
const mk = (label, type, value, placeholder) => {
|
||||
const row = el('div', 'wh-field');
|
||||
row.appendChild(el('label', 'wh-label', label));
|
||||
const input = el('input', 'icweb-input');
|
||||
input.setAttribute('type', type);
|
||||
if (placeholder) input.setAttribute('placeholder', placeholder);
|
||||
input.value = value || '';
|
||||
row.appendChild(input);
|
||||
box.appendChild(row);
|
||||
return input;
|
||||
};
|
||||
|
||||
const user = mk('Benutzername', 'text', 'admin@liveinvader.ls', 'name@domäne.ls');
|
||||
const pass = mk('Passwort', 'password', '');
|
||||
const err = el('div', 'wh-login-error');
|
||||
box.appendChild(err);
|
||||
|
||||
const row = el('div', 'icweb-modal-actions');
|
||||
const cancel = el('button', 'icweb-btn-ghost', 'Abbrechen');
|
||||
cancel.addEventListener('click', close);
|
||||
|
||||
const go = el('button', 'icweb-btn-primary', 'Anmelden');
|
||||
const submit = async () => {
|
||||
err.textContent = '';
|
||||
if (!user.value || !pass.value) {
|
||||
err.textContent = 'Bitte Benutzername und Passwort eingeben.';
|
||||
return;
|
||||
}
|
||||
go.disabled = true;
|
||||
const res = await ICWebNet.call('login', user.value, pass.value);
|
||||
go.disabled = false;
|
||||
pass.value = '';
|
||||
|
||||
if (!res.ok) { err.textContent = res.error || 'Anmeldung fehlgeschlagen.'; return; }
|
||||
close();
|
||||
await refresh();
|
||||
};
|
||||
go.addEventListener('click', submit);
|
||||
[user, pass].forEach(i =>
|
||||
i.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }));
|
||||
|
||||
row.append(cancel, go);
|
||||
box.appendChild(row);
|
||||
setTimeout(() => pass.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Profilliste ──────────────────────────────────────── */
|
||||
function header(title, sub) {
|
||||
const h = el('div', 'wh-head');
|
||||
h.appendChild(el('div', 'wh-head-title', title));
|
||||
if (sub) h.appendChild(el('div', 'wh-head-sub', sub));
|
||||
return h;
|
||||
}
|
||||
|
||||
function showProfiles() {
|
||||
const wrap = el('div', 'wh-page');
|
||||
wrap.appendChild(header(provider ? 'Unternehmensprofile' : 'Meine Profile',
|
||||
provider
|
||||
? 'Alle Profile auf Bleeter, die keiner Privatperson gehören.'
|
||||
: 'Profile, für die du Mitarbeiter freigeben darfst.'));
|
||||
|
||||
if (!session) {
|
||||
wrap.appendChild(info('🔑',
|
||||
'Melde dich unten links an, um Bleeter zu verwalten.'));
|
||||
return setMain(wrap);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
wrap.appendChild(info('🐦', provider
|
||||
? 'Noch kein Unternehmensprofil eingerichtet.'
|
||||
: 'Du verwaltest kein Bleeter-Profil.'));
|
||||
return setMain(wrap);
|
||||
}
|
||||
|
||||
const table = el('div', 'wh-table');
|
||||
profiles.forEach(p => {
|
||||
const row = el('div', 'wh-row');
|
||||
|
||||
const col = el('div');
|
||||
col.appendChild(el('div', 'wh-row-title', '@' + p.handle));
|
||||
col.appendChild(el('div', 'wh-row-sub',
|
||||
(p.display_name || '—') + ' · ' + typeLabel(p.profile_type)
|
||||
+ ' · ' + (p.member_count || 0) + ' Mitarbeiter'));
|
||||
row.appendChild(col);
|
||||
|
||||
const right = el('div', 'wh-row-right');
|
||||
if (p.is_verified) right.appendChild(el('span', 'wh-tag ok', 'verifiziert'));
|
||||
if (p.is_locked) right.appendChild(el('span', 'wh-tag bad', 'gesperrt'));
|
||||
|
||||
const members = el('button', 'icweb-btn-primary', 'Mitarbeiter');
|
||||
members.addEventListener('click', () => showMembers(p));
|
||||
right.appendChild(members);
|
||||
|
||||
row.appendChild(right);
|
||||
table.appendChild(row);
|
||||
});
|
||||
|
||||
wrap.appendChild(table);
|
||||
setMain(wrap);
|
||||
}
|
||||
|
||||
/* ── Profil einrichten (nur Anbieter) ─────────────────── */
|
||||
function showCreate() {
|
||||
const wrap = el('div', 'wh-page');
|
||||
wrap.appendChild(header('Profil einrichten',
|
||||
'Das Profil gehört keinem Konto – bedient wird es über die freigegebenen '
|
||||
+ 'Mitarbeiter. Deshalb gehört gleich ein Verantwortlicher dazu.'));
|
||||
|
||||
const form = el('div', 'wh-form');
|
||||
|
||||
const mk = (label, placeholder) => {
|
||||
const row = el('div', 'wh-field');
|
||||
row.appendChild(el('label', 'wh-label', label));
|
||||
const input = el('input', 'icweb-input');
|
||||
if (placeholder) input.setAttribute('placeholder', placeholder);
|
||||
row.appendChild(input);
|
||||
form.appendChild(row);
|
||||
return input;
|
||||
};
|
||||
|
||||
const handleRow = el('div', 'wh-field');
|
||||
handleRow.appendChild(el('label', 'wh-label', 'Handle'));
|
||||
const handleWrap = el('div', 'wh-inline');
|
||||
handleWrap.appendChild(el('span', 'wh-inline-suffix', '@'));
|
||||
const handle = el('input', 'icweb-input');
|
||||
handle.setAttribute('placeholder', 'weazelnews');
|
||||
handleWrap.appendChild(handle);
|
||||
handleRow.appendChild(handleWrap);
|
||||
form.appendChild(handleRow);
|
||||
|
||||
const display = mk('Anzeigename', 'Weazel News');
|
||||
const mail = mk('Kontaktadresse (optional)', 'presse@weazel-news.ls');
|
||||
|
||||
const typeRow = el('div', 'wh-field');
|
||||
typeRow.appendChild(el('label', 'wh-label', 'Art'));
|
||||
const type = el('select', 'icweb-input');
|
||||
PROFILE_TYPES.forEach(([v, l]) => {
|
||||
const o = el('option', null, l);
|
||||
o.value = v;
|
||||
type.appendChild(o);
|
||||
});
|
||||
typeRow.appendChild(type);
|
||||
form.appendChild(typeRow);
|
||||
|
||||
const ownerRow = el('div', 'wh-field');
|
||||
ownerRow.appendChild(el('label', 'wh-label', 'Verantwortlich (muss online sein)'));
|
||||
const owner = el('select', 'icweb-input');
|
||||
// Ohne ausdrücklichen Wert liefert select.value den Text der Option.
|
||||
const noOwner = el('option', null, '— später festlegen —');
|
||||
noOwner.value = '';
|
||||
owner.appendChild(noOwner);
|
||||
ownerRow.appendChild(owner);
|
||||
form.appendChild(ownerRow);
|
||||
|
||||
call('listCandidates', {}).then(res => {
|
||||
const list = (res.ok && res.payload && res.payload.candidates) || [];
|
||||
list.forEach(c => {
|
||||
const o = el('option', null, c.name);
|
||||
o.value = String(c.source);
|
||||
owner.appendChild(o);
|
||||
});
|
||||
});
|
||||
|
||||
const go = el('button', 'icweb-btn-primary', 'Einrichten');
|
||||
go.addEventListener('click', async () => {
|
||||
go.disabled = true;
|
||||
const res = await call('createBusinessProfile', {
|
||||
handle: handle.value,
|
||||
displayName: display.value,
|
||||
emailContact: mail.value,
|
||||
profileType: type.value,
|
||||
ownerSource: owner.value ? Number(owner.value) : null,
|
||||
});
|
||||
go.disabled = false;
|
||||
if (notify(res, 'Profil eingerichtet.')) refresh();
|
||||
});
|
||||
form.appendChild(go);
|
||||
|
||||
form.appendChild(el('div', 'wh-hint',
|
||||
'Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Unterstrich und Bindestrich. '
|
||||
+ 'Behörden- und Systemnamen sind reserviert.'));
|
||||
|
||||
wrap.appendChild(form);
|
||||
setMain(wrap);
|
||||
}
|
||||
|
||||
/* ── Mitarbeiter ──────────────────────────────────────── */
|
||||
async function showMembers(profile) {
|
||||
openId = profile.id;
|
||||
setMain(info('⏳', 'Lade Mitarbeiter…'));
|
||||
|
||||
const [membersRes, candRes] = await Promise.all([
|
||||
call('listProfileMembers', { profileId: profile.id }),
|
||||
call('listCandidates', { profileId: profile.id }),
|
||||
]);
|
||||
|
||||
const wrap = el('div', 'wh-page');
|
||||
|
||||
const head = el('div', 'wh-domainhead');
|
||||
const left = el('div');
|
||||
left.appendChild(el('div', 'wh-card-domain', '@' + profile.handle));
|
||||
left.appendChild(el('div', 'wh-card-title',
|
||||
(profile.display_name || '') + ' · ' + typeLabel(profile.profile_type)));
|
||||
head.appendChild(left);
|
||||
|
||||
const back = el('div', 'wh-tab-back', '← Übersicht');
|
||||
back.addEventListener('click', () => showProfiles());
|
||||
head.appendChild(back);
|
||||
wrap.appendChild(head);
|
||||
|
||||
if (!membersRes.ok) {
|
||||
wrap.appendChild(el('div', 'wh-hint', membersRes.error || 'Keine Berechtigung.'));
|
||||
return setMain(wrap);
|
||||
}
|
||||
|
||||
wrap.appendChild(el('div', 'wh-hint',
|
||||
'Freigegebene schreiben unter diesem Profil. „Verwalten" gibt zusätzlich das '
|
||||
+ 'Recht, weitere Leute freizugeben – wer nur schreiben darf, kann sich '
|
||||
+ 'nicht selbst hochstufen.'));
|
||||
|
||||
const members = (membersRes.payload && membersRes.payload.members) || [];
|
||||
const table = el('div', 'wh-table');
|
||||
if (!members.length) table.appendChild(el('div', 'wh-hint', 'Noch niemand freigegeben.'));
|
||||
|
||||
members.forEach(m => {
|
||||
const row = el('div', 'wh-row');
|
||||
|
||||
const col = el('div');
|
||||
col.appendChild(el('div', 'wh-row-title', m.name || m.char_id));
|
||||
col.appendChild(el('div', 'wh-row-sub', m.role || 'member'));
|
||||
row.appendChild(col);
|
||||
|
||||
const right = el('div', 'wh-row-right');
|
||||
right.appendChild(el('span', 'wh-tag ' + (m.online ? 'ok' : ''),
|
||||
m.online ? 'online' : 'offline'));
|
||||
if (m.can_post) right.appendChild(el('span', 'wh-tag', 'schreibt'));
|
||||
if (m.can_edit_profile) right.appendChild(el('span', 'wh-tag', 'Profil'));
|
||||
if (m.can_manage_members) right.appendChild(el('span', 'wh-tag warn', 'verwaltet'));
|
||||
|
||||
const rm = el('button', 'icweb-btn-danger', 'Entfernen');
|
||||
rm.addEventListener('click', () => {
|
||||
ICWebRender.confirmBox('Freigabe entfernen',
|
||||
(m.name || m.char_id) + ' schreibt dann nicht mehr unter @' + profile.handle + '.',
|
||||
async () => {
|
||||
const res = await call('setProfileMember', {
|
||||
profileId: profile.id, charId: m.char_id, remove: true,
|
||||
});
|
||||
if (notify(res, 'Freigabe entfernt.')) showMembers(profile);
|
||||
}, { danger: true, okLabel: 'Entfernen' });
|
||||
});
|
||||
right.appendChild(rm);
|
||||
|
||||
row.appendChild(right);
|
||||
table.appendChild(row);
|
||||
});
|
||||
wrap.appendChild(table);
|
||||
|
||||
/* Freigeben */
|
||||
wrap.appendChild(el('div', 'wh-subhead', 'Mitarbeiter freigeben'));
|
||||
|
||||
const candidates = (candRes.ok && candRes.payload && candRes.payload.candidates) || [];
|
||||
if (!candidates.length) {
|
||||
wrap.appendChild(el('div', 'wh-hint',
|
||||
'Niemand verfügbar. Die Person muss online sein, um freigegeben zu werden.'));
|
||||
return setMain(wrap);
|
||||
}
|
||||
|
||||
const form = el('div', 'wh-form wh-form-inline');
|
||||
|
||||
const pick = el('div', 'wh-field');
|
||||
pick.appendChild(el('label', 'wh-label', 'Person'));
|
||||
const sel = el('select', 'icweb-input');
|
||||
candidates.forEach(c => {
|
||||
const o = el('option', null, c.name);
|
||||
o.value = String(c.source);
|
||||
sel.appendChild(o);
|
||||
});
|
||||
pick.appendChild(sel);
|
||||
form.appendChild(pick);
|
||||
|
||||
const roleRow = el('div', 'wh-field');
|
||||
roleRow.appendChild(el('label', 'wh-label', 'Rechte'));
|
||||
const rights = el('select', 'icweb-input');
|
||||
[['post', 'Schreiben'],
|
||||
['edit', 'Schreiben und Profil bearbeiten'],
|
||||
['manage', 'Schreiben, Profil bearbeiten und Leute freigeben']].forEach(([v, l]) => {
|
||||
const o = el('option', null, l);
|
||||
o.value = v;
|
||||
rights.appendChild(o);
|
||||
});
|
||||
roleRow.appendChild(rights);
|
||||
form.appendChild(roleRow);
|
||||
|
||||
const add = el('button', 'icweb-btn-primary', 'Freigeben');
|
||||
add.addEventListener('click', async () => {
|
||||
const level = rights.value;
|
||||
const res = await call('setProfileMember', {
|
||||
profileId: profile.id,
|
||||
targetSource: Number(sel.value),
|
||||
role: level === 'manage' ? 'manager' : 'member',
|
||||
canPost: true,
|
||||
canEditProfile: level === 'edit' || level === 'manage',
|
||||
canManageMembers: level === 'manage',
|
||||
});
|
||||
if (notify(res, 'Freigabe gespeichert.')) showMembers(profile);
|
||||
});
|
||||
form.appendChild(add);
|
||||
|
||||
wrap.appendChild(form);
|
||||
setMain(wrap);
|
||||
}
|
||||
|
||||
return { open, onResponse };
|
||||
})();
|
||||
538
nui/js/apps/browser.js
Normal file
538
nui/js/apps/browser.js
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
/**
|
||||
* pc-live | Browser App
|
||||
* IC URL schema: ic://slug
|
||||
*/
|
||||
const BrowserApp = (() => {
|
||||
const WIN_ID = 'app-browser';
|
||||
const history = [];
|
||||
let histIdx = -1;
|
||||
|
||||
/* ── IC page templates ─────────────────────────────────── */
|
||||
const PageTemplates = {
|
||||
home: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div class="ic-home-hero">
|
||||
<h1>⚡ IC Browser</h1>
|
||||
<p style="color:var(--text-muted);font-size:.82rem">Browse the internal network. Enter an <code>ic://</code> address above.</p>
|
||||
<div class="ic-home-links">
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://dpa')">📰 DPA News</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://police')">🚔 LSPD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://ambulance')">🚑 LSMD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://fire')">🚒 LSFD</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://doj')">⚖️ DOJ</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://parking')">🅿️ Parking</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://weazel')">📺 Weazel News</a>
|
||||
<a class="ic-link-card" onclick="BrowserApp.navigate('ic://classifieds')">📋 LS Classifieds</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
dpa: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>📰 DPA – Deutsche Presse-Agentur</h1>
|
||||
<div class="article">
|
||||
<div class="article-date">Today, 09:15</div>
|
||||
<h2>New residential district approved in Vinewood Hills</h2>
|
||||
<p>The city council has approved plans for 120 new residential units in the Vinewood Hills area. Construction is set to begin next month pending environmental review.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Today, 07:42</div>
|
||||
<h2>Port Authority reports record cargo volume</h2>
|
||||
<p>Los Santos Port Authority announced a 14% increase in cargo processing year-over-year, citing improved logistics infrastructure and expanded berth capacity.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Yesterday, 18:00</div>
|
||||
<h2>Traffic advisory: Freeway 1 maintenance</h2>
|
||||
<p>LSDOT warns of lane closures on Freeway 1 near Palomino Highlands between 22:00 and 06:00 for the next three nights.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
psb: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>🚔 Los Santos Police Service Board</h1>
|
||||
<p>Official information and public notices from the Los Santos Police Department.</p>
|
||||
<div class="article">
|
||||
<div class="article-date">Official Notice</div>
|
||||
<h2>Public Safety Campaign – Q1</h2>
|
||||
<p>The LSPD is launching a public outreach campaign focused on traffic safety and community engagement. Residents are encouraged to report suspicious activity through official channels.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Recruitment</div>
|
||||
<h2>LSPD Cadet Applications Open</h2>
|
||||
<p>Applications for the upcoming cadet class are now open. Qualified candidates should contact the department for details on requirements and the application process.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
weazel: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<h1>📺 Weazel News</h1>
|
||||
<p style="color:var(--accent);font-size:.72rem;margin-bottom:16px">Los Santos' #1 Trusted News Source</p>
|
||||
<div class="article">
|
||||
<div class="article-date">Breaking</div>
|
||||
<h2>Explosion reported near LSIA</h2>
|
||||
<p>Witnesses report a large explosion near the Los Santos International Airport. LSFD units are on scene. Air traffic has been temporarily halted as a precaution.</p>
|
||||
</div>
|
||||
<div class="article">
|
||||
<div class="article-date">Entertainment</div>
|
||||
<h2>Vinewood Music Awards – Full List of Nominees</h2>
|
||||
<p>The annual Vinewood Music Awards nominees have been announced. This year's ceremony will take place at the Maze Bank Arena. Tickets available starting next week.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
// ── Org pages ────────────────────────────────────────────
|
||||
org_police: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚔</span>
|
||||
<div><h1 style="margin:0">Los Santos Police Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://police · pd.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">Notruf 110</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Polizeilicher Notruf</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@pd.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Offizielles Aktensystem</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Dienstlich</div>
|
||||
<h2>Strafverfolgung & Öffentliche Sicherheit</h2>
|
||||
<p>Das LSPD ist zuständig für die öffentliche Sicherheit, Strafverfolgung und Verbrechensbekämpfung in Los Santos. Bürger können sich jederzeit an unsere Beamten wenden.</p>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Bewerbungen</div>
|
||||
<h2>Stellenausschreibung – Cadet Program</h2>
|
||||
<p>Das LSPD nimmt regelmäßig neue Bewerber für das Cadet-Programm an. Kontakt über das offizielle Aktensystem oder persönlich beim Department.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_ambulance: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚑</span>
|
||||
<div><h1 style="margin:0">Los Santos Medical Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://ambulance · md.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:#e53935;margin:6px 0 2px">Notruf 911</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Medizinischer Notfall</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@md.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Patientenakten & Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Medizinische Versorgung</div>
|
||||
<h2>Notfallversorgung rund um die Uhr</h2>
|
||||
<p>Das LSMD bietet medizinische Erstversorgung, Krankentransport und chirurgische Eingriffe. Im Notfall bitte sofort den Notruf 911 wählen.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_fire: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🚒</span>
|
||||
<div><h1 style="margin:0">Los Santos Fire Department</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://fire · fd.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:#ff6f00;margin:6px 0 2px">Notruf 112</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Feuerwehr & Technische Hilfe</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@fd.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Offizielle Korrespondenz</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Aufgaben</div>
|
||||
<h2>Brandbekämpfung & Technische Hilfe</h2>
|
||||
<p>Die LSFD schützt Leben und Eigentum bei Bränden, Unfällen und Naturkatastrophen. Für technische Hilfeleistungen steht das Department rund um die Uhr bereit.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_doj: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">⚖️</span>
|
||||
<div><h1 style="margin:0">Department of Justice</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://doj · doj.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">📞</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">Hotline 200</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">DOJ Auskunft</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@doj.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Juristische Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Justizsystem</div>
|
||||
<h2>Strafrecht & Zivilrecht in Los Santos</h2>
|
||||
<p>Das Department of Justice verwaltet das Rechtssystem von Los Santos. Anwälte, Richter und Staatsanwälte koordinieren alle rechtlichen Vorgänge über dieses Department.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_dpa: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🏛️</span>
|
||||
<div><h1 style="margin:0">Department of Public Administration</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://dpa · dpa.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">public@dpa.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Öffentliche Anfragen</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">traffic@dpa.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Verkehr & Infrastruktur</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Verwaltung</div>
|
||||
<h2>Staatliche Verwaltung & Öffentlicher Dienst</h2>
|
||||
<p>Die DPA koordiniert Verwaltungsaufgaben, Genehmigungen und öffentliche Dienstleistungen. Anfragen können per Mail oder telefonisch gestellt werden.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
org_parking: () => `
|
||||
<div class="ic-page fade-in">
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px">
|
||||
<span style="font-size:2.4rem">🅿️</span>
|
||||
<div><h1 style="margin:0">Parkraumüberwachung Los Santos</h1><p style="margin:4px 0 0;color:var(--text-muted);font-size:.8rem">ic://parking · parking.gov</p></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin-bottom:20px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center">
|
||||
<div style="font-size:1.4rem">✉️</div><div style="font-weight:700;color:var(--accent);margin:6px 0 2px">aktensystem@parking.gov</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted)">Einsprüche & Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="article"><div class="article-date">Information</div>
|
||||
<h2>Knöllchen & Einsprüche</h2>
|
||||
<p>Bei Fragen zu Knöllchen oder Einsprüchen bitte schriftlich über aktensystem@parking.gov oder persönlich am Schalter.</p>
|
||||
</div>
|
||||
</div>`,
|
||||
|
||||
|
||||
classifieds: () => `
|
||||
<div class="ic-page fade-in">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="background:linear-gradient(135deg,rgba(0,170,255,0.07) 0%,transparent 70%);
|
||||
border:1px solid var(--border);border-radius:8px;
|
||||
padding:22px 24px;text-align:center;margin-bottom:22px">
|
||||
<div style="font-size:1.7rem;font-weight:700;color:var(--accent);letter-spacing:.06em">
|
||||
📋 LS CLASSIFIEDS
|
||||
</div>
|
||||
<div style="color:var(--text-muted);font-size:.74rem;margin-top:5px">
|
||||
Los Santos' largest private marketplace · <span style="color:var(--text-dim)">ic://classifieds</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category tiles -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:26px">
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🚗</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Vehicles</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">47 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🏠</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Real Estate</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">23 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">💼</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Jobs</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">12 listings</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px 8px;text-align:center;cursor:pointer;transition:border-color .15s"
|
||||
onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<div style="font-size:1.5rem">🔧</div>
|
||||
<div style="font-size:.78rem;font-weight:600;color:var(--text);margin-top:4px">Services</div>
|
||||
<div style="font-size:.65rem;color:var(--text-dim);margin-top:2px">31 listings</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Featured -->
|
||||
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.12em;color:var(--text-dim);
|
||||
margin-bottom:12px;padding-bottom:6px;border-bottom:1px solid var(--border)">
|
||||
⭐ Featured Listings
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(235px,1fr));gap:10px;margin-bottom:24px">
|
||||
|
||||
<!-- Listing: Car -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🚗</span>
|
||||
<span class="tag tag-accent" style="font-size:.6rem">FEATURED</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">2023 Ocelot Pariah</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Low mileage, fully tuned at Benny's. Custom matte black wrap. Clean history, no damage.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$285,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Vinewood · Posted 2h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Motorcycle -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏍</span>
|
||||
<span class="tag">Vehicles</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Western Daemon Custom</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Full performance upgrade, custom exhaust. Runs clean. Selling due to relocation.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$68,500</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Strawberry · Posted 6h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: House -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏠</span>
|
||||
<span class="tag">Real Estate</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">3-Bed House · Mirror Park</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Modern detached house, double garage, pool. Move-in ready. Quiet, well-connected area.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$1,200,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Mirror Park · Posted 5h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Job -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">💼</span>
|
||||
<span class="tag" style="color:var(--success);border-color:rgba(34,197,94,.3)">Jobs</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Mechanic – Premium Auto</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Looking for an experienced mechanic. Flexible shifts, good cut. Experience required.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--success);font-weight:700">$4,500 / week</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 LSIA Area · Posted 1d ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Apartment -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🏢</span>
|
||||
<span class="tag">Real Estate</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">Penthouse · Alta St.</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
Floor 28, panoramic city view, 2 bed/2 bath, concierge. Fully furnished, immediate occupancy.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--accent);font-weight:700">$3,800,000</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Downtown LS · Posted 12h ago</div>
|
||||
</div>
|
||||
|
||||
<!-- Listing: Service -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);border-radius:var(--radius);
|
||||
padding:14px;display:flex;flex-direction:column;gap:7px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:1.3rem">🔧</span>
|
||||
<span class="tag">Services</span>
|
||||
</div>
|
||||
<div style="font-weight:700;color:#fff;font-size:.85rem">24/7 Mobile Mechanic</div>
|
||||
<div style="font-size:.72rem;color:var(--text-muted);line-height:1.5">
|
||||
On-call repairs, towing, custom builds. Competitive rates. Contact via IC mail.
|
||||
</div>
|
||||
<div style="font-size:.95rem;color:var(--text-muted);font-weight:600">Contact via mail</div>
|
||||
<div style="font-size:.63rem;color:var(--text-dim)">📍 Citywide · Posted 3d ago</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="text-align:center;color:var(--text-dim);font-size:.62rem;
|
||||
border-top:1px solid var(--border);padding-top:12px">
|
||||
LS Classifieds is not responsible for the accuracy of listings. All trades are at your own risk.
|
||||
· To post an ad, send a mail to <span style="color:var(--text-muted)">classifieds@ls-net.ic</span>
|
||||
</div>
|
||||
|
||||
</div>`,
|
||||
};
|
||||
|
||||
/* Fehlerseite für ic-web-Domänen. Als DOM gebaut, weil der Fehlertext vom
|
||||
Server kommt und die Domäne aus der Adressleiste stammt. */
|
||||
function renderICError(domain, message) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'browser-404 fade-in';
|
||||
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = '404';
|
||||
const p1 = document.createElement('p');
|
||||
p1.textContent = 'ic://' + domain;
|
||||
const p2 = document.createElement('p');
|
||||
p2.textContent = message || 'Diese Adresse existiert nicht.';
|
||||
|
||||
wrap.append(h, p1, p2);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function render404(slug) {
|
||||
return `
|
||||
<div class="browser-404 fade-in">
|
||||
<h2>404</h2>
|
||||
<p>Page not found: <code>ic://${slug}</code></p>
|
||||
<p>Check the address and try again.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── URL helpers ────────────────────────────────────────── */
|
||||
function isExternal(url) {
|
||||
return /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
function normalizeUrl(raw) {
|
||||
raw = (raw || '').trim();
|
||||
if (!raw) return 'ic://home';
|
||||
// Already ic:// or http(s)://
|
||||
if (/^ic:\/\//i.test(raw) || /^https?:\/\//i.test(raw)) return raw;
|
||||
// Looks like a domain (contains dot) → assume https
|
||||
if (/^[a-z0-9\-]+\.[a-z]{2,}/i.test(raw)) return 'https://' + raw;
|
||||
// Otherwise treat as ic:// slug
|
||||
return 'ic://' + raw;
|
||||
}
|
||||
|
||||
/* ── Navigate ─────────────────────────────────────────── */
|
||||
async function navigate(rawUrl) {
|
||||
if (!rawUrl) return;
|
||||
const url = normalizeUrl(rawUrl);
|
||||
|
||||
// Update address bar
|
||||
const bar = document.getElementById('browser-address');
|
||||
if (bar) bar.value = url;
|
||||
|
||||
// History
|
||||
if (histIdx < 0 || history[histIdx] !== url) {
|
||||
history.splice(histIdx + 1);
|
||||
history.push(url);
|
||||
histIdx = history.length - 1;
|
||||
}
|
||||
updateNavButtons();
|
||||
|
||||
const viewport = document.getElementById('browser-viewport');
|
||||
if (!viewport) return;
|
||||
|
||||
// ── External URL → iframe ──────────────────────────────
|
||||
if (isExternal(url)) {
|
||||
viewport.classList.add('browser-viewport-external');
|
||||
viewport.innerHTML =
|
||||
`<iframe class="browser-iframe"
|
||||
src="${url.replace(/"/g, '"')}"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
allowfullscreen></iframe>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// ── IC internal page ───────────────────────────────────
|
||||
viewport.classList.remove('browser-viewport-external');
|
||||
const slug = url.replace(/^ic:\/\//i, '').replace(/\/$/, '').trim() || 'home';
|
||||
if (bar) bar.value = 'ic://' + slug;
|
||||
|
||||
viewport.innerHTML = '<div class="empty-state"><div class="empty-icon">⏳</div><p>Loading…</p></div>';
|
||||
|
||||
if (PageTemplates[slug]) {
|
||||
viewport.innerHTML = PageTemplates[slug]();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Von Spielern gepflegte Seiten (ic-web) ─────────────
|
||||
// Erkennbar an der Domänenform name.endung, optional mit Unterseite.
|
||||
// Vorrang haben die fest eingebauten Vorlagen oben.
|
||||
const parts = slug.split('/');
|
||||
if (/^[a-z0-9\-]+\.[a-z]{2,}$/i.test(parts[0]) && typeof ICWebNet !== 'undefined') {
|
||||
const res = await ICWebNet.call('getSite', parts[0].toLowerCase(), parts[1] || 'home');
|
||||
if (res && res.ok && res.data) {
|
||||
// Kein innerHTML: der Inhalt stammt von Spielern und wird als
|
||||
// DOM-Knoten aufgebaut, damit Markup darin niemals ausgeführt wird.
|
||||
viewport.replaceChildren(ICWebRender.renderPage(res.data));
|
||||
return;
|
||||
}
|
||||
viewport.replaceChildren(renderICError(parts[0], res && res.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetchNui('getBrowserPage', { slug });
|
||||
if (resp && resp.found && resp.page) {
|
||||
const p = resp.page;
|
||||
if (p.content && PageTemplates[p.content]) {
|
||||
viewport.innerHTML = PageTemplates[p.content]();
|
||||
} else {
|
||||
viewport.innerHTML = `<div class="ic-page fade-in"><h1>${p.title || slug}</h1><p>This page has no content yet.</p></div>`;
|
||||
}
|
||||
} else {
|
||||
viewport.innerHTML = render404(slug);
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (histIdx > 0) {
|
||||
histIdx--;
|
||||
navigate(history[histIdx]);
|
||||
}
|
||||
}
|
||||
function goForward() {
|
||||
if (histIdx < history.length - 1) {
|
||||
histIdx++;
|
||||
navigate(history[histIdx]);
|
||||
}
|
||||
}
|
||||
function updateNavButtons() {
|
||||
const b = document.getElementById('browser-back');
|
||||
const f = document.getElementById('browser-fwd');
|
||||
if (b) b.style.opacity = histIdx > 0 ? '1' : '0.35';
|
||||
if (f) f.style.opacity = histIdx < history.length - 1 ? '1' : '0.35';
|
||||
}
|
||||
|
||||
/* ── Open window ────────────────────────────────────────── */
|
||||
function open(startUrl) {
|
||||
const el = WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'Browser',
|
||||
icon: '🌐',
|
||||
width: 900,
|
||||
height: 600,
|
||||
content: `
|
||||
<div class="browser-chrome">
|
||||
<button class="browser-nav-btn" id="browser-back" onclick="BrowserApp.goBack()" title="Zurück">◀</button>
|
||||
<button class="browser-nav-btn" id="browser-fwd" onclick="BrowserApp.goForward()" title="Vor">▶</button>
|
||||
<button class="browser-nav-btn" id="browser-refresh" onclick="BrowserApp.refresh()" title="Neu laden">↺</button>
|
||||
<input id="browser-address" class="browser-address-input" type="text"
|
||||
placeholder="ic://home oder https://…"
|
||||
onkeydown="if(event.key==='Enter')BrowserApp.navigate(this.value)"/>
|
||||
<button class="browser-nav-btn browser-go-btn"
|
||||
onclick="BrowserApp.navigate(document.getElementById('browser-address').value)"
|
||||
title="Aufrufen">→</button>
|
||||
</div>
|
||||
<div class="browser-viewport fade-in" id="browser-viewport"></div>
|
||||
`,
|
||||
});
|
||||
|
||||
if (!el) return;
|
||||
navigate(startUrl || 'ic://home');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (histIdx >= 0) navigate(history[histIdx]);
|
||||
}
|
||||
|
||||
return { open, navigate, goBack, goForward, refresh };
|
||||
})();
|
||||
26
nui/js/apps/gesetzbuch.js
Normal file
26
nui/js/apps/gesetzbuch.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* pc-live | Gesetzbuch (external)
|
||||
* Öffnet die öffentliche Gesetze-Übersicht des Aktensystems im eingebetteten Fenster.
|
||||
*/
|
||||
const GesetzbuchApp = (() => {
|
||||
const WIN_ID = 'app-gesetzbuch';
|
||||
const URL = 'https://aktensystem.naturalbornplayers.de/gesetze.html';
|
||||
|
||||
function open() {
|
||||
WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'Gesetzbuch',
|
||||
icon: '📖',
|
||||
width: 1000,
|
||||
height: 680,
|
||||
content: `<iframe
|
||||
src="${URL}"
|
||||
style="flex:1;width:100%;height:100%;border:none;display:block;background:#0f172a"
|
||||
allowfullscreen
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>`,
|
||||
});
|
||||
}
|
||||
|
||||
return { open };
|
||||
})();
|
||||
74
nui/js/apps/ic_verwaltung.js
Normal file
74
nui/js/apps/ic_verwaltung.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* pc-live | IC-Verwaltung App
|
||||
* Renders the ic-verwaltung UI inside a WindowManager window via iframe.
|
||||
* Messages are relayed: Lua TriggerEvent → pc-live SendNUIMessage → postMessage → iframe.
|
||||
*/
|
||||
const ICVerwaltungApp = (() => {
|
||||
const WIN_ID = 'app-ic-verwaltung';
|
||||
const IFRAME_ID = 'ic-verwaltung-frame';
|
||||
const IC_ORIGIN = 'https://cfx-nui-ic-verwaltung';
|
||||
|
||||
let _iframe = null;
|
||||
|
||||
function open() {
|
||||
if (windows_has(WIN_ID)) {
|
||||
WindowManager.focus(WIN_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = `
|
||||
<iframe
|
||||
id="${IFRAME_ID}"
|
||||
src="${IC_ORIGIN}/html/index.html"
|
||||
style="width:100%;height:100%;border:none;display:block;background:#1a1a2e;"
|
||||
></iframe>`;
|
||||
|
||||
const winEl = WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'IC-Verwaltung',
|
||||
icon: '🏛',
|
||||
width: 960,
|
||||
height: 620,
|
||||
content,
|
||||
});
|
||||
|
||||
// Intercept titlebar X so Lua is notified when user closes the window manually
|
||||
if (winEl) {
|
||||
const closeBtn = winEl.querySelector('.wc-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
_iframe = null;
|
||||
fetchNui('closeIcVerwaltung', {});
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
_iframe = document.getElementById(IFRAME_ID);
|
||||
|
||||
// Tell Lua to open ic-verwaltung in pcMode — relay will carry the open message
|
||||
fetchNui('openIcVerwaltung', {});
|
||||
}
|
||||
|
||||
// Called by desktop.js when action === 'ic_verwaltung_relay'
|
||||
function onRelay(msg) {
|
||||
// ic-verwaltung's close action → close the PC window instead of posting back
|
||||
if (msg && msg.action === 'close') {
|
||||
WindowManager.close(WIN_ID);
|
||||
_iframe = null;
|
||||
return;
|
||||
}
|
||||
if (!_iframe) {
|
||||
_iframe = document.getElementById(IFRAME_ID);
|
||||
}
|
||||
if (_iframe && _iframe.contentWindow) {
|
||||
_iframe.contentWindow.postMessage(msg, IC_ORIGIN);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: check if a window id is already open (WindowManager has no .exists method)
|
||||
function windows_has(id) {
|
||||
return !!document.querySelector(`[data-wid="${id}"]`);
|
||||
}
|
||||
|
||||
return { open, onRelay };
|
||||
})();
|
||||
1153
nui/js/apps/mail.js
Normal file
1153
nui/js/apps/mail.js
Normal file
File diff suppressed because it is too large
Load diff
344
nui/js/apps/parkuhr.js
Normal file
344
nui/js/apps/parkuhr.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* pc-live | Parkuhr Dashboard
|
||||
* Integration with qb-parkuhr (data via qb-parkuhr:getPanelData callback)
|
||||
*/
|
||||
const ParkuhrApp = (() => {
|
||||
const WIN_ID = 'app-parkuhr';
|
||||
let _data = null; // { devices[], typeTariffs{}, currency }
|
||||
let _selectedDevice = null; // currently shown device or null = overview
|
||||
|
||||
const TYPE_META = {
|
||||
machine: { label: 'Automat', icon: '🏧', color: 'var(--accent)' },
|
||||
meter_small: { label: 'Parkuhr (S)', icon: '🅿', color: 'var(--success)' },
|
||||
meter_big: { label: 'Parkuhr (L)', icon: '🅿', color: 'var(--warning)' },
|
||||
};
|
||||
|
||||
/* ── Format helpers ──────────────────────────────────────── */
|
||||
function fmtMoney(val, cur = '$') {
|
||||
const n = parseFloat(val) || 0;
|
||||
return `${cur}${n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function fmtDuration(min) {
|
||||
if (min >= 10080) return `${min / 10080} W`;
|
||||
if (min >= 1440) return `${min / 1440} T`;
|
||||
if (min >= 60) return `${min / 60} Std.`;
|
||||
return `${min} Min.`;
|
||||
}
|
||||
|
||||
function fmtPos(pos) {
|
||||
if (!pos) return '—';
|
||||
return `${Math.round(pos.x)}, ${Math.round(pos.y)}`;
|
||||
}
|
||||
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '—';
|
||||
try {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return String(ts); }
|
||||
}
|
||||
|
||||
/* ── KPI card ────────────────────────────────────────────── */
|
||||
function kpiCard(icon, label, value, color = 'var(--text-muted)') {
|
||||
return `
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:12px">
|
||||
<div style="font-size:.63rem;color:var(--text-dim);margin-bottom:4px">${icon} ${label}</div>
|
||||
<div style="font-size:1.05rem;font-weight:700;color:${color}">${value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Overview page ───────────────────────────────────────── */
|
||||
function renderOverview() {
|
||||
if (!_data) {
|
||||
return `<div class="empty-state"><div class="empty-icon">⏳</div><p>Daten werden geladen…</p></div>`;
|
||||
}
|
||||
const devices = _data.devices || [];
|
||||
const cur = _data.currency || '$';
|
||||
|
||||
const totalRevenue = devices.reduce((s, d) => s + (parseFloat(d.revenue) || 0), 0);
|
||||
const totalSales = devices.reduce((s, d) => s + (d.sales?.length || 0), 0);
|
||||
const machineCount = devices.filter(d => d.type === 'machine').length;
|
||||
const meterCount = devices.filter(d => d.type !== 'machine').length;
|
||||
|
||||
// Merge & sort all sales across devices, show last 12
|
||||
const allSales = [];
|
||||
for (const dev of devices) {
|
||||
for (const s of (dev.sales || []).slice(-8)) {
|
||||
allSales.push({ ...s, devId: dev.id, devType: dev.type });
|
||||
}
|
||||
}
|
||||
allSales.sort((a, b) => new Date(b.ts) - new Date(a.ts));
|
||||
const recent = allSales.slice(0, 12);
|
||||
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- KPI row -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px">
|
||||
${kpiCard('💰', 'Gesamteinnahmen', fmtMoney(totalRevenue, cur), 'var(--accent)')}
|
||||
${kpiCard('📋', 'Transaktionen', totalSales)}
|
||||
${kpiCard('🏧', 'Automaten', machineCount)}
|
||||
${kpiCard('🅿', 'Parkuhren', meterCount)}
|
||||
</div>
|
||||
|
||||
<!-- Device revenue bar -->
|
||||
${devices.length > 0 ? `
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">Gerät-Einnahmen</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px">
|
||||
${devices.map(d => {
|
||||
const meta = TYPE_META[d.type] || { icon: '📍', label: d.type, color: 'var(--text-muted)' };
|
||||
const rev = parseFloat(d.revenue) || 0;
|
||||
const pct = totalRevenue > 0 ? Math.round((rev / totalRevenue) * 100) : 0;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:.72rem;cursor:pointer"
|
||||
onclick="ParkuhrApp._select(${d.id})">
|
||||
<span style="min-width:60px;color:var(--text-muted)">${meta.icon} #${d.id}</span>
|
||||
<div style="flex:1;background:var(--bg-input);border-radius:99px;height:6px;overflow:hidden">
|
||||
<div style="width:${pct}%;height:100%;background:${meta.color};border-radius:99px"></div>
|
||||
</div>
|
||||
<span style="min-width:70px;text-align:right;color:var(--text)">${fmtMoney(rev, cur)}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Recent transactions -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">Letzte Transaktionen</div>
|
||||
${recent.length === 0
|
||||
? `<div class="empty-state" style="padding:20px"><p>Keine Transaktionen vorhanden</p></div>`
|
||||
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||||
${recent.map(s => {
|
||||
const meta = TYPE_META[s.devType] || { icon: '📍' };
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||||
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||||
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||||
<span class="tag" style="font-size:.58rem">${meta.icon} #${s.devId}</span>
|
||||
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||||
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||||
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Device detail page ──────────────────────────────────── */
|
||||
function renderDevice(device) {
|
||||
const cur = _data?.currency || '$';
|
||||
const meta = TYPE_META[device.type] || { icon: '📍', label: device.type, color: 'var(--accent)' };
|
||||
const sales = (device.sales || []).slice(-20).reverse();
|
||||
const isMach = device.type === 'machine';
|
||||
|
||||
// Tariff list: device-specific for machines, type-wide for meters
|
||||
const tariffList = isMach
|
||||
? (device.tariffs || [])
|
||||
: (_data?.typeTariffs?.[device.type] || []);
|
||||
|
||||
return `
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<!-- Device info card -->
|
||||
<div style="background:var(--bg-sidebar);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:14px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||||
<span style="font-size:2rem">${meta.icon}</span>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:.92rem;font-weight:700;color:#fff">Gerät #${device.id}</div>
|
||||
<div style="font-size:.7rem;color:var(--text-muted)">
|
||||
${meta.label} · <span style="color:var(--text-dim)">${device.model || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="font-size:1.1rem;font-weight:700;color:${meta.color}">${fmtMoney(device.revenue, cur)}</div>
|
||||
<div style="font-size:.62rem;color:var(--text-dim)">Kassenstand</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:14px;font-size:.68rem;color:var(--text-dim);
|
||||
border-top:1px solid var(--border);padding-top:8px;margin-top:4px">
|
||||
<span>📍 ${fmtPos(device.pos)}</span>
|
||||
<span>👤 ${device.placed_by || '—'}</span>
|
||||
<span>📋 ${device.sales?.length || 0} Verkäufe (gespeichert)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tariffs -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 8px">
|
||||
Tarife
|
||||
${isMach
|
||||
? '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(gerätespezifisch)</span>'
|
||||
: '<span style="color:var(--text-dim);font-size:.6rem;margin-left:4px">(Typstandard, nicht editierbar)</span>'}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:${isMach ? '10px' : '0'}">
|
||||
${tariffList.length === 0
|
||||
? `<span style="color:var(--text-dim);font-size:.73rem">Keine Tarife definiert</span>`
|
||||
: tariffList.map(t => {
|
||||
const dur = t.duration ?? t.duration_min;
|
||||
return `
|
||||
<div style="background:var(--bg-input);border:1px solid var(--border);
|
||||
border-radius:var(--radius);padding:8px 10px;text-align:center;min-width:76px">
|
||||
<div style="font-size:.65rem;color:var(--text-dim)">${fmtDuration(dur)}</div>
|
||||
<div style="font-size:.9rem;font-weight:700;color:${meta.color};margin:2px 0">${fmtMoney(t.price, cur)}</div>
|
||||
${isMach ? `
|
||||
<button class="btn-danger btn-sm" style="padding:1px 5px;font-size:.58rem;margin-top:2px"
|
||||
onclick="ParkuhrApp._deleteTariff(${device.id},${dur})">✕</button>` : ''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
${isMach ? `
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<select id="tariff-dur" style="width:88px;height:28px;font-size:.72rem;padding:0 6px">
|
||||
<option value="60">1 Stunde</option>
|
||||
<option value="1440">1 Tag</option>
|
||||
<option value="10080">7 Tage</option>
|
||||
</select>
|
||||
<input id="tariff-price" type="number" min="0" step="0.01" placeholder="Preis (${cur})"
|
||||
style="width:110px;height:28px;padding:0 8px;font-size:.72rem"/>
|
||||
<button class="btn-primary btn-sm" onclick="ParkuhrApp._saveTariff(${device.id})">
|
||||
Tarif speichern
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Sales history -->
|
||||
<div>
|
||||
<div class="sidebar-section" style="padding:0 0 6px">
|
||||
Verkaufshistorie (letzte ${sales.length})
|
||||
</div>
|
||||
${sales.length === 0
|
||||
? `<div class="empty-state" style="padding:16px"><p>Keine Einträge</p></div>`
|
||||
: `<div style="display:flex;flex-direction:column;gap:2px">
|
||||
${sales.map(s => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:5px 10px;
|
||||
background:var(--bg-sidebar);border-radius:var(--radius-sm);font-size:.71rem">
|
||||
<span style="color:var(--text-dim);width:34px">${fmtTime(s.ts)}</span>
|
||||
<span style="color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.label || '—'}</span>
|
||||
<span style="color:var(--success);font-weight:600;min-width:52px;text-align:right">
|
||||
${s.price != null ? fmtMoney(s.price, cur) : '—'}
|
||||
</span>
|
||||
</div>`).join('')}
|
||||
</div>`}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Full window render ──────────────────────────────────── */
|
||||
function _refresh() {
|
||||
const body = WindowManager.getBody(WIN_ID);
|
||||
if (!body) return;
|
||||
|
||||
const devices = _data?.devices || [];
|
||||
const cur = _data?.currency || '$';
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="app-sidebar" style="width:170px;overflow-y:auto">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;
|
||||
padding:8px 14px 4px">
|
||||
<span class="sidebar-section" style="padding:0;font-size:.6rem">
|
||||
Geräte (${devices.length})
|
||||
</span>
|
||||
<button class="btn-ghost btn-sm" style="height:20px;padding:0 6px;font-size:.65rem"
|
||||
title="Aktualisieren" onclick="ParkuhrApp._reload()">↻</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-item ${_selectedDevice === null ? 'active' : ''}"
|
||||
onclick="ParkuhrApp._select(null)">
|
||||
📊 Übersicht
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
|
||||
${devices.length === 0
|
||||
? `<div style="padding:8px 14px;font-size:.7rem;color:var(--text-dim)">Keine Geräte</div>`
|
||||
: devices.map(d => {
|
||||
const m = TYPE_META[d.type] || { icon: '📍', label: d.type };
|
||||
const sel = _selectedDevice?.id === d.id;
|
||||
return `
|
||||
<div class="sidebar-item ${sel ? 'active' : ''}"
|
||||
onclick="ParkuhrApp._select(${d.id})">
|
||||
<div>
|
||||
<div style="font-size:.72rem;font-weight:600">${m.icon} #${d.id}</div>
|
||||
<div style="font-size:.6rem;color:var(--text-dim)">${fmtMoney(d.revenue, cur)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="app-content">
|
||||
${_selectedDevice === null ? renderOverview() : renderDevice(_selectedDevice)}
|
||||
</div>
|
||||
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Public actions ──────────────────────────────────────── */
|
||||
function _select(deviceId) {
|
||||
_selectedDevice = deviceId === null
|
||||
? null
|
||||
: (_data?.devices || []).find(d => d.id === deviceId) || null;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
function _saveTariff(deviceId) {
|
||||
const dur = parseInt(document.getElementById('tariff-dur')?.value);
|
||||
const price = parseFloat(document.getElementById('tariff-price')?.value);
|
||||
if (isNaN(dur) || isNaN(price) || price < 0) return;
|
||||
fetchNui('saveParkuhrTariff', { deviceId, duration: dur, price }).then(_reload);
|
||||
}
|
||||
|
||||
function _deleteTariff(deviceId, duration) {
|
||||
fetchNui('deleteParkuhrTariff', { deviceId, duration }).then(_reload);
|
||||
}
|
||||
|
||||
function _reload() {
|
||||
const body = WindowManager.getBody(WIN_ID);
|
||||
if (!body) return;
|
||||
body.innerHTML = `<div class="empty-state"><div class="empty-icon">⏳</div><p>Aktualisiere…</p></div>`;
|
||||
fetchNui('getParkuhrPanel', {}).then(data => {
|
||||
_data = data || { devices: [], typeTariffs: {}, currency: '$' };
|
||||
// Re-select from fresh data
|
||||
if (_selectedDevice) {
|
||||
_selectedDevice = (_data.devices || []).find(d => d.id === _selectedDevice.id) || null;
|
||||
}
|
||||
_refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Open ────────────────────────────────────────────────── */
|
||||
function open() {
|
||||
const created = WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'Parkuhr Dashboard',
|
||||
icon: '🅿',
|
||||
width: 860,
|
||||
height: 540,
|
||||
content: '',
|
||||
});
|
||||
if (!created) return;
|
||||
|
||||
_data = null;
|
||||
_selectedDevice = null;
|
||||
_refresh();
|
||||
|
||||
fetchNui('getParkuhrPanel', {}).then(data => {
|
||||
_data = data || { devices: [], typeTariffs: {}, currency: '$' };
|
||||
_refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return { open, _select, _saveTariff, _deleteTariff, _reload };
|
||||
})();
|
||||
30
nui/js/apps/pbs_dashboard.js
Normal file
30
nui/js/apps/pbs_dashboard.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* pc-live | PBS Dashboard – RP PhoneSystem Pro Admin
|
||||
* Bettet das PBS Web-Dashboard per iframe ein.
|
||||
* URL kommt vom Server-Convar `setr pbs_web_url "http://..."` (server.cfg).
|
||||
*/
|
||||
const PBSDashboardApp = (() => {
|
||||
const WIN_ID = 'app-pbs-dashboard';
|
||||
|
||||
async function open() {
|
||||
// URL vom FiveM-Server lesen (Convar pbs_web_url)
|
||||
const res = await fetchNui('getPbsUrl');
|
||||
const url = (res && res.url) ? res.url : 'http://localhost:4088';
|
||||
|
||||
WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'PBS – PhoneSystem Dashboard',
|
||||
icon: '📞',
|
||||
width: 1200,
|
||||
height: 720,
|
||||
content: `<iframe
|
||||
src="${url}"
|
||||
style="flex:1;width:100%;height:100%;border:none;display:block;background:#0f172a"
|
||||
allowfullscreen
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
></iframe>`,
|
||||
});
|
||||
}
|
||||
|
||||
return { open };
|
||||
})();
|
||||
192
nui/js/apps/store.js
Normal file
192
nui/js/apps/store.js
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* pc-live | Software Store App
|
||||
*/
|
||||
const StoreApp = (() => {
|
||||
const WIN_ID = 'app-store';
|
||||
let _apps = [];
|
||||
let _appsMap = {}; // app_id -> app (for dependency lookups)
|
||||
let _filter = { category: 'all', search: '', installedOnly: false };
|
||||
|
||||
// Category definitions – icon + label.
|
||||
// Order here determines sidebar order.
|
||||
const CATEGORY_META = {
|
||||
all: { label: 'All Apps', icon: '🗂' },
|
||||
system: { label: 'System', icon: '🖥' },
|
||||
utility: { label: 'Utility', icon: '🔧' },
|
||||
communication: { label: 'Communication', icon: '📡' },
|
||||
security: { label: 'Security', icon: '🔐' },
|
||||
business: { label: 'Business', icon: '💼' },
|
||||
government: { label: 'Government', icon: '🏛' },
|
||||
entertainment: { label: 'Entertainment', icon: '🎮' },
|
||||
darknet: { label: 'Darknet', icon: '🌑' },
|
||||
};
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────── */
|
||||
|
||||
// Only show categories that actually have apps in the current list
|
||||
function getActiveCategories() {
|
||||
const present = new Set(['all']);
|
||||
for (const app of _apps) {
|
||||
if (app.category) present.add(app.category);
|
||||
}
|
||||
return Object.keys(CATEGORY_META).filter(c => present.has(c));
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
let list = _apps;
|
||||
if (_filter.installedOnly) list = list.filter(a => a.installed);
|
||||
if (_filter.category !== 'all') list = list.filter(a => a.category === _filter.category);
|
||||
if (_filter.search) {
|
||||
const q = _filter.search.toLowerCase();
|
||||
list = list.filter(a =>
|
||||
(a.name || '').toLowerCase().includes(q) ||
|
||||
(a.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* ── Render ─────────────────────────────────────────────── */
|
||||
|
||||
function renderSidebar() {
|
||||
const cats = getActiveCategories();
|
||||
return `
|
||||
<div class="app-sidebar">
|
||||
<div class="sidebar-section">Categories</div>
|
||||
${cats.map(c => {
|
||||
const m = CATEGORY_META[c] || { icon: '📂', label: c };
|
||||
return `
|
||||
<div class="sidebar-item ${_filter.category === c ? 'active' : ''}"
|
||||
onclick="StoreApp._setCategory('${c}')">
|
||||
${m.icon} ${m.label}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCard(app) {
|
||||
const icon = app.icon || '📦';
|
||||
const priceLabel = app.price > 0 ? `$${app.price.toLocaleString()}` : 'Free';
|
||||
const priceClass = app.price > 0 ? '' : 'free';
|
||||
const catMeta = CATEGORY_META[app.category] || { icon: '📂' };
|
||||
|
||||
// Resolve missing dependencies
|
||||
const missingDeps = (app.dependencies || [])
|
||||
.filter(depId => { const d = _appsMap[depId]; return !d || !d.installed; })
|
||||
.map(depId => { const d = _appsMap[depId]; return d ? d.name : depId; });
|
||||
|
||||
// Action buttons
|
||||
let action = '';
|
||||
if (app.installed) {
|
||||
if (app.update_available) {
|
||||
action = `<button class="btn-primary btn-sm" onclick="StoreApp._install('${app.app_id}')">↑ Update</button>`;
|
||||
} else {
|
||||
action = `<button class="btn-ghost btn-sm" style="cursor:default;opacity:.5" disabled>✓ Installed</button>`;
|
||||
}
|
||||
if (!app.default) {
|
||||
action += `<button class="btn-danger btn-sm" onclick="StoreApp._uninstall('${app.app_id}')">✕</button>`;
|
||||
}
|
||||
} else {
|
||||
const locked = missingDeps.length > 0;
|
||||
action = `<button class="btn-primary btn-sm"
|
||||
${locked ? 'style="opacity:.4" title="Missing: ' + missingDeps.join(', ') + '"' : ''}
|
||||
onclick="StoreApp._install('${app.app_id}')">Install</button>`;
|
||||
}
|
||||
|
||||
// Info badges
|
||||
let badges = `<span class="tag">${catMeta.icon} ${app.category || 'other'}</span>`;
|
||||
badges += `<span class="tag">v${app.version || '?'}</span>`;
|
||||
if (app.installed) badges += `<span class="tag tag-accent">✓</span>`;
|
||||
if (app.update_available) badges += `<span class="tag" style="color:var(--warning);border-color:rgba(245,158,11,.4)">↑ Update</span>`;
|
||||
if (app.permissions?.job) badges += `<span class="tag" style="color:var(--text-dim)">🔑 ${app.permissions.job}</span>`;
|
||||
if (missingDeps.length) badges += `<span class="tag" style="color:var(--error);border-color:rgba(239,68,68,.3)">⚠ Needs: ${missingDeps.join(', ')}</span>`;
|
||||
|
||||
return `
|
||||
<div class="store-card ${app.installed ? 'store-card--installed' : ''}">
|
||||
<div class="store-card-icon">${icon}</div>
|
||||
<div class="store-card-name">${app.name || app.app_id}</div>
|
||||
<div class="store-card-desc">${app.description || ''}</div>
|
||||
<div class="store-card-footer"><div style="display:flex;gap:4px;flex-wrap:wrap">${badges}</div></div>
|
||||
<div class="store-card-footer">
|
||||
<span class="store-price ${priceClass}">${priceLabel}</span>
|
||||
<div style="display:flex;gap:4px">${action}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _refresh() {
|
||||
const body = WindowManager.getBody(WIN_ID);
|
||||
if (!body) return;
|
||||
|
||||
const list = filtered();
|
||||
const instCount = _apps.filter(a => a.installed).length;
|
||||
const totalCount = _apps.length;
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="app-layout" style="height:100%">
|
||||
${renderSidebar()}
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div class="app-content" style="flex:1">
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="Search apps…"
|
||||
value="${(_filter.search || '').replace(/"/g, '"')}"
|
||||
oninput="StoreApp._setSearch(this.value)" id="store-search"/>
|
||||
<button class="btn-sm ${_filter.installedOnly ? 'btn-primary' : 'btn-ghost'}"
|
||||
onclick="StoreApp._toggleInstalled()" title="Show installed only">
|
||||
✓ Installed
|
||||
</button>
|
||||
</div>
|
||||
${list.length === 0
|
||||
? `<div class="empty-state"><div class="empty-icon">📦</div><p>No apps found</p></div>`
|
||||
: `<div class="store-grid">${list.map(renderCard).join('')}</div>`
|
||||
}
|
||||
</div>
|
||||
<div class="store-statusbar">
|
||||
📦 <strong>${instCount}</strong> installed · ${totalCount} available
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Filter actions ─────────────────────────────────────── */
|
||||
function _setCategory(cat) { _filter.category = cat; _refresh(); }
|
||||
function _setSearch(q) { _filter.search = q; _refresh(); }
|
||||
function _toggleInstalled() {
|
||||
_filter.installedOnly = !_filter.installedOnly;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
/* ── Install / Uninstall ─────────────────────────────────── */
|
||||
function _install(appId) { fetchNui('installApp', { appId }); }
|
||||
function _uninstall(appId) { fetchNui('uninstallApp', { appId }); }
|
||||
|
||||
/* ── NUI event – store data received ────────────────────── */
|
||||
function onStoreData(apps) {
|
||||
_apps = apps || [];
|
||||
_appsMap = {};
|
||||
for (const a of _apps) _appsMap[a.app_id] = a;
|
||||
_refresh();
|
||||
}
|
||||
|
||||
/* ── Open ───────────────────────────────────────────────── */
|
||||
function open() {
|
||||
const created = WindowManager.create({
|
||||
id: WIN_ID,
|
||||
title: 'Software Store',
|
||||
icon: '🛒',
|
||||
width: 860,
|
||||
height: 560,
|
||||
content: '',
|
||||
});
|
||||
|
||||
if (created) {
|
||||
_apps = [];
|
||||
_appsMap = {};
|
||||
_filter = { category: 'all', search: '', installedOnly: false };
|
||||
_refresh();
|
||||
fetchNui('getStore', {});
|
||||
}
|
||||
}
|
||||
|
||||
return { open, _setCategory, _setSearch, _toggleInstalled, _install, _uninstall, onStoreData };
|
||||
})();
|
||||
1489
nui/js/apps/webhosting.js
Normal file
1489
nui/js/apps/webhosting.js
Normal file
File diff suppressed because it is too large
Load diff
452
nui/js/desktop.js
Normal file
452
nui/js/desktop.js
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
/**
|
||||
* pc-live | Desktop – boot, icon grid, clock, message router
|
||||
*/
|
||||
|
||||
/* ── NUI fetch helper ───────────────────────────────────── */
|
||||
async function fetchNui(event, data = {}) {
|
||||
try {
|
||||
const resp = await fetch(`https://pc-live/${event}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.ok ? await resp.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State ──────────────────────────────────────────────── */
|
||||
const Desktop = (() => {
|
||||
let _session = null;
|
||||
let _clockTimer = null;
|
||||
let _installedApps = [];
|
||||
|
||||
const APP_META = {
|
||||
'pc.browser': { name: 'Browser', icon: '🌐', open: () => BrowserApp.open() },
|
||||
'pc.mail': { name: 'Mail', icon: '✉', open: () => MailApp.open() },
|
||||
'pc.addressbook': { name: 'Adressbuch', icon: '👤', open: () => AddressBookApp.open() },
|
||||
'pc.store': { name: 'Software Store', icon: '🛒', open: () => StoreApp.open() },
|
||||
'pc.parkuhr': { name: 'Parkuhr Dashboard', icon: '🅿', open: () => ParkuhrApp.open() },
|
||||
'pc.ic_verwaltung': { name: 'IC-Verwaltung', icon: '🏛', open: () => ICVerwaltungApp.open() },
|
||||
'pc.gesetzbuch': { name: 'Gesetzbuch', icon: '📖', open: () => GesetzbuchApp.open() },
|
||||
'pbs.dashboard': { name: 'PBS Dashboard', icon: '📞', open: () => PBSDashboardApp.open() },
|
||||
'pc.webhosting': { name: 'Webhosting', icon: '🌍', open: () => WebhostingApp.open() },
|
||||
'pc.bleeter': { name: 'Bleeter', icon: '🐦', open: () => BleeterApp.open() },
|
||||
'pc.bleeteradmin': { name: 'Bleeter-Verwaltung', icon: '🛠', open: () => BleeterAdminApp.open() },
|
||||
};
|
||||
|
||||
/* ── Boot sequence ────────────────────────────────────── */
|
||||
function boot(session) {
|
||||
_session = session;
|
||||
MailApp.initMailboxes(session.sharedMailboxes || [], session.personalAddress || '',
|
||||
session.personalSignature || '');
|
||||
// Freigeschaltete Postfächer nachladen, damit die ✕-Knöpfe stimmen.
|
||||
MailApp.refreshMailboxes();
|
||||
|
||||
// Apply accent color
|
||||
if (session.accentColor) {
|
||||
document.documentElement.style.setProperty('--accent', session.accentColor);
|
||||
}
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const bootEl = document.getElementById('boot-screen');
|
||||
const fill = document.getElementById('boot-bar-fill');
|
||||
const desktopEl = document.getElementById('desktop');
|
||||
|
||||
// Set labels in titlebar and boot screen
|
||||
const label = session.pcLabel || 'Terminal';
|
||||
const pcLabel = document.getElementById('pc-label');
|
||||
const pcBootLabel = document.getElementById('pc-boot-label');
|
||||
if (pcLabel) pcLabel.textContent = label;
|
||||
if (pcBootLabel) pcBootLabel.textContent = label;
|
||||
|
||||
app.classList.add('visible');
|
||||
|
||||
// Animate progress bar
|
||||
const duration = 2500;
|
||||
fill.style.transition = `width ${duration}ms linear`;
|
||||
requestAnimationFrame(() => { fill.style.width = '100%'; });
|
||||
|
||||
setTimeout(() => {
|
||||
bootEl.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
bootEl.style.display = 'none';
|
||||
desktopEl.classList.add('visible');
|
||||
startClock();
|
||||
renderIcons(session.installedApps || []);
|
||||
// Startseite automatisch öffnen falls konfiguriert
|
||||
if (session.startPage) {
|
||||
BrowserApp.open(session.startPage);
|
||||
}
|
||||
// Auto-Open-App (z.B. mobiler Streifenwagen-Terminal → IC-Verwaltung/Personalakte)
|
||||
if (session.autoOpenApp) {
|
||||
openApp(session.autoOpenApp);
|
||||
}
|
||||
}, 600);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/* ── Clock ────────────────────────────────────────────── */
|
||||
function startClock() {
|
||||
const el = document.getElementById('clock');
|
||||
const tick = () => {
|
||||
if (!el) return;
|
||||
const now = new Date();
|
||||
el.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
};
|
||||
tick();
|
||||
_clockTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function stopClock() {
|
||||
clearInterval(_clockTimer);
|
||||
_clockTimer = null;
|
||||
}
|
||||
|
||||
/* ── Desktop Icons ────────────────────────────────────── */
|
||||
function renderIcons(apps) {
|
||||
_installedApps = apps || [];
|
||||
const grid = document.getElementById('icon-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
|
||||
_installedApps.forEach(app => {
|
||||
const meta = APP_META[app.app_id] || {};
|
||||
const icon = meta.icon || app.icon || '📦';
|
||||
const name = meta.name || app.name || app.app_id;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'desktop-icon';
|
||||
div.innerHTML = `
|
||||
<div class="icon-glyph">${icon}</div>
|
||||
<div class="icon-label">${name}</div>`;
|
||||
div.addEventListener('dblclick', () => openApp(app.app_id));
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function openApp(appId) {
|
||||
const meta = APP_META[appId];
|
||||
if (meta && meta.open) {
|
||||
meta.open();
|
||||
} else {
|
||||
showNotification('⚠ App not yet available');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Close ────────────────────────────────────────────── */
|
||||
function close() {
|
||||
WindowManager.closeAll();
|
||||
stopClock();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const bootEl = document.getElementById('boot-screen');
|
||||
const desktopEl = document.getElementById('desktop');
|
||||
const fill = document.getElementById('boot-bar-fill');
|
||||
const grid = document.getElementById('icon-grid');
|
||||
const taskbar = document.getElementById('taskbar-apps');
|
||||
const container = document.getElementById('pc-container');
|
||||
const minBtn = document.getElementById('pc-minimize-btn');
|
||||
|
||||
desktopEl.classList.remove('visible');
|
||||
bootEl.style.display = 'flex';
|
||||
bootEl.classList.remove('fade-out');
|
||||
fill.style.transition = '';
|
||||
fill.style.width = '0%';
|
||||
if (grid) grid.innerHTML = '';
|
||||
if (taskbar) taskbar.innerHTML = '';
|
||||
|
||||
// Reset PC container position and minimized state
|
||||
if (container) {
|
||||
container.classList.remove('minimized');
|
||||
container.style.left = '50%';
|
||||
container.style.top = '50%';
|
||||
container.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
if (minBtn) { minBtn.textContent = '─'; minBtn.title = 'Minimize'; }
|
||||
|
||||
app.classList.remove('visible');
|
||||
app.classList.remove('interact-mode');
|
||||
_session = null;
|
||||
}
|
||||
|
||||
/* ── Message handler ───────────────────────────────────── */
|
||||
window.addEventListener('message', (e) => {
|
||||
const { action, data } = e.data || {};
|
||||
switch (action) {
|
||||
case 'boot':
|
||||
boot(data);
|
||||
break;
|
||||
case 'close':
|
||||
close();
|
||||
break;
|
||||
case 'exitInteract':
|
||||
exitInteractMode();
|
||||
break;
|
||||
case 'setInstalledApps':
|
||||
renderIcons(data);
|
||||
break;
|
||||
case 'setAccentColor':
|
||||
document.documentElement.style.setProperty('--accent', data.color);
|
||||
break;
|
||||
|
||||
// Mail
|
||||
case 'setInbox': MailApp.onInbox(data); break;
|
||||
case 'setMailContent': MailApp.onMailContent(data); break;
|
||||
case 'mailSent': MailApp.onMailSent(); break;
|
||||
case 'mailDeleted': MailApp.onMailDeleted(data.id); break;
|
||||
case 'newMailNotify':
|
||||
// Show unread badge on desktop icon (quick visual cue)
|
||||
showNotification('📧 New mail received');
|
||||
break;
|
||||
|
||||
// Store
|
||||
case 'setStoreList': StoreApp.onStoreData(data); break;
|
||||
|
||||
// Mail extended
|
||||
case 'setSent': MailApp.onSetSent(data); break;
|
||||
case 'folderCreated': MailApp.onFolderCreated(data); break;
|
||||
case 'folderDeleted': MailApp.onFolderDeleted(data.id, data.address); break;
|
||||
case 'foldersData': MailApp.onFoldersData(data.address, data.folders); break;
|
||||
case 'folderMailsData': MailApp.onFolderMailsData(data.folderId, data.rows); break;
|
||||
case 'mailMoved': MailApp.onMailMoved(data.id, data.folderId); break;
|
||||
|
||||
// Geteilte Postfächer
|
||||
case 'sharedMailboxes': MailApp.onSharedMailboxes(data.mailboxes, data.personalAddress,
|
||||
data.personalSignature); break;
|
||||
case 'signatureSaved': MailApp.onSignatureSaved(data); break;
|
||||
|
||||
// Mail-Einrichtung
|
||||
case 'mailAddressStatus': MailApp.onMailAddressStatus(data); break;
|
||||
case 'mailCreateResult': MailApp.onMailCreateResult(data); break;
|
||||
|
||||
// Calendar
|
||||
case 'calendarData': MailApp.onCalendarData(data); break;
|
||||
case 'calendarEventAdded': MailApp.onCalendarEventAdded(data); break;
|
||||
case 'calendarEventUpdated': MailApp.onCalendarEventUpdated(data); break;
|
||||
case 'calendarEventDeleted': MailApp.onCalendarEventDeleted(data.id); break;
|
||||
|
||||
// Contacts
|
||||
case 'contactsData': AddressBookApp.onContactsData(data); break;
|
||||
case 'contactAdded': AddressBookApp.onContactAdded(data); break;
|
||||
case 'contactUpdated': AddressBookApp.onContactUpdated(data); break;
|
||||
case 'contactDeleted': AddressBookApp.onContactDeleted(data.id); break;
|
||||
|
||||
// IC-Verwaltung relay: forward Lua SendNUIMessage into the iframe
|
||||
case 'ic_verwaltung_relay': ICVerwaltungApp.onRelay(data); break;
|
||||
|
||||
// Webhosting (ic-web): Antwort auf eine Anfrage mit Anfrage-Id
|
||||
case 'icweb_response': ICWebNet.onResponse(data); break;
|
||||
// Bleeter schickt alles über einen Kanal: Zustand und Verwaltungsantworten.
|
||||
case 'bleeter_data': BleeterApp.onData(data); break;
|
||||
case 'bleeter_notify': BleeterApp.onNotify(data); break;
|
||||
case 'bleeter_uploaded': BleeterApp.onUploaded(data); break;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Keyboard: Escape closes PC ────────────────────────── */
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && document.getElementById('desktop')?.classList.contains('visible')) {
|
||||
doClosePC();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Notification toast ─────────────────────────────────── */
|
||||
function showNotification(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `
|
||||
position:fixed;top:50px;right:16px;z-index:9000;
|
||||
background:var(--bg-window);border:1px solid var(--border);
|
||||
border-left:3px solid var(--accent);
|
||||
border-radius:var(--radius);padding:10px 14px;
|
||||
font-size:.78rem;color:var(--text);
|
||||
box-shadow:var(--shadow);
|
||||
animation:fadeIn .2s ease;
|
||||
`;
|
||||
el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
return { boot, close, openApp, showNotification,
|
||||
get session() { return _session; } };
|
||||
})();
|
||||
|
||||
/* ── Close button ───────────────────────────────────────── */
|
||||
function doClosePC() {
|
||||
fetchNui('closePC', {});
|
||||
}
|
||||
|
||||
/* ── PC Container: Minimize / Maximize ─────────────────── */
|
||||
function toggleMinimizePC() {
|
||||
const container = document.getElementById('pc-container');
|
||||
const btn = document.getElementById('pc-minimize-btn');
|
||||
if (!container) return;
|
||||
const isMin = container.classList.contains('minimized');
|
||||
container.classList.toggle('minimized', !isMin);
|
||||
if (btn) {
|
||||
btn.textContent = isMin ? '─' : '□';
|
||||
btn.title = isMin ? 'Minimize' : 'Restore';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Interaktionsmodus: PC transparent + Fokus zurück ins Spiel ── */
|
||||
function toggleInteractMode() {
|
||||
const app = document.getElementById('app');
|
||||
const btn = document.getElementById('pc-interact-btn');
|
||||
if (!app) return;
|
||||
const on = !app.classList.contains('interact-mode');
|
||||
app.classList.toggle('interact-mode', on);
|
||||
if (btn) btn.classList.toggle('active', on);
|
||||
// interact AN -> NUI-Fokus AUS (man interagiert mit dem Spiel); AUS -> Fokus zurück auf den PC
|
||||
fetchNui('setFocus', { focus: !on });
|
||||
}
|
||||
function exitInteractMode() {
|
||||
const app = document.getElementById('app');
|
||||
const btn = document.getElementById('pc-interact-btn');
|
||||
if (app) app.classList.remove('interact-mode');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
|
||||
/* ── PC Size (viewport %) ────────────────────────────────── */
|
||||
const PCScale = (() => {
|
||||
const STEP = 5;
|
||||
const MIN = 40;
|
||||
const MAX = 98;
|
||||
const KEY = 'pc_live_vp';
|
||||
|
||||
let _vp = parseInt(localStorage.getItem(KEY) || '88', 10);
|
||||
|
||||
function apply() {
|
||||
_vp = Math.max(MIN, Math.min(MAX, _vp));
|
||||
document.documentElement.style.setProperty('--pc-vp', _vp);
|
||||
localStorage.setItem(KEY, _vp);
|
||||
const c = document.getElementById('pc-container');
|
||||
if (c) {
|
||||
c.style.left = '50%';
|
||||
c.style.top = '50%';
|
||||
c.style.transform = 'translate(-50%, -50%)';
|
||||
}
|
||||
const disp = document.getElementById('pc-vp-display');
|
||||
const slider = document.getElementById('pc-vp-slider');
|
||||
if (disp) disp.textContent = _vp + '%';
|
||||
if (slider) slider.value = _vp;
|
||||
}
|
||||
|
||||
function step(dir) { _vp += dir * STEP; apply(); }
|
||||
function setVP(v) { _vp = parseInt(v, 10); apply(); }
|
||||
|
||||
document.addEventListener('wheel', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
step(e.deltaY < 0 ? 1 : -1);
|
||||
}, { passive: false });
|
||||
|
||||
apply();
|
||||
return { step, setVP };
|
||||
})();
|
||||
|
||||
/* ── PC Font Size ─────────────────────────────────────────── */
|
||||
const PCFontSize = (() => {
|
||||
const STEP = 1;
|
||||
const MIN = 11;
|
||||
const MAX = 20;
|
||||
const KEY = 'pc_live_fs';
|
||||
|
||||
let _fs = parseInt(localStorage.getItem(KEY) || '13', 10);
|
||||
|
||||
function apply() {
|
||||
_fs = Math.max(MIN, Math.min(MAX, _fs));
|
||||
document.documentElement.style.setProperty('--pc-fs', _fs);
|
||||
localStorage.setItem(KEY, _fs);
|
||||
const disp = document.getElementById('pc-fs-display');
|
||||
const slider = document.getElementById('pc-fs-slider');
|
||||
if (disp) disp.textContent = _fs + 'px';
|
||||
if (slider) slider.value = _fs;
|
||||
}
|
||||
|
||||
function step(dir) { _fs += dir * STEP; apply(); }
|
||||
function set(v) { _fs = parseInt(v, 10); apply(); }
|
||||
|
||||
apply();
|
||||
return { step, set };
|
||||
})();
|
||||
|
||||
/* ── Settings Panel ───────────────────────────────────────── */
|
||||
const PCSettings = (() => {
|
||||
let _open = false;
|
||||
|
||||
function toggle() {
|
||||
_open = !_open;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel) panel.classList.toggle('open', _open);
|
||||
if (btn) btn.classList.toggle('active', _open);
|
||||
// Sync slider values when opening
|
||||
if (_open) {
|
||||
const fsSlider = document.getElementById('pc-fs-slider');
|
||||
const vpSlider = document.getElementById('pc-vp-slider');
|
||||
if (fsSlider) fsSlider.value = parseInt(localStorage.getItem('pc_live_fs') || '13', 10);
|
||||
if (vpSlider) vpSlider.value = parseInt(localStorage.getItem('pc_live_vp') || '88', 10);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
_open = false;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel) panel.classList.remove('open');
|
||||
if (btn) btn.classList.remove('active');
|
||||
}
|
||||
|
||||
// Schließen bei Klick außerhalb
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!_open) return;
|
||||
const panel = document.getElementById('pc-settings-panel');
|
||||
const btn = document.getElementById('pc-settings-btn');
|
||||
if (panel && !panel.contains(e.target) && btn && !btn.contains(e.target)) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
return { toggle, close };
|
||||
})();
|
||||
|
||||
/* ── PC Container: Draggable via Titelleiste ────────────── */
|
||||
(function initPCDrag() {
|
||||
const container = document.getElementById('pc-container');
|
||||
const titlebar = document.getElementById('pc-titlebar');
|
||||
if (!container || !titlebar) return;
|
||||
|
||||
let startX, startY, startL, startT;
|
||||
|
||||
titlebar.addEventListener('mousedown', (e) => {
|
||||
// Ignore clicks on buttons
|
||||
if (e.target.closest('#pc-minimize-btn, #close-pc-btn, #pc-settings-btn, #pc-settings-panel')) return;
|
||||
|
||||
// Convert centered transform to explicit pixel position
|
||||
const rect = container.getBoundingClientRect();
|
||||
container.style.left = rect.left + 'px';
|
||||
container.style.top = rect.top + 'px';
|
||||
container.style.transform = 'none';
|
||||
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = rect.left; startT = rect.top;
|
||||
|
||||
const onMove = (e2) => {
|
||||
const newL = startL + e2.clientX - startX;
|
||||
const newT = startT + e2.clientY - startY;
|
||||
const maxL = window.innerWidth - container.offsetWidth;
|
||||
const maxT = window.innerHeight - container.offsetHeight;
|
||||
container.style.left = Math.max(0, Math.min(maxL, newL)) + 'px';
|
||||
container.style.top = Math.max(0, Math.min(maxT, newT)) + 'px';
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
})();
|
||||
252
nui/js/windowManager.js
Normal file
252
nui/js/windowManager.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* pc-live | Window Manager
|
||||
* Handles: create, focus, drag, resize, maximize, minimize, close, taskbar
|
||||
*/
|
||||
const WindowManager = (() => {
|
||||
const layer = document.getElementById('window-layer');
|
||||
const taskbarApps = document.getElementById('taskbar-apps');
|
||||
let zCounter = 10;
|
||||
const windows = new Map(); // id -> { el, tbBtn, minimized, maximized, preMax, title, icon }
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────────── */
|
||||
function nextZ() { return ++zCounter; }
|
||||
|
||||
function clampPos(el) {
|
||||
const lw = layer.clientWidth;
|
||||
const lh = layer.clientHeight;
|
||||
let l = parseInt(el.style.left) || 0;
|
||||
let t = parseInt(el.style.top) || 0;
|
||||
const w = el.offsetWidth;
|
||||
const h = el.offsetHeight;
|
||||
el.style.left = Math.min(Math.max(0, l), lw - w) + 'px';
|
||||
el.style.top = Math.min(Math.max(0, t), lh - Math.max(40, h - 300)) + 'px';
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────── */
|
||||
function makeDraggable(el, handle) {
|
||||
let startX, startY, startL, startT;
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
if (e.target.classList.contains('wc-btn')) return;
|
||||
if (e.target.classList.contains('wc-resize')) return;
|
||||
const win = windows.get(el.dataset.wid);
|
||||
if (win && win.maximized) return;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = parseInt(el.style.left) || 0;
|
||||
startT = parseInt(el.style.top) || 0;
|
||||
focus(el.dataset.wid);
|
||||
const mm = (e2) => {
|
||||
el.style.left = (startL + e2.clientX - startX) + 'px';
|
||||
el.style.top = (startT + e2.clientY - startY) + 'px';
|
||||
};
|
||||
const mu = () => {
|
||||
clampPos(el);
|
||||
document.removeEventListener('mousemove', mm);
|
||||
document.removeEventListener('mouseup', mu);
|
||||
};
|
||||
document.addEventListener('mousemove', mm);
|
||||
document.addEventListener('mouseup', mu);
|
||||
});
|
||||
// Double-click titlebar → toggle maximize
|
||||
handle.addEventListener('dblclick', (e) => {
|
||||
if (e.target.classList.contains('wc-btn')) return;
|
||||
toggleMaximize(el.dataset.wid);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Resize ───────────────────────────────────────────── */
|
||||
function makeResizable(el, id) {
|
||||
const dirs = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
|
||||
dirs.forEach(dir => {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'wc-resize wc-resize-' + dir;
|
||||
el.appendChild(h);
|
||||
|
||||
let startX, startY, startL, startT, startW, startH;
|
||||
const MIN_W = 320, MIN_H = 200;
|
||||
|
||||
h.addEventListener('mousedown', (e) => {
|
||||
const win = windows.get(id);
|
||||
if (!win || win.maximized) return;
|
||||
e.stopPropagation();
|
||||
focus(id);
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
startL = parseInt(el.style.left) || 0;
|
||||
startT = parseInt(el.style.top) || 0;
|
||||
startW = el.offsetWidth;
|
||||
startH = el.offsetHeight;
|
||||
|
||||
const mm = (e2) => {
|
||||
const dx = e2.clientX - startX;
|
||||
const dy = e2.clientY - startY;
|
||||
let newL = startL, newT = startT, newW = startW, newH = startH;
|
||||
|
||||
if (dir.includes('e')) newW = Math.max(MIN_W, startW + dx);
|
||||
if (dir.includes('w')) { newW = Math.max(MIN_W, startW - dx); newL = startL + startW - newW; }
|
||||
if (dir.includes('s')) newH = Math.max(MIN_H, startH + dy);
|
||||
if (dir.includes('n')) { newH = Math.max(MIN_H, startH - dy); newT = startT + startH - newH; }
|
||||
|
||||
el.style.left = newL + 'px';
|
||||
el.style.top = newT + 'px';
|
||||
el.style.width = newW + 'px';
|
||||
el.style.height = newH + 'px';
|
||||
};
|
||||
const mu = () => {
|
||||
document.removeEventListener('mousemove', mm);
|
||||
document.removeEventListener('mouseup', mu);
|
||||
};
|
||||
document.addEventListener('mousemove', mm);
|
||||
document.addEventListener('mouseup', mu);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Focus ────────────────────────────────────────────── */
|
||||
function focus(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
windows.forEach((w, wid) => {
|
||||
w.el.classList.toggle('focused', wid === id);
|
||||
w.tbBtn && w.tbBtn.classList.toggle('active', wid === id);
|
||||
});
|
||||
win.el.style.zIndex = nextZ();
|
||||
}
|
||||
|
||||
/* ── Maximize / Restore ───────────────────────────────── */
|
||||
function maximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win || win.maximized) return;
|
||||
win.preMax = {
|
||||
left: win.el.style.left,
|
||||
top: win.el.style.top,
|
||||
width: win.el.style.width,
|
||||
height: win.el.style.height,
|
||||
};
|
||||
win.maximized = true;
|
||||
win.el.classList.add('maximized');
|
||||
win.el.style.left = '0';
|
||||
win.el.style.top = '0';
|
||||
win.el.style.width = layer.clientWidth + 'px';
|
||||
win.el.style.height = layer.clientHeight + 'px';
|
||||
const btn = win.el.querySelector('.wc-maximize');
|
||||
if (btn) btn.title = 'Restore';
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function unmaximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win || !win.maximized || !win.preMax) return;
|
||||
win.maximized = false;
|
||||
win.el.classList.remove('maximized');
|
||||
win.el.style.left = win.preMax.left;
|
||||
win.el.style.top = win.preMax.top;
|
||||
win.el.style.width = win.preMax.width;
|
||||
win.el.style.height = win.preMax.height;
|
||||
win.preMax = null;
|
||||
const btn = win.el.querySelector('.wc-maximize');
|
||||
if (btn) btn.title = 'Maximize';
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function toggleMaximize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
if (win.maximized) unmaximize(id); else maximize(id);
|
||||
}
|
||||
|
||||
/* ── Create ───────────────────────────────────────────── */
|
||||
function create({ id, title, icon = '🖥', width = 700, height = 480, content }) {
|
||||
if (windows.has(id)) {
|
||||
restore(id);
|
||||
return windows.get(id).el;
|
||||
}
|
||||
|
||||
const lw = layer.clientWidth;
|
||||
const lh = layer.clientHeight;
|
||||
const left = Math.max(20, Math.floor(lw / 2 - width / 2));
|
||||
const top = Math.max(20, Math.floor(lh / 2 - height / 2));
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'window fade-in';
|
||||
el.dataset.wid = id;
|
||||
el.style.cssText = `left:${left}px;top:${top}px;width:${width}px;height:${height}px;z-index:${nextZ()}`;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="window-titlebar">
|
||||
<span class="window-title-icon">${icon}</span>
|
||||
<span class="window-title-text">${title}</span>
|
||||
<div class="window-controls">
|
||||
<button class="wc-btn wc-minimize" title="Minimize">─</button>
|
||||
<button class="wc-btn wc-maximize" title="Maximize">□</button>
|
||||
<button class="wc-btn wc-close" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-body">${content || ''}</div>
|
||||
`;
|
||||
|
||||
el.querySelector('.wc-minimize').addEventListener('click', () => minimize(id));
|
||||
el.querySelector('.wc-maximize').addEventListener('click', () => toggleMaximize(id));
|
||||
el.querySelector('.wc-close').addEventListener('click', () => close(id));
|
||||
el.addEventListener('mousedown', () => focus(id));
|
||||
|
||||
makeDraggable(el, el.querySelector('.window-titlebar'));
|
||||
makeResizable(el, id);
|
||||
|
||||
layer.appendChild(el);
|
||||
|
||||
const tbBtn = document.createElement('div');
|
||||
tbBtn.className = 'taskbar-btn';
|
||||
tbBtn.innerHTML = `<span>${icon}</span><span>${title}</span>`;
|
||||
tbBtn.addEventListener('click', () => toggle(id));
|
||||
taskbarApps.appendChild(tbBtn);
|
||||
|
||||
windows.set(id, { el, tbBtn, minimized: false, maximized: false, preMax: null, title, icon });
|
||||
focus(id);
|
||||
return el;
|
||||
}
|
||||
|
||||
/* ── Minimize / restore / toggle ────────────────────────── */
|
||||
function minimize(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.minimized = true;
|
||||
win.el.classList.add('minimized');
|
||||
win.tbBtn && win.tbBtn.classList.remove('active');
|
||||
win.tbBtn && win.tbBtn.classList.add('minimized');
|
||||
}
|
||||
|
||||
function restore(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.minimized = false;
|
||||
win.el.classList.remove('minimized');
|
||||
win.tbBtn && win.tbBtn.classList.remove('minimized');
|
||||
focus(id);
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
if (win.minimized) restore(id); else minimize(id);
|
||||
}
|
||||
|
||||
/* ── Close ───────────────────────────────────────────────── */
|
||||
function close(id) {
|
||||
const win = windows.get(id);
|
||||
if (!win) return;
|
||||
win.el.remove();
|
||||
win.tbBtn && win.tbBtn.remove();
|
||||
windows.delete(id);
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
windows.forEach((_, id) => close(id));
|
||||
}
|
||||
|
||||
/* ── Get body element ────────────────────────────────────── */
|
||||
function getBody(id) {
|
||||
const win = windows.get(id);
|
||||
return win ? win.el.querySelector('.window-body') : null;
|
||||
}
|
||||
|
||||
return { create, close, closeAll, focus, minimize, restore, toggle, maximize, unmaximize, toggleMaximize, getBody };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue