Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled
2607 lines
145 KiB
HTML
2607 lines
145 KiB
HTML
<!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="1–5">
|
||
<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 & 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ── 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>
|