Weiter am Ingame Chastity Game gearbeitet
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-26 22:53:05 +02:00
parent 4f2048bdc8
commit 0aa794600e
44 changed files with 2156 additions and 1419 deletions

View File

@@ -0,0 +1,215 @@
-- Migration: Inhalte aus chastity_game_set in die normalen Aufgabengruppen-Tabellen übernehmen.
--
-- Jedes chastity_game_set eines Users wird zu einer privaten CHASTITY_ONLY-Aufgabengruppe:
-- aufgaben (JSON) → aufgabe + aufgabe_benoetigt_passiv
-- zeitstrafen (JSON) → sperre + sperre_sperre_fuer
-- finisher (JSON) → finisher
--
-- Die Prozedur prüft zuerst, ob chastity_game_set existiert auf leeren Datenbanken
-- ist sie dadurch ein No-op.
DROP PROCEDURE IF EXISTS proc_migrate_chastity_game_set;
CREATE PROCEDURE proc_migrate_chastity_game_set()
BEGIN
DECLARE v_set_id VARCHAR(36);
DECLARE v_owner_id VARCHAR(36);
DECLARE v_set_name VARCHAR(255);
DECLARE v_user_name VARCHAR(255);
DECLARE v_aufgaben_json TEXT;
DECLARE v_zeitstr_json TEXT;
DECLARE v_finisher_json TEXT;
DECLARE v_gruppe_id VARCHAR(36);
DECLARE v_aufgabe_id VARCHAR(36);
DECLARE v_sperre_id VARCHAR(36);
DECLARE v_finisher_id VARCHAR(36);
DECLARE v_outer_count INT;
DECLARE v_inner_count INT;
DECLARE i INT;
DECLARE j INT;
DECLARE tbl_exists INT DEFAULT 0;
DECLARE done INT DEFAULT FALSE;
DECLARE cur CURSOR FOR
SELECT id, owner_id, name, aufgaben, zeitstrafen, finisher
FROM chastity_game_set;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
-- Add columns that may not yet exist (Hibernate ddl-auto runs after Flyway)
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'aufgabe' AND COLUMN_NAME = 'level') THEN
ALTER TABLE aufgabe ADD COLUMN level INT NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sperre' AND COLUMN_NAME = 'level') THEN
ALTER TABLE sperre ADD COLUMN level INT NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sperre' AND COLUMN_NAME = 'temp_unlock_before_required') THEN
ALTER TABLE sperre ADD COLUMN temp_unlock_before_required TINYINT(1) NULL;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sperre' AND COLUMN_NAME = 'temp_unlock_after_required') THEN
ALTER TABLE sperre ADD COLUMN temp_unlock_after_required TINYINT(1) NULL;
END IF;
SELECT COUNT(*) INTO tbl_exists
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'chastity_game_set';
IF tbl_exists > 0 THEN
OPEN cur;
set_loop: LOOP
FETCH cur INTO v_set_id, v_owner_id, v_set_name,
v_aufgaben_json, v_zeitstr_json, v_finisher_json;
IF done THEN LEAVE set_loop; END IF;
SELECT name INTO v_user_name
FROM user
WHERE user_id = v_owner_id
LIMIT 1;
SET v_gruppe_id = UUID();
-- ── Aufgabengruppe anlegen ───────────────────────────────────────
INSERT INTO aufgaben_gruppe
(gruppen_id, name, beschreibung, user_id, private_gruppe, bild, von, available_in)
VALUES
(v_gruppe_id,
v_set_name,
'Importiert aus Chastity Game Set',
v_owner_id,
TRUE,
NULL,
v_user_name,
'CHASTITY_ONLY');
-- ── Aufgaben ────────────────────────────────────────────────────
-- GameSetAufgabe.minutes → sekunden_von / sekunden_bis (× 60)
-- GameSetAufgabe.benoetigt → aufgabe_benoetigt_passiv
SET v_outer_count = IFNULL(JSON_LENGTH(v_aufgaben_json), 0);
SET i = 0;
WHILE i < v_outer_count DO
SET v_aufgabe_id = UUID();
INSERT INTO aufgabe
(aufgabe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id)
VALUES (
v_aufgabe_id,
JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].title'))),
JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].description'))),
CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].level'))), 'null') AS SIGNED),
CASE
WHEN JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].minutes')) IS NOT NULL
THEN CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].minutes'))), 'null') AS SIGNED) * 60
ELSE NULL
END,
CASE
WHEN JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].minutes')) IS NOT NULL
THEN CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].minutes'))), 'null') AS SIGNED) * 60
ELSE NULL
END,
v_gruppe_id
);
SET v_inner_count = IFNULL(
JSON_LENGTH(JSON_EXTRACT(v_aufgaben_json, CONCAT('$[', i, '].benoetigt'))),
0);
SET j = 0;
WHILE j < v_inner_count DO
SET @wz = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_aufgaben_json,
CONCAT('$[', i, '].benoetigt[', j, ']'))), 'null');
IF @wz IS NOT NULL AND @wz != '' THEN
INSERT IGNORE INTO aufgabe_benoetigt_passiv (aufgabe_id, werkzeug)
VALUES (v_aufgabe_id, @wz);
END IF;
SET j = j + 1;
END WHILE;
SET i = i + 1;
END WHILE;
-- ── Zeitstrafen → Sperren ────────────────────────────────────────
-- GameSetZeitstrafe.minMinutes / maxMinutes → minuten_von / minuten_bis
-- GameSetZeitstrafe.level / tempUnlock* → level / temp_unlock_before/after_required
-- GameSetZeitstrafe.sperrt → sperre_sperre_fuer
SET v_outer_count = IFNULL(JSON_LENGTH(v_zeitstr_json), 0);
SET i = 0;
WHILE i < v_outer_count DO
SET v_sperre_id = UUID();
INSERT INTO sperre
(sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis,
level, temp_unlock_before_required, temp_unlock_after_required, gruppe_id)
VALUES (
v_sperre_id,
JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].title'))),
JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].description'))),
IF(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].releaseText')) IS NOT NULL,
JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].releaseText'))),
NULL),
CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].minMinutes'))), 'null') AS SIGNED),
CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].maxMinutes'))), 'null') AS SIGNED),
CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].level'))), 'null') AS SIGNED),
CASE JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].tempUnlockBeforeRequired')))
WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END,
CASE JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].tempUnlockAfterRequired')))
WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE NULL END,
v_gruppe_id
);
SET v_inner_count = IFNULL(
JSON_LENGTH(JSON_EXTRACT(v_zeitstr_json, CONCAT('$[', i, '].sperrt'))),
0);
SET j = 0;
WHILE j < v_inner_count DO
SET @wz = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v_zeitstr_json,
CONCAT('$[', i, '].sperrt[', j, ']'))), 'null');
IF @wz IS NOT NULL AND @wz != '' THEN
INSERT IGNORE INTO sperre_sperre_fuer (sperre_id, werkzeug)
VALUES (v_sperre_id, @wz);
END IF;
SET j = j + 1;
END WHILE;
SET i = i + 1;
END WHILE;
-- ── Finisher ─────────────────────────────────────────────────────
-- GameSetFinisher hat kein geschlecht und keine benoetigt-Listen
-- tempUnlockBeforeRequired / tempUnlockAfterRequired haben kein Gegenstück in finisher
SET v_outer_count = IFNULL(JSON_LENGTH(v_finisher_json), 0);
SET i = 0;
WHILE i < v_outer_count DO
SET v_finisher_id = UUID();
INSERT INTO finisher
(finisher_id, kurz_text, text, geschlecht, gruppe_id)
VALUES (
v_finisher_id,
JSON_UNQUOTE(JSON_EXTRACT(v_finisher_json, CONCAT('$[', i, '].title'))),
JSON_UNQUOTE(JSON_EXTRACT(v_finisher_json, CONCAT('$[', i, '].description'))),
NULL,
v_gruppe_id
);
SET i = i + 1;
END WHILE;
END LOOP;
CLOSE cur;
END IF;
END;
CALL proc_migrate_chastity_game_set();
DROP PROCEDURE IF EXISTS proc_migrate_chastity_game_set;

View File

@@ -416,7 +416,7 @@
<label class="label-with-hint">
<span>Beschreibung *</span>
<button type="button" class="btn-hint" onclick="togglePlaceholderHint()" title="Platzhalter-Hilfe">i</button>
<button type="button" id="iPlaceholderHintBtn" class="btn-hint" onclick="togglePlaceholderHint()" title="Platzhalter-Hilfe">i</button>
</label>
<div id="iPlaceholderHint" style="display:none;">
<div class="placeholder-hint">
@@ -451,15 +451,17 @@
<div id="iLevelRow">
<label for="iLevel">Level *</label>
<input type="number" id="iLevel" min="1" max="5" placeholder="15">
<label>Dauer (Sekunden)</label>
<div class="modal-two-col">
<div>
<label for="iSekVon" style="margin-top:0;">Von</label>
<input type="number" id="iSekVon" min="0" placeholder="z. B. 30">
</div>
<div>
<label for="iSekBis" style="margin-top:0;">Bis</label>
<input type="number" id="iSekBis" min="0" placeholder="z. B. 120">
<div id="iSekRow">
<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>
@@ -749,7 +751,7 @@
<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, type)}
${renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId, type)}
${g.availableIn !== 'CHASTITY_ONLY' ? renderSubSection('Strafen', sortByLevelThenName(g.strafen || []), 'strafe', renderStrafe, g.gruppenId, type) : ''}
${renderSubSection('Zeitstrafen',sortByName(g.sperren || []), 'zeitstrafe',renderZeitstrafe, g.gruppenId, type)}
${renderSubSection('Finisher', sortByGeschlecht(g.finisher || []), 'finisher', renderFinisher, g.gruppenId, type)}
</div>
@@ -1086,7 +1088,9 @@
pubCb.checked = !g.privateGruppe;
pubCb.disabled = g.privateGruppe; // Veröffentlichen nur über den Veröffentlichen-Button
document.getElementById('gPublicLabel').style.display = 'block';
document.getElementById('gAvailableIn').value = g.availableIn || 'BDSM_ONLY';
const avSel = document.getElementById('gAvailableIn');
avSel.value = g.availableIn || 'BDSM_ONLY';
avSel.disabled = true;
const imgWrap = document.getElementById('gCurrentImgWrap');
if (g.bild) {
document.getElementById('gCurrentImg').src = 'data:image/png;base64,' + g.bild;
@@ -1104,7 +1108,8 @@
document.getElementById('gDesc').value = '';
document.getElementById('gPublic').checked = false;
document.getElementById('gPublicLabel').style.display = 'none';
document.getElementById('gAvailableIn').value = 'BDSM_ONLY';
document.getElementById('gAvailableIn').value = 'BDSM_ONLY';
document.getElementById('gAvailableIn').disabled = false;
document.getElementById('gCurrentImgWrap').style.display = 'none';
gruppeModal.classList.add('open');
document.getElementById('gName').focus();
@@ -1113,7 +1118,8 @@
function closeGruppeModal() {
gruppeModal.classList.remove('open');
document.getElementById('gPublic').disabled = false;
document.getElementById('gPublic').disabled = false;
document.getElementById('gAvailableIn').disabled = false;
}
document.getElementById('openCreateBtn').addEventListener('click', () => openModal(null));
@@ -1359,6 +1365,7 @@
let currentItemGruppeId = null;
let currentItemKind = null; // 'aufgabe' | 'strafe' | 'zeitstrafe'
let currentItemEditId = null; // null = neu, sonst ID des zu bearbeitenden Items
let _isChastityMode = false;
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' };
@@ -1366,6 +1373,11 @@
function _setupItemModal(kind) {
const isZeit = kind === 'zeitstrafe';
const isFinisher = kind === 'finisher';
const isChastity = (_gruppeData[currentItemGruppeId]?.availableIn === 'CHASTITY_ONLY');
_isChastityMode = isChastity;
// Placeholder hint button + text
document.getElementById('iPlaceholderHintBtn').style.display = isChastity ? 'none' : '';
document.querySelector('#iPlaceholderHint .placeholder-hint').innerHTML =
isFinisher
? 'In Texten können Platzhalter verwendet werden:<br>' +
@@ -1374,15 +1386,38 @@
: '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 = isFinisher ? 'block' : 'none';
document.getElementById('iLevelRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugAktivRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugPassivRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherAktivRow').style.display = isFinisher ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherPassivRow').style.display = isFinisher ? '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';
// Geschlecht: finisher only, and not in chastity
document.getElementById('iGeschlechtRow').style.display = (isFinisher && !isChastity) ? 'block' : 'none';
// Level row: aufgabe/strafe always; zeitstrafe only in chastity
document.getElementById('iLevelRow').style.display = (!isZeit && !isFinisher) || (isZeit && isChastity) ? 'block' : 'none';
// Sekunden sub-section: not for chastity zeitstrafe
document.getElementById('iSekRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
// Aktiv Werkzeuge: aufgabe/strafe only
document.getElementById('iWerkzeugAktivRow').style.display = (!isZeit && !isFinisher) ? 'block' : 'none';
document.querySelector('#iWerkzeugAktivRow label').textContent = isChastity ? 'Benötigt' : 'Benötigt (aktiv)';
['VAGINA', 'PENIS', 'UMSCHNALLDILDO'].forEach(v => {
const lbl = document.querySelector(`#iWerkzeugAktiv input[value="${v}"]`)?.closest('label');
if (lbl) lbl.style.display = isChastity ? 'none' : '';
});
// Passiv Werkzeuge: aufgabe/strafe, not in chastity
document.getElementById('iWerkzeugPassivRow').style.display = (!isZeit && !isFinisher && !isChastity) ? 'block' : 'none';
// Finisher Werkzeuge: not in chastity
document.getElementById('iWerkzeugFinisherAktivRow').style.display = (isFinisher && !isChastity) ? 'block' : 'none';
document.getElementById('iWerkzeugFinisherPassivRow').style.display = (isFinisher && !isChastity) ? 'block' : 'none';
// Zeitstrafe rows
document.getElementById('iMinutenRow').style.display = isZeit ? 'block' : 'none';
document.getElementById('iSperreFuerRow').style.display = isZeit ? 'block' : 'none';
['VAGINA', 'PENIS'].forEach(v => {
const lbl = document.querySelector(`#iSperreFuer input[value="${v}"]`)?.closest('label');
if (lbl) lbl.style.display = isChastity ? 'none' : '';
});
document.getElementById('iReleaseTextRow').style.display = isZeit ? 'block' : 'none';
}
function _resetItemFields() {
@@ -1449,6 +1484,9 @@
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; });
if (_isChastityMode) {
document.getElementById('iLevel').value = d.level != null ? d.level : '';
}
}
const preSelected = (d.benoetigteToys || []).filter(t => t.toyId);
@@ -1480,7 +1518,7 @@
}
function buildItems(filter) {
const f = filter || '';
_items = STATIC.filter(s => !f || s.toLowerCase().includes(f)).map(s => ({ label: s, insert: s }));
_items = (_isChastityMode ? [] : 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' });
@@ -1606,13 +1644,14 @@
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; }
const geschlecht = _isChastityMode ? null : document.querySelector('#iGeschlecht input:checked')?.value;
if (!_isChastityMode && !geschlecht) { showItemError('Bitte ein Geschlecht auswählen.'); return; }
payload = {
kurzText, text, geschlecht,
kurzText, text,
geschlecht: geschlecht || null,
gruppeId: isEdit ? undefined : currentItemGruppeId,
benoetigtAktiv: checkedValues('iWerkzeugFinisherAktiv'),
benoetigtPassiv: checkedValues('iWerkzeugFinisherPassiv'),
benoetigtAktiv: _isChastityMode ? [] : checkedValues('iWerkzeugFinisherAktiv'),
benoetigtPassiv: _isChastityMode ? [] : checkedValues('iWerkzeugFinisherPassiv'),
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId }))
};
url = isEdit ? apiUrl(`/finisher/${currentItemEditId}`) : apiUrl('/finisher');
@@ -1624,6 +1663,14 @@
const sperreFuer = checkedValues('iSperreFuer');
if (sperreFuer.length === 0) { showItemError('Bitte mindestens ein Werkzeug für die Sperre auswählen.'); return; }
let zeitLevel = null;
if (_isChastityMode) {
const lv = document.getElementById('iLevel').value.trim();
if (!lv) { showItemError('Bitte ein Level angeben.'); return; }
zeitLevel = parseInt(lv, 10);
if (isNaN(zeitLevel) || zeitLevel < 1 || zeitLevel > 5) { showItemError('Level muss zwischen 1 und 5 liegen.'); return; }
}
const minBis = document.getElementById('iMinBis').value.trim();
payload = {
kurzText, text,
@@ -1632,6 +1679,7 @@
minutenBis: minBis ? parseInt(minBis, 10) : null,
releaseText: document.getElementById('iReleaseText').value.trim() || null,
sperreFuer,
level: zeitLevel,
benoetigteToys: _selectedToys.map(t => ({ toyId: t.toyId }))
};
url = isEdit ? `/sperre/${currentItemEditId}` : '/sperre'; // BDSM-only

View File

@@ -1711,6 +1711,14 @@
document.getElementById('btnDrawOk').style.display = 'none';
document.getElementById('btnSpeedConfirm').style.display = '';
}
if (dto.card === 'GAME_CARD') {
const btn = document.getElementById('btnDrawOk');
btn.textContent = '▶ Spiel starten';
btn.onclick = function() {
window.location.href = '/games/chastity/taskgame.html?lockId=' + lockId;
};
}
}, 700);
}, 1000);
})

View File

@@ -268,47 +268,6 @@
.sim-stat-val { font-size:1rem; font-weight:700; color:var(--color-text); }
.sim-stat-lbl { font-size:0.7rem; color:var(--color-muted); margin-top:0.15rem; text-transform:uppercase; letter-spacing:0.05em; }
/* ── Spiel-Sets (gruppe-card style) ── */
.gs-card { background:var(--color-card); border:1px solid var(--color-secondary); border-radius:10px; overflow:hidden; cursor:pointer; transition:border-color 0.15s; }
.gs-card:hover { border-color:var(--color-primary); }
.gs-card-header { display:flex; align-items:center; gap:0.75rem; padding:0.85rem 1rem; user-select:none; }
.gs-card-meta { flex:1; min-width:0; }
.gs-card-name { font-size:0.95rem; font-weight:600; color:var(--color-text); }
.gs-card-header-actions { display:flex; gap:0.4rem; flex-shrink:0; }
.gs-sub { margin-bottom:0.85rem; }
.gs-sub:last-child { margin-bottom:0; }
.gs-sub-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:0.35rem; }
.gs-sub-title { font-size:0.7rem; font-weight:700; letter-spacing:0.06em; text-transform:uppercase; color:var(--color-primary); }
.gs-sub-warn { color:#e74c3c !important; }
.gs-item-list { display:flex; flex-direction:column; gap:0.25rem; }
.gs-list-item { border-radius:6px; background:var(--color-secondary); overflow:hidden; }
.gs-list-item-row { display:flex; align-items:center; gap:0.5rem; padding:0.3rem 0.5rem 0.3rem 0.75rem; cursor:pointer; user-select:none; transition:background 0.12s; }
.gs-list-item-row:hover { background:rgba(255,255,255,0.04); }
.gs-list-item.open .gs-list-item-row { background:rgba(233,69,96,0.08); }
.gs-list-item-text { color:var(--color-text); flex:1; min-width:0; font-size:0.83rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.gs-list-item-badges { display:flex; gap:0.3rem; flex-shrink:0; align-items:center; }
.gs-badge { font-size:0.68rem; padding:0.1rem 0.4rem; border-radius:20px; background:rgba(233,69,96,0.15); color:var(--color-primary); white-space:nowrap; }
.gs-badge-neutral { background:rgba(255,255,255,0.07); color:var(--color-muted); }
.gs-list-item-detail { display:none; padding:0.4rem 0.75rem 0.5rem; border-top:1px solid rgba(255,255,255,0.06); font-size:0.79rem; color:var(--color-muted); line-height:1.5; }
.gs-list-item.open .gs-list-item-detail { display:block; }
.gs-detail-text { color:var(--color-text); white-space:pre-wrap; margin-bottom:0.3rem; }
.gs-detail-actions { display:flex; gap:0.35rem; margin-top:0.4rem; justify-content:flex-end; }
.gs-btn-sub-add { background:none; border:1px solid var(--color-secondary); border-radius:5px; color:var(--color-muted); font-size:0.73rem; padding:0.15rem 0.5rem; cursor:pointer; transition:border-color 0.15s,color 0.15s; width:auto; }
.gs-btn-sub-add:hover { border-color:var(--color-primary); color:var(--color-primary); }
.gs-btn-item-edit { background:none; border:1px solid rgba(136,136,136,0.45); border-radius:5px; color:var(--color-muted); font-size:0.73rem; padding:0.18rem 0.55rem; cursor:pointer; transition:border-color 0.15s,color 0.15s; width:auto; }
.gs-btn-item-edit:hover { border-color:var(--color-text); color:var(--color-text); }
.gs-btn-item-delete { background:none; border:1px solid rgba(233,69,96,0.4); border-radius:5px; color:var(--color-primary); font-size:0.73rem; padding:0.18rem 0.55rem; cursor:pointer; transition:background 0.15s; width:auto; }
.gs-btn-item-delete:hover { background:rgba(233,69,96,0.15); }
.gs-sub-empty { font-size:0.78rem; color:var(--color-muted); padding:0.15rem 0; }
#gsSetModal, #gsItemModal { z-index:600; }
.gs-check-group { display:flex; flex-wrap:wrap; gap:0.4rem; }
.gs-check-chip { display:inline-flex; align-items:center; gap:0.4rem; background:var(--color-secondary); border:1px solid rgba(255,255,255,0.1); border-radius:20px; padding:0.25rem 0.7rem; cursor:pointer; font-size:0.82rem; color:var(--color-text); transition:border-color 0.15s; user-select:none; }
.gs-check-chip:has(input:checked) { border-color:var(--color-primary); }
.gs-check-chip input { accent-color:var(--color-primary); width:auto; cursor:pointer; margin:0; flex-shrink:0; }
</style>
</head>
<body class="app">
@@ -329,17 +288,6 @@
<div class="template-list" id="taskSetList"></div>
<p id="taskSetEmpty" style="display:none;color:var(--color-muted);font-size:0.9rem;">Noch keine Aufgaben-Sets vorhanden.</p>
<!-- Spiel-Sets (für Spiel-Karte) -->
<div style="display:flex;align-items:center;justify-content:space-between;margin:2rem 0 1rem;gap:1rem;flex-wrap:wrap;">
<div>
<h2 style="margin:0;">Spiel-Sets</h2>
<p style="margin:0.25rem 0 0;font-size:0.8rem;color:var(--color-muted);">Aufgaben-Sets für die Spiel-Karte im Karten-Lock · max. 5 Sets</p>
</div>
<button id="btnNewGameSet" onclick="openGsSetModal(null)" style="width:auto;padding:0.55rem 1.2rem;">+ Set anlegen</button>
</div>
<div class="template-list" id="gameSetList"></div>
<p id="gameSetEmpty" style="display:none;color:var(--color-muted);font-size:0.9rem;">Noch keine Spiel-Sets vorhanden.</p>
<h2 style="margin:2rem 0 1rem;">Abonnierte Vorlagen</h2>
<div class="template-list" id="subscribedList"></div>
<p id="subscribedEmpty" style="display:none;color:var(--color-muted);font-size:0.9rem;">Keine abonnierten Vorlagen vorhanden.</p>
@@ -443,24 +391,6 @@
<label for="fShowRemaining">Art der verbleibenden Karten anzeigen</label>
</div>
</div>
<!-- Spiel-Karte (CardLock) -->
<div class="form-section">
<div class="form-section-title">Spiel-Karte (optional)</div>
<div class="form-row" style="margin-bottom:0.5rem;">
<label>Spiel-Set</label>
<select id="fGameSetId" onchange="onGameSetChange()">
<option value="">Kein Spiel-Set</option>
</select>
</div>
<button class="btn-add" type="button" onclick="openGsSetModal(null,'template')">+ Neues Set anlegen</button>
<div id="gameSetSpieldauerRow" style="display:none;margin-top:0.75rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.3rem;">
<label for="sldGameSpieldauer" style="font-size:0.88rem;">Spieldauer</label>
<span class="setting-value" id="valGameSpieldauer">Mittel</span>
</div>
<input type="range" id="sldGameSpieldauer" min="0" max="4" value="2" oninput="updateGameSpieldauer(this.value)" style="width:100%;">
</div>
</div>
<!-- Aufgaben (CardLock) -->
<div class="form-section">
<div class="form-section-title">Aufgaben (optional)</div>
@@ -719,114 +649,6 @@
</div>
</div>
<!-- Spiel-Set: Set erstellen / umbenennen -->
<div class="modal-backdrop" id="gsSetModal">
<div class="modal-box" style="max-width:420px;" onclick="event.stopPropagation()">
<h2 id="gsSetModalTitle" style="margin:0 0 1.25rem;color:var(--color-primary);">Neues Spiel-Set</h2>
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Name *</label>
<input type="text" id="gsSetName" maxlength="100" placeholder="Set-Name"
style="width:100%;box-sizing:border-box;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;"
onkeydown="if(event.key==='Enter')saveGsSet()">
<div id="gsSetError" style="color:#e74c3c;font-size:0.82rem;margin-top:0.75rem;display:none;"></div>
<div style="display:flex;justify-content:flex-end;gap:0.75rem;margin-top:1.5rem;">
<button onclick="closeGsSetModal()" style="background:var(--color-secondary);color:var(--color-text);border:none;border-radius:6px;padding:0.55rem 1.1rem;font-size:0.9rem;cursor:pointer;width:auto;">Abbrechen</button>
<button onclick="saveGsSet()" style="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;width:auto;">Speichern</button>
</div>
</div>
</div>
<!-- Spiel-Set: Item hinzufügen / bearbeiten -->
<div class="modal-backdrop" id="gsItemModal">
<div class="modal-box" style="max-width:460px;" onclick="event.stopPropagation()">
<h2 id="gsItemModalTitle" style="margin:0 0 1.25rem;color:var(--color-primary);">Aufgabe</h2>
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Titel *</label>
<input type="text" id="gsItemTitle" maxlength="150" placeholder="Titel …"
style="width:100%;box-sizing:border-box;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;margin-bottom:0.85rem;transition:border-color 0.2s;">
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Beschreibung (optional)</label>
<textarea id="gsItemDesc" rows="3" maxlength="600" placeholder="Beschreibung …"
style="width:100%;box-sizing:border-box;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.88rem;font-family:inherit;resize:vertical;outline:none;margin-bottom:0.85rem;"></textarea>
<!-- Aufgabe: Level + Dauer -->
<div id="gsItemAufgabeRow" style="display:none;margin-bottom:0.85rem;">
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Level *</label>
<select id="gsItemAufgabeLevel" style="width:100%;box-sizing:border-box;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;appearance:none;margin-bottom:0.65rem;">
<option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option>
</select>
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Dauer (Minuten, optional)</label>
<input type="number" id="gsItemMinutes" min="1" max="9999" placeholder=""
style="width:100%;box-sizing:border-box;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;">
</div>
<!-- Aufgabe: benötigt -->
<div id="gsItemBenoetigtRow" style="display:none;margin-bottom:0.85rem;">
<div style="font-size:0.8rem;color:#aaa;margin-bottom:0.4rem;">Benötigt</div>
<div class="gs-check-group">
<label class="gs-check-chip"><input type="checkbox" id="gsItemBen_MUND">Mund</label>
<label class="gs-check-chip"><input type="checkbox" id="gsItemBen_ANUS">Anus</label>
</div>
</div>
<!-- Zeitstrafe: Level + Strafminuten -->
<div id="gsItemZeitstrafeRow" style="display:none;margin-bottom:0.85rem;">
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Level *</label>
<select id="gsItemZeitstrafeLevel" style="width:100%;box-sizing:border-box;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;appearance:none;margin-bottom:0.65rem;">
<option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option>
</select>
<label style="display:block;font-size:0.8rem;color:#aaa;margin-bottom:0.3rem;">Strafminuten (Von Bis)</label>
<div style="display:flex;gap:0.6rem;align-items:center;">
<input type="number" id="gsItemMinMin" min="1" max="9999" placeholder="Min."
style="flex:1;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;">
<span style="color:var(--color-muted);"></span>
<input type="number" id="gsItemMaxMin" min="1" max="9999" placeholder="Max."
style="flex:1;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;">
</div>
<label style="display:block;font-size:0.8rem;color:#aaa;margin:0.65rem 0 0.3rem;">Text bei Aufhebung (optional)</label>
<textarea id="gsItemReleaseText" rows="2" maxlength="2000"
placeholder="Text der angezeigt wird, wenn die Sperre endet…"
style="width:100%;box-sizing:border-box;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.88rem;font-family:inherit;resize:vertical;outline:none;transition:border-color 0.2s;line-height:1.45;"></textarea>
</div>
<!-- Zeitstrafe: sperrt -->
<div id="gsItemSperrtRow" style="display:none;margin-bottom:0.85rem;">
<div style="font-size:0.8rem;color:#aaa;margin-bottom:0.4rem;">Sperrt</div>
<div class="gs-check-group">
<label class="gs-check-chip"><input type="checkbox" id="gsItemSperr_MUND"> Mund</label>
<label class="gs-check-chip"><input type="checkbox" id="gsItemSperr_ANUS"> Anus</label>
</div>
</div>
<!-- Zeitstrafe + Finisher: Temp. Öffnung -->
<div id="gsItemUnlockRow" style="display:none;margin-bottom:0.85rem;">
<div style="font-size:0.8rem;color:#aaa;margin-bottom:0.4rem;">Temp. Öffnung</div>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;margin-bottom:0.35rem;cursor:pointer;">
<input type="checkbox" id="gsItemBefore" style="accent-color:var(--color-primary);width:15px;height:15px;"> Vor der Maßnahme erforderlich
</label>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.88rem;cursor:pointer;">
<input type="checkbox" id="gsItemAfter" style="accent-color:var(--color-primary);width:15px;height:15px;"> Nach der Maßnahme erforderlich
</label>
</div>
<div id="gsItemError" style="color:#e74c3c;font-size:0.82rem;display:none;margin-bottom:0.5rem;"></div>
<div style="display:flex;justify-content:flex-end;gap:0.75rem;margin-top:0.5rem;">
<button onclick="closeGsItemModal()" style="background:var(--color-secondary);color:var(--color-text);border:none;border-radius:6px;padding:0.55rem 1.1rem;font-size:0.9rem;cursor:pointer;width:auto;">Abbrechen</button>
<button onclick="saveGsItem()" style="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;width:auto;">Speichern</button>
</div>
</div>
</div>
<!-- Spiel-Set: Inhalt-Popup -->
<div class="modal-backdrop" id="gsEditModal">
<div class="modal-box" style="max-width:600px;width:calc(100% - 2rem);" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;justify-content:space-between;gap:0.75rem;margin-bottom:1.25rem;">
<h2 id="gsEditModalTitle" style="margin:0;color:var(--color-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></h2>
<button onclick="closeGsEditModal()" style="background:none;border:none;color:var(--color-muted);font-size:1.3rem;line-height:1;cursor:pointer;padding:0;width:auto;flex-shrink:0;"></button>
</div>
<div id="gsEditModalContent"></div>
</div>
</div>
<script src="/js/card-defs.js"></script>
<script src="/js/card-display.js"></script>
<script src="/js/icons.js"></script>
@@ -1223,51 +1045,6 @@
if (res.ok || res.status === 204) await loadTaskSets();
}
const GS_TOOLS = [
{ value: 'UMSCHNALLDILDO', label: 'Strap-on' },
{ value: 'MUND', label: 'Oral' },
{ value: 'ANUS', label: 'Anal' },
];
function gsGetChecked(prefix) {
return GS_TOOLS.filter(t => document.getElementById(prefix + t.value)?.checked).map(t => t.value);
}
function gsSetChecked(prefix, values) {
GS_TOOLS.forEach(t => {
const el = document.getElementById(prefix + t.value);
if (el) el.checked = (values || []).includes(t.value);
});
}
const GAME_SPIELDAUER = [
{ label: 'Sehr kurz' },
{ label: 'Kurz' },
{ label: 'Mittel' },
{ label: 'Lang' },
{ label: 'Sehr lang' },
];
function populateGameSetSelect() {
const sel = document.getElementById('fGameSetId');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">Kein Spiel-Set</option>';
_gameSets.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id; opt.textContent = s.name;
sel.appendChild(opt);
});
sel.value = cur;
}
function onGameSetChange() {
const val = document.getElementById('fGameSetId')?.value;
document.getElementById('gameSetSpieldauerRow').style.display = val ? '' : 'none';
}
function updateGameSpieldauer(val) {
document.getElementById('valGameSpieldauer').textContent = GAME_SPIELDAUER[val]?.label || '';
}
function populateTaskSetSelects() {
for (const selId of ['fCardTaskSetId', 'fTimelockTaskSetId']) {
const sel = document.getElementById(selId);
@@ -1476,14 +1253,6 @@
onTaskSetChange('card');
onTaskSetChange('timelock');
// Spiel-Set
populateGameSetSelect();
document.getElementById('fGameSetId').value = template?.gameSetId || '';
onGameSetChange();
const sdIdx = template?.gameSpieldauerIdx ?? 2;
document.getElementById('sldGameSpieldauer').value = sdIdx;
updateGameSpieldauer(sdIdx);
alignModalToContent();
document.getElementById('modalBackdrop').classList.add('open');
document.getElementById('modalDiscardConfirm').style.display = 'none';
@@ -1527,13 +1296,7 @@
document.getElementById('modalBackdrop').addEventListener('click', e => { if (e.target===e.currentTarget) tryCloseModal(); });
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (document.getElementById('gsItemModal').classList.contains('open')) {
e.preventDefault(); closeGsItemModal();
} else if (document.getElementById('gsSetModal').classList.contains('open')) {
e.preventDefault(); closeGsSetModal();
} else if (document.getElementById('gsEditModal').classList.contains('open')) {
e.preventDefault(); closeGsEditModal();
} else if (document.getElementById('taskSetModalBackdrop').classList.contains('open')) {
if (document.getElementById('taskSetModalBackdrop').classList.contains('open')) {
e.preventDefault(); tryCloseTaskSetModal();
} else if (document.getElementById('modalBackdrop').classList.contains('open')) {
e.preventDefault(); tryCloseModal();
@@ -1588,9 +1351,6 @@
if (totalMax===0) { showModalError('Das Deck muss mindestens eine Karte enthalten.'); firstError=firstError||document.getElementById('modalError'); }
const hasTaskCards = (parseInt(document.getElementById('min_TASK').value)||0)>0 || (parseInt(document.getElementById('max_TASK').value)||0)>0;
if (hasTaskCards && !document.getElementById('fCardTaskSetId').value) { showModalError('Aufgaben-Karten konfiguriert, aber kein Aufgaben-Set ausgewählt.'); firstError=firstError||document.getElementById('modalError'); }
const hasGameCards = (parseInt(document.getElementById('min_GAME_CARD').value)||0)>0 || (parseInt(document.getElementById('max_GAME_CARD').value)||0)>0;
if (hasGameCards && !document.getElementById('fGameSetId').value) { showModalError('Spiel-Karten konfiguriert, aber kein Spiel-Set ausgewählt.'); firstError=firstError||document.getElementById('modalError'); }
if (firstError) { firstError.scrollIntoView({behavior:'smooth',block:'center'}); return; }
const cardCountsMin={}, cardCountsMax={};
@@ -1609,8 +1369,6 @@
taskSetId: document.getElementById('fCardTaskSetId').value || null,
requiresVerification: document.getElementById('fRequiresVerification').checked,
taskMode: document.querySelector('input[name="modalCardTaskMode"]:checked')?.value||'RANDOM',
gameSetId: document.getElementById('fGameSetId').value || null,
gameSpieldauerIdx: parseInt(document.getElementById('sldGameSpieldauer').value) || 2,
};
} else {
// TimeLock
@@ -1895,426 +1653,8 @@
observer.observe(document.getElementById('scrollSentinel'));
resetList();
loadGameSets();
// ════════════════════════════════════════════════
// Spiel-Sets
// ════════════════════════════════════════════════
let _gameSets = [];
let _gsEditSetId = null; // set being renamed
let _gsSetCaller = null; // 'template' when opened from the template modal
let _gsOpenSetId = null; // set currently open in the content popup
let _gsItemType = null; // 'aufgabe' | 'zeitstrafe' | 'finisher'
let _gsItemSetId = null;
let _gsItemIdx = null; // null = new, number = editing
async function loadGameSets() {
try {
const res = await fetch('/chastity/game-sets');
if (!res.ok) return;
_gameSets = await res.json();
renderGameSetList();
populateGameSetSelect();
if (_gsOpenSetId) renderGsEditModalContent(_gsOpenSetId);
} catch (e) { console.error(e); }
}
function renderGameSetList() {
const list = document.getElementById('gameSetList');
list.innerHTML = '';
document.getElementById('gameSetEmpty').style.display = _gameSets.length ? 'none' : '';
document.getElementById('btnNewGameSet').disabled = _gameSets.length >= 5;
_gameSets.forEach(s => {
const aufgaben = s.aufgaben || [];
const zeitstrafen = s.zeitstrafen || [];
const finisher = s.finisher || [];
const levelCounts = [1,2,3,4,5].map(l => aufgaben.filter(a => a.level === l).length);
const lvlBadges = levelCounts.map((c, i) => {
const cls = c >= 3 ? 'gs-badge gs-badge-neutral' : 'gs-badge';
return `<span class="${cls}">L${i+1}: ${c}</span>`;
}).join('');
const finBadgeCls = finisher.length >= 1 ? 'gs-badge gs-badge-neutral' : 'gs-badge';
const card = document.createElement('div');
card.className = 'gs-card';
card.id = 'gscard_' + s.id;
card.addEventListener('click', () => openGsEditModal(s.id));
card.innerHTML = `
<div class="gs-card-header">
<div class="gs-card-meta">
<div class="gs-card-name">${esc(s.name)}</div>
<div style="display:flex;gap:0.3rem;flex-wrap:wrap;margin-top:0.3rem;">
${lvlBadges}
<span class="gs-badge gs-badge-neutral">Zeitstrafen: ${zeitstrafen.length}</span>
<span class="${finBadgeCls}">Finisher: ${finisher.length}</span>
</div>
</div>
<div class="gs-card-header-actions" onclick="event.stopPropagation()">
<button class="gs-btn-item-edit" onclick="openGsSetModal('${s.id}')">✎</button>
<button class="gs-btn-item-delete" onclick="deleteGameSet('${s.id}',${JSON.stringify(s.name)})">✕</button>
</div>
</div>`;
list.appendChild(card);
});
}
function toggleGsListItem(id) {
document.getElementById(id)?.classList.toggle('open');
}
// ── Set content popup ──────────────────────────
function openGsEditModal(setId) {
_gsOpenSetId = setId;
renderGsEditModalContent(setId);
document.getElementById('gsEditModal').classList.add('open');
}
function closeGsEditModal() {
document.getElementById('gsEditModal').classList.remove('open');
_gsOpenSetId = null;
}
function renderGsEditModalContent(setId) {
const container = document.getElementById('gsEditModalContent');
if (!container) return;
const s = _gameSets.find(x => x.id === setId);
if (!s) { closeGsEditModal(); return; }
document.getElementById('gsEditModalTitle').textContent = s.name;
const aufgaben = s.aufgaben || [];
const zeitstrafen = s.zeitstrafen || [];
const finisher = s.finisher || [];
let html = '';
for (let l = 1; l <= 5; l++) {
const items = aufgaben.map((a, i) => ({...a, _gi: i})).filter(a => a.level === l);
const warnCls = items.length < 3 ? ' gs-sub-warn' : '';
const itemsHtml = items.map(a => gsAufgabeRowHtml(s.id, a._gi, a)).join('') ||
'<div class="gs-sub-empty"></div>';
html += `<div class="gs-sub">
<div class="gs-sub-header">
<span class="gs-sub-title${warnCls}">Level ${l} <span style="font-weight:400;">(${items.length}/3+)</span></span>
<button class="gs-btn-sub-add" onclick="openGsItemModal('aufgabe','${s.id}',null,${l})">+ Aufgabe</button>
</div>
<div class="gs-item-list">${itemsHtml}</div></div>`;
}
const zeitHtml = zeitstrafen.map((z, i) => gsZeitstrafeRowHtml(s.id, i, z)).join('') ||
'<div class="gs-sub-empty"></div>';
html += `<div class="gs-sub">
<div class="gs-sub-header">
<span class="gs-sub-title">Zeitstrafen <span style="font-weight:400;">(${zeitstrafen.length})</span></span>
<button class="gs-btn-sub-add" onclick="openGsItemModal('zeitstrafe','${s.id}',null,null)">+ Zeitstrafe</button>
</div>
<div class="gs-item-list">${zeitHtml}</div></div>`;
const finWarnCls = finisher.length < 1 ? ' gs-sub-warn' : '';
const finHtml = finisher.map((f, i) => gsFinisherRowHtml(s.id, i, f)).join('') ||
'<div class="gs-sub-empty"></div>';
html += `<div class="gs-sub">
<div class="gs-sub-header">
<span class="gs-sub-title${finWarnCls}">Finisher <span style="font-weight:400;">(${finisher.length}/1+)</span></span>
<button class="gs-btn-sub-add" onclick="openGsItemModal('finisher','${s.id}',null,null)">+ Finisher</button>
</div>
<div class="gs-item-list">${finHtml}</div></div>`;
container.innerHTML = html;
}
// ── Row HTML helpers ───────────────────────────
function gsAufgabeRowHtml(setId, gi, a) {
const toolLabels = (a.benoetigt || []).map(v => GS_TOOLS.find(t => t.value === v)?.label).filter(Boolean);
const badges = [
a.minutes ? `<span class="gs-badge gs-badge-neutral">${a.minutes} Min.</span>` : '',
...toolLabels.map(l => `<span class="gs-badge gs-badge-neutral">${l}</span>`),
].join('');
const desc = a.description ? `<div class="gs-detail-text">${esc(a.description)}</div>` : '';
return `<div class="gs-list-item" id="gsli_${setId}_a_${gi}">
<div class="gs-list-item-row" onclick="toggleGsListItem('gsli_${setId}_a_${gi}')">
<span class="gs-list-item-text">${esc(a.title)}</span>
<div class="gs-list-item-badges">${badges}</div>
</div>
<div class="gs-list-item-detail">${desc}
<div class="gs-detail-actions">
<button class="gs-btn-item-edit" onclick="openGsItemModal('aufgabe','${setId}',${gi},null)">✎ Bearbeiten</button>
<button class="gs-btn-item-edit" onclick="duplicateGsItem('aufgabe','${setId}',${gi})">⧉ Kopie</button>
<button class="gs-btn-item-delete" onclick="deleteGsItem('aufgabe','${setId}',${gi})">✕ Löschen</button>
</div>
</div></div>`;
}
function gsZeitstrafeRowHtml(setId, idx, z) {
const timeStr = (z.minMinutes != null ? z.minMinutes : '?') + '' + (z.maxMinutes != null ? z.maxMinutes : '?') + ' Min.';
const sperrtLabels = (z.sperrt || []).map(v => GS_TOOLS.find(t => t.value === v)?.label).filter(Boolean);
const badges = [
z.level ? `<span class="gs-badge">L${z.level}</span>` : '',
`<span class="gs-badge gs-badge-neutral">${timeStr}</span>`,
...sperrtLabels.map(l => `<span class="gs-badge gs-badge-neutral">🔒 ${l}</span>`),
z.releaseText ? `<span class="gs-badge gs-badge-neutral">📝 Aufhebung</span>` : '',
z.tempUnlockBeforeRequired ? `<span class="gs-badge gs-badge-neutral">🔓 Vorher</span>` : '',
z.tempUnlockAfterRequired ? `<span class="gs-badge gs-badge-neutral">🔓 Nachher</span>` : '',
].join('');
const releaseRow = z.releaseText ? `<div style="font-size:0.75rem;color:var(--color-muted);margin-bottom:0.15rem;">Bei Aufhebung:</div><div class="gs-detail-text">${esc(z.releaseText)}</div>` : '';
const desc = z.description ? `<div class="gs-detail-text">${esc(z.description)}</div>` : '';
return `<div class="gs-list-item" id="gsli_${setId}_z_${idx}">
<div class="gs-list-item-row" onclick="toggleGsListItem('gsli_${setId}_z_${idx}')">
<span class="gs-list-item-text">${esc(z.title)}</span>
<div class="gs-list-item-badges">${badges}</div>
</div>
<div class="gs-list-item-detail">${desc}${releaseRow}
<div class="gs-detail-actions">
<button class="gs-btn-item-edit" onclick="openGsItemModal('zeitstrafe','${setId}',${idx},null)">✎ Bearbeiten</button>
<button class="gs-btn-item-edit" onclick="duplicateGsItem('zeitstrafe','${setId}',${idx})">⧉ Kopie</button>
<button class="gs-btn-item-delete" onclick="deleteGsItem('zeitstrafe','${setId}',${idx})">✕ Löschen</button>
</div>
</div></div>`;
}
function gsFinisherRowHtml(setId, idx, f) {
const badges = [
f.tempUnlockBeforeRequired ? `<span class="gs-badge gs-badge-neutral">🔓 Vorher</span>` : '',
f.tempUnlockAfterRequired ? `<span class="gs-badge gs-badge-neutral">🔓 Nachher</span>` : '',
].join('');
const desc = f.description ? `<div class="gs-detail-text">${esc(f.description)}</div>` : '';
return `<div class="gs-list-item" id="gsli_${setId}_f_${idx}">
<div class="gs-list-item-row" onclick="toggleGsListItem('gsli_${setId}_f_${idx}')">
<span class="gs-list-item-text">${esc(f.title)}</span>
<div class="gs-list-item-badges">${badges}</div>
</div>
<div class="gs-list-item-detail">${desc}
<div class="gs-detail-actions">
<button class="gs-btn-item-edit" onclick="openGsItemModal('finisher','${setId}',${idx},null)">✎ Bearbeiten</button>
<button class="gs-btn-item-edit" onclick="duplicateGsItem('finisher','${setId}',${idx})">⧉ Kopie</button>
<button class="gs-btn-item-delete" onclick="deleteGsItem('finisher','${setId}',${idx})">✕ Löschen</button>
</div>
</div></div>`;
}
// ── Set create / rename modal ──────────────────
function openGsSetModal(id, caller) {
_gsEditSetId = id || null;
_gsSetCaller = caller || null;
document.getElementById('gsSetModalTitle').textContent = id ? 'Spiel-Set umbenennen' : 'Neues Spiel-Set';
document.getElementById('gsSetName').value = id ? (_gameSets.find(s => s.id === id)?.name || '') : '';
document.getElementById('gsSetError').style.display = 'none';
document.getElementById('gsSetModal').classList.add('open');
setTimeout(() => document.getElementById('gsSetName').focus(), 50);
}
function closeGsSetModal() {
document.getElementById('gsSetModal').classList.remove('open');
_gsEditSetId = _gsSetCaller = null;
}
async function saveGsSet() {
const name = document.getElementById('gsSetName').value.trim();
const errEl = document.getElementById('gsSetError');
if (!name) { errEl.textContent = 'Name ist ein Pflichtfeld.'; errEl.style.display = ''; return; }
errEl.style.display = 'none';
const set = _gsEditSetId ? _gameSets.find(s => s.id === _gsEditSetId) : null;
const url = _gsEditSetId ? `/chastity/game-sets/${_gsEditSetId}` : '/chastity/game-sets';
const method = _gsEditSetId ? 'PUT' : 'POST';
const body = { name,
aufgaben: set?.aufgaben || [],
zeitstrafen: set?.zeitstrafen || [],
finisher: set?.finisher || [] };
try {
const res = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (res.ok) {
const saved = await res.json().catch(() => null);
const caller = _gsSetCaller;
closeGsSetModal();
await loadGameSets();
if (caller === 'template' && saved?.id) {
document.getElementById('fGameSetId').value = saved.id;
onGameSetChange();
markDirty();
}
return;
}
const b = await res.json().catch(() => ({}));
errEl.textContent = b.error || 'Fehler.'; errEl.style.display = '';
} catch (e) { errEl.textContent = 'Netzwerkfehler.'; errEl.style.display = ''; }
}
async function deleteGameSet(id, name) {
if (!confirm(`Spiel-Set „${name}" wirklich löschen?`)) return;
const res = await fetch(`/chastity/game-sets/${id}`, { method: 'DELETE' });
if (res.ok || res.status === 204) loadGameSets();
}
// ── Item modal ─────────────────────────────────
function openGsItemModal(type, setId, itemIdx, contextLevel) {
_gsItemType = type;
_gsItemSetId = setId;
_gsItemIdx = itemIdx !== null && itemIdx !== undefined ? itemIdx : null;
const titles = { aufgabe: 'Aufgabe', zeitstrafe: 'Zeitstrafe', finisher: 'Finisher' };
document.getElementById('gsItemModalTitle').textContent =
(_gsItemIdx !== null ? 'Bearbeiten: ' : 'Neu: ') + titles[type];
// Reset fields
document.getElementById('gsItemTitle').value = '';
document.getElementById('gsItemDesc').value = '';
document.getElementById('gsItemMinutes').value = '';
document.getElementById('gsItemMinMin').value = '';
document.getElementById('gsItemMaxMin').value = '';
document.getElementById('gsItemReleaseText').value = '';
document.getElementById('gsItemBefore').checked = false;
document.getElementById('gsItemAfter').checked = false;
document.getElementById('gsItemAufgabeLevel').value = contextLevel || 1;
document.getElementById('gsItemZeitstrafeLevel').value = 1;
document.getElementById('gsItemError').style.display = 'none';
gsSetChecked('gsItemBen_', []);
gsSetChecked('gsItemSperr_', []);
// Show/hide type-specific rows
document.getElementById('gsItemAufgabeRow').style.display = type === 'aufgabe' ? '' : 'none';
document.getElementById('gsItemBenoetigtRow').style.display = type === 'aufgabe' ? '' : 'none';
document.getElementById('gsItemZeitstrafeRow').style.display = type === 'zeitstrafe' ? '' : 'none';
document.getElementById('gsItemSperrtRow').style.display = type === 'zeitstrafe' ? '' : 'none';
document.getElementById('gsItemUnlockRow').style.display = (type === 'zeitstrafe' || type === 'finisher') ? '' : 'none';
// Pre-fill when editing
if (_gsItemIdx !== null) {
const set = _gameSets.find(s => s.id === setId);
if (set) {
let item;
if (type === 'aufgabe') item = set.aufgaben[_gsItemIdx];
if (type === 'zeitstrafe') item = set.zeitstrafen[_gsItemIdx];
if (type === 'finisher') item = set.finisher[_gsItemIdx];
if (item) {
document.getElementById('gsItemTitle').value = item.title || '';
document.getElementById('gsItemDesc').value = item.description || '';
if (type === 'aufgabe') {
document.getElementById('gsItemAufgabeLevel').value = item.level || 1;
document.getElementById('gsItemMinutes').value = item.minutes || '';
gsSetChecked('gsItemBen_', item.benoetigt || []);
}
if (type === 'zeitstrafe') {
document.getElementById('gsItemZeitstrafeLevel').value = item.level || 1;
document.getElementById('gsItemMinMin').value = item.minMinutes ?? '';
document.getElementById('gsItemMaxMin').value = item.maxMinutes ?? '';
document.getElementById('gsItemReleaseText').value = item.releaseText || '';
gsSetChecked('gsItemSperr_', item.sperrt || []);
}
if (type === 'zeitstrafe' || type === 'finisher') {
document.getElementById('gsItemBefore').checked = !!item.tempUnlockBeforeRequired;
document.getElementById('gsItemAfter').checked = !!item.tempUnlockAfterRequired;
}
}
}
}
document.getElementById('gsItemModal').classList.add('open');
setTimeout(() => document.getElementById('gsItemTitle').focus(), 50);
}
function closeGsItemModal() {
document.getElementById('gsItemModal').classList.remove('open');
_gsItemType = _gsItemSetId = _gsItemIdx = null;
}
async function saveGsItem() {
const title = document.getElementById('gsItemTitle').value.trim();
const errEl = document.getElementById('gsItemError');
if (!title) { errEl.textContent = 'Titel ist ein Pflichtfeld.'; errEl.style.display = ''; return; }
errEl.style.display = 'none';
const set = _gameSets.find(s => s.id === _gsItemSetId);
if (!set) return;
const updated = {
name: set.name,
aufgaben: JSON.parse(JSON.stringify(set.aufgaben || [])),
zeitstrafen: JSON.parse(JSON.stringify(set.zeitstrafen || [])),
finisher: JSON.parse(JSON.stringify(set.finisher || [])),
};
const desc = document.getElementById('gsItemDesc').value.trim() || null;
let item;
if (_gsItemType === 'aufgabe') {
const min = parseInt(document.getElementById('gsItemMinutes').value);
const ben = gsGetChecked('gsItemBen_');
item = { title, description: desc,
level: parseInt(document.getElementById('gsItemAufgabeLevel').value) || 1,
minutes: isNaN(min) ? null : min,
benoetigt: ben.length ? ben : null };
if (_gsItemIdx !== null) updated.aufgaben[_gsItemIdx] = item;
else updated.aufgaben.push(item);
} else if (_gsItemType === 'zeitstrafe') {
const minMin = parseInt(document.getElementById('gsItemMinMin').value);
const maxMin = parseInt(document.getElementById('gsItemMaxMin').value);
const sperrt = gsGetChecked('gsItemSperr_');
const releaseText = document.getElementById('gsItemReleaseText').value.trim() || null;
item = { title, description: desc,
level: parseInt(document.getElementById('gsItemZeitstrafeLevel').value) || 1,
minMinutes: isNaN(minMin) ? null : minMin,
maxMinutes: isNaN(maxMin) ? null : maxMin,
releaseText,
tempUnlockBeforeRequired: document.getElementById('gsItemBefore').checked,
tempUnlockAfterRequired: document.getElementById('gsItemAfter').checked,
sperrt: sperrt.length ? sperrt : null };
if (_gsItemIdx !== null) updated.zeitstrafen[_gsItemIdx] = item;
else updated.zeitstrafen.push(item);
} else if (_gsItemType === 'finisher') {
item = { title, description: desc,
tempUnlockBeforeRequired: document.getElementById('gsItemBefore').checked,
tempUnlockAfterRequired: document.getElementById('gsItemAfter').checked };
if (_gsItemIdx !== null) updated.finisher[_gsItemIdx] = item;
else updated.finisher.push(item);
}
try {
const res = await fetch(`/chastity/game-sets/${_gsItemSetId}`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(updated)
});
if (res.ok) { closeGsItemModal(); await loadGameSets(); }
else { const b = await res.json().catch(()=>({})); errEl.textContent = b.error||'Fehler.'; errEl.style.display = ''; }
} catch (e) { errEl.textContent = 'Netzwerkfehler.'; errEl.style.display = ''; }
}
async function deleteGsItem(type, setId, idx) {
if (!confirm('Eintrag wirklich löschen?')) return;
const set = _gameSets.find(s => s.id === setId);
if (!set) return;
const updated = {
name: set.name,
aufgaben: JSON.parse(JSON.stringify(set.aufgaben || [])),
zeitstrafen: JSON.parse(JSON.stringify(set.zeitstrafen || [])),
finisher: JSON.parse(JSON.stringify(set.finisher || [])),
};
if (type === 'aufgabe') updated.aufgaben.splice(idx, 1);
if (type === 'zeitstrafe') updated.zeitstrafen.splice(idx, 1);
if (type === 'finisher') updated.finisher.splice(idx, 1);
const res = await fetch(`/chastity/game-sets/${setId}`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(updated)
});
if (res.ok) loadGameSets();
}
async function duplicateGsItem(type, setId, idx) {
const set = _gameSets.find(s => s.id === setId);
if (!set) return;
const updated = {
name: set.name,
aufgaben: JSON.parse(JSON.stringify(set.aufgaben || [])),
zeitstrafen: JSON.parse(JSON.stringify(set.zeitstrafen || [])),
finisher: JSON.parse(JSON.stringify(set.finisher || [])),
};
if (type === 'aufgabe') updated.aufgaben.splice(idx + 1, 0, JSON.parse(JSON.stringify(set.aufgaben[idx])));
if (type === 'zeitstrafe') updated.zeitstrafen.splice(idx + 1, 0, JSON.parse(JSON.stringify(set.zeitstrafen[idx])));
if (type === 'finisher') updated.finisher.splice(idx + 1, 0, JSON.parse(JSON.stringify(set.finisher[idx])));
const res = await fetch(`/chastity/game-sets/${setId}`, {
method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(updated)
});
if (res.ok) loadGameSets();
}
document.getElementById('gsSetModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGsSetModal(); });
document.getElementById('gsItemModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGsItemModal(); });
document.getElementById('gsEditModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGsEditModal(); });
</script>
</body>
</html>

View File

@@ -0,0 +1,433 @@
<!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>Task Game xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
.game-card {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 14px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.game-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.game-text {
font-size: 1rem;
line-height: 1.6;
color: var(--color-text);
white-space: pre-wrap;
}
.game-timer {
font-size: 2.2rem;
font-weight: 700;
color: var(--color-primary);
text-align: center;
margin: 0.75rem 0;
letter-spacing: 0.04em;
}
.game-timer.urgent { color: #e74c3c; }
.game-level-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.game-level-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--color-secondary);
transition: background 0.3s;
}
.game-level-dot.active { background: var(--color-primary); }
.lock-messages {
background: rgba(233,69,96,0.1);
border: 1px solid rgba(233,69,96,0.3);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1.25rem;
}
.lock-messages p { margin: 0 0 0.5rem; font-size: 0.9rem; line-height: 1.5; }
.lock-messages p:last-child { margin: 0; }
.init-box {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 14px;
padding: 1.5rem;
}
.init-box h2 { font-size: 1.1rem; margin: 0 0 0.75rem; }
.group-list { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1rem; }
.group-item {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-secondary);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.group-item:hover, .group-item.selected {
border-color: var(--color-primary);
background: rgba(var(--color-primary-rgb, 233,69,96), 0.06);
}
.group-item input[type=radio] { accent-color: var(--color-primary); }
.btn-primary {
width: 100%;
padding: 0.75rem;
border-radius: 10px;
border: none;
background: var(--color-primary);
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-secondary {
width: 100%;
padding: 0.65rem;
border-radius: 10px;
border: 1px solid var(--color-secondary);
background: transparent;
color: var(--color-text);
font-size: 0.92rem;
cursor: pointer;
margin-top: 0.6rem;
}
#finisherBox {
background: linear-gradient(135deg, rgba(233,69,96,0.15), rgba(155,89,182,0.12));
border: 1px solid rgba(233,69,96,0.4);
border-radius: 14px;
padding: 1.5rem;
text-align: center;
}
#finisherBox .trophy { font-size: 2.5rem; margin-bottom: 0.5rem; }
#finisherBox h2 { font-size: 1.1rem; margin: 0 0 0.75rem; color: var(--color-primary); }
</style>
</head>
<body>
<div id="app" class="container" style="max-width:480px;margin:0 auto;padding:1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.25rem;">
<button id="btnBack" onclick="goBack()"
style="background:none;border:none;color:var(--color-muted);font-size:1.3rem;cursor:pointer;padding:0.2rem 0.4rem;"></button>
<h1 style="margin:0;font-size:1.15rem;font-weight:700;">🎯 Task Game</h1>
</div>
<!-- Level-Anzeige -->
<div class="game-level-bar" id="levelBar" style="display:none;">
<span style="font-size:0.78rem;color:var(--color-muted);font-weight:600;">Level</span>
<div id="levelDots" style="display:flex;gap:0.35rem;"></div>
<span id="levelText" style="font-size:0.78rem;color:var(--color-muted);margin-left:auto;"></span>
</div>
<!-- Freigegebene Locks (checkLocks-Meldungen) -->
<div id="lockMessages" class="lock-messages" style="display:none;"></div>
<!-- Initialisierung: Gruppe wählen -->
<div id="initBox" class="init-box" style="display:none;">
<h2>Spiel-Set auswählen</h2>
<p style="font-size:0.85rem;color:var(--color-muted);margin:0 0 1rem;">
Wähle die Aufgabengruppe für dieses Spiel.
</p>
<div id="groupList" class="group-list"></div>
<button class="btn-primary" id="btnStart" disabled onclick="startGame()">▶ Spiel starten</button>
</div>
<!-- Laufendes Spiel -->
<div id="gameBox" style="display:none;">
<!-- Task oder Lock in Queue -->
<div id="queueBox" class="game-card" style="display:none;">
<div class="game-label" id="queueLabel"></div>
<div class="game-text" id="queueText"></div>
<div style="display:flex;gap:0.6rem;margin-top:1.1rem;">
<button class="btn-primary" id="btnOk" onclick="handleOk()">OK</button>
</div>
</div>
<!-- Aktive Aufgabe (läuft) -->
<div id="activeBox" class="game-card" style="display:none;">
<div class="game-label">Aktive Aufgabe</div>
<div class="game-text" id="activeText"></div>
<div class="game-timer" id="activeTimer" style="display:none;"></div>
<div style="display:flex;gap:0.6rem;margin-top:1.1rem;">
<button class="btn-primary" id="btnErledigt" onclick="handleErledigt()">✓ Erledigt</button>
</div>
</div>
<!-- Finisher -->
<div id="finisherBox" style="display:none;">
<div class="trophy">🏆</div>
<h2>Level 6 erreicht!</h2>
<div class="game-label" id="finisherTitle"></div>
<div class="game-text" id="finisherText" style="margin-top:0.5rem;text-align:left;"></div>
<button class="btn-secondary" onclick="goBack()" style="margin-top:1.25rem;">Zurück zum Lock</button>
</div>
</div>
<div id="loadingHint" style="text-align:center;color:var(--color-muted);padding:2rem 0;font-size:0.9rem;">
Wird geladen…
</div>
<div id="errorBox" style="display:none;background:rgba(231,76,60,0.1);border:1px solid rgba(231,76,60,0.3);
border-radius:10px;padding:1rem;font-size:0.88rem;color:#e74c3c;margin-top:1rem;"></div>
</div>
<script>
const params = new URLSearchParams(location.search);
const lockId = params.get('lockId');
let _state = null;
let _timerInt = null;
let _pendingIsLock = false;
let _pendingHasDuration = false;
function goBack() {
if (lockId) location.href = '/games/chastity/activelock.html?lockId=' + lockId;
else history.back();
}
// ── Init ──────────────────────────────────────────────────────────────────
async function boot() {
try {
const r = await fetch('/lock-game/state');
if (r.status === 404) {
await loadGroups();
return;
}
if (!r.ok) throw new Error('Fehler beim Laden des Spielzustands');
_state = await r.json();
hide('loadingHint');
await runGameLoop();
} catch (e) {
showError(e.message);
}
}
async function loadGroups() {
const r = await fetch('/lock-game/groups');
const groups = r.ok ? await r.json() : [];
hide('loadingHint');
const list = document.getElementById('groupList');
if (groups.length === 0) {
list.innerHTML = '<p style="font-size:0.85rem;color:var(--color-muted);">Keine passenden Aufgabengruppen gefunden.<br>Erstelle zuerst eine Gruppe vom Typ „Chastity Only".</p>';
} else {
list.innerHTML = groups.map(g => `
<label class="group-item" onclick="selectGroup(this)">
<input type="radio" name="gruppe" value="${g.gruppenId}" style="display:none;">
<div>
<div style="font-weight:600;font-size:0.95rem;">${esc(g.name)}</div>
${g.beschreibung ? `<div style="font-size:0.8rem;color:var(--color-muted);">${esc(g.beschreibung)}</div>` : ''}
</div>
</label>`).join('');
}
show('initBox');
}
function selectGroup(el) {
document.querySelectorAll('.group-item').forEach(i => i.classList.remove('selected'));
el.classList.add('selected');
el.querySelector('input').checked = true;
document.getElementById('btnStart').disabled = false;
}
async function startGame() {
const sel = document.querySelector('input[name="gruppe"]:checked');
if (!sel) return;
hide('initBox');
show('loadingHint');
try {
const r = await fetch('/lock-game/init?aufgabenGruppeId=' + sel.value, { method: 'POST' });
if (!r.ok) throw new Error('Initialisierung fehlgeschlagen');
const stateR = await fetch('/lock-game/state');
_state = await stateR.json();
hide('loadingHint');
await runGameLoop();
} catch (e) {
showError(e.message);
}
}
// ── Game Loop ─────────────────────────────────────────────────────────────
async function runGameLoop() {
hide('queueBox');
hide('activeBox');
hide('finisherBox');
clearTimer();
if (_state.level >= 6) {
await showFinisher();
return;
}
renderLevelBar(_state.level);
// Aktive Aufgabe läuft noch
if (_state.activeTask) {
showActiveTask(_state.activeTask, _state.activeTaskEnd);
return;
}
// Queue leer → nächsten Task holen
if (!_state.taskInQueue && !_state.lockInQueue) {
await fetch('/lock-game/next-task', { method: 'POST' });
const r = await fetch('/lock-game/state');
_state = await r.json();
}
showQueue();
}
function showQueue() {
if (_state.lockInQueue) {
let sperre;
try { sperre = JSON.parse(_state.lockInQueue); } catch { sperre = {}; }
_pendingIsLock = true;
_pendingHasDuration = !!(sperre.minutenVon || sperre.minutenBis);
document.getElementById('queueLabel').textContent = '🔒 Neue Sperre';
document.getElementById('queueText').textContent = sperre.text || _state.lockInQueue;
} else if (_state.taskInQueue) {
let aufgabe;
try { aufgabe = JSON.parse(_state.taskInQueue); } catch { aufgabe = {}; }
_pendingIsLock = false;
_pendingHasDuration = !!(aufgabe.sekundenVon || aufgabe.sekundenBis);
document.getElementById('queueLabel').textContent = '🎯 Neue Aufgabe';
document.getElementById('queueText').textContent = aufgabe.text || _state.taskInQueue;
}
show('gameBox');
show('queueBox');
}
function showActiveTask(text, endIso) {
document.getElementById('activeText').textContent = text;
show('gameBox');
show('activeBox');
if (endIso) {
const end = new Date(endIso);
startTimer(end, document.getElementById('activeTimer'));
}
}
async function handleOk() {
// Locks prüfen (nach jeder Aktion)
await checkAndShowLocks();
if (_pendingIsLock || _pendingHasDuration) {
// Task/Sperre aktivieren und Timer starten
const r = await fetch('/lock-game/apply-task', { method: 'POST' });
if (!r.ok) { showError('Fehler'); return; }
const stateR = await fetch('/lock-game/state');
_state = await stateR.json();
} else {
// Keine Dauer → sofort nächsten Task
await fetch('/lock-game/next-task', { method: 'POST' });
const r = await fetch('/lock-game/state');
_state = await r.json();
}
await runGameLoop();
}
async function handleErledigt() {
await checkAndShowLocks();
if (_state.level >= 6) {
await showFinisher();
return;
}
await fetch('/lock-game/next-task', { method: 'POST' });
const r = await fetch('/lock-game/state');
_state = await r.json();
await runGameLoop();
}
async function checkAndShowLocks() {
const r = await fetch('/lock-game/check-locks', { method: 'POST' });
if (!r.ok) return;
const texts = await r.json();
if (texts && texts.length > 0) {
const box = document.getElementById('lockMessages');
box.innerHTML = texts.map(t => `<p>🔓 ${esc(t)}</p>`).join('');
show('lockMessages');
await new Promise(res => setTimeout(res, 2000));
}
}
async function showFinisher() {
show('gameBox');
const r = await fetch('/lock-game/finisher');
if (!r.ok) {
document.getElementById('finisherTitle').textContent = '';
document.getElementById('finisherText').textContent = 'Glückwunsch du hast Level 6 erreicht!';
} else {
const f = await r.json();
document.getElementById('finisherTitle').textContent = f.kurzText || '';
document.getElementById('finisherText').textContent = f.text || '';
}
show('finisherBox');
}
// ── Level-Bar ─────────────────────────────────────────────────────────────
function renderLevelBar(level) {
const bar = document.getElementById('levelBar');
const dots = document.getElementById('levelDots');
dots.innerHTML = [1,2,3,4,5].map(i =>
`<div class="game-level-dot${i <= level ? ' active' : ''}"></div>`
).join('');
document.getElementById('levelText').textContent = 'Level ' + Math.min(level, 5);
show('levelBar');
}
// ── Timer ─────────────────────────────────────────────────────────────────
function startTimer(endDate, el) {
el.style.display = '';
clearTimer();
_timerInt = setInterval(() => {
const diff = Math.max(0, Math.round((endDate - Date.now()) / 1000));
const m = String(Math.floor(diff / 60)).padStart(2, '0');
const s = String(diff % 60).padStart(2, '0');
el.textContent = m + ':' + s;
el.classList.toggle('urgent', diff < 30);
if (diff === 0) clearTimer();
}, 1000);
}
function clearTimer() {
if (_timerInt) { clearInterval(_timerInt); _timerInt = null; }
}
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
function show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
function hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
function esc(s) { return String(s).replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function showError(msg) {
hide('loadingHint');
const box = document.getElementById('errorBox');
box.textContent = msg || 'Ein Fehler ist aufgetreten.';
box.style.display = '';
}
boot();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB