2026-05-17 21:10:41 +02:00

2413 lines
74 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blair — Ghost Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;1,300;1,400&family=Share+Tech+Mono&display=swap"
rel="stylesheet">
<style>
:root {
--bg: #0a0a0f;
--bg2: #111118;
--bg3: #16161f;
--bg4: #1d1d2a;
--bg5: #22222e;
--border: rgba(255, 255, 255, .07);
--border2: rgba(255, 255, 255, .13);
--border3: rgba(255, 255, 255, .22);
--text: #e8e4d8;
--muted: #7a7568;
--muted2: #a09890;
--accent: #c8a96e;
--accent2: #e8c98e;
--red: #c04040;
--red2: #e05050;
--green: #4a9c6a;
--green2: #5ab87e;
--blue: #4a7ac0;
--blue2: #6a9add;
--purple: #8a5ac0;
--purple2: #a87add;
--discord: #5865F2;
--orange2: #e09860;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Crimson Pro', Georgia, serif;
font-size: 16px;
line-height: 1.6;
min-height: 100vh;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: radial-gradient(ellipse 80% 60% at 20% 10%, rgba(100, 40, 140, .06) 0%, transparent 60%), radial-gradient(ellipse 60% 80% at 80% 90%, rgba(40, 80, 160, .05) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
.wrap {
position: relative;
z-index: 1;
max-width: 1440px;
margin: 0 auto;
padding: 0 24px 80px;
}
/* HEADER */
.site-header {
padding: 34px 0 24px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.header-inner {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.site-title {
font-family: 'Cinzel', serif;
font-size: 40px;
font-weight: 700;
letter-spacing: 8px;
color: var(--accent);
text-transform: uppercase;
line-height: 1;
}
.site-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 4px;
color: var(--muted);
text-transform: uppercase;
margin-top: 5px;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.auth-chip {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 3px;
padding: 7px 13px;
}
.auth-avatar {
width: 26px;
height: 26px;
border-radius: 50%;
object-fit: cover;
}
.auth-name {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 1.5px;
color: var(--text);
}
.auth-role {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 2px;
margin-left: 4px;
}
.role-admin {
background: rgba(192, 64, 64, .15);
color: var(--red2);
border: 1px solid rgba(192, 64, 64, .3);
}
.role-user {
background: rgba(74, 122, 192, .12);
color: var(--blue2);
border: 1px solid rgba(74, 122, 192, .3);
}
.mode-pills {
display: flex;
gap: 4px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 4px;
}
.mode-pill {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: none;
border: none;
color: var(--muted);
padding: 7px 14px;
border-radius: 3px;
cursor: pointer;
transition: all .18s;
position: relative;
}
.mode-pill:hover {
color: var(--muted2);
}
.mode-pill.active {
background: var(--bg5);
color: var(--accent);
border: 1px solid var(--border2);
}
.mode-pill.ap.active {
background: rgba(192, 64, 64, .12);
color: var(--red2);
border-color: rgba(192, 64, 64, .3);
}
.mode-pill.up.active {
background: rgba(74, 122, 192, .12);
color: var(--blue2);
border-color: rgba(74, 122, 192, .3);
}
.pill-badge {
position: absolute;
top: -6px;
right: -6px;
background: var(--red);
color: #fff;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
border-radius: 10px;
padding: 1px 5px;
min-width: 16px;
text-align: center;
display: none;
}
.pill-badge.show {
display: block;
}
/* TABS */
.top-nav {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
margin-bottom: 28px;
padding-top: 6px;
}
.nav-btn {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 3px;
text-transform: uppercase;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
padding: 10px 18px 12px;
cursor: pointer;
transition: all .18s;
margin-bottom: -1px;
}
.nav-btn:hover {
color: var(--muted2);
}
.nav-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.panel {
display: none;
}
.panel.active {
display: block;
}
/* EV FILTER */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 24px;
}
.filter-label-sm {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
flex-basis: 100%;
margin-bottom: 4px;
}
.ev-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 3px;
cursor: pointer;
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 1.5px;
color: var(--muted2);
text-transform: uppercase;
transition: all .18s;
user-select: none;
}
.ev-btn:hover {
border-color: var(--border3);
color: var(--text);
}
.ev-btn.ef {
background: rgba(200, 169, 110, .12);
border-color: var(--accent);
color: var(--accent2);
}
.ev-btn.ex {
background: rgba(192, 64, 64, .12);
border-color: var(--red);
color: var(--red2);
text-decoration: line-through;
}
.ev-num {
background: var(--bg4);
border-radius: 10px;
padding: 1px 7px;
font-size: 9px;
color: var(--muted);
}
.ev-btn.ef .ev-num {
background: rgba(200, 169, 110, .2);
color: var(--accent);
}
.result-bar {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--muted);
flex-basis: 100%;
display: none;
}
.result-bar span {
color: var(--accent);
}
/* GHOST GRID */
.ghost-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(295px, 1fr));
gap: 14px;
}
.ghost-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: all .22s;
}
.ghost-card:hover {
border-color: var(--border2);
transform: translateY(-2px);
}
.ghost-card.hl {
border-color: rgba(200, 169, 110, .45);
box-shadow: 0 0 0 1px rgba(200, 169, 110, .12) inset;
}
.ghost-card.dm {
opacity: .2;
pointer-events: none;
}
.card-hd {
padding: 15px 17px 11px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.card-name {
font-family: 'Cinzel', serif;
font-size: 16px;
font-weight: 600;
color: var(--accent2);
letter-spacing: .8px;
line-height: 1.2;
}
.card-num {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
margin-top: 3px;
}
.sb {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 3px 8px;
border-radius: 2px;
flex-shrink: 0;
white-space: nowrap;
}
.sb-lo {
background: rgba(74, 156, 106, .15);
color: var(--green2);
border: 1px solid rgba(74, 156, 106, .3);
}
.sb-mi {
background: rgba(200, 169, 110, .12);
color: var(--accent2);
border: 1px solid rgba(200, 169, 110, .25);
}
.sb-hi {
background: rgba(192, 64, 64, .15);
color: var(--red2);
border: 1px solid rgba(192, 64, 64, .3);
}
.sb-va {
background: rgba(138, 90, 192, .12);
color: var(--purple2);
border: 1px solid rgba(138, 90, 192, .3);
}
.card-evs {
padding: 11px 17px;
display: flex;
flex-wrap: wrap;
gap: 5px;
border-bottom: 1px solid var(--border);
}
.ev-pill {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 1.5px;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 2px;
background: var(--bg4);
border: 1px solid var(--border2);
color: var(--muted2);
transition: all .18s;
}
.ev-pill.pf {
background: rgba(200, 169, 110, .15);
border-color: rgba(200, 169, 110, .4);
color: var(--accent2);
}
.ev-pill.px {
background: rgba(192, 64, 64, .12);
border-color: rgba(192, 64, 64, .35);
color: var(--red2);
text-decoration: line-through;
}
.card-tip {
padding: 11px 17px 13px;
font-size: 13.5px;
color: var(--muted2);
line-height: 1.5;
font-style: italic;
}
/* CHEAT TABLE */
.cheat-wrap {
overflow-x: auto;
}
.cheat-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.cheat-table th {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--muted);
padding: 10px 11px;
border-bottom: 1px solid var(--border2);
text-align: center;
white-space: nowrap;
position: sticky;
top: 0;
background: var(--bg);
}
.cheat-table th:first-child {
text-align: left;
min-width: 105px;
}
.cheat-table td {
padding: 8px 11px;
border-bottom: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.cheat-table td:first-child {
text-align: left;
font-family: 'Cinzel', serif;
font-size: 13px;
color: var(--accent2);
cursor: pointer;
letter-spacing: .5px;
}
.cheat-table td:first-child:hover {
color: var(--accent);
text-decoration: underline;
}
.cheat-table tr:hover td {
background: rgba(255, 255, 255, .02);
}
.chk {
color: var(--accent);
}
.tip-cell {
text-align: left;
font-size: 12px;
color: var(--muted2);
font-style: italic;
min-width: 200px;
max-width: 360px;
}
.scc {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 2px 7px;
border-radius: 2px;
display: inline-block;
}
/* GUIDE */
.guide-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.guide-card {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 15px 17px;
}
.guide-card-title {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 8px;
}
.guide-card-body {
font-size: 13.5px;
color: var(--muted2);
line-height: 1.75;
}
.guide-card-body strong {
color: var(--text);
}
.divider {
display: flex;
align-items: center;
gap: 14px;
margin: 28px 0 18px;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--border);
}
.divider-text {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 4px;
color: var(--muted);
text-transform: uppercase;
white-space: nowrap;
}
.acc-l {
border-left: 2px solid var(--accent);
}
.red-l {
border-left: 2px solid var(--red);
}
.blue-l {
border-left: 2px solid var(--blue);
}
.purple-l {
border-left: 2px solid var(--purple);
}
/* MODALS */
.modal-bg {
display: none;
position: fixed;
inset: 0;
background: rgba(5, 5, 10, .88);
z-index: 200;
overflow-y: auto;
padding: 36px 18px;
}
.modal-bg.open {
display: flex;
align-items: flex-start;
justify-content: center;
}
.modal-box {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 6px;
max-width: 760px;
width: 100%;
position: relative;
animation: mIn .2s ease;
}
.modal-box.wide {
max-width: 920px;
}
@keyframes mIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.modal-close {
position: absolute;
top: 14px;
right: 16px;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 20px;
padding: 4px 8px;
transition: color .15s;
}
.modal-close:hover {
color: var(--text);
}
.modal-hd {
padding: 26px 30px 18px;
border-bottom: 1px solid var(--border);
}
.modal-title {
font-family: 'Cinzel', serif;
font-size: 26px;
font-weight: 700;
color: var(--accent);
letter-spacing: 3px;
}
.modal-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
margin-top: 5px;
}
.modal-body {
padding: 22px 30px 30px;
}
.wiki-sec {
margin-bottom: 22px;
}
.wiki-sec:last-child {
margin-bottom: 0;
}
.wiki-hd {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.wiki-text {
font-size: 14.5px;
color: var(--muted2);
line-height: 1.7;
}
.wiki-text strong {
color: var(--text);
}
.wiki-ev-row {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.wiki-ev-p {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
padding: 5px 13px;
border-radius: 3px;
background: var(--bg3);
border: 1px solid var(--border2);
color: var(--muted2);
}
.tells-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.tell {
display: flex;
gap: 11px;
align-items: flex-start;
padding: 9px 13px;
background: var(--bg3);
border-radius: 3px;
border-left: 2px solid var(--accent);
}
.tell-ico {
flex-shrink: 0;
font-size: 13px;
margin-top: 2px;
}
.tell-text {
font-size: 13.5px;
color: var(--muted2);
}
.tell-text strong {
color: var(--text);
}
.sanity-blk {
display: inline-flex;
align-items: center;
gap: 11px;
background: var(--bg3);
border-radius: 3px;
padding: 9px 15px;
border: 1px solid var(--border2);
}
.sanity-pct {
font-family: 'Cinzel', serif;
font-size: 22px;
}
.sanity-note {
font-size: 13px;
color: var(--muted2);
}
/* ADMIN */
.admin-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.admin-title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 600;
color: var(--red2);
letter-spacing: 2px;
}
.admin-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
margin-top: 3px;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 10px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 12px 15px;
}
.stat-val {
font-family: 'Cinzel', serif;
font-size: 22px;
color: var(--accent2);
line-height: 1;
}
.stat-label {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
color: var(--muted);
text-transform: uppercase;
margin-top: 4px;
}
.admin-ghost-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.agr {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 12px 16px;
transition: border-color .18s;
flex-wrap: wrap;
}
.agr:hover {
border-color: var(--border3);
}
.agr-name {
font-family: 'Cinzel', serif;
font-size: 14px;
color: var(--accent2);
letter-spacing: .5px;
min-width: 100px;
}
.agr-evs {
display: flex;
gap: 5px;
flex-wrap: wrap;
flex: 1;
}
.agr-ev {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
padding: 2px 7px;
background: var(--bg4);
border: 1px solid var(--border2);
border-radius: 2px;
color: var(--muted2);
}
.agr-meta {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
white-space: nowrap;
}
.agr-btns {
display: flex;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.icon-btn {
background: none;
border: 1px solid var(--border2);
border-radius: 3px;
padding: 5px 10px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
transition: all .18s;
}
.icon-btn.ibe:hover {
color: var(--accent2);
border-color: rgba(200, 169, 110, .4);
}
.icon-btn.ibd:hover {
color: var(--red2);
border-color: rgba(192, 64, 64, .4);
}
.sub-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.sub-item {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 15px 18px;
}
.sub-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.sub-ghost {
font-family: 'Cinzel', serif;
font-size: 13px;
color: var(--accent2);
}
.sub-type {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 2px;
border: 1px solid;
}
.stt {
color: var(--blue2);
border-color: rgba(74, 122, 192, .35);
background: rgba(74, 122, 192, .1);
}
.ste {
color: var(--orange2);
border-color: rgba(192, 120, 64, .35);
background: rgba(192, 120, 64, .1);
}
.stn {
color: var(--green2);
border-color: rgba(74, 156, 106, .35);
background: rgba(74, 156, 106, .1);
}
.sub-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
margin-left: auto;
}
.sub-content {
font-size: 13.5px;
color: var(--muted2);
line-height: 1.6;
margin-bottom: 10px;
font-style: italic;
}
.sub-actions {
display: flex;
gap: 7px;
flex-wrap: wrap;
}
.badge-count {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--red);
color: #fff;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
border-radius: 10px;
padding: 1px 6px;
margin-left: 6px;
min-width: 18px;
}
.empty-state {
text-align: center;
padding: 40px 24px;
color: var(--muted);
font-style: italic;
font-size: 14px;
}
.wl-section {
background: var(--bg3);
border: 1px solid rgba(192, 64, 64, .25);
border-radius: 4px;
padding: 18px 20px;
margin-bottom: 20px;
}
.wl-title {
font-family: 'Cinzel', serif;
font-size: 14px;
color: var(--red2);
letter-spacing: 1px;
margin-bottom: 12px;
}
.wl-row {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg4);
border-radius: 3px;
padding: 8px 12px;
border: 1px solid var(--border2);
margin-bottom: 6px;
}
.wl-name {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--accent2);
}
.wl-id {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--muted2);
}
.wl-date {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
margin-left: auto;
}
/* FORMS */
.form-section {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 20px 22px;
}
.form-title {
font-family: 'Cinzel', serif;
font-size: 15px;
color: var(--accent2);
letter-spacing: 1px;
margin-bottom: 16px;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.form-row.full {
grid-template-columns: 1fr;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-label {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--muted);
}
.form-input,
.form-select,
.form-textarea {
background: var(--bg4);
border: 1px solid var(--border2);
border-radius: 3px;
color: var(--text);
font-family: 'Crimson Pro', Georgia, serif;
font-size: 14px;
padding: 8px 12px;
transition: border-color .18s;
width: 100%;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--border3);
}
.form-textarea {
resize: vertical;
min-height: 80px;
line-height: 1.55;
}
.form-select option {
background: var(--bg3);
}
.form-hint {
font-size: 12px;
color: var(--muted);
font-style: italic;
}
.ev-checks {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ev-check-lbl {
display: flex;
align-items: center;
gap: 6px;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--muted2);
cursor: pointer;
padding: 5px 10px;
background: var(--bg4);
border: 1px solid var(--border2);
border-radius: 2px;
transition: all .18s;
user-select: none;
}
.ev-check-lbl:hover {
border-color: var(--border3);
color: var(--text);
}
.ev-check-lbl input {
display: none;
}
.ev-check-lbl.ck {
background: rgba(200, 169, 110, .12);
border-color: rgba(200, 169, 110, .4);
color: var(--accent2);
}
.tells-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.tell-row {
display: flex;
gap: 8px;
align-items: center;
}
.tell-row .form-input {
flex: 1;
}
.tell-ico-in {
width: 58px;
text-align: center;
flex-shrink: 0;
}
.remove-btn {
background: none;
border: 1px solid rgba(192, 64, 64, .3);
border-radius: 3px;
color: var(--red2);
cursor: pointer;
padding: 6px 10px;
font-size: 12px;
transition: all .18s;
flex-shrink: 0;
}
.remove-btn:hover {
background: rgba(192, 64, 64, .12);
}
.add-tell-btn {
background: none;
border: 1px dashed var(--border2);
border-radius: 3px;
color: var(--muted);
cursor: pointer;
padding: 7px;
font-size: 11px;
font-family: 'Share Tech Mono', monospace;
letter-spacing: 2px;
text-transform: uppercase;
transition: all .18s;
width: 100%;
}
.add-tell-btn:hover {
border-color: var(--border3);
color: var(--muted2);
}
.form-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 4px;
flex-wrap: wrap;
}
/* BUTTONS */
.btn-sm {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: none;
border: 1px solid var(--border2);
border-radius: 3px;
padding: 7px 14px;
color: var(--muted);
cursor: pointer;
transition: all .18s;
}
.btn-sm:hover {
color: var(--text);
border-color: var(--border3);
}
.btn-accent {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: rgba(200, 169, 110, .12);
border: 1px solid rgba(200, 169, 110, .35);
border-radius: 3px;
padding: 8px 16px;
color: var(--accent2);
cursor: pointer;
transition: all .18s;
}
.btn-accent:hover {
background: rgba(200, 169, 110, .22);
}
.btn-danger {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: rgba(192, 64, 64, .12);
border: 1px solid rgba(192, 64, 64, .35);
border-radius: 3px;
padding: 8px 16px;
color: var(--red2);
cursor: pointer;
transition: all .18s;
}
.btn-danger:hover {
background: rgba(192, 64, 64, .22);
}
.btn-success {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: rgba(74, 156, 106, .12);
border: 1px solid rgba(74, 156, 106, .35);
border-radius: 3px;
padding: 8px 16px;
color: var(--green2);
cursor: pointer;
transition: all .18s;
}
.btn-success:hover {
background: rgba(74, 156, 106, .22);
}
.btn-blue {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 2px;
text-transform: uppercase;
background: rgba(74, 122, 192, .12);
border: 1px solid rgba(74, 122, 192, .35);
border-radius: 3px;
padding: 8px 16px;
color: var(--blue2);
cursor: pointer;
transition: all .18s;
}
.btn-blue:hover {
background: rgba(74, 122, 192, .22);
}
.btn-discord {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
background: var(--discord);
border: none;
border-radius: 3px;
padding: 11px 22px;
color: #fff;
cursor: pointer;
transition: opacity .18s;
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-discord:hover {
opacity: .88;
}
.btn-discord svg {
width: 18px;
height: 18px;
}
.btn-logout {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
background: none;
border: 1px solid var(--border2);
border-radius: 3px;
padding: 5px 10px;
color: var(--muted);
cursor: pointer;
transition: all .18s;
}
.btn-logout:hover {
color: var(--red2);
border-color: rgba(192, 64, 64, .4);
}
/* LOGIN */
.login-screen {
display: none;
position: fixed;
inset: 0;
background: var(--bg);
z-index: 500;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-screen.show {
display: flex;
}
.login-box {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 48px 52px;
max-width: 440px;
width: 100%;
text-align: center;
}
.login-logo {
font-family: 'Cinzel', serif;
font-size: 48px;
font-weight: 700;
letter-spacing: 10px;
color: var(--accent);
line-height: 1;
margin-bottom: 8px;
}
.login-sub {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 4px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 36px;
}
.login-desc {
font-size: 14px;
color: var(--muted2);
line-height: 1.7;
margin-bottom: 28px;
}
.login-desc strong {
color: var(--text);
}
.login-or {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 3px;
color: var(--muted);
margin: 18px 0;
display: block;
}
.login-guest-note {
font-size: 12px;
color: var(--muted);
margin-top: 18px;
font-style: italic;
}
/* COMMUNITY */
.user-title {
font-family: 'Cinzel', serif;
font-size: 22px;
font-weight: 600;
color: var(--blue2);
letter-spacing: 2px;
margin-bottom: 3px;
}
.usi {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 13px 16px;
margin-bottom: 10px;
}
.usi-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.usi-st {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 2px;
border: 1px solid;
}
.ssp {
color: var(--accent2);
border-color: rgba(200, 169, 110, .35);
background: rgba(200, 169, 110, .1);
}
.ssa {
color: var(--green2);
border-color: rgba(74, 156, 106, .35);
background: rgba(74, 156, 106, .1);
}
.ssr {
color: var(--red2);
border-color: rgba(192, 64, 64, .35);
background: rgba(192, 64, 64, .1);
}
/* LOADING */
.loading {
display: none;
position: fixed;
inset: 0;
background: rgba(5, 5, 10, .7);
z-index: 400;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 16px;
}
.loading.show {
display: flex;
}
.spinner {
width: 36px;
height: 36px;
border: 2px solid var(--border2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-txt {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
}
/* CONFIRM */
.confirm-bg {
display: none;
position: fixed;
inset: 0;
background: rgba(5, 5, 10, .88);
z-index: 300;
align-items: center;
justify-content: center;
}
.confirm-bg.open {
display: flex;
}
.confirm-box {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 6px;
padding: 28px 32px;
max-width: 380px;
width: 100%;
text-align: center;
}
.confirm-title {
font-family: 'Cinzel', serif;
font-size: 16px;
color: var(--red2);
letter-spacing: 1px;
margin-bottom: 10px;
}
.confirm-text {
font-size: 14px;
color: var(--muted2);
margin-bottom: 22px;
line-height: 1.6;
}
.confirm-btns {
display: flex;
gap: 10px;
justify-content: center;
}
/* TOAST */
.toast-wrap {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
letter-spacing: 1px;
padding: 11px 18px;
border-radius: 4px;
border-left: 3px solid;
animation: tIn .25s ease;
pointer-events: all;
max-width: 340px;
}
@keyframes tIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.toast-success {
background: rgba(74, 156, 106, .18);
border-color: var(--green2);
color: var(--green2);
}
.toast-danger {
background: rgba(192, 64, 64, .18);
border-color: var(--red2);
color: var(--red2);
}
.toast-info {
background: rgba(74, 122, 192, .18);
border-color: var(--blue2);
color: var(--blue2);
}
@media(max-width:640px) {
.site-title {
font-size: 28px;
letter-spacing: 4px;
}
.form-row {
grid-template-columns: 1fr;
}
.ghost-grid {
grid-template-columns: 1fr;
}
.modal-hd,
.modal-body {
padding-left: 18px;
padding-right: 18px;
}
.login-box {
padding: 32px 24px;
}
.login-logo {
font-size: 34px;
letter-spacing: 6px;
}
}
</style>
</head>
<body>
<!-- LOGIN -->
<div class="login-screen" id="login-screen">
<div class="login-box">
<div class="login-logo">BLAIR</div>
<div class="login-sub">Ghost Investigation Dashboard</div>
<div class="login-desc">
Login with discord to <strong>submit tips</strong> and see the status of your submissions.<br><br>
<strong>Admin-Access</strong> is only granted to approved Discord accounts.
</div>
<button class="btn-discord" onclick="location.href='/auth/discord'">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.101 18.08.114 18.1.134 18.114a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
Login with Discord
</button>
<span class="login-or">or</span>
<button class="btn-sm" onclick="guestMode()">Continue as guest (only read-access)</button>
<div class="login-guest-note">Guests can only read Ghost Cards, Cheatsheet and Hunt Guide.</div>
</div>
</div>
<!-- LOADING -->
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="loading-txt" id="loading-txt">Loading...</div>
</div>
<!-- APP -->
<div class="wrap" id="app" style="display:none;">
<header class="site-header">
<div class="header-inner">
<div>
<div class="site-title">BLAIR</div>
<div class="site-sub">Ghost Investigation Dashboard for Blair &nbsp;·&nbsp; made by non_player1 & qiqi.june
</div>
</div>
<div class="header-right">
<div class="auth-chip" id="auth-chip" style="display:none;">
<img class="auth-avatar" id="auth-av" src="" alt="">
<span class="auth-name" id="auth-nm"></span>
<span class="auth-role" id="auth-rl"></span>
<button class="btn-logout" onclick="doLogout()">Logout</button>
</div>
<span id="guest-badge"
style="display:none;font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:2px;color:var(--muted);">GAST</span>
<div class="mode-pills">
<button class="mode-pill active" onclick="setMode('viewer')">👁 Viewer</button>
<button class="mode-pill up" id="pill-user" onclick="setMode('user')" style="display:none;">✦ Community<span
class="pill-badge" id="badge-user"></span></button>
<button class="mode-pill ap" id="pill-admin" onclick="setMode('admin')" style="display:none;">⚙ Admin<span
class="pill-badge" id="badge-admin"></span></button>
</div>
</div>
</div>
</header>
<!-- VIEWER -->
<div id="mode-viewer">
<nav class="top-nav" id="vnav">
<button class="nav-btn active" onclick="switchTab('cards')">Ghost Cards</button>
<button class="nav-btn" onclick="switchTab('cheat')">Cheatsheet</button>
<button class="nav-btn" onclick="switchTab('guide')">Hunt Guide</button>
</nav>
<div class="panel active" id="vtab-cards">
<div class="filter-row" id="ev-filter">
<div class="filter-label-sm">Evidence Filter — single-click: found &nbsp;·&nbsp; twice: excluded</div>
</div>
<div class="ghost-grid" id="ghost-grid"></div>
</div>
<div class="panel" id="vtab-cheat">
<div class="cheat-wrap">
<table class="cheat-table">
<thead>
<tr>
<th>Ghost</th>
<th>EMF</th>
<th></th>
<th></th>
<th>UV</th>
<th></th>
<th>📻</th>
<th>SLS</th>
<th>Sanity</th>
<th>Tips</th>
</tr>
</thead>
<tbody id="cheat-tbody"></tbody>
</table>
</div>
</div>
<div class="panel" id="vtab-guide">
<div style="max-width:800px;">
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Hunt Mechanics</div>
<div class="divider-line"></div>
</div>
<div class="guide-grid">
<div class="guide-card">
<div class="guide-card-title">Grace Period</div>
<div class="guide-card-body">Easy — 5s<br>Medium — 4s<br>Hard — 3s<br><strong
style="color:var(--red2)">Nightmare — 2s</strong></div>
</div>
<div class="guide-card">
<div class="guide-card-title">Sanity Drain/min</div>
<div class="guide-card-body">Easy — 2%<br>Medium — 2%<br>Hard — 3%<br><strong
style="color:var(--red2)">Nightmare — ~5%</strong></div>
</div>
<div class="guide-card">
<div class="guide-card-title">Hunt Triggers</div>
<div class="guide-card-body">Sanity ≤ ghost threshold<br>Cursed objects bypass sanity<br>Spirit Board — no
goodbye = hunt<br>Strigoi tap = 1/8 hunt chance</div>
</div>
<div class="guide-card">
<div class="guide-card-title">Survival Priority</div>
<div class="guide-card-body">1. Crucifix in ghost room<br>2. Incense (stun/cleanse)<br>3. Break line of
sight<br>4. Hide in closet/vehicle</div>
</div>
</div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Salt Tips</div>
<div class="divider-line"></div>
</div>
<div class="guide-card" style="margin-bottom:24px;">
<div class="guide-card-body"
style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px 20px;">
<div><strong>Wraith</strong> — NEVER steps in salt.</div>
<div><strong>Harrow</strong> — Salt outside room = not Harrow.</div>
<div><strong>Revenant</strong> — Normal steps = no Revenant.</div>
<div><strong>Jiangshi</strong> — Skips the 2nd footstep.</div>
<div><strong>Yurei</strong> — Takes extra time to step on salt.</div>
<div><strong>Demon</strong> — Fast, accelerating steps.</div>
<div><strong>Strigoi</strong> — 4-finger UV handprint.</div>
<div><strong>Oni</strong> — Fast salt steps; opens/closes doors.</div>
</div>
</div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Cursed Objects</div>
<div class="divider-line"></div>
</div>
<div class="guide-grid">
<div class="guide-card red-l">
<div class="guide-card-title">Spirit Board</div>
<div class="guide-card-body">No "Goodbye" = forced hunt. ZoZo overrides 50%.</div>
</div>
<div class="guide-card purple-l">
<div class="guide-card-title">Tarot Cards</div>
<div class="guide-card-body">Death (hunt 10%), Moon (→0% sanity), Sun (→100%), Fool (negates prev).</div>
</div>
<div class="guide-card acc-l">
<div class="guide-card-title">Summoning Circle</div>
<div class="guide-card-body">Light all 5 candles → ghost manifests → hunt after 5s.</div>
</div>
<div class="guide-card blue-l">
<div class="guide-card-title">Music Box</div>
<div class="guide-card-body">Triggers cursed hunt once/match. Can lure ghost during hunts.</div>
</div>
</div>
</div>
</div>
</div>
<!-- ADMIN -->
<div id="mode-admin" style="display:none;margin-top:24px;">
<div class="admin-header">
<div>
<div class="admin-title">⚙ Admin Dashboard</div>
<div class="admin-sub">Ghost Database Management · Nur für Whitelist-Admins</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-accent" onclick="openGhostForm(null)">+ New Ghost</button>
<button class="btn-sm" onclick="exportGhosts()">↓ Export</button>
</div>
</div>
<div class="stats-row" id="admin-stats"></div>
<div class="wl-section">
<div class="wl-title">🔐 Admin Whitelist</div>
<div id="wl-list" style="margin-bottom:12px;"></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" class="form-input" id="wl-id" placeholder="Discord ID (18-stellig)" style="width:210px;">
<input type="text" class="form-input" id="wl-name" placeholder="Username (optional)" style="width:160px;">
<button class="btn-success" onclick="addAdmin()">+ Hinzufügen</button>
</div>
<div class="form-hint" style="margin-top:8px;">Discord → Einstellungen → Erweitert → Entwicklermodus →
Rechtsklick auf Nutzer → ID kopieren</div>
</div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text" id="sub-title">Community Submissions</div>
<div class="divider-line"></div>
</div>
<div class="sub-list" id="admin-sub-list" style="margin-bottom:28px;"></div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Ghost Database</div>
<div class="divider-line"></div>
</div>
<div class="admin-ghost-list" id="admin-ghost-list"></div>
</div>
<!-- COMMUNITY -->
<div id="mode-user" style="display:none;margin-top:24px;max-width:820px;">
<div class="user-title">✦ Community Hub</div>
<div
style="font-family:'Share Tech Mono',monospace;font-size:9px;letter-spacing:3px;color:var(--muted);text-transform:uppercase;margin:4px 0 22px;">
Tips einreichen · Fehler melden · Infos ergänzen</div>
<div class="form-section" style="margin-bottom:28px;">
<div class="form-title">Etwas einreichen</div>
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label class="form-label">Geist</label><select class="form-select" id="usr-ghost">
<option value="">— Geist auswählen —</option>
</select></div>
<div class="form-group"><label class="form-label">Art</label>
<select class="form-select" id="usr-type">
<option value="tip">💡 Neuer Tip / Trick</option>
<option value="edit">✏️ Fehler / Korrektur</option>
<option value="new">🆕 Neuer Geist fehlt</option>
</select>
</div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Beschreibung</label>
<textarea class="form-textarea" id="usr-content"
placeholder="Beschreib deinen Tip so genau wie möglich..." rows="4"></textarea>
<span class="form-hint">Je detaillierter, desto besser für den Admin!</span>
</div>
</div>
<div class="form-footer"><button class="btn-blue" onclick="submitTip()">Einreichen →</button></div>
</div>
</div>
<div class="divider">
<div class="divider-line"></div>
<div class="divider-text">Deine Einreichungen</div>
<div class="divider-line"></div>
</div>
<div id="user-sub-list"></div>
</div>
</div>
<!-- Ghost View Modal -->
<div class="modal-bg" id="ghost-modal" onclick="if(event.target===this)closeModal('ghost-modal')">
<div class="modal-box"><button class="modal-close" onclick="closeModal('ghost-modal')"></button>
<div class="modal-hd">
<div class="modal-title" id="gm-title"></div>
<div class="modal-sub" id="gm-sub"></div>
</div>
<div class="modal-body" id="gm-body"></div>
</div>
</div>
<!-- Ghost Editor Modal -->
<div class="modal-bg" id="edit-modal" onclick="if(event.target===this)closeModal('edit-modal')">
<div class="modal-box wide"><button class="modal-close" onclick="closeModal('edit-modal')"></button>
<div class="modal-hd">
<div class="modal-title" id="em-title">Geist bearbeiten</div>
<div class="modal-sub" id="em-sub">Admin · Ghost Editor</div>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-row">
<div class="form-group"><label class="form-label">Name</label><input type="text" class="form-input"
id="ef-name"></div>
<div class="form-group"><label class="form-label">Sanity Threshold (%)</label><input type="number"
class="form-input" id="ef-sanity" min="0" max="100"></div>
</div>
<div class="form-row">
<div class="form-group"><label class="form-label">Hunt Type</label>
<select class="form-select" id="ef-hunt">
<option value="mid">Mid (50%)</option>
<option value="low">Low (≤35%)</option>
<option value="high">High (≥80%)</option>
<option value="variable">Variable</option>
</select>
</div>
<div class="form-group"><label class="form-label">Sanity Note</label><input type="text" class="form-input"
id="ef-ss" placeholder="z.B. Target's individual sanity"></div>
</div>
<div class="form-group"><label class="form-label">Evidence (genau 3 auswählen)</label>
<div class="ev-checks" id="ef-evs"></div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Kurz-Tip (Ghost Card)</label><textarea
class="form-textarea" id="ef-tip" rows="2"></textarea></div>
</div>
<div class="form-row full">
<div class="form-group"><label class="form-label">Beschreibung (Wiki)</label><textarea class="form-textarea"
id="ef-desc" rows="3"></textarea></div>
</div>
<div class="form-group">
<label class="form-label">Identification Tells</label>
<div class="tells-editor" id="ef-tells"></div>
<button class="add-tell-btn" onclick="addTellRow()">+ Tell hinzufügen</button>
</div>
<div class="form-footer">
<button class="btn-sm" onclick="closeModal('edit-modal')">Abbrechen</button>
<button class="btn-accent" onclick="saveGhost()">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="confirm-bg" id="confirm-bg">
<div class="confirm-box">
<div class="confirm-title" id="confirm-title">Sicher?</div>
<div class="confirm-text" id="confirm-text"></div>
<div class="confirm-btns"><button class="btn-sm" onclick="closeConfirm()">Abbrechen</button><button
class="btn-danger" id="confirm-ok">Bestätigen</button></div>
</div>
</div>
<div class="toast-wrap" id="toast-wrap"></div>
<script>
/* ── CONSTANTS ── */
const EV_KEYS = ["EMF", "Freezing", "Writing", "UV", "Orbs", "SpiritBox", "SLS"];
const EV_LABELS = { EMF: "EMF 5", Freezing: "Freezing", Writing: "Writing", UV: "UV", Orbs: "Orbs", SpiritBox: "Spirit Box", SLS: "SLS" };
/* ── STATE ── */
let ghosts = [], currentUser = null, ev_state = {}, editingId = null, confirmCb = null;
EV_KEYS.forEach(k => ev_state[k] = 0);
/* ── INIT ── */
async function init() {
showLoading("Lade...");
const r = await fetch('/auth/me');
const { user } = await r.json();
currentUser = user;
if (!user) { hideLoading(); showLogin(); return; }
await loadGhosts();
setupUI();
hideLoading();
document.getElementById('app').style.display = 'block';
}
function guestMode() {
hideLogin();
loadGhosts().then(() => {
setupGuestUI();
document.getElementById('app').style.display = 'block';
});
}
/* ── UI SETUP ── */
function setupUI() {
const u = currentUser;
const chip = document.getElementById('auth-chip');
chip.style.display = 'flex';
const av = document.getElementById('auth-av');
if (u.avatar) { av.src = u.avatar; av.style.display = 'block'; } else { av.style.display = 'none'; }
document.getElementById('auth-nm').textContent = u.username;
const rl = document.getElementById('auth-rl');
if (u.isAdmin) { rl.textContent = 'Admin'; rl.className = 'auth-role role-admin'; }
else { rl.textContent = 'User'; rl.className = 'auth-role role-user'; }
document.getElementById('pill-user').style.display = 'block';
if (u.isAdmin) document.getElementById('pill-admin').style.display = 'block';
buildCards(); buildCheat();
populateGhostSelect();
if (u.isAdmin) updateAdminBadge();
}
function setupGuestUI() {
document.getElementById('guest-badge').style.display = 'block';
buildCards(); buildCheat();
}
/* ── AUTH ── */
async function doLogout() {
await fetch('/auth/logout', { method: 'POST' });
location.reload();
}
/* ── FETCH HELPERS ── */
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(path, opts);
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Fehler');
return d;
}
/* ── GHOSTS ── */
async function loadGhosts() {
const data = await api('GET', '/api/ghosts');
ghosts = data.map(row => ({
...row,
evidence: Array.isArray(row.evidence) ? row.evidence : (typeof row.evidence === 'string' ? JSON.parse(row.evidence) : []),
tells: Array.isArray(row.tells) ? row.tells : (typeof row.tells === 'string' ? JSON.parse(row.tells) : []),
}));
}
function sbC(g) { return g.hunt === 'high' ? 'sb-hi' : g.hunt === 'low' ? 'sb-lo' : g.hunt === 'variable' ? 'sb-va' : 'sb-mi'; }
function buildFilter() {
const bar = document.getElementById('ev-filter'), lbl = bar.querySelector('.filter-label-sm');
bar.innerHTML = ''; bar.appendChild(lbl);
EV_KEYS.forEach(ev => {
const cnt = ghosts.filter(g => g.evidence.includes(ev)).length;
const b = document.createElement('button'); b.className = 'ev-btn'; b.dataset.ev = ev;
b.innerHTML = `${EV_LABELS[ev]} <span class="ev-num">${cnt}</span>`;
b.onclick = () => { ev_state[ev] = (ev_state[ev] + 1) % 3; b.classList.toggle('ef', ev_state[ev] === 1); b.classList.toggle('ex', ev_state[ev] === 2); applyFilter(); };
bar.appendChild(b);
});
const rb = document.createElement('button'); rb.className = 'btn-sm'; rb.textContent = 'Reset';
rb.onclick = () => { EV_KEYS.forEach(k => ev_state[k] = 0); document.querySelectorAll('.ev-btn').forEach(b => b.classList.remove('ef', 'ex')); applyFilter(); };
bar.appendChild(rb);
const ri = document.createElement('div'); ri.className = 'result-bar'; ri.id = 'result-bar'; bar.appendChild(ri);
}
function applyFilter() {
const found = EV_KEYS.filter(k => ev_state[k] === 1), excl = EV_KEYS.filter(k => ev_state[k] === 2);
let m = 0;
document.querySelectorAll('.ghost-card').forEach(c => {
const g = ghosts[c.dataset.idx]; if (!g) return;
const ok = !found.some(e => !g.evidence.includes(e)) && !excl.some(e => g.evidence.includes(e));
const act = found.length || excl.length;
c.classList.toggle('hl', act && ok); c.classList.toggle('dm', act && !ok);
if (ok && act) m++;
c.querySelectorAll('.ev-pill').forEach(p => { p.classList.toggle('pf', ev_state[p.dataset.ev] === 1); p.classList.toggle('px', ev_state[p.dataset.ev] === 2); });
});
const ri = document.getElementById('result-bar');
if (ri) { ri.style.display = (found.length || excl.length) ? 'block' : 'none'; ri.innerHTML = `<span>${m}</span> possible ghost${m !== 1 ? 's' : ''} matching evidence`; }
}
function buildCards() {
buildFilter();
const grid = document.getElementById('ghost-grid'); grid.innerHTML = '';
ghosts.forEach((g, i) => {
const d = document.createElement('div'); d.className = 'ghost-card'; d.dataset.idx = i;
d.innerHTML = `<div class="card-hd"><div><div class="card-name">${g.name}</div><div class="card-num">Ghost #${String(i + 1).padStart(2, '0')}</div></div><div class="sb ${sbC(g)}">${g.sanity}%${g.hunt === 'variable' ? '↕' : ''}</div></div><div class="card-evs">${g.evidence.map(e => `<span class="ev-pill" data-ev="${e}">${EV_LABELS[e] || e}</span>`).join('')}</div><div class="card-tip">${g.tip || ''}</div>`;
d.onclick = () => openViewModal(i); grid.appendChild(d);
});
}
function buildCheat() {
const tb = document.getElementById('cheat-tbody'); tb.innerHTML = '';
ghosts.forEach((g, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td onclick="openViewModal(${i})">${g.name}</td>${EV_KEYS.map(e => `<td>${g.evidence.includes(e) ? '<span class="chk">✓</span>' : ''}</td>`).join('')}<td><span class="scc ${sbC(g)}">${g.sanity}%${g.hunt === 'variable' ? '↕' : ''}</span></td><td class="tip-cell">${(g.tip || '').split(' · ').map(t => `<div>· ${t}</div>`).join('')}</td>`;
tb.appendChild(tr);
});
}
function populateGhostSelect() {
const sel = document.getElementById('usr-ghost');
sel.innerHTML = '<option value="">— Geist auswählen —</option>' + ghosts.map(g => `<option value="${g.name}">${g.name}</option>`).join('');
}
function rebuildAll() { buildCards(); buildCheat(); populateGhostSelect(); }
/* ── VIEW MODAL ── */
function openViewModal(idx) {
const g = ghosts[idx]; if (!g) return;
const sc = g.sanity >= 80 ? 'var(--red2)' : g.sanity <= 35 ? 'var(--green2)' : 'var(--accent2)';
document.getElementById('gm-title').textContent = g.name.toUpperCase();
document.getElementById('gm-sub').textContent = 'Evidence · ' + g.evidence.map(e => EV_LABELS[e] || e).join(' · ');
document.getElementById('gm-body').innerHTML = `
<div class="wiki-sec"><div class="wiki-hd">Overview</div><div class="wiki-text">${g.description || 'No description yet.'}</div></div>
<div class="wiki-sec"><div class="wiki-hd">Evidence</div><div class="wiki-ev-row">${g.evidence.map(e => `<span class="wiki-ev-p">${EV_LABELS[e] || e}</span>`).join('')}</div></div>
<div class="wiki-sec"><div class="wiki-hd">Hunt Threshold</div><div class="sanity-blk"><span class="sanity-pct" style="color:${sc}">${g.sanity}%${g.hunt === 'variable' ? ' ↕' : ''}</span><span class="sanity-note">${g.sanity_special || ''}</span></div></div>
<div class="wiki-sec"><div class="wiki-hd">Identification Tells</div><div class="tells-list">${(g.tells || []).map(t => `<div class="tell"><span class="tell-ico">${t.icon}</span><span class="tell-text">${t.text}</span></div>`).join('')}</div></div>`;
openModal('ghost-modal');
}
/* ── MODE ── */
function setMode(m) {
['viewer', 'admin', 'user'].forEach(x => document.getElementById('mode-' + x).style.display = x === m ? 'block' : 'none');
document.querySelectorAll('.mode-pill').forEach(p => p.classList.remove('active'));
const mp = { viewer: document.querySelectorAll('.mode-pill')[0], user: document.getElementById('pill-user'), admin: document.getElementById('pill-admin') };
if (mp[m]) mp[m].classList.add('active');
if (m === 'admin') renderAdmin();
if (m === 'user') { populateGhostSelect(); renderUserSubs(); }
}
function switchTab(t) {
['cards', 'cheat', 'guide'].forEach((x, i) => {
document.getElementById('vtab-' + x).classList.toggle('active', x === t);
document.querySelectorAll('#vnav .nav-btn')[i].classList.toggle('active', x === t);
});
}
/* ── ADMIN ── */
async function renderAdmin() {
// Stats
try {
const s = await api('GET', '/admin/stats');
document.getElementById('admin-stats').innerHTML = `
<div class="stat-card"><div class="stat-val">${s.ghosts}</div><div class="stat-label">Ghosts</div></div>
<div class="stat-card"><div class="stat-val">${s.total}</div><div class="stat-label">Submissions</div></div>
<div class="stat-card"><div class="stat-val" style="color:${s.pending ? 'var(--accent2)' : 'var(--muted2)'}">${s.pending}</div><div class="stat-label">Pending</div></div>
<div class="stat-card"><div class="stat-val">${s.approved}</div><div class="stat-label">Approved</div></div>
<div class="stat-card"><div class="stat-val">${s.rejected}</div><div class="stat-label">Rejected</div></div>`;
// badge
const b = document.getElementById('badge-admin'); b.textContent = s.pending; b.classList.toggle('show', s.pending > 0);
} catch { }
// Whitelist
try {
const wl = await api('GET', '/admin/whitelist');
const wlEl = document.getElementById('wl-list');
if (!wl.length) { wlEl.innerHTML = '<div style="font-size:13px;color:var(--muted);font-style:italic;">Noch keine weiteren Admins.</div>'; }
else { wlEl.innerHTML = wl.map(w => `<div class="wl-row"><span style="font-size:16px">⚙</span><span class="wl-name">${escH(w.discord_username || '—')}</span><span class="wl-id">${w.discord_id}</span><span class="wl-date">${fmtTime(w.added_at)}</span><button class="icon-btn ibd" onclick="removeAdmin('${w.discord_id}')" title="Entfernen">🗑</button></div>`).join(''); }
} catch { }
// Submissions (pending)
try {
const subs = await api('GET', '/admin/submissions?status=pending');
const sl = document.getElementById('admin-sub-list');
document.getElementById('sub-title').innerHTML = `Community Submissions${subs.length ? `<span class="badge-count">${subs.length}</span>` : ''}`;
if (!subs.length) { sl.innerHTML = '<div class="empty-state">Keine ausstehenden Einreichungen 📭</div>'; }
else {
sl.innerHTML = ''; subs.forEach(sub => {
const tl = sub.type === 'tip' ? '💡 Tip' : sub.type === 'edit' ? '✏️ Korrektur' : '🆕 Neuer Geist';
const tc = sub.type === 'tip' ? 'stt' : sub.type === 'edit' ? 'ste' : 'stn';
const d = document.createElement('div'); d.className = 'sub-item';
d.innerHTML = `<div class="sub-meta"><span class="sub-ghost">${escH(sub.ghost_name || 'Allgemein')}</span><span class="sub-type ${tc}">${tl}</span><span style="font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted)">von ${escH(sub.username || 'Anonym')}</span><span class="sub-time">${fmtTime(sub.created_at)}</span></div><div class="sub-content">${escH(sub.content)}</div><div class="sub-actions"><button class="btn-success" onclick="approveSub(${sub.id})">✓ Annehmen</button><button class="btn-danger" onclick="rejectSub(${sub.id})">✕ Ablehnen</button><button class="btn-accent" onclick="injectSub(${sub.id},'${escH(sub.ghost_name)}')">→ Bearbeiten & Einfügen</button></div>`;
sl.appendChild(d);
});
}
} catch { }
// Ghost list
const gl = document.getElementById('admin-ghost-list'); gl.innerHTML = '';
ghosts.forEach(g => {
const row = document.createElement('div'); row.className = 'agr';
row.innerHTML = `<div class="agr-name">${g.name}</div><div class="agr-evs">${g.evidence.map(e => `<span class="agr-ev">${EV_LABELS[e] || e}</span>`).join('')}</div><div class="agr-meta">von ${g.updated_by || '—'}</div><div class="agr-btns"><button class="icon-btn ibe" onclick="openGhostForm('${g.id}')" title="Bearbeiten">✏</button><button class="icon-btn ibd" onclick="confirmDel('${g.id}')" title="Löschen">🗑</button></div>`;
gl.appendChild(row);
});
}
async function updateAdminBadge() {
try { const s = await api('GET', '/admin/stats'); const b = document.getElementById('badge-admin'); b.textContent = s.pending; b.classList.toggle('show', s.pending > 0); } catch { }
}
async function addAdmin() {
const id = document.getElementById('wl-id').value.trim(), name = document.getElementById('wl-name').value.trim();
if (!id || id.length < 10) { toast("Gültige Discord ID eingeben!", "danger"); return; }
try { await api('POST', '/admin/whitelist', { discord_id: id, discord_username: name || id }); toast("Admin hinzugefügt ✓", "success"); document.getElementById('wl-id').value = ''; document.getElementById('wl-name').value = ''; renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
}
async function removeAdmin(id) {
showConfirm("Admin entfernen?", "Diese Person verliert sofort den Admin-Zugriff.", async () => {
try { await api('DELETE', `/admin/whitelist/${id}`); toast("Admin entfernt.", "danger"); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
});
}
async function approveSub(id) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); toast("Angenommen ✓", "success"); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
}
async function rejectSub(id) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'rejected' }); toast("Abgelehnt.", "danger"); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
}
async function injectSub(id, ghostName) {
try { await api('PATCH', `/admin/submissions/${id}`, { status: 'approved' }); } catch { }
const g = ghosts.find(x => x.name === ghostName);
openGhostForm(g?.id || null);
setTimeout(() => toast("💡 Submission-Info anzeigen: prüfe den Tip!", "info"), 300);
renderAdmin();
}
/* ── GHOST EDITOR ── */
function openGhostForm(id) {
const g = id ? ghosts.find(x => x.id === id) : null;
editingId = id || null;
document.getElementById('em-title').textContent = g ? `Bearbeiten: ${g.name}` : 'Neuen Geist erstellen';
document.getElementById('em-sub').textContent = g ? `Admin · Ghost Editor · ID: ${g.id}` : 'Admin · Ghost Editor · Neu';
document.getElementById('ef-name').value = g?.name || '';
document.getElementById('ef-sanity').value = g?.sanity ?? 50;
document.getElementById('ef-hunt').value = g?.hunt || 'mid';
document.getElementById('ef-ss').value = g?.sanity_special || '';
document.getElementById('ef-tip').value = g?.tip || '';
document.getElementById('ef-desc').value = g?.description || '';
const evDiv = document.getElementById('ef-evs'); evDiv.innerHTML = '';
EV_KEYS.forEach(ev => {
const ck = g?.evidence.includes(ev);
const lbl = document.createElement('label'); lbl.className = 'ev-check-lbl' + (ck ? ' ck' : '');
lbl.innerHTML = `<input type="checkbox" value="${ev}"${ck ? ' checked' : ''}>${EV_LABELS[ev]}`;
lbl.querySelector('input').onchange = e => lbl.classList.toggle('ck', e.target.checked);
evDiv.appendChild(lbl);
});
const td = document.getElementById('ef-tells'); td.innerHTML = '';
(g?.tells || [{ icon: '💡', text: '' }]).forEach(t => addTellRow(t.icon, t.text));
openModal('edit-modal');
}
function addTellRow(icon = '💡', text = '') {
const td = document.getElementById('ef-tells');
const row = document.createElement('div'); row.className = 'tell-row';
row.innerHTML = `<input type="text" class="form-input tell-ico-in" value="${escH(icon)}" placeholder="🔥" maxlength="4"><input type="text" class="form-input" value="${escH(text)}" placeholder="Tell text... use &lt;strong&gt;bold&lt;/strong&gt;"><button class="remove-btn" onclick="this.parentElement.remove()">✕</button>`;
td.appendChild(row);
}
async function saveGhost() {
const name = document.getElementById('ef-name').value.trim();
if (!name) { toast("Name darf nicht leer sein!", "danger"); return; }
const evs = Array.from(document.querySelectorAll('#ef-evs input:checked')).map(i => i.value);
if (evs.length !== 3) { toast(`Genau 3 Evidenzen auswählen (aktuell: ${evs.length})!`, "danger"); return; }
const tells = Array.from(document.querySelectorAll('.tell-row')).map(row => { const ins = row.querySelectorAll('input'); return { icon: ins[0].value || '💡', text: ins[1].value }; }).filter(t => t.text.trim());
const data = {
id: editingId || name.toLowerCase().replace(/[^a-z0-9]/g, '') + Date.now().toString(36),
name, evidence: evs, sanity: parseInt(document.getElementById('ef-sanity').value) || 50,
hunt: document.getElementById('ef-hunt').value,
sanity_special: document.getElementById('ef-ss').value,
tip: document.getElementById('ef-tip').value,
description: document.getElementById('ef-desc').value,
tells
};
showLoading("Speichere...");
try {
if (editingId) { await api('PUT', `/admin/ghosts/${editingId}`, data); }
else { await api('POST', '/admin/ghosts', data); }
toast(`${name} gespeichert!`, "success");
closeModal('edit-modal');
await loadGhosts(); rebuildAll(); renderAdmin();
} catch (e) { toast(e.message, "danger"); }
hideLoading();
}
async function confirmDel(id) {
const g = ghosts.find(x => x.id === id); if (!g) return;
showConfirm(`${g.name} löschen?`, `Dauerhaft aus der Datenbank entfernen.`, async () => {
showLoading("Lösche...");
try { await api('DELETE', `/admin/ghosts/${id}`); toast(`${g.name} gelöscht.`, "danger"); await loadGhosts(); rebuildAll(); renderAdmin(); }
catch (e) { toast(e.message, "danger"); }
hideLoading();
});
}
/* ── COMMUNITY ── */
async function submitTip() {
const ghost = document.getElementById('usr-ghost').value, type = document.getElementById('usr-type').value, content = document.getElementById('usr-content').value.trim();
if (!content) { toast("Bitte Inhalt eingeben!", "danger"); return; }
showLoading("Sende...");
try { await api('POST', '/api/submissions', { ghost_name: ghost, type, content }); toast("Einreichung gesendet ✓", "success"); document.getElementById('usr-content').value = ''; renderUserSubs(); }
catch (e) { toast(e.message, "danger"); }
hideLoading();
}
async function renderUserSubs() {
const ul = document.getElementById('user-sub-list');
try {
const subs = await api('GET', '/api/submissions/mine');
if (!subs.length) { ul.innerHTML = '<div class="empty-state">📭 Du hast noch nichts eingereicht.</div>'; return; }
ul.innerHTML = '';
subs.forEach(sub => {
const tl = sub.type === 'tip' ? '💡 Tip' : sub.type === 'edit' ? '✏️ Korrektur' : '🆕 Neuer Geist';
const sc = sub.status === 'approved' ? 'ssa' : sub.status === 'rejected' ? 'ssr' : 'ssp';
const sl = sub.status === 'approved' ? '✓ Angenommen' : sub.status === 'rejected' ? '✕ Abgelehnt' : '⏳ Ausstehend';
const d = document.createElement('div'); d.className = 'usi';
d.innerHTML = `<div class="usi-meta"><span style="font-family:'Cinzel',serif;font-size:13px;color:var(--accent2)">${escH(sub.ghost_name || 'Allgemein')}</span><span style="font-family:'Share Tech Mono',monospace;font-size:9px;color:var(--muted2)">${tl}</span><span class="usi-st ${sc}">${sl}</span><span class="sub-time">${fmtTime(sub.created_at)}</span></div><div style="font-size:13.5px;color:var(--muted2);line-height:1.55;font-style:italic">${escH(sub.content)}</div>${sub.admin_note ? `<div style="font-size:12px;color:var(--accent2);margin-top:6px">Admin: ${escH(sub.admin_note)}</div>` : ''}`;
ul.appendChild(d);
});
const approved = subs.filter(s => s.status === 'approved').length;
const ub = document.getElementById('badge-user'); ub.textContent = approved; ub.classList.toggle('show', approved > 0);
} catch { ul.innerHTML = '<div class="empty-state">Fehler beim Laden.</div>'; }
}
/* ── EXPORT ── */
function exportGhosts() { const b = new Blob([JSON.stringify({ ghosts }, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'blair-ghosts.json'; a.click(); toast("Exportiert ↓", "info"); }
/* ── MODAL / CONFIRM / TOAST ── */
function openModal(id) { document.getElementById(id).classList.add('open'); document.body.style.overflow = 'hidden'; }
function closeModal(id) { document.getElementById(id).classList.remove('open'); document.body.style.overflow = ''; }
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal('ghost-modal'); closeModal('edit-modal'); closeConfirm(); } });
function showConfirm(title, text, cb) { document.getElementById('confirm-title').textContent = title; document.getElementById('confirm-text').textContent = text; confirmCb = cb; document.getElementById('confirm-bg').classList.add('open'); }
function closeConfirm() { document.getElementById('confirm-bg').classList.remove('open'); confirmCb = null; }
document.getElementById('confirm-ok').onclick = () => { if (confirmCb) confirmCb(); closeConfirm(); };
function toast(msg, type = 'info') { const w = document.getElementById('toast-wrap'), t = document.createElement('div'); t.className = `toast toast-${type}`; t.textContent = msg; w.appendChild(t); setTimeout(() => { t.style.cssText = 'opacity:0;transition:opacity .3s'; setTimeout(() => t.remove(), 350); }, 3400); }
/* ── LOADING / LOGIN ── */
function showLoading(msg = 'Lade...') { document.getElementById('loading-txt').textContent = msg; document.getElementById('loading').classList.add('show'); }
function hideLoading() { document.getElementById('loading').classList.remove('show'); }
function showLogin() { document.getElementById('login-screen').classList.add('show'); }
function hideLogin() { document.getElementById('login-screen').classList.remove('show'); }
/* ── UTILS ── */
function fmtTime(ts) { if (!ts) return '—'; const d = new Date(ts); return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); }
function escH(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
/* ── BOOT ── */
init();
</script>
</body>
</html>