Files
xxx-sphere-web/bin/main/static/admin/admin.html
Mario 87c85b1b17
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
Bugfixes, Dating angefangen
2026-04-01 22:06:46 +02:00

2607 lines
145 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Administration xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Tabs ── */
.tabs { display:flex; gap:0; margin-bottom:1.5rem; border-bottom:1px solid var(--color-secondary); }
.tab-btn { background:none; border:none; border-bottom:3px solid transparent; border-radius:0;
padding:0.6rem 1.25rem; font-size:0.95rem; font-weight:600; color:var(--color-muted);
cursor:pointer; margin-bottom:-1px; transition:color .15s,border-color .15s; }
.tab-btn:hover { color:var(--color-text); background:none; }
.tab-btn.active { color:var(--color-primary); border-bottom-color:var(--color-primary); }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
/* ── Meldungen-Tabelle ── */
.data-table { width:100%; border-collapse:collapse; font-size:0.9rem; }
.data-table th { text-align:left; padding:0.55rem 0.9rem;
background:var(--color-secondary); color:var(--color-text); font-weight:600; }
.data-table td { padding:0.55rem 0.9rem; border-bottom:1px solid var(--color-secondary); vertical-align:top; }
.data-table tr:last-child td { border-bottom:none; }
.data-table tr:hover td { background:rgba(255,255,255,0.03); }
.table-card { background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; overflow:hidden; margin-bottom:1.25rem; overflow-x:auto; }
/* ── Admins-Formular ── */
.form-section { background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; padding:1rem; margin-bottom:1.25rem; }
.form-section h3 { margin:0 0 0.9rem 0; font-size:0.95rem; color:var(--color-muted);
text-transform:uppercase; letter-spacing:.06em; font-weight:600; }
.form-row { display:flex; flex-wrap:wrap; gap:0.65rem; align-items:flex-end; }
.form-row input, .form-row select {
flex:1 1 180px; padding:0.45rem 0.75rem; border-radius:6px;
border:1px solid var(--color-secondary); background:var(--color-secondary);
color:var(--color-text); font-size:0.9rem; font-family:inherit; outline:none; transition:border-color .2s; }
.form-row input:focus, .form-row select:focus { border-color:var(--color-primary); }
.form-row button { flex:0 0 auto; margin:0; width:auto; padding:0.45rem 1.1rem; }
/* ── Status-Badges (Meldungen / Admins) ── */
.badge-status { display:inline-block; padding:.15rem .55rem; border-radius:4px; font-size:.78rem; font-weight:600; }
.badge-offen { background:var(--color-primary); color:#fff; }
.badge-bearbeitet { background:var(--color-success,#2ecc71); color:#fff; }
.badge-abgelehnt { background:var(--color-muted,#666); color:#fff; }
.badge-admin { background:var(--color-secondary); color:var(--color-text); }
.badge-superadmin { background:var(--color-primary); color:#fff; }
/* ── Action-Buttons in Tabellen ── */
.tbl-btn { background:none; border:1px solid var(--color-secondary); color:var(--color-muted);
border-radius:5px; padding:.25rem .65rem; font-size:.82rem; cursor:pointer;
margin:0 .2rem 0 0; width:auto; transition:border-color .15s,color .15s; }
.tbl-btn:hover { border-color:var(--color-primary); color:var(--color-primary); background:none; }
.tbl-btn.tbl-btn-ok { border-color:var(--color-success,#2ecc71); color:var(--color-success,#2ecc71); }
.tbl-btn.tbl-btn-ok:hover { background:var(--color-success,#2ecc71); color:#fff; }
/* ── Filter-Zeile ── */
.filter-row { display:flex; align-items:center; gap:.65rem; margin-bottom:1rem; flex-wrap:wrap; }
.filter-row label { font-size:.85rem; color:var(--color-muted); }
.filter-row select { padding:.4rem .7rem; border-radius:6px;
border:1px solid var(--color-secondary); background:var(--color-secondary);
color:var(--color-text); font-size:.9rem; outline:none; transition:border-color .2s; }
.filter-row select:focus { border-color:var(--color-primary); }
.empty-hint { color:var(--color-muted); font-size:.9rem; padding:.75rem 0; }
.superadmin-only { display:none; }
.word-break { word-break:break-all; font-size:.78rem; }
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:0.6rem; padding-bottom:0.5rem; border-bottom:1px solid var(--color-secondary); }
.section-title { font-size:1.1rem; font-weight:600; color:var(--color-primary); margin:0; }
.section-actions { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; }
.btn-add { display:flex; align-items:center; gap:0.4rem; background:var(--color-primary); color:#fff;
border:none; border-radius:6px; padding:0.4rem 0.85rem; font-size:0.85rem; font-weight:600;
cursor:pointer; transition:background 0.15s; }
.btn-add:hover { background:#c73652; }
.btn-action { background:var(--color-secondary); color:var(--color-text); border:none; border-radius:6px;
padding:0.4rem 0.85rem; font-size:0.85rem; font-weight:600; cursor:pointer;
transition:background 0.15s,color 0.15s,opacity 0.15s; }
.btn-action:disabled { opacity:0.35; cursor:default; }
.btn-action:not(:disabled):hover { background:var(--color-primary); color:#fff; }
.btn-action-danger:not(:disabled):hover { background:rgba(233,69,96,0.18); color:var(--color-primary); }
.btn-sub-add { background:none; border:1px solid var(--color-secondary); border-radius:5px;
color:var(--color-muted); font-size:0.75rem; padding:0.15rem 0.5rem;
cursor:pointer; transition:border-color 0.15s,color 0.15s; }
.btn-sub-add:hover { border-color:var(--color-primary); color:var(--color-primary); }
.action-error { font-size:0.82rem; color:var(--color-primary); min-height:1.1em; margin-bottom:0.4rem; }
.empty, .loading { color:var(--color-muted); font-size:0.9rem; padding:0.75rem 0; }
/* ── Toy-Grid ── */
.toy-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(270px,1fr)); gap:0.85rem; }
.toy-card { display:flex; align-items:center; gap:0.85rem; background:var(--color-card);
border:1px solid var(--color-secondary); border-radius:10px; padding:0.8rem 0.9rem;
transition:border-color 0.15s; cursor:pointer; }
.toy-card:hover { border-color:var(--color-primary); }
.toy-card.selected { border-color:var(--color-primary); background:rgba(233,69,96,0.06); }
.toy-img { width:52px; height:52px; border-radius:7px; object-fit:cover; flex-shrink:0; }
.toy-img-placeholder { width:52px; height:52px; border-radius:7px; background:var(--color-secondary);
display:flex; align-items:center; justify-content:center; font-size:1.4rem; flex-shrink:0; color:var(--color-muted); }
.toy-info { flex:1; min-width:0; }
.toy-name { font-size:0.9rem; font-weight:600; color:var(--color-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.toy-desc { font-size:0.78rem; color:var(--color-muted); margin-top:0.2rem;
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
/* ── Gruppe-Card ── */
.gruppe-list { display:flex; flex-direction:column; gap:0.75rem; }
.gruppe-card { background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:10px; overflow:hidden; transition:border-color 0.15s; }
.gruppe-card.selected { border-color:var(--color-primary); background:rgba(233,69,96,0.05); }
.gruppe-header { display:flex; align-items:center; gap:0.9rem; padding:0.85rem 1rem; cursor:pointer; user-select:none; }
.gruppe-img { width:48px; height:48px; border-radius:7px; object-fit:cover; flex-shrink:0; }
.gruppe-img-placeholder { width:48px; height:48px; border-radius:7px; background:var(--color-secondary);
display:flex; align-items:center; justify-content:center; font-size:1.3rem; flex-shrink:0; color:var(--color-muted); }
.gruppe-meta { flex:1; min-width:0; }
.gruppe-name { font-size:0.95rem; font-weight:600; color:var(--color-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.gruppe-info { font-size:0.75rem; color:var(--color-muted); margin-top:0.2rem; }
.gruppe-badges { display:flex; gap:0.3rem; margin-top:0.25rem; flex-wrap:wrap; }
.gruppe-badge { font-size:0.65rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-muted); }
.gruppe-badge-public { background:rgba(46,204,113,0.15); color:var(--color-success); }
.gruppe-toggle { font-size:0.75rem; color:var(--color-muted); flex-shrink:0; transition:transform 0.2s; }
.gruppe-card.open .gruppe-toggle { transform:rotate(90deg); }
.gruppe-body { border-top:1px solid var(--color-secondary); padding:1rem 1rem 0.75rem; }
.gruppe-desc { font-size:0.82rem; color:var(--color-muted); margin-bottom:0.85rem; line-height:1.5; }
.sub-section + .sub-section { margin-top:0.85rem; }
.sub-section-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem; }
.sub-section-title { font-size:0.72rem; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--color-primary); }
.sub-empty { font-size:0.78rem; color:var(--color-muted); padding:0.2rem 0; }
/* ── Items ── */
.item-list { display:flex; flex-direction:column; gap:0.3rem; }
.item { border-radius:6px; background:var(--color-secondary); overflow:hidden; }
.item-row { display:flex; align-items:center; justify-content:space-between; gap:0.75rem;
padding:0.35rem 0.6rem; cursor:pointer; user-select:none; transition:background 0.12s; }
.item-row:hover { background:rgba(255,255,255,0.04); }
.item.open .item-row { background:rgba(233,69,96,0.08); }
.item-text { color:var(--color-text); flex:1; min-width:0; font-size:0.82rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.item-badges { display:flex; gap:0.35rem; flex-shrink:0; }
.badge { font-size:0.7rem; padding:0.1rem 0.45rem; border-radius:20px;
background:rgba(233,69,96,0.15); color:var(--color-primary); white-space:nowrap; }
.badge-neutral { background:rgba(255,255,255,0.07); color:var(--color-muted); }
.item-detail { display:none; padding:0.5rem 0.6rem 0.6rem; border-top:1px solid rgba(255,255,255,0.06);
font-size:0.8rem; color:var(--color-muted); line-height:1.55; }
.item.open .item-detail { display:block; }
.item-detail-text { margin-bottom:0.4rem; color:var(--color-text); white-space:pre-wrap; }
.item-detail-row { display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; margin-top:0.25rem; }
.item-detail-label { font-size:0.72rem; color:var(--color-muted); }
.item-detail-chip { font-size:0.7rem; padding:0.1rem 0.5rem; border-radius:20px; background:rgba(255,255,255,0.07); color:var(--color-text); }
.item-detail-chip-toy { background:rgba(233,69,96,0.12); color:var(--color-primary); }
.item-action-btns { display:flex; gap:0.4rem; margin-top:0.5rem; justify-content:space-between; }
.btn-item-edit { background:none; border:1px solid rgba(136,136,136,0.45); border-radius:5px;
color:var(--color-muted); font-size:0.75rem; padding:0.2rem 0.6rem; cursor:pointer; transition:border-color 0.15s,color 0.15s; }
.btn-item-edit:hover { border-color:var(--color-text); color:var(--color-text); }
.btn-item-delete { background:none; border:1px solid rgba(233,69,96,0.4); border-radius:5px;
color:var(--color-primary); font-size:0.75rem; padding:0.2rem 0.6rem; cursor:pointer; transition:background 0.15s; }
.btn-item-delete:hover { background:rgba(233,69,96,0.15); }
/* ── Modal ── */
.modal-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200;
align-items:center; justify-content:center; }
.modal-backdrop.open { display:flex; }
.modal { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:12px;
padding:2rem; width:100%; max-width:460px; box-shadow:0 12px 40px rgba(0,0,0,0.6);
max-height:90vh; overflow-y:auto; }
.modal h2 { color:var(--color-primary); font-size:1.1rem; margin-bottom:1.25rem; }
.modal label { display:block; font-size:0.8rem; color:#aaa; margin-top:1rem; margin-bottom:0.3rem; }
.modal input[type="text"], .modal input[type="number"], .modal textarea {
width:100%; padding:0.6rem 0.85rem; border:1px solid var(--color-secondary); border-radius:6px;
background:var(--color-secondary); color:var(--color-text); font-size:0.95rem; outline:none;
transition:border-color 0.2s; resize:vertical; box-sizing:border-box; }
.modal input[type="text"]:focus, .modal input[type="number"]:focus, .modal textarea:focus { border-color:var(--color-primary); }
.modal input[type="file"] { font-size:0.85rem; color:var(--color-muted); margin-top:0.25rem; }
.modal-actions { display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.5rem; }
.modal-actions .btn-cancel { background:var(--color-secondary); color:var(--color-text); border:none;
border-radius:6px; padding:0.55rem 1.1rem; font-size:0.9rem; cursor:pointer; transition:background 0.15s; }
.modal-actions .btn-cancel:hover { background:#1a4a8a; }
.modal-actions .btn-save { background:var(--color-primary); color:#fff; border:none;
border-radius:6px; padding:0.55rem 1.1rem; font-size:0.9rem; font-weight:600; cursor:pointer; transition:background 0.15s; }
.modal-actions .btn-save:hover { background:#c73652; }
.modal-actions .btn-save:disabled { opacity:0.5; cursor:default; }
.modal-error { color:var(--color-primary); font-size:0.82rem; margin-top:0.75rem; display:none; }
/* ── Item-Modal extras ── */
.label-with-hint { display:flex; align-items:center; gap:0.4rem; }
.btn-hint { background:none; border:1px solid rgba(136,136,136,0.4); border-radius:50%;
color:var(--color-muted); font-size:0.7rem; font-style:italic; font-weight:700;
width:16px; height:16px; line-height:1; padding:0; cursor:pointer; flex-shrink:0; transition:border-color 0.15s,color 0.15s; }
.btn-hint:hover { border-color:var(--color-primary); color:var(--color-primary); }
.placeholder-hint { background:rgba(255,255,255,0.04); border:1px solid rgba(136,136,136,0.25);
border-radius:6px; padding:0.5rem 0.7rem; margin-bottom:0.3rem;
font-size:0.78rem; color:var(--color-muted); line-height:1.6; }
.placeholder-hint code { background:rgba(233,69,96,0.12); color:var(--color-primary);
border-radius:3px; padding:0.05rem 0.3rem; font-size:0.75rem; }
#iTextAC { position:fixed; z-index:9999; background:var(--color-surface,#1e1e2e);
border:1px solid var(--color-border,#444); border-radius:6px;
box-shadow:0 4px 14px rgba(0,0,0,.5); display:none; overflow:hidden; min-width:180px; max-height:280px; overflow-y:auto; }
.ac-item { padding:0.45rem 0.9rem; cursor:pointer; font-size:0.88rem;
font-family:monospace; color:var(--color-text,#cdd6f4); user-select:none; }
.ac-item:hover, .ac-item-active { background:var(--color-primary,#cba6f7); color:#1e1e2e; }
.ac-separator { padding:0.2rem 0.7rem; font-size:0.72rem; color:var(--color-muted);
text-transform:uppercase; letter-spacing:0.05em; background:rgba(255,255,255,0.04);
pointer-events:none; border-top:1px solid rgba(136,136,136,0.2); margin-top:2px; }
.modal-two-col { display:flex; gap:0.75rem; }
.modal-two-col > * { flex:1; }
.werkzeug-checks { display:flex; flex-wrap:nowrap; gap:0.25rem; margin-top:0.5rem; }
.werkzeug-check { flex:1; display:flex; flex-direction:column; align-items:center; gap:0.3rem;
font-size:0.73rem; cursor:pointer; text-align:center; line-height:1.2; }
.werkzeug-check input[type="checkbox"] { accent-color:var(--color-primary); width:15px; height:15px; flex-shrink:0; }
.selected-toys-row { display:flex; flex-wrap:wrap; gap:0.35rem; margin-top:0.4rem; min-height:1.2rem; }
.sel-toy-chip { display:inline-flex; align-items:center; gap:0.3rem; padding:0.18rem 0.5rem 0.18rem 0.55rem;
border-radius:20px; font-size:0.78rem; border:1px solid rgba(233,69,96,0.5);
background:rgba(233,69,96,0.1); color:var(--color-text); }
.sel-toy-chip img { width:16px; height:16px; border-radius:3px; object-fit:cover; flex-shrink:0; }
.sel-toy-chip .toy-remove { background:none; border:none; color:var(--color-muted); cursor:pointer;
padding:0; font-size:0.75rem; line-height:1; margin-left:0.1rem; }
.sel-toy-chip .toy-remove:hover { color:var(--color-primary); }
.btn-toy-add { background:none; border:1px dashed rgba(136,136,136,0.5); border-radius:20px;
color:var(--color-muted); font-size:0.78rem; padding:0.18rem 0.7rem;
cursor:pointer; margin-top:0.4rem; transition:border-color 0.15s,color 0.15s; }
.btn-toy-add:hover { border-color:var(--color-primary); color:var(--color-primary); }
.toy-search-input { width:100%; box-sizing:border-box; padding:0.45rem 0.7rem;
background:var(--color-secondary); border:1px solid rgba(136,136,136,0.3);
border-radius:6px; color:var(--color-text); font-size:0.9rem; margin-bottom:0.75rem; }
.toy-search-input:focus { outline:none; border-color:var(--color-primary); }
.toy-search-results { display:flex; flex-wrap:wrap; gap:0.35rem; max-height:240px; overflow-y:auto; padding-right:2px; }
.toy-result-chip { display:inline-flex; align-items:center; gap:0.3rem; padding:0.2rem 0.6rem;
border-radius:20px; cursor:pointer; font-size:0.8rem; border:1px solid var(--color-secondary);
background:var(--color-secondary); color:var(--color-muted);
transition:border-color 0.15s,background 0.15s,color 0.15s; user-select:none; }
.toy-result-chip:hover { border-color:var(--color-primary); color:var(--color-text); }
.toy-result-chip.selected { border-color:var(--color-primary); background:rgba(233,69,96,0.15); color:var(--color-primary); }
.toy-result-chip img { width:18px; height:18px; border-radius:3px; object-fit:cover; flex-shrink:0; }
@media (max-width: 768px) { .toy-grid { grid-template-columns:1fr; } }
</style>
</head>
<body class="app">
<!-- Toy-Modal -->
<div class="modal-backdrop" id="toyModal">
<div class="modal">
<h2 id="toyModalTitle">Neues System-Toy</h2>
<label for="toyModalName">Name *</label>
<input type="text" id="toyModalName" placeholder="z.B. Vibrator" maxlength="100">
<label for="toyModalDesc">Beschreibung</label>
<textarea id="toyModalDesc" rows="3" placeholder="Kurze Beschreibung…" maxlength="500"></textarea>
<label>Bild (optional)</label>
<div id="toyCurrentImgWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="toyCurrentImg" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues Bild wählen zum Ersetzen</span>
</div>
<input type="file" id="toyModalBild" accept="image/*">
<div class="modal-error" id="toyModalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="toyModalCancel">Abbrechen</button>
<button class="btn-save" id="toyModalSave">Speichern</button>
</div>
</div>
</div>
<!-- Gruppe-Modal -->
<div class="modal-backdrop" id="gruppeModal">
<div class="modal">
<h2 id="gruppeModalTitle">Neue System-Gruppe</h2>
<label for="gName">Name *</label>
<input type="text" id="gName" maxlength="100" placeholder="Gruppenname">
<label for="gVon">Quelle (z.B. System)</label>
<input type="text" id="gVon" maxlength="100" placeholder="z.B. System">
<label for="gDesc">Beschreibung</label>
<textarea id="gDesc" rows="3" maxlength="1000" placeholder="Kurze Beschreibung…"></textarea>
<label>Bild (optional)</label>
<div id="gCurrentImgWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="gCurrentImg" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues wählen zum Ersetzen</span>
</div>
<input type="file" id="gBild" accept="image/*">
<div class="modal-error" id="gruppeModalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="gruppeModalCancel">Abbrechen</button>
<button class="btn-save" id="gruppeModalSave">Speichern</button>
</div>
</div>
</div>
<!-- Bildimport-Modal -->
<div class="modal-backdrop" id="bildImportModal">
<div class="modal" style="max-width:540px;">
<h2>Toy-Bildimport</h2>
<p style="font-size:0.85rem;color:var(--color-muted);margin:0 0 1rem 0;">
Wähle mehrere Bilder aus. Für jedes Bild wird ein System-Toy angelegt
Name und Beschreibung werden aus dem Dateinamen abgeleitet.
Die Namen können vor dem Import angepasst werden.
</p>
<input type="file" id="bildImportInput" multiple accept="image/*">
<div id="bildImportPreview" style="margin-top:1rem;max-height:300px;overflow-y:auto;"></div>
<div id="bildImportProgress" style="display:none;font-size:0.85rem;padding:0.5rem 0.75rem;
background:var(--color-secondary);border-radius:6px;margin-top:0.75rem;"></div>
<div class="modal-error" id="bildImportError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="bildImportCancel">Abbrechen</button>
<button class="btn-save" id="bildImportStart" disabled>Importieren</button>
</div>
</div>
</div>
<!-- Item-Modal -->
<div class="modal-backdrop" id="itemModal">
<div class="modal">
<h2 id="itemModalTitle">Aufgabe hinzufügen</h2>
<label for="iKurzText">Kurzbezeichnung *</label>
<input type="text" id="iKurzText" maxlength="200" placeholder="Kurzer Name">
<label class="label-with-hint">
<span>Beschreibung *</span>
<button type="button" class="btn-hint" onclick="togglePlaceholderHint()" title="Platzhalter-Hilfe">i</button>
</label>
<div id="iPlaceholderHint" style="display:none;">
<div class="placeholder-hint">
In Texten können Platzhalter verwendet werden:<br>
<code>{AKTIV}</code> Name des aktiven Parts<br>
<code>{PASSIV}</code> Name des passiven Parts
</div>
</div>
<textarea id="iText" rows="4" maxlength="4000" placeholder="Ausführliche Beschreibung…"></textarea>
<div id="iTextAC"></div>
<div id="iGeschlechtRow">
<label>Geschlecht der Person die kommt *</label>
<div style="display:flex; gap:1.5rem; margin-top:0.5rem;" id="iGeschlecht">
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="WEIBLICH" style="accent-color:var(--color-primary);"> Weiblich
</label>
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="DIVERS" style="accent-color:var(--color-primary);"> Divers
</label>
<label style="display:flex; align-items:center; gap:0.4rem; font-size:0.85rem; cursor:pointer;">
<input type="radio" name="iGeschlechtRadio" value="MAENNLICH" style="accent-color:var(--color-primary);"> Männlich
</label>
</div>
</div>
<div id="iLevelRow">
<label for="iLevel">Level *</label>
<input type="number" id="iLevel" min="1" max="5" placeholder="15">
<label>Dauer (Sekunden)</label>
<div class="modal-two-col">
<div><label for="iSekVon" style="margin-top:0;">Von</label><input type="number" id="iSekVon" min="0" placeholder="z. B. 30"></div>
<div><label for="iSekBis" style="margin-top:0;">Bis</label><input type="number" id="iSekBis" min="0" placeholder="z. B. 120"></div>
</div>
</div>
<div id="iWerkzeugAktivRow">
<label>Benötigt (aktiv)</label>
<div class="werkzeug-checks" id="iWerkzeugAktiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
<label class="werkzeug-check"><span>Umschnall-Dildo</span><input type="checkbox" value="UMSCHNALLDILDO"></label>
</div>
</div>
<div id="iWerkzeugPassivRow">
<label>Benötigt (passiv)</label>
<div class="werkzeug-checks" id="iWerkzeugPassiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
<label class="werkzeug-check"><span>Umschnall-Dildo</span><input type="checkbox" value="UMSCHNALLDILDO"></label>
</div>
</div>
<div id="iWerkzeugFinisherAktivRow">
<label>Benötigt (Person die kommt)</label>
<div class="werkzeug-checks" id="iWerkzeugFinisherAktiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
</div>
</div>
<div id="iWerkzeugFinisherPassivRow">
<label>Benötigt (Person die zum Kommen bringt)</label>
<div class="werkzeug-checks" id="iWerkzeugFinisherPassiv">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
<label class="werkzeug-check"><span>Umschnall-Dildo</span><input type="checkbox" value="UMSCHNALLDILDO"></label>
</div>
</div>
<div id="iMinutenRow">
<label>Dauer (Minuten) *</label>
<div class="modal-two-col">
<div><label for="iMinVon" style="margin-top:0;">Von *</label><input type="number" id="iMinVon" min="0" placeholder="z. B. 5"></div>
<div><label for="iMinBis" style="margin-top:0;">Bis</label><input type="number" id="iMinBis" min="0" placeholder="z. B. 30"></div>
</div>
</div>
<div id="iSperreFuerRow">
<label>Sperrt *</label>
<div class="werkzeug-checks" id="iSperreFuer">
<label class="werkzeug-check"><span>Mund</span><input type="checkbox" value="MUND"></label>
<label class="werkzeug-check"><span>Vagina</span><input type="checkbox" value="VAGINA"></label>
<label class="werkzeug-check"><span>Penis</span><input type="checkbox" value="PENIS"></label>
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
</div>
</div>
<div id="iReleaseTextRow">
<label for="iReleaseText">Text bei Aufhebung</label>
<textarea id="iReleaseText" rows="2" maxlength="2000" placeholder="Text der angezeigt wird, wenn die Sperre endet…"></textarea>
</div>
<label>Benötigte Toys (optional)</label>
<div class="selected-toys-row" id="iSelectedToys"></div>
<button class="btn-toy-add" type="button" id="iToyAddBtn">+ Toy hinzufügen</button>
<div id="iToySearchArea" style="display:none; margin-top:0.5rem;">
<input class="toy-search-input" type="text" id="toySearchInput" placeholder="Name filtern…" autocomplete="off">
<div class="toy-search-results" id="toySearchResults"></div>
<div id="toySearchEmpty" style="font-size:0.82rem; color:var(--color-muted); display:none; margin-top:0.4rem;">Keine Toys gefunden.</div>
</div>
<div class="modal-error" id="itemModalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="itemCancelBtn">Abbrechen</button>
<button class="btn-save" id="itemSaveBtn">Speichern</button>
</div>
</div>
</div>
<!-- Bestätigungs-Modal -->
<div class="modal-backdrop" id="confirmModal">
<div class="modal" style="max-width:380px;">
<h2>Wirklich löschen?</h2>
<p id="confirmModalText" style="font-size:0.9rem;color:var(--color-muted);margin:0 0 0.5rem 0;"></p>
<div class="modal-actions">
<button class="btn-cancel" id="confirmModalCancel">Abbrechen</button>
<button class="btn-save" id="confirmModalOk">Löschen</button>
</div>
</div>
</div>
<!-- Verschieben-Modal -->
<div class="modal-backdrop" id="moveModal">
<div class="modal" style="max-width:400px;">
<h2>In andere Gruppe verschieben</h2>
<p id="moveModalItemName" style="font-size:0.9rem;color:var(--color-muted);margin:0 0 1rem 0;"></p>
<div class="form-group">
<label for="moveModalSelect">Zielgruppe</label>
<select id="moveModalSelect" class="form-control" style="height:2.6rem;font-size:1rem;"></select>
</div>
<p id="moveModalError" style="color:var(--color-danger);font-size:0.85rem;min-height:1.2em;margin:0.5rem 0 0 0;"></p>
<div class="modal-actions">
<button class="btn-cancel" id="moveModalCancel">Abbrechen</button>
<button class="btn-save" id="moveModalOk">Verschieben</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<div class="tabs">
<button class="tab-btn active" data-tab="meldungen">Meldungen</button>
<button class="tab-btn" data-tab="feedback">Feedback</button>
<button class="tab-btn" data-tab="aufgabengruppen">Aufgabengruppen</button>
<button class="tab-btn" data-tab="toys">Toys</button>
<button class="tab-btn" data-tab="vorlieben">Vorlieben</button>
<button class="tab-btn superadmin-only" data-tab="admins">Admins</button>
<button class="tab-btn superadmin-only" data-tab="abonnements">Abonnements</button>
<button class="tab-btn superadmin-only" data-tab="schnittstellen">Schnittstellen</button>
</div>
<!-- ── Meldungen ── -->
<div class="tab-panel active" id="panel-meldungen">
<div class="filter-row">
<label>Status:</label>
<select id="meldungFilter" onchange="loadMeldungen()">
<option value="">Alle</option>
<option value="OFFEN">Offen</option>
<option value="BEARBEITET">Bearbeitet</option>
<option value="ABGELEHNT">Abgelehnt</option>
</select>
</div>
<div class="table-card">
<table class="data-table">
<thead>
<tr>
<th>Melder</th><th>Typ</th><th>Ziel-ID</th>
<th>Grund</th><th>Gemeldet</th><th>Status</th><th></th>
</tr>
</thead>
<tbody id="meldungenBody">
<tr><td colspan="7" class="empty-hint">Wird geladen…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── Feedback ── -->
<div class="tab-panel" id="panel-feedback">
<div class="section">
<div class="section-header">
<h2 class="section-title">📬 Ungelesen</h2>
<div class="section-actions">
<button class="btn-action" onclick="loadFeedback()">↺ Aktualisieren</button>
</div>
</div>
<div id="feedback-ungelesen-list"><span class="loading">Wird geladen…</span></div>
</div>
<div class="section">
<div class="section-header">
<h2 class="section-title" style="color:#f39c12">🔧 In Arbeit</h2>
</div>
<div id="feedback-inarbeit-list"><span class="loading">Wird geladen…</span></div>
</div>
<div class="section">
<div class="section-header">
<h2 class="section-title" style="color:var(--color-muted)">✅ Beantwortet</h2>
</div>
<div id="feedback-beantwortet-list"><span class="loading">Wird geladen…</span></div>
</div>
</div>
<!-- ── Feedback Antwort-Modal ── -->
<div class="modal-backdrop" id="feedbackAntwortModal">
<div class="modal">
<h2>✉️ Antwort senden</h2>
<input type="hidden" id="feedbackAntwortId">
<label>Antworttext</label>
<textarea id="feedbackAntwortText" rows="6" placeholder="Nachricht an den Nutzer…" style="width:100%;box-sizing:border-box;"></textarea>
<p style="font-size:0.8rem;color:var(--color-muted);margin-top:0.5rem;">Der Nutzer erhält diese Nachricht als Direktnachricht vom Support-Account.</p>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeFeedbackAntwort()">Abbrechen</button>
<button class="btn-save" onclick="submitFeedbackAntwort()">Absenden</button>
</div>
<div class="modal-error" id="feedbackAntwortError"></div>
</div>
</div>
<!-- ── Aufgabengruppen ── -->
<div class="tab-panel" id="panel-aufgabengruppen">
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Aufgabengruppen</h2>
<div class="section-actions">
<button class="btn-action" id="gruppeExportBtn">⬇ Export</button>
<button class="btn-action" id="gruppeJsonImportBtn">⬆ Import</button>
<input type="file" id="gruppeImportFile" accept=".zip" style="display:none">
<button class="btn-action" id="gruppeDuplicateBtn" disabled>⧉ Duplizieren</button>
<button class="btn-action" id="gruppeEditBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="gruppeDeleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="gruppeCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="gruppeActionError"></div>
<div class="loading" id="gruppeLoading">Wird geladen…</div>
<div class="gruppe-list" id="gruppeList"></div>
</div>
</div>
<!-- ── Toys ── -->
<div class="tab-panel" id="panel-toys">
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Toys</h2>
<div class="section-actions">
<button class="btn-action" id="toyExportBtn">⬇ Export</button>
<button class="btn-action" id="toyJsonImportBtn">⬆ Import</button>
<button class="btn-action" id="toyBildImportBtn">Bildimport</button>
<input type="file" id="toyImportFile" accept=".zip" style="display:none">
<button class="btn-action" id="toyDuplicateBtn" disabled>⧉ Duplizieren</button>
<button class="btn-action" id="toyEditBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="toyDeleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="toyCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="toyActionError"></div>
<div class="toy-grid" id="toyGrid"></div>
<div class="loading" id="toyLoading" style="display:none;"></div>
</div>
</div>
<!-- ── Vorlieben ── -->
<div class="tab-panel" id="panel-vorlieben">
<div class="section">
<div class="section-header">
<h2 class="section-title">Vorlieben</h2>
<div class="section-actions">
<button class="btn-action" id="vlExportBtn">⬇ Export</button>
<button class="btn-action" id="vlImportBtn">⬆ Import</button>
<input type="file" id="vlImportFile" accept=".json" style="display:none">
<button class="btn-add" id="vlKatCreateBtn">+ Neue Kategorie</button>
</div>
</div>
<div class="action-error" id="vlError"></div>
<!-- Kategorie-Formular -->
<div class="form-section" id="vlKatForm" style="display:none;">
<h3 id="vlKatFormTitle">Kategorie anlegen</h3>
<div class="form-row">
<input type="hidden" id="vlKatId">
<input type="text" id="vlKatName" placeholder="Name der Kategorie">
<input type="number" id="vlKatSort" placeholder="Reihenfolge (0=oben)" value="0" min="0" style="flex:0 0 160px;">
<button onclick="saveKategorie()">Speichern</button>
<button class="secondary" onclick="cancelKategorie()">Abbrechen</button>
</div>
</div>
<!-- Item-Formular -->
<div class="form-section" id="vlItemForm" style="display:none;">
<h3 id="vlItemFormTitle">Vorliebe anlegen</h3>
<div class="form-row">
<input type="hidden" id="vlItemId">
<select id="vlItemKat" style="flex:0 0 220px;"></select>
<input type="text" id="vlItemName" placeholder="Name der Vorliebe">
<input type="number" id="vlItemSort" placeholder="Reihenfolge" value="0" min="0" style="flex:0 0 140px;">
<button onclick="saveItem()">Speichern</button>
<button class="secondary" onclick="cancelItem()">Abbrechen</button>
</div>
</div>
<!-- Kategorien-Liste -->
<div class="gruppe-list" id="vlKatList"><p class="empty-hint">Wird geladen…</p></div>
</div>
</div>
<!-- ── Admins (nur Superadmin) ── -->
<div class="tab-panel superadmin-only" id="panel-admins">
<div class="form-section">
<h3>Admin hinzufügen</h3>
<div class="form-row">
<input type="text" id="adminSearch" placeholder="Benutzername suchen…" oninput="searchAdminUsers()">
<select id="adminRolle">
<option value="ADMIN">Admin</option>
<option value="SUPERADMIN">Superadmin</option>
</select>
</div>
<div id="adminSearchResults" style="margin-top:0.5rem;display:none;
background:var(--color-secondary);border-radius:8px;overflow:hidden;"></div>
<div id="adminAddError" style="color:var(--color-primary);font-size:0.82rem;margin-top:0.4rem;min-height:1em;"></div>
</div>
<div class="table-card">
<table class="data-table">
<thead>
<tr><th>Benutzername</th><th>Rolle</th><th>Seit</th><th></th></tr>
</thead>
<tbody id="adminsBody">
<tr><td colspan="4" class="empty-hint">Wird geladen…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── Abonnements (nur Superadmin) ── -->
<div class="tab-panel superadmin-only" id="panel-abonnements">
<!-- Aktive Abonnements Übersicht -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Aktive Abonnements</h2>
<button class="btn-action" onclick="loadAllSubscriptions()">Aktualisieren</button>
</div>
<div class="table-card">
<table class="data-table" id="aboOverviewTable">
<thead>
<tr>
<th>Benutzer</th>
<th>Typ</th>
<th>Gestartet</th>
<th>Gültig bis</th>
</tr>
</thead>
<tbody id="aboOverviewBody">
<tr><td colspan="4" style="color:var(--color-muted);font-style:italic;">Laden…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Abonnement verschenken -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Abonnement verschenken</h2>
</div>
<div class="form-section">
<p style="font-size:0.85rem;color:var(--color-muted);margin:0 0 1.25rem 0;">
Suche einen Benutzer und schenke ihm 1 Monat Premium. Hat der Benutzer bereits ein
aktives Abo, wird die Laufzeit um 1 Monat verlängert.
</p>
<label style="display:block;font-size:.8rem;color:#aaa;margin-bottom:.3rem;">Benutzer suchen</label>
<div style="position:relative;" id="aboComboWrap">
<input type="text" id="aboSearchInput" placeholder="Name eingeben…"
autocomplete="off"
style="width:100%;box-sizing:border-box;padding:.6rem .85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:.95rem;outline:none;transition:border-color .2s;"
onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'">
<input type="hidden" id="aboUserId">
<div id="aboDropdown" style="display:none;position:absolute;top:calc(100% + 3px);left:0;right:0;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:8px;max-height:200px;overflow-y:auto;z-index:200;box-shadow:0 4px 16px rgba(0,0,0,0.25);"></div>
</div>
<!-- Aktueller Abo-Status -->
<div id="aboStatus" style="display:none;margin-top:1rem;padding:.75rem 1rem;background:var(--color-secondary);border-radius:8px;font-size:.88rem;line-height:1.6;"></div>
<div id="aboError" style="color:var(--color-primary);font-size:.82rem;margin-top:.75rem;min-height:1.1em;"></div>
<div style="margin-top:1.25rem;">
<button id="aboBtnGift" class="btn-add" onclick="giftSubscription()" disabled
style="opacity:.45;cursor:not-allowed;">
🎁 1 Monat Premium schenken
</button>
</div>
</div>
</div>
</div>
<!-- ── Schnittstellen (nur Superadmin) ── -->
<div class="tab-panel superadmin-only" id="panel-schnittstellen">
<div class="section">
<div class="section-header">
<h2 class="section-title">TTLock-Konfiguration</h2>
</div>
<div class="form-section">
<h3>API-Zugangsdaten</h3>
<label for="ttClientId" style="display:block;font-size:.8rem;color:#aaa;margin-top:.75rem;margin-bottom:.3rem;">Client ID</label>
<input type="text" id="ttClientId" placeholder="Client ID" style="width:100%;box-sizing:border-box;padding:.6rem .85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:.95rem;outline:none;transition:border-color .2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'">
<label for="ttClientSecret" style="display:block;font-size:.8rem;color:#aaa;margin-top:1rem;margin-bottom:.3rem;">Client Secret</label>
<input type="text" id="ttClientSecret" placeholder="Client Secret" style="width:100%;box-sizing:border-box;padding:.6rem .85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:.95rem;outline:none;transition:border-color .2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'">
<label for="ttBaseUrl" style="display:block;font-size:.8rem;color:#aaa;margin-top:1rem;margin-bottom:.3rem;">Base URL</label>
<input type="text" id="ttBaseUrl" placeholder="https://euapi.ttlock.com/v3/" style="width:100%;box-sizing:border-box;padding:.6rem .85rem;border:1px solid var(--color-secondary);border-radius:6px;background:var(--color-secondary);color:var(--color-text);font-size:.95rem;outline:none;transition:border-color .2s;" onfocus="this.style.borderColor='var(--color-primary)'" onblur="this.style.borderColor='var(--color-secondary)'">
<div id="ttSaveError" style="color:var(--color-primary);font-size:.82rem;margin-top:.75rem;min-height:1.1em;"></div>
<div style="margin-top:1.25rem;display:flex;gap:.75rem;justify-content:flex-end;">
<button class="btn-action" onclick="loadTtlockConfig()">Zurücksetzen</button>
<button class="btn-add" onclick="saveTtlockConfig()">Speichern</button>
</div>
</div>
</div>
</div>
</div><!-- .content -->
</div><!-- .main -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="/js/shared.js"></script>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/social-sidebar.js"></script>
<script src="/js/meldung.js"></script>
<script>
// ── Init ──────────────────────────────────────────────────────────────────
async function init() {
const r = await fetch('/admin/me');
if (!r.ok) { window.location.href = '/userhome.html'; return; }
const admin = await r.json();
if (admin.rolle === 'SUPERADMIN') {
document.querySelectorAll('.superadmin-only').forEach(el => el.classList.remove('superadmin-only'));
}
loadMeldungen();
loadFeedback();
loadAdminGruppen();
loadAdminToys();
if (admin.rolle === 'SUPERADMIN') { loadAdmins(); loadTtlockConfig(); loadAllSubscriptions(); }
const _savedAdminTab = localStorage.getItem('tab_admin');
if (_savedAdminTab) {
const _btn = document.querySelector(`.tab-btn[data-tab="${_savedAdminTab}"]`);
if (_btn && _btn.offsetParent !== null) _btn.click();
}
}
// ── Tab-Navigation ────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn[data-tab]').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('panel-' + btn.dataset.tab).classList.add('active');
localStorage.setItem('tab_admin', btn.dataset.tab);
if (btn.dataset.tab === 'feedback') loadFeedback();
});
});
// ── Meldungen ─────────────────────────────────────────────────────────────
async function loadFeedback() {
const r = await fetch('/admin/feedback');
if (!r.ok) return;
const data = await r.json();
renderFeedbackList('feedback-ungelesen-list', data.ungelesen, 'ungelesen');
renderFeedbackList('feedback-inarbeit-list', data.inArbeit, 'inArbeit');
renderFeedbackList('feedback-beantwortet-list', data.beantwortet, 'beantwortet');
}
function renderFeedbackList(containerId, list, status) {
const el = document.getElementById(containerId);
const emptyTexts = { ungelesen: 'Keine ungelesenen Einträge.', inArbeit: 'Niemand arbeitet gerade an einem Eintrag.', beantwortet: 'Noch keine beantworteten Einträge.' };
if (!list || list.length === 0) {
el.innerHTML = '<span class="empty">' + (emptyTexts[status] || '') + '</span>';
return;
}
el.innerHTML = list.map(f => {
const actions = status === 'ungelesen'
? `<button class="btn-item-edit" onclick="feedbackAnnehmen('${f.feedbackId}',event)">🔧 In Arbeit nehmen</button>`
: status === 'inArbeit'
? `<button class="btn-item-edit" onclick="openFeedbackAntwort('${f.feedbackId}',event)">✉️ Antworten &amp; abschließen</button>`
: '';
const inArbeitBadge = f.inArbeitVonName
? `<span class="badge" style="background:rgba(243,156,18,0.15);color:#f39c12;">🔧 ${esc(f.inArbeitVonName)}</span>`
: '';
return `
<div class="item" id="fb-item-${f.feedbackId}">
<div class="item-row" onclick="toggleFbItem('${f.feedbackId}')">
<span class="item-text"><strong>${esc(f.name)}</strong> ${esc(f.grund)}</span>
<span class="item-badges">
${inArbeitBadge}
<span class="badge badge-neutral">${esc(f.seite)}</span>
<span class="badge badge-neutral">${formatDate(f.eingegangen)}</span>
</span>
</div>
<div class="item-detail">
<div class="item-detail-row">
<span class="item-detail-label">Von:</span><span class="item-detail-chip">${esc(f.name)}</span>
<span class="item-detail-label">Seite:</span><span class="item-detail-chip">${esc(f.seite)}</span>
<span class="item-detail-label">Grund:</span><span class="item-detail-chip">${esc(f.grund)}</span>
</div>
<div class="item-detail-text">${esc(f.text)}</div>
${actions ? `<div class="item-action-btns">${actions}</div>` : ''}
</div>
</div>`;
}).join('');
}
function toggleFbItem(id) {
document.getElementById('fb-item-' + id)?.classList.toggle('open');
}
async function feedbackAnnehmen(id, e) {
e?.stopPropagation();
const res = await fetch('/admin/feedback/' + id + '/annehmen', { method: 'PUT' });
if (res.status === 409) {
alert('Dieser Eintrag wird bereits von jemand anderem bearbeitet.');
}
loadFeedback();
}
function openFeedbackAntwort(id, e) {
e?.stopPropagation();
document.getElementById('feedbackAntwortId').value = id;
document.getElementById('feedbackAntwortText').value = '';
document.getElementById('feedbackAntwortError').style.display = 'none';
document.getElementById('feedbackAntwortModal').classList.add('open');
}
function closeFeedbackAntwort() {
document.getElementById('feedbackAntwortModal').classList.remove('open');
}
async function submitFeedbackAntwort() {
const id = document.getElementById('feedbackAntwortId').value;
const text = document.getElementById('feedbackAntwortText').value.trim();
const errEl = document.getElementById('feedbackAntwortError');
if (!text) { errEl.textContent = 'Bitte einen Text eingeben.'; errEl.style.display = 'block'; return; }
const res = await fetch('/admin/feedback/' + id + '/antworten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (res.ok) {
closeFeedbackAntwort();
loadFeedback();
} else {
errEl.textContent = 'Fehler beim Senden.';
errEl.style.display = 'block';
}
}
function formatDate(dt) {
if (!dt) return '';
return new Date(dt).toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
}
function esc(s) {
if (!s) return '';
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function loadMeldungen() {
const filter = document.getElementById('meldungFilter').value;
const r = await fetch('/admin/meldungen' + (filter ? '?status=' + filter : ''));
if (!r.ok) return;
const list = await r.json();
const tbody = document.getElementById('meldungenBody');
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-hint">Keine Meldungen.</td></tr>';
return;
}
tbody.innerHTML = list.map(m => `
<tr>
<td>${esc(m.melderName)}</td>
<td>${m.zielTyp}</td>
<td class="word-break">${m.zielId}</td>
<td style="max-width:220px">${esc(m.grund || '—')}</td>
<td style="white-space:nowrap">${fmtDate(m.gemeldetAt)}</td>
<td><span class="badge-status badge-${m.status.toLowerCase()}">${m.status}</span></td>
<td style="white-space:nowrap">
${m.status === 'OFFEN' ? `
<button class="tbl-btn tbl-btn-ok" onclick="setMeldungStatus('${m.meldungId}','BEARBEITET')">✔ Erledigt</button>
<button class="tbl-btn" onclick="setMeldungStatus('${m.meldungId}','ABGELEHNT')">✖ Ablehnen</button>` : ''}
</td>
</tr>`).join('');
}
async function setMeldungStatus(id, status) {
const r = await fetch(`/admin/meldungen/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (r.ok) loadMeldungen();
}
// ── Bestätigungs-Modal ────────────────────────────────────────────────────
let _confirmCallback = null;
function openConfirmModal(text, callback) {
document.getElementById('confirmModalText').textContent = text;
_confirmCallback = callback;
document.getElementById('confirmModal').classList.add('open');
}
function closeConfirmModal() {
_confirmCallback = null;
document.getElementById('confirmModal').classList.remove('open');
}
document.getElementById('confirmModalCancel').addEventListener('click', closeConfirmModal);
document.getElementById('confirmModalOk').addEventListener('click', () => {
const cb = _confirmCallback; closeConfirmModal(); if (cb) cb();
});
document.getElementById('confirmModal').addEventListener('click', e => {
if (e.target === document.getElementById('confirmModal')) closeConfirmModal();
});
// ── Aufgabengruppen ───────────────────────────────────────────────────────
let pendingExpandId = null;
async function loadAdminGruppen() {
const loadEl = document.getElementById('gruppeLoading');
loadEl.textContent = 'Wird geladen…'; loadEl.style.display = 'block';
const r = await fetch('/admin/aufgabengruppen');
loadEl.style.display = 'none';
if (!r.ok) { loadEl.textContent = 'Fehler beim Laden.'; loadEl.style.display = 'block'; return; }
const list = await r.json();
resetGruppeSelection();
renderAdminGruppen(list);
if (pendingExpandId) {
const id = pendingExpandId; pendingExpandId = null;
if (document.getElementById('gruppe-' + id)) {
selectedGruppeId = id; expandGruppe(id); updateGruppeButtons(true);
}
}
}
const _gruppeData = {};
const _itemData = {};
const WERKZEUG_LABEL = { MUND: 'Mund', VAGINA: 'Vagina', PENIS: 'Penis', ANUS: 'Anus', UMSCHNALLDILDO: 'Umschnall-Dildo' };
const GESCHLECHT_LABEL = { WEIBLICH: 'Weiblich', DIVERS: 'Divers', MAENNLICH: 'Männlich' };
function renderAdminGruppen(gruppen) {
const listEl = document.getElementById('gruppeList');
if (!gruppen || gruppen.length === 0) {
listEl.innerHTML = '<p class="empty">Keine System-Aufgabengruppen vorhanden.</p>'; return;
}
listEl.innerHTML = gruppen.map(g => {
_gruppeData[g.gruppenId] = g;
const ac = (g.aufgaben || []).length, sc = (g.strafen || []).length;
const zc = (g.sperren || []).length, fc = (g.finisher || []).length;
const counts = [
ac ? `${ac} Aufgabe${ac !== 1 ? 'n' : ''}` : '',
sc ? `${sc} Strafe${sc !== 1 ? 'n' : ''}` : '',
zc ? `${zc} Zeitstrafe${zc !== 1 ? 'n' : ''}` : '',
fc ? `${fc} Finisher` : ''
].filter(Boolean).join(' · ');
return `
<div class="gruppe-card" id="gruppe-${esc(g.gruppenId)}">
<div class="gruppe-header" onclick="selectAndToggleGruppe('${esc(g.gruppenId)}')">
${g.bild ? `<img class="gruppe-img" src="data:image/png;base64,${g.bild}" alt="${esc(g.name)}">` : `<div class="gruppe-img-placeholder">✓</div>`}
<div class="gruppe-meta">
<div class="gruppe-name">${esc(g.name)}</div>
<div class="gruppe-info">${g.von ? esc(g.von) + (counts ? ' · ' : '') : ''}${counts || 'Keine Einträge'}</div>
<div class="gruppe-badges"><span class="gruppe-badge gruppe-badge-public">Öffentlich</span></div>
</div>
<span class="gruppe-toggle">▶</span>
</div>
<div class="gruppe-body" id="body-${esc(g.gruppenId)}" style="display:none;">
${g.beschreibung ? `<div class="gruppe-desc">${esc(g.beschreibung)}</div>` : ''}
${renderSubSection('Aufgaben', sortByLevelThenName(g.aufgaben || []), 'aufgabe', renderAufgabe, g.gruppenId)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId)}
${renderSubSection('Zeitstrafen', sortByName(g.sperren || []), 'zeitstrafe', renderZeitstrafe, g.gruppenId)}
${renderSubSection('Finisher', sortByGeschlecht(g.finisher || []), 'finisher', renderFinisher, g.gruppenId)}
</div>
</div>`;
}).join('');
openItemId = null;
}
function renderSubSection(title, items, kind, renderFn, gruppenId) {
const KIND_LABEL = { aufgabe: 'Aufgabe', strafe: 'Strafe', zeitstrafe: 'Zeitstrafe', finisher: 'Finisher' };
const addBtn = `<button class="btn-sub-add" onclick="openItemModal('${esc(gruppenId)}','${kind}')">+ ${KIND_LABEL[kind] || title}</button>`;
return `<div class="sub-section">
<div class="sub-section-header">
<span class="sub-section-title">${esc(title)} (${items.length})</span>
${addBtn}
</div>
${items.length === 0
? '<div class="sub-empty">Keine Einträge</div>'
: `<div class="item-list">${items.map(item => renderFn(item, gruppenId)).join('')}</div>`}
</div>`;
}
function werkzeugChips(list) {
if (!list || !list.length) return '';
return list.map(w => `<span class="item-detail-chip">${esc(WERKZEUG_LABEL[w] || w)}</span>`).join('');
}
function toyChips(list) {
if (!list || !list.length) return '';
return list.map(t => `<span class="item-detail-chip item-detail-chip-toy">${esc(t.name || t)}</span>`).join('');
}
function renderAufgabe(a, gruppenId) {
_itemData[a.aufgabeId] = { ...a, _kind: 'aufgabe', _gruppenId: gruppenId };
const badges = [];
const zeit = formatSek(a.sekundenVon, a.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (a.level != null) badges.push(`<span class="badge">Level ${esc(String(a.level))}</span>`);
const rows = [];
if (a.text) rows.push(`<div class="item-detail-text">${esc(a.text)}</div>`);
if (a.benoetigtAktiv && a.benoetigtAktiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(a.benoetigtAktiv)}</div>`);
if (a.benoetigtPassiv && a.benoetigtPassiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(a.benoetigtPassiv)}</div>`);
if (a.benoetigteToys && a.benoetigteToys.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(a.benoetigteToys)}</div>`);
return itemCard(a.aufgabeId, a.kurzText, badges, rows, 'aufgabe', gruppenId);
}
function renderStrafe(s, gruppenId) {
_itemData[s.strafeId] = { ...s, _kind: 'strafe', _gruppenId: gruppenId };
const badges = [];
const zeit = formatSek(s.sekundenVon, s.sekundenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
if (s.level != null) badges.push(`<span class="badge">Level ${esc(String(s.level))}</span>`);
const rows = [];
if (s.text) rows.push(`<div class="item-detail-text">${esc(s.text)}</div>`);
if (s.benoetigtAktiv && s.benoetigtAktiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(s.benoetigtAktiv)}</div>`);
if (s.benoetigtPassiv && s.benoetigtPassiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(s.benoetigtPassiv)}</div>`);
if (s.benoetigteToys && s.benoetigteToys.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(s.benoetigteToys)}</div>`);
return itemCard(s.strafeId, s.kurzText, badges, rows, 'strafe', gruppenId);
}
function renderZeitstrafe(z, gruppenId) {
_itemData[z.sperreId] = { ...z, _kind: 'zeitstrafe', _gruppenId: gruppenId };
const badges = [];
const zeit = formatMin(z.minutenVon, z.minutenBis);
if (zeit) badges.push(`<span class="badge badge-neutral">${esc(zeit)}</span>`);
const rows = [];
if (z.text) rows.push(`<div class="item-detail-text">${esc(z.text)}</div>`);
if (z.releaseText) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Bei Aufhebung:</span><span style="font-size:0.78rem;">${esc(z.releaseText)}</span></div>`);
if (z.sperreFuer && z.sperreFuer.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Sperrt:</span>${werkzeugChips(z.sperreFuer)}</div>`);
if (z.benoetigteToys && z.benoetigteToys.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(z.benoetigteToys)}</div>`);
return itemCard(z.sperreId, z.kurzText, badges, rows, 'zeitstrafe', gruppenId);
}
function renderFinisher(f, gruppenId) {
_itemData[f.finisherId] = { ...f, _kind: 'finisher', _gruppenId: gruppenId };
const badges = [];
if (f.geschlecht) badges.push(`<span class="badge badge-neutral">${esc(GESCHLECHT_LABEL[f.geschlecht] || f.geschlecht)}</span>`);
const rows = [];
if (f.text) rows.push(`<div class="item-detail-text">${esc(f.text)}</div>`);
if (f.benoetigtAktiv && f.benoetigtAktiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Aktiv:</span>${werkzeugChips(f.benoetigtAktiv)}</div>`);
if (f.benoetigtPassiv && f.benoetigtPassiv.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Passiv:</span>${werkzeugChips(f.benoetigtPassiv)}</div>`);
if (f.benoetigteToys && f.benoetigteToys.length) rows.push(`<div class="item-detail-row"><span class="item-detail-label">Toys:</span>${toyChips(f.benoetigteToys)}</div>`);
return itemCard(f.finisherId, f.kurzText, badges, rows, 'finisher', gruppenId);
}
function itemCard(id, kurzText, badges, rows, kind, gruppenId) {
const actionBtns = `<div class="item-action-btns">
<button class="btn-item-edit" onclick="openEditItemModal('${esc(id)}',event)">✎ Bearbeiten</button>
<button class="btn-item-edit" onclick="duplicateItem('${kind}','${esc(id)}','${esc(gruppenId)}',event)">⧉ Duplizieren</button>
<button class="btn-item-edit" onclick="openMoveModal('${kind}','${esc(id)}','${esc(gruppenId)}',event)">↪ Verschieben</button>
<button class="btn-item-delete" onclick="deleteItem('${kind}','${esc(id)}','${esc(gruppenId)}',event)">✕ Löschen</button>
</div>`;
return `<div class="item" id="item-${esc(id)}">
<div class="item-row" onclick="toggleItem('${esc(id)}')">
<span class="item-text">${esc(kurzText)}</span>
${badges.length ? `<span class="item-badges">${badges.join('')}</span>` : ''}
</div>
<div class="item-detail">${rows.join('')}${actionBtns}</div>
</div>`;
}
let openItemId = null;
function toggleItem(id) {
if (openItemId === id) { document.getElementById('item-' + id).classList.remove('open'); openItemId = null; return; }
if (openItemId) { const p = document.getElementById('item-' + openItemId); if (p) p.classList.remove('open'); }
const el = document.getElementById('item-' + id);
if (el) el.classList.add('open');
openItemId = id;
}
const ITEM_DELETE_URL = { aufgabe: '/aufgabe', strafe: '/strafe', zeitstrafe: '/sperre', finisher: '/finisher' };
const ITEM_DELETE_FIELD = { aufgabe: 'aufgabeId', strafe: 'strafeId', zeitstrafe: 'sperreId', finisher: 'finisherId' };
function deleteItem(kind, itemId, gruppenId, event) {
event.stopPropagation();
openConfirmModal('Eintrag wirklich löschen?', () => {
fetch(ITEM_DELETE_URL[kind], {
method: 'DELETE', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [ITEM_DELETE_FIELD[kind]]: itemId })
}).then(r => {
if (r.ok || r.status === 202) { openItemId = null; pendingExpandId = gruppenId; loadAdminGruppen(); }
else document.getElementById('gruppeActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').';
}).catch(() => { document.getElementById('gruppeActionError').textContent = 'Verbindungsfehler.'; });
});
}
async function duplicateItem(kind, itemId, gruppenId, event) {
event.stopPropagation();
const d = _itemData[itemId]; if (!d) return;
const ITEM_URL = { aufgabe: '/aufgabe', strafe: '/strafe', zeitstrafe: '/sperre', finisher: '/finisher' };
let payload;
if (kind === 'aufgabe' || kind === 'strafe') {
payload = { kurzText: d.kurzText + ' (Kopie)', text: d.text, level: d.level, gruppeId: gruppenId,
sekundenVon: d.sekundenVon ?? null, sekundenBis: d.sekundenBis ?? null,
benoetigtAktiv: d.benoetigtAktiv || [], benoetigtPassiv: d.benoetigtPassiv || [],
benoetigteToys: (d.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) };
} else if (kind === 'finisher') {
payload = { kurzText: d.kurzText + ' (Kopie)', text: d.text, geschlecht: d.geschlecht, gruppeId: gruppenId,
benoetigtAktiv: d.benoetigtAktiv || [], benoetigtPassiv: d.benoetigtPassiv || [],
benoetigteToys: (d.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) };
} else {
payload = { kurzText: d.kurzText + ' (Kopie)', text: d.text, gruppeId: gruppenId,
minutenVon: d.minutenVon, minutenBis: d.minutenBis ?? null,
releaseText: d.releaseText || null, sperreFuer: d.sperreFuer || [],
benoetigteToys: (d.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) };
}
const r = await fetch(ITEM_URL[kind], {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
if (r.ok || r.status === 201) { openItemId = null; pendingExpandId = gruppenId; loadAdminGruppen(); }
else document.getElementById('gruppeActionError').textContent = 'Fehler beim Duplizieren (HTTP ' + r.status + ').';
}
let _moveState = null;
function openMoveModal(kind, itemId, currentGruppeId, event) {
event.stopPropagation();
const item = _itemData[itemId]; if (!item) return;
_moveState = { kind, itemId, currentGruppeId };
document.getElementById('moveModalItemName').textContent = item.kurzText || itemId;
document.getElementById('moveModalError').textContent = '';
const sel = document.getElementById('moveModalSelect');
sel.innerHTML = '';
Object.values(_gruppeData)
.filter(g => g.gruppenId !== currentGruppeId)
.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de'))
.forEach(g => {
const opt = document.createElement('option');
opt.value = g.gruppenId;
opt.textContent = g.name;
sel.appendChild(opt);
});
document.getElementById('moveModal').classList.add('open');
}
function closeMoveModal() {
document.getElementById('moveModal').classList.remove('open');
_moveState = null;
}
document.getElementById('moveModalCancel').addEventListener('click', closeMoveModal);
document.getElementById('moveModal').addEventListener('click', e => {
if (e.target === document.getElementById('moveModal')) closeMoveModal();
});
document.getElementById('moveModalOk').addEventListener('click', async () => {
if (!_moveState) return;
const { kind, itemId, currentGruppeId } = _moveState;
const targetGruppeId = document.getElementById('moveModalSelect').value;
if (!targetGruppeId) return;
const r = await fetch(`/admin/aufgabengruppen/items/${kind}/${itemId}/move?targetGruppeId=${targetGruppeId}`, {
method: 'PUT'
});
if (r.ok || r.status === 204) {
closeMoveModal();
openItemId = null;
pendingExpandId = currentGruppeId;
loadAdminGruppen();
} else {
document.getElementById('moveModalError').textContent = 'Fehler beim Verschieben (HTTP ' + r.status + ').';
}
});
function sortByLevelThenName(items) {
return items.slice().sort((a, b) => {
const la = a.level ?? 999, lb = b.level ?? 999;
if (la !== lb) return la - lb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function sortByName(items) { return items.slice().sort((a, b) => (a.kurzText || '').localeCompare(b.kurzText || '', 'de')); }
const GESCHLECHT_ORDER = { WEIBLICH: 0, DIVERS: 1, MAENNLICH: 2 };
function sortByGeschlecht(items) {
return items.slice().sort((a, b) => {
const ga = GESCHLECHT_ORDER[a.geschlecht] ?? 99, gb = GESCHLECHT_ORDER[b.geschlecht] ?? 99;
if (ga !== gb) return ga - gb;
return (a.kurzText || '').localeCompare(b.kurzText || '', 'de');
});
}
function formatSek(von, bis) {
if (von != null && bis != null) return `${von}${bis} s`;
if (von != null) return `ab ${von} s`; if (bis != null) return `bis ${bis} s`; return '';
}
function formatMin(von, bis) {
if (von != null && bis != null) return `${von}${bis} min`;
if (von != null) return `ab ${von} min`; if (bis != null) return `bis ${bis} min`; return '';
}
// ── Gruppe-Selektion ──────────────────────────────────────────────────────
let selectedGruppeId = null;
function selectAndToggleGruppe(id) {
if (selectedGruppeId === id) { collapseGruppe(id); selectedGruppeId = null; updateGruppeButtons(false); return; }
if (selectedGruppeId) collapseGruppe(selectedGruppeId);
selectedGruppeId = id;
expandGruppe(id);
updateGruppeButtons(true);
document.getElementById('gruppeActionError').textContent = '';
}
function expandGruppe(id) {
const card = document.getElementById('gruppe-' + id), body = document.getElementById('body-' + id);
if (!card) return; card.classList.add('selected', 'open'); body.style.display = 'block';
}
function collapseGruppe(id) {
const card = document.getElementById('gruppe-' + id), body = document.getElementById('body-' + id);
if (!card) return; card.classList.remove('selected', 'open'); body.style.display = 'none';
}
function updateGruppeButtons(sel) {
document.getElementById('gruppeDuplicateBtn').disabled = !sel;
document.getElementById('gruppeEditBtn').disabled = !sel;
document.getElementById('gruppeDeleteBtn').disabled = !sel;
}
function resetGruppeSelection() {
if (selectedGruppeId) collapseGruppe(selectedGruppeId);
selectedGruppeId = null; updateGruppeButtons(false);
document.getElementById('gruppeActionError').textContent = '';
}
// ── Gruppe-Modal ──────────────────────────────────────────────────────────
const gruppeModal = document.getElementById('gruppeModal');
const gruppeModalSave = document.getElementById('gruppeModalSave');
let currentEditGruppeId = null;
function openGruppeModal(editId) {
currentEditGruppeId = editId || null;
document.getElementById('gruppeModalError').style.display = 'none';
document.getElementById('gBild').value = '';
if (currentEditGruppeId) {
const g = _gruppeData[currentEditGruppeId];
if (!g) return;
document.getElementById('gruppeModalTitle').textContent = 'Gruppe bearbeiten';
document.getElementById('gName').value = g.name || '';
document.getElementById('gVon').value = g.von || '';
document.getElementById('gDesc').value = g.beschreibung || '';
const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) { document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild; imgWrap.style.display = 'flex'; }
else imgWrap.style.display = 'none';
} else {
document.getElementById('gruppeModalTitle').textContent = 'Neue System-Gruppe';
document.getElementById('gName').value = '';
document.getElementById('gVon').value = '';
document.getElementById('gDesc').value = '';
document.getElementById('gCurrentImgWrap').style.display = 'none';
}
gruppeModal.classList.add('open');
document.getElementById('gName').focus();
}
function closeGruppeModal() { gruppeModal.classList.remove('open'); }
document.getElementById('gruppeCreateBtn').addEventListener('click', () => openGruppeModal(null));
document.getElementById('gruppeEditBtn').addEventListener('click', () => { if (selectedGruppeId) openGruppeModal(selectedGruppeId); });
document.getElementById('gruppeModalCancel').addEventListener('click', closeGruppeModal);
gruppeModal.addEventListener('click', e => { if (e.target === gruppeModal) closeGruppeModal(); });
document.getElementById('gruppeDeleteBtn').addEventListener('click', () => {
if (!selectedGruppeId) return;
openConfirmModal('System-Aufgabengruppe und alle Inhalte wirklich löschen?', () => {
const btn = document.getElementById('gruppeDeleteBtn'); btn.disabled = true;
fetch(`/admin/aufgabengruppen/${selectedGruppeId}`, { method: 'DELETE' })
.then(r => {
if (r.ok || r.status === 204) { loadAdminGruppen(); }
else { document.getElementById('gruppeActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').'; btn.disabled = false; }
})
.catch(() => { document.getElementById('gruppeActionError').textContent = 'Verbindungsfehler.'; btn.disabled = false; });
});
});
document.getElementById('gruppeDuplicateBtn').addEventListener('click', async () => {
if (!selectedGruppeId) return;
const g = _gruppeData[selectedGruppeId]; if (!g) return;
const btn = document.getElementById('gruppeDuplicateBtn');
btn.disabled = true;
const errEl = document.getElementById('gruppeActionError');
errEl.textContent = '';
const gr = await fetch('/admin/aufgabengruppen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: g.name + ' (Kopie)', beschreibung: g.beschreibung || null, von: g.von || null, bild: g.bild || null })
});
if (gr.status !== 201 && !gr.ok) {
errEl.textContent = gr.status === 409 ? `"${g.name} (Kopie)" existiert bereits.` : 'Fehler beim Duplizieren (HTTP ' + gr.status + ').';
btn.disabled = false; return;
}
const newGruppeId = (await gr.json()).gruppenId;
for (const a of (g.aufgaben || []))
await fetch('/aufgabe', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: a.kurzText, text: a.text, level: a.level, gruppeId: newGruppeId,
sekundenVon: a.sekundenVon ?? null, sekundenBis: a.sekundenBis ?? null,
benoetigtAktiv: a.benoetigtAktiv || [], benoetigtPassiv: a.benoetigtPassiv || [],
benoetigteToys: (a.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) }) });
for (const s of (g.strafen || []))
await fetch('/strafe', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: s.kurzText, text: s.text, level: s.level, gruppeId: newGruppeId,
sekundenVon: s.sekundenVon ?? null, sekundenBis: s.sekundenBis ?? null,
benoetigtAktiv: s.benoetigtAktiv || [], benoetigtPassiv: s.benoetigtPassiv || [],
benoetigteToys: (s.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) }) });
for (const z of (g.sperren || []))
await fetch('/sperre', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: z.kurzText, text: z.text, gruppeId: newGruppeId,
minutenVon: z.minutenVon, minutenBis: z.minutenBis ?? null,
releaseText: z.releaseText || null, sperreFuer: z.sperreFuer || [],
benoetigteToys: (z.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) }) });
for (const f of (g.finisher || []))
await fetch('/finisher', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: f.kurzText, text: f.text, geschlecht: f.geschlecht, gruppeId: newGruppeId,
benoetigtAktiv: f.benoetigtAktiv || [], benoetigtPassiv: f.benoetigtPassiv || [],
benoetigteToys: (f.benoetigteToys || []).filter(t => t.toyId).map(t => ({ toyId: t.toyId })) }) });
pendingExpandId = newGruppeId;
loadAdminGruppen();
});
gruppeModalSave.addEventListener('click', async () => {
const name = document.getElementById('gName').value.trim();
if (!name) { showGruppeModalError('Bitte einen Namen eingeben.'); return; }
gruppeModalSave.disabled = true; gruppeModalSave.textContent = 'Speichert…';
let bildBase64 = null;
const fi = document.getElementById('gBild');
if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]);
const payload = { name, von: document.getElementById('gVon').value.trim() || null, beschreibung: document.getElementById('gDesc').value.trim() || null, bild: bildBase64 };
const isEdit = currentEditGruppeId != null;
fetch(isEdit ? `/admin/aufgabengruppen/${currentEditGruppeId}` : '/admin/aufgabengruppen', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201 || r.status === 204) {
closeGruppeModal(); if (isEdit) pendingExpandId = currentEditGruppeId; loadAdminGruppen();
} else showGruppeModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
})
.catch(() => showGruppeModalError('Verbindungsfehler.'))
.finally(() => { gruppeModalSave.disabled = false; gruppeModalSave.textContent = 'Speichern'; });
});
function showGruppeModalError(msg) { const el = document.getElementById('gruppeModalError'); el.textContent = msg; el.style.display = 'block'; }
// ── Item-Modal ────────────────────────────────────────────────────────────
const itemModal = document.getElementById('itemModal');
const itemSaveBtn = document.getElementById('itemSaveBtn');
let currentItemGruppeId = null, currentItemKind = null, currentItemEditId = null;
const ITEM_TITLES_NEW = { aufgabe: 'Aufgabe hinzufügen', strafe: 'Strafe hinzufügen', zeitstrafe: 'Zeitstrafe hinzufügen', finisher: 'Finisher hinzufügen' };
const ITEM_TITLES_EDIT = { aufgabe: 'Aufgabe bearbeiten', strafe: 'Strafe bearbeiten', zeitstrafe: 'Zeitstrafe bearbeiten', finisher: 'Finisher bearbeiten' };
function _setupItemModal(kind) {
const isZeit = kind === 'zeitstrafe', isFin = kind === 'finisher';
document.querySelector('#iPlaceholderHint .placeholder-hint').innerHTML = isFin
? 'In Texten können Platzhalter verwendet werden:<br><code>{AKTIV}</code> Name der Person die kommt<br><code>{PASSIV}</code> Name der Person die zum Kommen bringt'
: 'In Texten können Platzhalter verwendet werden:<br><code>{AKTIV}</code> Name des aktiven Parts<br><code>{PASSIV}</code> Name des passiven Parts';
document.getElementById('iGeschlechtRow').style.display = isFin ? 'block' : 'none';
document.getElementById('iLevelRow').style.display = (!isZeit && !isFin) ? 'block' : 'none';
document.getElementById('iWerkzeugAktivRow').style.display = (!isZeit && !isFin) ? 'block' : 'none';
document.getElementById('iWerkzeugPassivRow').style.display = (!isZeit && !isFin) ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherAktivRow').style.display = isFin ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherPassivRow').style.display = isFin ? 'block' : 'none';
document.getElementById('iMinutenRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iSperreFuerRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iReleaseTextRow').style.display = isZeit ? 'block' : 'none';
}
function _resetItemFields() {
['iKurzText','iText','iLevel','iSekVon','iSekBis','iMinVon','iMinBis','iReleaseText'].forEach(id => document.getElementById(id).value = '');
['iWerkzeugAktiv','iWerkzeugPassiv','iWerkzeugFinisherAktiv','iWerkzeugFinisherPassiv','iSperreFuer','iGeschlecht']
.forEach(id => document.querySelectorAll(`#${id} input`).forEach(cb => cb.checked = false));
_selectedToys = []; renderSelectedToys();
document.getElementById('itemModalError').style.display = 'none';
}
function openItemModal(gruppenId, kind) {
currentItemGruppeId = gruppenId; currentItemKind = kind; currentItemEditId = null;
document.getElementById('itemModalTitle').textContent = ITEM_TITLES_NEW[kind];
_resetItemFields(); _setupItemModal(kind); _initToys([]);
itemModal.classList.add('open'); document.getElementById('iKurzText').focus();
}
function openEditItemModal(itemId, event) {
event.stopPropagation();
const d = _itemData[itemId]; if (!d) return;
currentItemGruppeId = d._gruppenId; currentItemKind = d._kind; currentItemEditId = itemId;
document.getElementById('itemModalTitle').textContent = ITEM_TITLES_EDIT[d._kind];
_resetItemFields(); _setupItemModal(d._kind);
document.getElementById('iKurzText').value = d.kurzText || '';
document.getElementById('iText').value = d.text || '';
if (d._kind === 'aufgabe' || d._kind === 'strafe') {
document.getElementById('iLevel').value = d.level != null ? d.level : '';
document.getElementById('iSekVon').value = d.sekundenVon != null ? d.sekundenVon : '';
document.getElementById('iSekBis').value = d.sekundenBis != null ? d.sekundenBis : '';
(d.benoetigtAktiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugAktiv input[value="${w}"]`); if (cb) cb.checked = true; });
(d.benoetigtPassiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugPassiv input[value="${w}"]`); if (cb) cb.checked = true; });
} else if (d._kind === 'finisher') {
(d.benoetigtAktiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugFinisherAktiv input[value="${w}"]`); if (cb) cb.checked = true; });
(d.benoetigtPassiv || []).forEach(w => { const cb = document.querySelector(`#iWerkzeugFinisherPassiv input[value="${w}"]`); if (cb) cb.checked = true; });
if (d.geschlecht) { const rb = document.querySelector(`#iGeschlecht input[value="${d.geschlecht}"]`); if (rb) rb.checked = true; }
} else {
document.getElementById('iMinVon').value = d.minutenVon != null ? d.minutenVon : '';
document.getElementById('iMinBis').value = d.minutenBis != null ? d.minutenBis : '';
document.getElementById('iReleaseText').value = d.releaseText || '';
(d.sperreFuer || []).forEach(w => { const cb = document.querySelector(`#iSperreFuer input[value="${w}"]`); if (cb) cb.checked = true; });
}
_initToys((d.benoetigteToys || []).filter(t => t.toyId));
itemModal.classList.add('open'); document.getElementById('iKurzText').focus();
}
function closeItemModal() { itemModal.classList.remove('open'); closeToySearch(); document.getElementById('iPlaceholderHint').style.display = 'none'; document.getElementById('iTextAC').style.display = 'none'; }
function togglePlaceholderHint() { const el = document.getElementById('iPlaceholderHint'); el.style.display = el.style.display === 'none' ? 'block' : 'none'; }
(function() {
const STATIC = ['{AKTIV}', '{PASSIV}'];
const ta = document.getElementById('iText');
const ac = document.getElementById('iTextAC');
let _allToys = [];
let _items = [];
let _wordStart = 0;
let activeIdx = -1;
function currentWord() {
const pos = ta.selectionStart;
let start = pos;
while (start > 0 && !/\s/.test(ta.value[start - 1])) start--;
return { word: ta.value.slice(start, pos).toLowerCase(), start };
}
function buildItems(filter) {
const f = filter || '';
_items = STATIC.filter(s => !f || s.toLowerCase().includes(f)).map(s => ({ label: s, insert: s }));
const toys = _allToys.filter(t => !f || t.name.toLowerCase().includes(f));
if (toys.length) {
_items.push({ separator: true, label: 'Toys' });
toys.forEach(t => _items.push({ label: t.name, insert: t.name, toyId: t.toyId }));
}
}
function selectables() { return _items.map((it, i) => it.separator ? null : i).filter(i => i !== null); }
function renderItems() {
ac.innerHTML = '';
activeIdx = -1;
_items.forEach((item, i) => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'ac-separator';
sep.textContent = item.label;
ac.appendChild(sep);
} else {
const div = document.createElement('div');
div.className = 'ac-item';
div.dataset.idx = String(i);
div.textContent = item.label;
div.addEventListener('mousedown', e => { e.preventDefault(); doInsert(item); });
div.addEventListener('mouseover', () => setActive(i));
ac.appendChild(div);
}
});
const s = selectables();
if (s.length) { setActive(s[0]); ac.style.display = 'block'; } else hideAC();
}
function showAC(toys) {
_allToys = toys || [];
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
const rect = ta.getBoundingClientRect();
ac.style.left = rect.left + 'px';
ac.style.top = (rect.bottom + 4) + 'px';
renderItems();
}
function hideAC() { ac.style.display = 'none'; activeIdx = -1; }
function setActive(i) {
activeIdx = i;
let activeEl = null;
ac.querySelectorAll('.ac-item').forEach(el => {
const on = parseInt(el.dataset.idx) === i;
el.classList.toggle('ac-item-active', on);
if (on) activeEl = el;
});
if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
}
function doInsert(item) {
const end = ta.selectionStart;
ta.value = ta.value.slice(0, _wordStart) + item.insert + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = _wordStart + item.insert.length;
ta.focus();
if (item.toyId) {
const already = (_selectedToys || []).find(t => t.toyId === item.toyId);
if (!already) toggleToyFromSearch(item.toyId);
}
hideAC();
}
ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.code === 'Space') {
e.preventDefault();
_loadAvailableToys().then(toys => showAC(toys)).catch(() => showAC([]));
return;
}
if (ac.style.display !== 'block') return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); hideAC(); return; }
const s = selectables();
if (!s.length) return;
const pos = s.indexOf(activeIdx);
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(s[(pos + 1) % s.length]); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(s[(pos - 1 + s.length) % s.length]); }
else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const item = _items[activeIdx];
if (item && !item.separator) doInsert(item);
}
});
ta.addEventListener('input', () => {
if (ac.style.display !== 'block') return;
const { word, start } = currentWord();
_wordStart = start;
buildItems(word);
renderItems();
});
document.addEventListener('mousedown', e => { if (!ac.contains(e.target) && e.target !== ta) hideAC(); });
})();
document.getElementById('itemCancelBtn').addEventListener('click', closeItemModal);
itemModal.addEventListener('click', e => { if (e.target === itemModal) closeItemModal(); });
itemSaveBtn.addEventListener('click', async () => {
const kurzText = document.getElementById('iKurzText').value.trim();
const text = document.getElementById('iText').value.trim();
if (!kurzText) { showItemError('Bitte eine Kurzbezeichnung eingeben.'); return; }
if (!text) { showItemError('Bitte eine Beschreibung eingeben.'); return; }
const kind = currentItemKind, isEdit = currentItemEditId !== null;
let url, method, payload;
if (kind === 'aufgabe' || kind === 'strafe') {
const levelVal = document.getElementById('iLevel').value.trim();
if (!levelVal) { showItemError('Bitte ein Level angeben.'); return; }
const level = parseInt(levelVal, 10);
if (isNaN(level) || level < 1 || level > 5) { showItemError('Level muss zwischen 1 und 5 liegen.'); return; }
const sekVon = document.getElementById('iSekVon').value.trim(), sekBis = document.getElementById('iSekBis').value.trim();
payload = { kurzText, text, level,
gruppeId: isEdit ? undefined : currentItemGruppeId,
sekundenVon: sekVon ? parseInt(sekVon, 10) : null, sekundenBis: sekBis ? parseInt(sekBis, 10) : null,
benoetigtAktiv: checkedValues('iWerkzeugAktiv'), benoetigtPassiv: checkedValues('iWerkzeugPassiv'),
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId })) };
const base = kind === 'aufgabe' ? '/aufgabe' : '/strafe';
url = isEdit ? `${base}/${currentItemEditId}` : base; method = isEdit ? 'PUT' : 'POST';
} else if (kind === 'finisher') {
const geschlecht = document.querySelector('#iGeschlecht input:checked')?.value;
if (!geschlecht) { showItemError('Bitte ein Geschlecht auswählen.'); return; }
payload = { kurzText, text, geschlecht, gruppeId: isEdit ? undefined : currentItemGruppeId,
benoetigtAktiv: checkedValues('iWerkzeugFinisherAktiv'), benoetigtPassiv: checkedValues('iWerkzeugFinisherPassiv'),
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId })) };
url = isEdit ? `/finisher/${currentItemEditId}` : '/finisher'; method = isEdit ? 'PUT' : 'POST';
} else {
const minVon = document.getElementById('iMinVon').value.trim();
if (!minVon) { showItemError('Bitte eine Mindestdauer in Minuten angeben.'); return; }
const sperreFuer = checkedValues('iSperreFuer');
if (!sperreFuer.length) { showItemError('Bitte mindestens ein Werkzeug für die Sperre auswählen.'); return; }
const minBis = document.getElementById('iMinBis').value.trim();
payload = { kurzText, text, gruppeId: isEdit ? undefined : currentItemGruppeId,
minutenVon: parseInt(minVon, 10), minutenBis: minBis ? parseInt(minBis, 10) : null,
releaseText: document.getElementById('iReleaseText').value.trim() || null,
sperreFuer, benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId })) };
url = isEdit ? `/sperre/${currentItemEditId}` : '/sperre'; method = isEdit ? 'PUT' : 'POST';
}
itemSaveBtn.disabled = true; itemSaveBtn.textContent = 'Speichert…';
fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
.then(r => {
if (r.ok || r.status === 201) { closeItemModal(); pendingExpandId = currentItemGruppeId; loadAdminGruppen(); }
else showItemError('Fehler beim Speichern (HTTP ' + r.status + ').');
})
.catch(() => showItemError('Verbindungsfehler.'))
.finally(() => { itemSaveBtn.disabled = false; itemSaveBtn.textContent = 'Speichern'; });
});
function showItemError(msg) { const el = document.getElementById('itemModalError'); el.textContent = msg; el.style.display = 'block'; }
function checkedValues(id) { return Array.from(document.querySelectorAll(`#${id} input:checked`)).map(cb => cb.value); }
// ── Toy-Auswahl (Item-Modal) ──────────────────────────────────────────────
let _selectedToys = [];
let _allAvailableToys = null;
function _loadAvailableToys() {
if (_allAvailableToys !== null) return Promise.resolve(_allAvailableToys);
return fetch('/toy/available').then(r => r.json()).then(toys => { _allAvailableToys = toys || []; return _allAvailableToys; });
}
function renderSelectedToys() {
const c = document.getElementById('iSelectedToys');
c.innerHTML = _selectedToys.map(t => {
const img = t.bild ? `<img src="data:image/png;base64,${t.bild}" alt="">` : '';
return `<span class="sel-toy-chip">${img}${esc(t.name)}<button class="toy-remove" type="button" onclick="removeToy('${esc(t.toyId)}')" title="Entfernen">✕</button></span>`;
}).join('');
}
function removeToy(toyId) {
_selectedToys = _selectedToys.filter(t => t.toyId !== toyId); renderSelectedToys();
const chip = document.querySelector(`#toySearchResults .toy-result-chip[data-id="${toyId}"]`);
if (chip) chip.classList.remove('selected');
}
document.getElementById('iToyAddBtn').addEventListener('click', toggleToySearch);
document.getElementById('toySearchInput').addEventListener('input', renderToySearchResults);
function toggleToySearch() {
const area = document.getElementById('iToySearchArea');
if (area.style.display === 'none') {
area.style.display = 'block';
document.getElementById('toySearchInput').value = '';
document.getElementById('toySearchResults').innerHTML = '';
document.getElementById('toySearchEmpty').style.display = 'none';
document.getElementById('iToyAddBtn').textContent = '▲ Suche schließen';
_loadAvailableToys().then(() => renderToySearchResults()).catch(() => {
document.getElementById('toySearchResults').innerHTML = '<span style="font-size:0.82rem;color:var(--color-muted)">Fehler beim Laden.</span>';
});
document.getElementById('toySearchInput').focus();
} else closeToySearch();
}
function closeToySearch() {
document.getElementById('iToySearchArea').style.display = 'none';
document.getElementById('iToyAddBtn').textContent = '+ Toy hinzufügen';
}
function renderToySearchResults() {
const query = document.getElementById('toySearchInput').value.trim().toLowerCase();
const results = document.getElementById('toySearchResults');
const empty = document.getElementById('toySearchEmpty');
if (!_allAvailableToys) return;
const filtered = query ? _allAvailableToys.filter(t => t.name.toLowerCase().includes(query)) : _allAvailableToys;
if (!filtered.length) { results.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
const selIds = new Set(_selectedToys.map(t => t.toyId));
results.innerHTML = filtered.map(t => {
const sel = selIds.has(t.toyId) ? ' selected' : '';
const img = t.bild ? `<img src="data:image/png;base64,${t.bild}" alt="">` : '';
return `<span class="toy-result-chip${sel}" data-id="${esc(t.toyId)}" onclick="toggleToyFromSearch('${esc(t.toyId)}')">${img}${esc(t.name)}</span>`;
}).join('');
}
function toggleToyFromSearch(toyId) {
const toy = (_allAvailableToys || []).find(t => t.toyId === toyId); if (!toy) return;
const idx = _selectedToys.findIndex(t => t.toyId === toyId);
if (idx >= 0) _selectedToys.splice(idx, 1); else _selectedToys.push({ toyId: toy.toyId, name: toy.name, bild: toy.bild });
renderSelectedToys(); renderToySearchResults();
}
function _initToys(pre) { _selectedToys = pre || []; renderSelectedToys(); }
// ── System-Toys ───────────────────────────────────────────────────────────
let allAdminToys = [];
let selectedToyId = null;
async function loadAdminToys() {
const loadEl = document.getElementById('toyLoading');
loadEl.textContent = 'Wird geladen…'; loadEl.style.display = 'block';
const r = await fetch('/admin/toys');
loadEl.style.display = 'none';
if (!r.ok) { document.getElementById('toyActionError').textContent = 'Fehler beim Laden.'; return; }
allAdminToys = await r.json();
renderToyGrid();
}
function renderToyGrid() {
const grid = document.getElementById('toyGrid');
resetToySelection();
if (!allAdminToys.length) { grid.innerHTML = '<p class="empty">Keine System-Toys vorhanden.</p>'; return; }
grid.innerHTML = allAdminToys.map(toy => `
<div class="toy-card" data-id="${esc(toy.toyId)}" onclick="selectToy('${esc(toy.toyId)}')">
${toy.bild ? `<img class="toy-img" src="data:image/png;base64,${toy.bild}" alt="${esc(toy.name)}">` : `<div class="toy-img-placeholder">◈</div>`}
<div class="toy-info">
<div class="toy-name">${esc(toy.name)}</div>
${toy.beschreibung ? `<div class="toy-desc">${esc(toy.beschreibung)}</div>` : ''}
</div>
</div>`).join('');
}
function selectToy(toyId) {
const prev = document.querySelector('#toyGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
selectedToyId = selectedToyId === toyId ? null : toyId;
if (selectedToyId) document.querySelector(`#toyGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
const has = selectedToyId != null;
document.getElementById('toyDuplicateBtn').disabled = !has;
document.getElementById('toyEditBtn').disabled = !has;
document.getElementById('toyDeleteBtn').disabled = !has;
document.getElementById('toyActionError').textContent = '';
}
function resetToySelection() {
selectedToyId = null;
document.getElementById('toyDuplicateBtn').disabled = true;
document.getElementById('toyEditBtn').disabled = true;
document.getElementById('toyDeleteBtn').disabled = true;
document.getElementById('toyActionError').textContent = '';
}
const toyModal = document.getElementById('toyModal');
const toyModalSave = document.getElementById('toyModalSave');
let currentEditToyId = null;
function openToyModal(editId) {
currentEditToyId = editId || null;
document.getElementById('toyModalError').style.display = 'none';
document.getElementById('toyModalBild').value = '';
if (currentEditToyId) {
const toy = allAdminToys.find(t => t.toyId === currentEditToyId); if (!toy) return;
document.getElementById('toyModalTitle').textContent = 'Toy bearbeiten';
document.getElementById('toyModalName').value = toy.name || '';
document.getElementById('toyModalDesc').value = toy.beschreibung || '';
const imgWrap = document.getElementById('toyCurrentImgWrap');
if (toy.bild) { document.getElementById('toyCurrentImg').src = 'data:image/png;base64,' + toy.bild; imgWrap.style.display = 'flex'; }
else imgWrap.style.display = 'none';
} else {
document.getElementById('toyModalTitle').textContent = 'Neues System-Toy';
document.getElementById('toyModalName').value = '';
document.getElementById('toyModalDesc').value = '';
document.getElementById('toyCurrentImgWrap').style.display = 'none';
}
toyModal.classList.add('open'); document.getElementById('toyModalName').focus();
}
function closeToyModal() { toyModal.classList.remove('open'); }
document.getElementById('toyCreateBtn').addEventListener('click', () => openToyModal(null));
document.getElementById('toyEditBtn').addEventListener('click', () => { if (selectedToyId) openToyModal(selectedToyId); });
document.getElementById('toyDuplicateBtn').addEventListener('click', async () => {
if (!selectedToyId) return;
const toy = allAdminToys.find(t => t.toyId === selectedToyId);
if (!toy) return;
const btn = document.getElementById('toyDuplicateBtn');
btn.disabled = true;
const newName = toy.name + ' (Kopie)';
const r = await fetch('/admin/toys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName, beschreibung: toy.beschreibung || null, bild: toy.bild || null })
});
if (r.ok || r.status === 201) {
_allAvailableToys = null;
await loadAdminToys();
} else if (r.status === 409) {
document.getElementById('toyActionError').textContent = `"${newName}" existiert bereits.`;
btn.disabled = false;
} else {
document.getElementById('toyActionError').textContent = 'Fehler beim Duplizieren (HTTP ' + r.status + ').';
btn.disabled = false;
}
});
document.getElementById('toyModalCancel').addEventListener('click', closeToyModal);
toyModal.addEventListener('click', e => { if (e.target === toyModal) closeToyModal(); });
document.getElementById('toyDeleteBtn').addEventListener('click', () => {
if (!selectedToyId) return;
if (!confirm('System-Toy wirklich löschen?')) return;
const btn = document.getElementById('toyDeleteBtn'); btn.disabled = true;
fetch(`/admin/toys/${selectedToyId}`, { method: 'DELETE' })
.then(r => {
if (r.ok || r.status === 204) { _allAvailableToys = null; loadAdminToys(); }
else if (r.status === 409) { document.getElementById('toyActionError').textContent = 'Toy wird in Aufgaben verwendet und kann nicht gelöscht werden.'; btn.disabled = false; }
else { document.getElementById('toyActionError').textContent = 'Fehler beim Löschen (HTTP ' + r.status + ').'; btn.disabled = false; }
})
.catch(() => { document.getElementById('toyActionError').textContent = 'Verbindungsfehler.'; btn.disabled = false; });
});
toyModalSave.addEventListener('click', async () => {
const name = document.getElementById('toyModalName').value.trim();
if (!name) { showToyModalError('Bitte einen Namen eingeben.'); return; }
toyModalSave.disabled = true; toyModalSave.textContent = 'Speichert…';
let bildBase64 = null;
const fi = document.getElementById('toyModalBild');
if (fi.files.length > 0) bildBase64 = await toBase64(fi.files[0]);
const payload = { name, beschreibung: document.getElementById('toyModalDesc').value.trim() || null, bild: bildBase64 };
const isEdit = currentEditToyId != null;
fetch(isEdit ? `/admin/toys/${currentEditToyId}` : '/admin/toys', {
method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201 || r.status === 204) { closeToyModal(); _allAvailableToys = null; loadAdminToys(); }
else if (r.status === 409) showToyModalError('Ein Toy mit diesem Namen existiert bereits.');
else showToyModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
})
.catch(() => showToyModalError('Verbindungsfehler.'))
.finally(() => { toyModalSave.disabled = false; toyModalSave.textContent = 'Speichern'; });
});
function showToyModalError(msg) { const el = document.getElementById('toyModalError'); el.textContent = msg; el.style.display = 'block'; }
// ── Admins ────────────────────────────────────────────────────────────────
async function loadAdmins() {
const r = await fetch('/admin/admins'); if (!r.ok) return;
const list = await r.json();
const tbody = document.getElementById('adminsBody');
if (!list.length) { tbody.innerHTML = '<tr><td colspan="4" class="empty-hint">Keine Admins vorhanden.</td></tr>'; return; }
tbody.innerHTML = list.map(a => `
<tr>
<td>${esc(a.userName)}</td>
<td><span class="badge-status badge-${a.rolle.toLowerCase()}">${a.rolle}</span></td>
<td style="white-space:nowrap">${fmtDate(a.createdAt)}</td>
<td><button class="tbl-btn" onclick="deleteAdmin('${a.adminId}', '${esc(a.userName)}')">Entfernen</button></td>
</tr>`).join('');
}
let _adminSearchTimer = null;
function searchAdminUsers() {
clearTimeout(_adminSearchTimer);
const q = document.getElementById('adminSearch').value.trim();
const box = document.getElementById('adminSearchResults');
if (!q) { box.style.display = 'none'; box.innerHTML = ''; return; }
_adminSearchTimer = setTimeout(async () => {
const r = await fetch(`/admin/users/search?q=${encodeURIComponent(q)}`);
if (!r.ok) return;
const list = await r.json();
if (!list.length) {
box.innerHTML = '<div style="padding:0.6rem 0.9rem;font-size:0.88rem;color:var(--color-muted);">Keine Benutzer gefunden.</div>';
} else {
box.innerHTML = list.map(u => `
<div style="display:flex;align-items:center;justify-content:space-between;
padding:0.5rem 0.9rem;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;"
onmouseenter="this.style.background='rgba(255,255,255,0.04)'"
onmouseleave="this.style.background=''">
<span style="font-size:0.9rem;">${esc(u.name)}</span>
<button class="tbl-btn tbl-btn-ok" onclick="addAdminFromSearch('${u.userId}', '${esc(u.name)}')">+ Hinzufügen</button>
</div>`).join('');
}
box.style.display = 'block';
}, 300);
}
async function addAdminFromSearch(userId, userName) {
const rolle = document.getElementById('adminRolle').value;
const errEl = document.getElementById('adminAddError');
errEl.textContent = '';
const r = await fetch('/admin/admins', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, rolle })
});
if (r.status === 201) {
document.getElementById('adminSearch').value = '';
document.getElementById('adminSearchResults').style.display = 'none';
loadAdmins();
} else if (r.status === 409) {
errEl.textContent = `${userName} ist bereits Admin.`;
} else {
errEl.textContent = `Fehler beim Hinzufügen (HTTP ${r.status}).`;
}
}
async function deleteAdmin(id, userName) {
openConfirmModal(`Admin-Berechtigung von „${userName}" wirklich entziehen?`, async () => {
const r = await fetch(`/admin/admins/${id}`, { method: 'DELETE' });
if (r.ok || r.status === 204) {
loadAdmins();
} else if (r.status === 400) {
document.getElementById('adminAddError').textContent = 'Du kannst dich nicht selbst entfernen.';
document.querySelector('.tab-btn[data-tab="admins"]')?.click();
} else {
document.getElementById('adminAddError').textContent = `Fehler (HTTP ${r.status}).`;
}
});
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
}
function toBase64(file) {
const MAX = 128;
return new Promise((resolve, reject) => {
const img = new Image(), url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject; img.src = url;
});
}
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (gruppeModal.classList.contains('open')) { closeGruppeModal(); return; }
if (toyModal.classList.contains('open')) { closeToyModal(); return; }
if (itemModal.classList.contains('open')) { closeItemModal(); return; }
if (bildImportModal.classList.contains('open')) { closeBildImportModal(); return; }
});
// ── Export / Import (ZIP) ──────────────────────────────────────────────────
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a); URL.revokeObjectURL(url);
}
// ── Toy-Export (ZIP) ──────────────────────────────────────────────────────
document.getElementById('toyExportBtn').addEventListener('click', async () => {
const errEl = document.getElementById('toyActionError');
const r = await fetch('/admin/toys');
if (!r.ok) { errEl.textContent = 'Export fehlgeschlagen.'; return; }
const toys = await r.json();
const zip = new JSZip();
const meta = toys.map(toy => {
const entry = { name: toy.name, beschreibung: toy.beschreibung || null };
if (toy.bild) {
const imgPath = `images/${toy.toyId}.png`;
zip.file(imgPath, toy.bild, { base64: true });
entry.bild = imgPath;
}
return entry;
});
zip.file('toys.json', JSON.stringify(meta, null, 2));
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
downloadBlob(blob, 'toys-export.zip');
});
// ── Toy ZIP-Import ────────────────────────────────────────────────────────
const toyImportFile = document.getElementById('toyImportFile');
document.getElementById('toyJsonImportBtn').addEventListener('click', () => toyImportFile.click());
toyImportFile.addEventListener('change', async function () {
if (!this.files.length) return;
const file = this.files[0]; this.value = '';
const errEl = document.getElementById('toyActionError');
errEl.style.color = 'var(--color-muted)'; errEl.textContent = 'Importiere…';
let zip, data;
try {
zip = await JSZip.loadAsync(file);
data = JSON.parse(await zip.file('toys.json').async('string'));
} catch(e) { errEl.style.color = ''; errEl.textContent = 'Fehler: Ungültige ZIP-Datei (erwartet toys.json).'; return; }
if (!Array.isArray(data)) { errEl.style.color = ''; errEl.textContent = 'Fehler: Ungültiges Format.'; return; }
let imp = 0, skip = 0, fail = 0;
for (const toy of data) {
if (!toy.name) { fail++; continue; }
let bildBase64 = null;
if (toy.bild) { const f = zip.file(toy.bild); if (f) bildBase64 = await f.async('base64'); }
const r = await fetch('/admin/toys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: toy.name, beschreibung: toy.beschreibung || null, bild: bildBase64 })
});
if (r.status === 201) imp++;
else if (r.status === 409) skip++;
else fail++;
}
_allAvailableToys = null;
await loadAdminToys();
errEl.style.color = imp > 0 ? 'var(--color-success, #2ecc71)' : '';
errEl.textContent = `Import: ${imp} importiert, ${skip} übersprungen (bereits vorhanden)${fail ? ', ' + fail + ' Fehler' : ''}.`;
});
// ── Aufgaben-Export (ZIP) ─────────────────────────────────────────────────
document.getElementById('gruppeExportBtn').addEventListener('click', async () => {
const errEl = document.getElementById('gruppeActionError');
const r = await fetch('/admin/aufgabengruppen');
if (!r.ok) { errEl.textContent = 'Export fehlgeschlagen.'; return; }
const gruppen = await r.json();
const zip = new JSZip();
const meta = gruppen.map(g => {
const entry = { ...g };
if (g.bild) {
const imgPath = `images/${g.gruppenId}.png`;
zip.file(imgPath, g.bild, { base64: true });
entry.bild = imgPath;
}
return entry;
});
zip.file('aufgabengruppen.json', JSON.stringify(meta, null, 2));
const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
downloadBlob(blob, 'aufgabengruppen-export.zip');
});
// ── Aufgaben ZIP-Import ───────────────────────────────────────────────────
const gruppeImportFile = document.getElementById('gruppeImportFile');
document.getElementById('gruppeJsonImportBtn').addEventListener('click', () => gruppeImportFile.click());
gruppeImportFile.addEventListener('change', async function () {
if (!this.files.length) return;
const file = this.files[0]; this.value = '';
const errEl = document.getElementById('gruppeActionError');
let zip, data;
try {
zip = await JSZip.loadAsync(file);
data = JSON.parse(await zip.file('aufgabengruppen.json').async('string'));
} catch(e) { errEl.style.color = ''; errEl.textContent = 'Fehler: Ungültige ZIP-Datei (erwartet aufgabengruppen.json).'; return; }
if (!Array.isArray(data)) { errEl.style.color = ''; errEl.textContent = 'Fehler: Ungültiges Format.'; return; }
// Toys für Name→ID-Mapping laden
const toysRes = await fetch('/admin/toys');
const availToys = toysRes.ok ? await toysRes.json() : [];
const toyByName = {};
for (const t of availToys) toyByName[t.name.toLowerCase()] = t;
function remapToys(list) {
return (list || []).map(t => { const m = toyByName[(t.name || '').toLowerCase()]; return m ? { toyId: m.toyId } : null; }).filter(Boolean);
}
let gImp = 0, gFail = 0, iImp = 0;
errEl.style.color = 'var(--color-muted)';
for (let gi = 0; gi < data.length; gi++) {
const g = data[gi];
errEl.textContent = `Importiere ${gi + 1} / ${data.length} Gruppen…`;
if (!g.name) { gFail++; continue; }
let bildBase64 = null;
if (g.bild) { const f = zip.file(g.bild); if (f) bildBase64 = await f.async('base64'); }
const gr = await fetch('/admin/aufgabengruppen', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: g.name, beschreibung: g.beschreibung || null, von: g.von || null, bild: bildBase64 })
});
if (gr.status !== 201 && !gr.ok) { gFail++; continue; }
const gId = (await gr.json()).gruppenId;
gImp++;
for (const a of (g.aufgaben || [])) {
const r = await fetch('/aufgabe', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: a.kurzText, text: a.text, level: a.level, gruppeId: gId,
sekundenVon: a.sekundenVon ?? null, sekundenBis: a.sekundenBis ?? null,
benoetigtAktiv: a.benoetigtAktiv || [], benoetigtPassiv: a.benoetigtPassiv || [],
benoetigteToys: remapToys(a.benoetigteToys) }) });
if (r.ok || r.status === 201) iImp++;
}
for (const s of (g.strafen || [])) {
const r = await fetch('/strafe', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: s.kurzText, text: s.text, level: s.level, gruppeId: gId,
sekundenVon: s.sekundenVon ?? null, sekundenBis: s.sekundenBis ?? null,
benoetigtAktiv: s.benoetigtAktiv || [], benoetigtPassiv: s.benoetigtPassiv || [],
benoetigteToys: remapToys(s.benoetigteToys) }) });
if (r.ok || r.status === 201) iImp++;
}
for (const z of (g.sperren || [])) {
const r = await fetch('/sperre', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: z.kurzText, text: z.text, gruppeId: gId,
minutenVon: z.minutenVon, minutenBis: z.minutenBis ?? null,
releaseText: z.releaseText || null, sperreFuer: z.sperreFuer || [],
benoetigteToys: remapToys(z.benoetigteToys) }) });
if (r.ok || r.status === 201) iImp++;
}
for (const f of (g.finisher || [])) {
const r = await fetch('/finisher', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kurzText: f.kurzText, text: f.text, geschlecht: f.geschlecht, gruppeId: gId,
benoetigtAktiv: f.benoetigtAktiv || [], benoetigtPassiv: f.benoetigtPassiv || [],
benoetigteToys: remapToys(f.benoetigteToys) }) });
if (r.ok || r.status === 201) iImp++;
}
}
await loadAdminGruppen();
errEl.style.color = gImp > 0 ? 'var(--color-success, #2ecc71)' : '';
errEl.textContent = `Import: ${gImp} Gruppe${gImp !== 1 ? 'n' : ''} mit ${iImp} Einträgen importiert${gFail ? ', ' + gFail + ' fehlgeschlagen' : ''}.`;
});
// ── Toy-Bildimport ────────────────────────────────────────────────────────
const bildImportModal = document.getElementById('bildImportModal');
let _pendingBildImport = [];
function filenameToName(filename) {
return filename.replace(/\.[^.]+$/, '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function openBildImportModal() {
_pendingBildImport = [];
document.getElementById('bildImportInput').value = '';
document.getElementById('bildImportPreview').innerHTML = '';
document.getElementById('bildImportProgress').style.display = 'none';
document.getElementById('bildImportError').style.display = 'none';
document.getElementById('bildImportStart').disabled = true;
document.getElementById('bildImportCancel').disabled = false;
document.getElementById('bildImportCancel').textContent = 'Abbrechen';
bildImportModal.classList.add('open');
}
function closeBildImportModal() { bildImportModal.classList.remove('open'); }
document.getElementById('toyBildImportBtn').addEventListener('click', openBildImportModal);
document.getElementById('bildImportCancel').addEventListener('click', closeBildImportModal);
bildImportModal.addEventListener('click', e => { if (e.target === bildImportModal) closeBildImportModal(); });
document.getElementById('bildImportInput').addEventListener('change', function () {
_pendingBildImport = Array.from(this.files).map(f => ({ file: f, name: filenameToName(f.name) }));
renderBildImportPreview();
document.getElementById('bildImportStart').disabled = _pendingBildImport.length === 0;
document.getElementById('bildImportProgress').style.display = 'none';
document.getElementById('bildImportError').style.display = 'none';
});
function renderBildImportPreview() {
const preview = document.getElementById('bildImportPreview');
if (!_pendingBildImport.length) { preview.innerHTML = ''; return; }
preview.innerHTML =
`<p style="font-size:0.82rem;color:var(--color-muted);margin:0 0 0.5rem 0;">${_pendingBildImport.length} Bild${_pendingBildImport.length !== 1 ? 'er' : ''} ausgewählt Namen können angepasst werden:</p>` +
'<div style="display:flex;flex-direction:column;gap:0.3rem;">' +
_pendingBildImport.map((item, i) => `
<div style="display:grid;grid-template-columns:1fr 1.2rem 1fr;align-items:center;gap:0.4rem;
padding:0.3rem 0.5rem;background:var(--color-secondary);border-radius:5px;">
<span style="font-size:0.72rem;color:var(--color-muted);overflow:hidden;text-overflow:ellipsis;
white-space:nowrap;" title="${esc(item.file.name)}">${esc(item.file.name)}</span>
<span style="font-size:0.75rem;color:var(--color-muted);text-align:center;">→</span>
<input type="text" data-idx="${i}" value="${esc(item.name)}"
style="background:transparent;border:none;border-bottom:1px solid rgba(136,136,136,0.35);
color:var(--color-text);font-size:0.82rem;padding:0.1rem 0.2rem;
outline:none;min-width:0;width:100%;">
</div>`).join('') +
'</div>';
}
document.getElementById('bildImportStart').addEventListener('click', async () => {
// Namen aus den Eingabefeldern aktualisieren
document.querySelectorAll('#bildImportPreview input[data-idx]').forEach(inp => {
const idx = parseInt(inp.dataset.idx, 10);
if (!isNaN(idx) && _pendingBildImport[idx]) _pendingBildImport[idx].name = inp.value.trim();
});
const startBtn = document.getElementById('bildImportStart');
const cancelBtn = document.getElementById('bildImportCancel');
const progress = document.getElementById('bildImportProgress');
startBtn.disabled = true; cancelBtn.disabled = true;
progress.style.display = 'block'; progress.style.color = 'var(--color-muted)';
document.getElementById('bildImportError').style.display = 'none';
let imp = 0, skip = 0, fail = 0;
for (let i = 0; i < _pendingBildImport.length; i++) {
const { file, name } = _pendingBildImport[i];
progress.textContent = `Importiere ${i + 1} / ${_pendingBildImport.length}`;
try {
const bild = await toBase64(file);
const toyName = name || filenameToName(file.name);
const r = await fetch('/admin/toys', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: toyName, beschreibung: toyName, bild })
});
if (r.status === 201) imp++;
else if (r.status === 409) skip++;
else fail++;
} catch(e) { fail++; }
}
_allAvailableToys = null;
await loadAdminToys();
progress.style.color = imp > 0 ? 'var(--color-success, #2ecc71)' : 'var(--color-primary)';
progress.textContent = `Fertig: ${imp} importiert, ${skip} übersprungen (Name bereits vergeben)${fail ? ', ' + fail + ' Fehler' : ''}.`;
_pendingBildImport = [];
document.getElementById('bildImportStart').disabled = true;
cancelBtn.disabled = false; cancelBtn.textContent = 'Schließen';
});
// ── TTLock-Konfiguration ──────────────────────────────────────────────────
async function loadTtlockConfig() {
const r = await fetch('/admin/ttlock'); if (!r.ok) return;
const cfg = await r.json();
document.getElementById('ttClientId').value = cfg.clientId || '';
document.getElementById('ttClientSecret').value = cfg.clientSecret || '';
document.getElementById('ttBaseUrl').value = cfg.baseUrl || '';
document.getElementById('ttSaveError').textContent = '';
}
async function saveTtlockConfig() {
const err = document.getElementById('ttSaveError');
err.textContent = '';
const body = {
clientId: document.getElementById('ttClientId').value.trim(),
clientSecret: document.getElementById('ttClientSecret').value.trim(),
baseUrl: document.getElementById('ttBaseUrl').value.trim()
};
const r = await fetch('/admin/ttlock', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
err.style.color = 'var(--color-success, #2ecc71)';
err.textContent = 'Gespeichert.';
await loadTtlockConfig();
setTimeout(() => { err.textContent = ''; err.style.color = ''; }, 3000);
} else {
err.style.color = '';
err.textContent = 'Fehler beim Speichern.';
}
}
// ── Abonnement-Hilfsfunktionen ────────────────────────────────────────────
function parseLocalDate(d) {
if (!d) return null;
if (Array.isArray(d)) return new Date(d[0], d[1] - 1, d[2]);
return new Date(d);
}
function formatLocalDate(d) {
const parsed = parseLocalDate(d);
if (!parsed || isNaN(parsed)) return '';
return parsed.toLocaleDateString('de-DE');
}
async function loadAllSubscriptions() {
const tbody = document.getElementById('aboOverviewBody');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--color-muted);font-style:italic;">Laden…</td></tr>';
const r = await fetch('/admin/subscriptions');
if (!r.ok) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--color-muted);">Fehler beim Laden.</td></tr>';
return;
}
const list = await r.json();
if (list.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--color-muted);font-style:italic;">Keine aktiven Abonnements.</td></tr>';
return;
}
tbody.innerHTML = list.map(s => {
const until = formatLocalDate(s.validUntil);
const since = formatLocalDate(s.subscribedAt);
return `<tr>
<td>${escAdminHtml(s.userName)}</td>
<td><strong>${escAdminHtml(s.subscriptionType)}</strong></td>
<td>${since}</td>
<td>${until}</td>
</tr>`;
}).join('');
}
// ── Abonnement verschenken ────────────────────────────────────────────────
(function() {
let aboSearchTimer = null;
document.getElementById('aboSearchInput').addEventListener('input', function() {
clearTimeout(aboSearchTimer);
document.getElementById('aboUserId').value = '';
document.getElementById('aboStatus').style.display = 'none';
setAboBtn(false);
const q = this.value.trim();
if (q.length < 2) { closeAboDropdown(); return; }
aboSearchTimer = setTimeout(() => searchAboUsers(q), 280);
});
document.getElementById('aboSearchInput').addEventListener('blur', function() {
setTimeout(closeAboDropdown, 150);
});
})();
async function searchAboUsers(q) {
const r = await fetch('/admin/users/search/all?q=' + encodeURIComponent(q));
if (!r.ok) return;
const users = await r.json();
const dd = document.getElementById('aboDropdown');
dd.innerHTML = '';
if (users.length === 0) {
dd.innerHTML = '<div style="padding:.55rem .85rem;font-size:.85rem;color:var(--color-muted);font-style:italic;">Keine Treffer.</div>';
} else {
users.forEach(u => {
const div = document.createElement('div');
div.style.cssText = 'padding:.55rem .85rem;cursor:pointer;font-size:.9rem;color:var(--color-text);';
div.textContent = u.name;
div.addEventListener('mouseover', () => div.style.background = 'var(--color-secondary)');
div.addEventListener('mouseout', () => div.style.background = '');
div.addEventListener('mousedown', e => {
e.preventDefault();
document.getElementById('aboSearchInput').value = u.name;
document.getElementById('aboUserId').value = u.userId;
closeAboDropdown();
loadAboStatus(u.userId);
});
dd.appendChild(div);
});
}
dd.style.display = '';
}
function closeAboDropdown() {
document.getElementById('aboDropdown').style.display = 'none';
}
async function loadAboStatus(userId) {
const statusEl = document.getElementById('aboStatus');
const errEl = document.getElementById('aboError');
errEl.textContent = '';
statusEl.style.display = 'none';
setAboBtn(false);
const r = await fetch('/admin/subscriptions/user/' + userId);
if (!r.ok) { errEl.textContent = 'Fehler beim Laden des Abo-Status.'; return; }
const s = await r.json();
let html = `<strong>${escAdminHtml(s.userName)}</strong><br>`;
if (s.validUntil) {
const until = formatLocalDate(s.validUntil);
const isActive = parseLocalDate(s.validUntil) >= new Date();
html += `Aktuelles Abo: <strong style="color:${isActive ? '#2ecc71' : 'var(--color-muted)'};">${s.subscriptionType}</strong>`;
html += ` gültig bis <strong>${until}</strong>`;
if (isActive) html += `<br><span style="font-size:.8rem;color:var(--color-muted);">Nach dem Schenken: gültig bis <strong>${addOneMonth(s.validUntil)}</strong></span>`;
} else {
html += `Kein aktives Abonnement (STANDARD)`;
}
statusEl.innerHTML = html;
statusEl.style.display = '';
setAboBtn(true);
}
function addOneMonth(dateVal) {
const d = parseLocalDate(dateVal);
if (!d || isNaN(d)) return '';
d.setMonth(d.getMonth() + 1);
return d.toLocaleDateString('de-DE');
}
function setAboBtn(enabled) {
const btn = document.getElementById('aboBtnGift');
btn.disabled = !enabled;
btn.style.opacity = enabled ? '' : '.45';
btn.style.cursor = enabled ? '' : 'not-allowed';
}
async function giftSubscription() {
const userId = document.getElementById('aboUserId').value;
const errEl = document.getElementById('aboError');
if (!userId) return;
errEl.textContent = '';
setAboBtn(false);
const r = await fetch('/admin/subscriptions/gift', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (r.ok) {
const s = await r.json();
const until = formatLocalDate(s.validUntil);
errEl.style.color = '#2ecc71';
errEl.textContent = `✅ 1 Monat Premium geschenkt gültig bis ${until}`;
setTimeout(() => { errEl.textContent = ''; errEl.style.color = ''; }, 5000);
loadAboStatus(userId);
loadAllSubscriptions();
} else {
errEl.style.color = '';
errEl.textContent = 'Fehler beim Verschenken.';
setAboBtn(true);
}
}
function escAdminHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Vorlieben-Verwaltung ───────────────────────────────────────────────────
let _vlKategorien = [];
let _vlItems = [];
async function loadVorliebenAdmin() {
const [rKat, rItem] = await Promise.all([
fetch('/admin/vorlieben/kategorien'),
fetch('/admin/vorlieben/items'),
]);
if (!rKat.ok || !rItem.ok) return;
_vlKategorien = await rKat.json();
_vlItems = await rItem.json();
renderVlListe();
renderVlKatDropdown();
}
function renderVlListe() {
const container = document.getElementById('vlKatList');
if (!_vlKategorien.length) {
container.innerHTML = '<p class="empty-hint">Keine Kategorien vorhanden. Lege zuerst eine Kategorie an.</p>';
return;
}
const itemsByKat = {};
_vlKategorien.forEach(k => { itemsByKat[k.kategorieId] = []; });
_vlItems.forEach(i => { if (itemsByKat[i.kategorieId]) itemsByKat[i.kategorieId].push(i); });
container.innerHTML = _vlKategorien.map(k => {
const items = itemsByKat[k.kategorieId] || [];
const itemsHtml = items.length
? `<div class="item-list">${items.map(i => `
<div class="item">
<div class="item-row" style="cursor:default;">
<span class="item-text">${escAdminHtml(i.name)}</span>
<span style="font-size:0.72rem;color:var(--color-muted);flex-shrink:0;margin-right:0.5rem;">#${i.sortOrder}</span>
<div class="item-badges" style="flex-shrink:0;">
<button class="btn-item-edit" onclick="editItem('${i.itemId}')">✎</button>
<button class="btn-item-delete" onclick="deleteVorliebeItem('${i.itemId}','${escAdminHtml(i.name).replace(/'/g,"\\'")}')">✕</button>
</div>
</div>
</div>`).join('')}</div>`
: `<p class="sub-empty">Keine Vorlieben in dieser Kategorie.</p>`;
return `
<div class="gruppe-card open" id="vlkat-${k.kategorieId}">
<div class="gruppe-header" onclick="this.closest('.gruppe-card').classList.toggle('open')">
<div class="gruppe-meta">
<div class="gruppe-name">${escAdminHtml(k.name)}</div>
<div class="gruppe-info">${items.length} Vorliebe${items.length !== 1 ? 'n' : ''} · Reihenfolge: ${k.sortOrder}</div>
</div>
<div style="display:flex;gap:0.4rem;align-items:center;flex-shrink:0;">
<button class="btn-item-edit" onclick="event.stopPropagation();addItemToKat('${k.kategorieId}')">+ Vorliebe</button>
<button class="btn-item-edit" onclick="event.stopPropagation();editKategorie('${k.kategorieId}')">✎</button>
<button class="btn-item-delete" onclick="event.stopPropagation();deleteKategorie('${k.kategorieId}','${escAdminHtml(k.name).replace(/'/g,"\\'")}')">✕</button>
<span class="gruppe-toggle">▶</span>
</div>
</div>
<div class="gruppe-body">
<div class="sub-section">
<div class="sub-section-header">
<span class="sub-section-title">Vorlieben</span>
</div>
${itemsHtml}
</div>
</div>
</div>`;
}).join('');
}
function renderVlKatDropdown() {
const sel = document.getElementById('vlItemKat');
const cur = sel.value;
sel.innerHTML = _vlKategorien.map(k =>
`<option value="${k.kategorieId}">${escAdminHtml(k.name)}</option>`).join('');
if (cur) sel.value = cur;
}
// ── Kategorien CRUD ──
document.getElementById('vlKatCreateBtn').addEventListener('click', () => {
document.getElementById('vlKatId').value = '';
document.getElementById('vlKatName').value = '';
document.getElementById('vlKatSort').value = '0';
document.getElementById('vlKatFormTitle').textContent = 'Kategorie anlegen';
document.getElementById('vlItemForm').style.display = 'none';
document.getElementById('vlKatForm').style.display = '';
document.getElementById('vlKatName').focus();
});
function cancelKategorie() { document.getElementById('vlKatForm').style.display = 'none'; }
function editKategorie(id) {
const k = _vlKategorien.find(x => x.kategorieId === id);
if (!k) return;
document.getElementById('vlKatId').value = k.kategorieId;
document.getElementById('vlKatName').value = k.name;
document.getElementById('vlKatSort').value = k.sortOrder;
document.getElementById('vlKatFormTitle').textContent = 'Kategorie bearbeiten';
document.getElementById('vlItemForm').style.display = 'none';
document.getElementById('vlKatForm').style.display = '';
document.getElementById('vlKatName').focus();
}
async function saveKategorie() {
const id = document.getElementById('vlKatId').value;
const name = document.getElementById('vlKatName').value.trim();
const sort = parseInt(document.getElementById('vlKatSort').value) || 0;
const errEl = document.getElementById('vlError');
if (!name) { errEl.textContent = 'Name darf nicht leer sein.'; return; }
const url = id ? `/admin/vorlieben/kategorien/${id}` : '/admin/vorlieben/kategorien';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, sortOrder: sort }) });
if (r.ok || r.status === 201) {
errEl.textContent = '';
document.getElementById('vlKatForm').style.display = 'none';
await loadVorliebenAdmin();
} else { errEl.textContent = 'Fehler beim Speichern.'; }
}
function deleteKategorie(id, name) {
openConfirmModal(`Kategorie „${name}" löschen?`, async () => {
const errEl = document.getElementById('vlError');
const r = await fetch(`/admin/vorlieben/kategorien/${id}`, { method: 'DELETE' });
if (r.ok) { await loadVorliebenAdmin(); }
else if (r.status === 409) { errEl.textContent = 'Kategorie enthält noch Vorlieben bitte zuerst alle Vorlieben dieser Kategorie löschen.'; }
else { errEl.textContent = 'Fehler beim Löschen.'; }
});
}
// ── Items CRUD ──
document.getElementById('vlKatCreateBtn'); // already bound above
function addItemToKat(katId) {
document.getElementById('vlItemId').value = '';
document.getElementById('vlItemKat').value = katId;
document.getElementById('vlItemName').value = '';
document.getElementById('vlItemSort').value = '0';
document.getElementById('vlItemFormTitle').textContent = 'Vorliebe anlegen';
document.getElementById('vlKatForm').style.display = 'none';
document.getElementById('vlItemForm').style.display = '';
document.getElementById('vlItemName').focus();
}
function cancelItem() { document.getElementById('vlItemForm').style.display = 'none'; }
function editItem(id) {
const i = _vlItems.find(x => x.itemId === id);
if (!i) return;
document.getElementById('vlItemId').value = i.itemId;
document.getElementById('vlItemKat').value = i.kategorieId;
document.getElementById('vlItemName').value = i.name;
document.getElementById('vlItemSort').value = i.sortOrder;
document.getElementById('vlItemFormTitle').textContent = 'Vorliebe bearbeiten';
document.getElementById('vlKatForm').style.display = 'none';
document.getElementById('vlItemForm').style.display = '';
document.getElementById('vlItemName').focus();
}
async function saveItem() {
const id = document.getElementById('vlItemId').value;
const katId = document.getElementById('vlItemKat').value;
const name = document.getElementById('vlItemName').value.trim();
const sort = parseInt(document.getElementById('vlItemSort').value) || 0;
const errEl = document.getElementById('vlError');
if (!name || !katId) { errEl.textContent = 'Name und Kategorie sind erforderlich.'; return; }
const url = id ? `/admin/vorlieben/items/${id}` : '/admin/vorlieben/items';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ kategorieId: katId, name, sortOrder: sort }) });
if (r.ok || r.status === 201) {
errEl.textContent = '';
document.getElementById('vlItemForm').style.display = 'none';
await loadVorliebenAdmin();
} else { errEl.textContent = 'Fehler beim Speichern.'; }
}
function deleteVorliebeItem(id, name) {
openConfirmModal(`Vorliebe „${name}" löschen? Alle Nutzerbewertungen werden ebenfalls gelöscht.`, async () => {
const errEl = document.getElementById('vlError');
const r = await fetch(`/admin/vorlieben/items/${id}`, { method: 'DELETE' });
if (r.ok) { await loadVorliebenAdmin(); }
else { errEl.textContent = 'Fehler beim Löschen.'; }
});
}
// ── Export / Import ──
document.getElementById('vlExportBtn').addEventListener('click', async () => {
const r = await fetch('/admin/vorlieben/export');
if (!r.ok) { document.getElementById('vlError').textContent = 'Export fehlgeschlagen.'; return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'vorlieben-export.json';
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
});
const vlImportFile = document.getElementById('vlImportFile');
document.getElementById('vlImportBtn').addEventListener('click', () => vlImportFile.click());
vlImportFile.addEventListener('change', async function () {
if (!this.files.length) return;
const file = this.files[0]; this.value = '';
const errEl = document.getElementById('vlError');
errEl.style.color = 'var(--color-muted)'; errEl.textContent = 'Importiere…';
let data;
try { data = JSON.parse(await file.text()); }
catch(e) { errEl.style.color = ''; errEl.textContent = 'Fehler: Ungültige JSON-Datei.'; return; }
const r = await fetch('/admin/vorlieben/import', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data)
});
if (!r.ok) { errEl.style.color = ''; errEl.textContent = 'Import fehlgeschlagen.'; return; }
const res = await r.json();
await loadVorliebenAdmin();
errEl.style.color = 'var(--color-success,#2ecc71)';
errEl.textContent = `Import: ${res.kategorienCreated} Kategorien neu, ${res.itemsCreated} Vorlieben neu, ${res.kategorienSkipped + res.itemsSkipped} übersprungen.`;
setTimeout(() => { errEl.textContent = ''; errEl.style.color = ''; }, 5000);
});
// Vorlieben-Tab beim ersten Öffnen laden
document.querySelector('.tab-btn[data-tab="vorlieben"]')?.addEventListener('click', () => {
if (!_vlKategorien.length && !_vlItems.length) loadVorliebenAdmin();
});
init();
</script>
</body>
</html>