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

View File

@@ -3,12 +3,12 @@ package de.oaa.xxx.games.bdsm;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
public class AufgabeAnzeige {
private String nameAktiverMitspieler;
private String aufgabeText;
private Integer timer;
@@ -16,6 +16,7 @@ public class AufgabeAnzeige {
private Integer level;
private UUID mitspielerId;
private boolean eigenesGeraet;
private LocalDateTime aufgabeBis;
@Override
public String toString() {

View File

@@ -109,6 +109,16 @@ public class AufgabenGruppeController {
return ResponseEntity.ok(list);
}
@GetMapping("/chastity")
public ResponseEntity<AufgabenGruppeList> getChastity(@RequestParam(required = false) String search, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
String searchPattern = search != null && !search.isEmpty() ? "%" + search + "%" : null;
AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.listChastityWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 100))
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list);
}
@GetMapping("/own")
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam("userId") UUID userId) {
AufgabenGruppeList list = new AufgabenGruppeList();

View File

@@ -56,7 +56,7 @@ public class FinisherController {
@PostMapping
public ResponseEntity<Void> create(@RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null
|| finisher.getGeschlecht() == null || finisher.getGruppeId() == null) {
|| finisher.getGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(finisher.getGruppeId()).orElse(null);
@@ -77,7 +77,7 @@ public class FinisherController {
@PutMapping("/{finisherId}")
public ResponseEntity<Void> update(@PathVariable("finisherId") UUID finisherId, @RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null || finisher.getGeschlecht() == null) {
if (finisher.getKurzText() == null || finisher.getText() == null) {
return ResponseEntity.badRequest().build();
}
FinisherEntity entity = finisherRepository.findById(finisherId).orElse(null);

View File

@@ -88,6 +88,9 @@ public class SperreController {
entity.setReleaseText(sperre.getReleaseText());
entity.setMinutenVon(sperre.getMinutenVon());
entity.setMinutenBis(sperre.getMinutenBis());
entity.setLevel(sperre.getLevel());
entity.setTempUnlockBeforeRequired(sperre.getTempUnlockBeforeRequired());
entity.setTempUnlockAfterRequired(sperre.getTempUnlockAfterRequired());
entity.setSperreFuer(sperre.getSperreFuer());
entity.setBenoetigteToys(resolveToys(sperre.getBenoetigteToys()));
sperreRepository.save(entity);

View File

@@ -0,0 +1,15 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import java.util.UUID;
public record GameState(
UUID gameID,
UUID userId,
Integer level,
String activeTask,
LocalDateTime activeTaskEnd,
String taskInQueue,
String lockInQueue) {
}

View File

@@ -0,0 +1,205 @@
package de.oaa.xxx.games.chastity.common;
import java.security.Principal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.AvailableIn;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.entity.AufgabeEntity;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.FinisherEntity;
import de.oaa.xxx.games.common.entity.SperreEntity;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/lock-game")
public class LockGameController {
private static final int AUFGABEN_PRO_LEVEL = 3;
private final LockGameRepository lockGameRepository;
private final LockGameLockRepository lockGameLockRepository;
private final AufgabenGruppeRepository aufgabenGruppeRepository;
private final AufgabeRepository aufgabeRepository;
private final SperreRepository sperreRepository;
private final FinisherRepository finisherRepository;
private final UserService userService;
private final ObjectMapper objectMapper;
public LockGameController(LockGameRepository lockGameRepository,
LockGameLockRepository lockGameLockRepository,
AufgabenGruppeRepository aufgabenGruppeRepository,
AufgabeRepository aufgabeRepository,
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
UserService userService,
ObjectMapper objectMapper) {
this.lockGameRepository = lockGameRepository;
this.lockGameLockRepository = lockGameLockRepository;
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
this.aufgabeRepository = aufgabeRepository;
this.sperreRepository = sperreRepository;
this.finisherRepository = finisherRepository;
this.userService = userService;
this.objectMapper = objectMapper;
}
/** Verfügbare CHASTITY_ONLY-Gruppen des angemeldeten Users. */
@GetMapping("/groups")
public ResponseEntity<List<Map<String, Object>>> getGroups(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var gruppen = aufgabenGruppeRepository.findByUserId(userId).stream()
.filter(g -> g.getAvailableIn() == AvailableIn.CHASTITY_ONLY)
.map(g -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("gruppenId", g.getGruppenId().toString());
m.put("name", g.getName());
m.put("beschreibung", g.getBeschreibung() != null ? g.getBeschreibung() : "");
return m;
}).toList();
return ResponseEntity.ok(gruppen);
}
/**
* Initialisiert (oder startet neu) ein Lock-Game für den angemeldeten User.
* Lädt die angegebene Aufgabengruppe und baut daraus die AufgabenList.
*/
@PostMapping("/init")
public ResponseEntity<?> init(@RequestParam UUID aufgabenGruppeId, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var gruppeOpt = aufgabenGruppeRepository.findById(aufgabenGruppeId);
if (gruppeOpt.isEmpty())
return ResponseEntity.notFound().build();
AufgabenGruppeEntity gruppe = gruppeOpt.get();
if (gruppe.getUserId() != null && !gruppe.getUserId().equals(userId))
return ResponseEntity.status(403).build();
var aufgaben = aufgabeRepository.findByAufgabenGruppeIn(List.of(gruppe)).stream()
.map(AufgabeEntity::toAufgabe).toList();
var sperren = sperreRepository.findByAufgabenGruppeIn(List.of(gruppe)).stream()
.map(SperreEntity::toSperre).toList();
var finisher = finisherRepository.findByAufgabenGruppe(gruppe).stream()
.map(FinisherEntity::toFinisher).toList();
AufgabenList list = new AufgabenList();
list.setAufgaben(aufgaben);
list.setSperren(sperren);
list.setFinisher(finisher);
list.setStrafen(List.of());
try {
String aufgabenJson = objectMapper.writeValueAsString(list);
LockGameEntity game = lockGameRepository.findByUserId(userId).orElseGet(() -> {
LockGameEntity g = new LockGameEntity();
g.setGameId(UUID.randomUUID());
g.setUserId(userId);
return g;
});
game.setAufgaben(aufgabenJson);
game.setLevel(1);
game.setAufgabenProLevel(AUFGABEN_PRO_LEVEL);
game.setAufgabenAufAktuellemLevel(0);
game.setZeitfaktorZeitstrafen(1.0);
game.setSetupId(aufgabenGruppeId);
game.setActiveTask(null);
game.setActiveTaskEnd(null);
game.setTaskInQueue(null);
game.setLockInQueue(null);
lockGameLockRepository.deleteAll(lockGameLockRepository.findByGameId(game.getGameId()));
lockGameRepository.save(game);
return ResponseEntity.ok(Map.of("gameId", game.getGameId().toString()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/state")
public ResponseEntity<GameState> getState(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var opt = lockGameRepository.findByUserId(userId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
try {
return ResponseEntity.ok(buildService(opt.get()).getGameState());
} catch (Exception ex) {
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/next-task")
public ResponseEntity<?> nextTask(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var opt = lockGameRepository.findByUserId(userId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
try {
buildService(opt.get()).initNextTask();
return ResponseEntity.noContent().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/apply-task")
public ResponseEntity<Map<String, Object>> applyTask(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var opt = lockGameRepository.findByUserId(userId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
try {
Integer seconds = buildService(opt.get()).applyTaskInQueue();
return ResponseEntity.ok(Map.of("seconds", seconds != null ? seconds : 0));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/check-locks")
public ResponseEntity<List<String>> checkLocks(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var opt = lockGameRepository.findByUserId(userId);
if (opt.isEmpty()) return ResponseEntity.ok(List.of());
try {
return ResponseEntity.ok(buildService(opt.get()).checkLocks());
} catch (Exception e) {
return ResponseEntity.ok(List.of());
}
}
@GetMapping("/finisher")
public ResponseEntity<?> getFinisher(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var opt = lockGameRepository.findByUserId(userId);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
try {
var finisher = buildService(opt.get()).getFinisher();
Map<String, Object> result = new LinkedHashMap<>();
result.put("kurzText", finisher.getKurzText());
result.put("text", finisher.getText());
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
private LockGameService buildService(LockGameEntity entity) throws Exception {
return new LockGameService(entity, lockGameRepository, lockGameLockRepository);
}
}

View File

@@ -22,10 +22,10 @@ public class LockGameEntity {
@Id
@Column
private UUID sessionId;
private UUID gameId;
@Column(unique = true)
private UUID userId;
@OneToMany(mappedBy = "lockId", fetch = FetchType.EAGER)
@OneToMany(mappedBy = "gameId", fetch = FetchType.EAGER)
private List<LockGameLockEntity> activeLocks = new ArrayList<>();
@Column
private Integer aufgabenProLevel;
@@ -38,9 +38,13 @@ public class LockGameEntity {
@Column
private Double zeitfaktorZeitstrafen;
@Column(columnDefinition = "TEXT")
private String activeTaskJson;
private String activeTask;
@Column
private LocalDateTime taskStartedAt;
private LocalDateTime activeTaskEnd;
@Column
private String taskInQueue;
@Column
private String lockInQueue;
@Column
private UUID setupId;
}

View File

@@ -1,5 +1,6 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@@ -28,7 +29,7 @@ public class LockGameLockEntity {
@Column
private UUID lockGameLockId;
@Column
private UUID lockId;
private UUID gameId;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class, fetch = FetchType.EAGER)
@CollectionTable(name = "aktiveSperre_fuer", joinColumns = @JoinColumn(name = "aktiveSperreId"))
@@ -36,4 +37,6 @@ public class LockGameLockEntity {
private List<Werkzeug> lockFor = new ArrayList<>();
@Column
private String releaseText;
@Column
private LocalDateTime releaseTime;
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.chastity.common;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LockGameLockRepository extends JpaRepository<LockGameLockEntity, UUID> {
List<LockGameLockEntity> findByGameId(UUID gameId);
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.chastity.common;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LockGameRepository extends JpaRepository<LockGameEntity, UUID>{
Optional<LockGameEntity> findByUserId(UUID userId);
}

View File

@@ -1,27 +1,182 @@
package de.oaa.xxx.games.chastity.common;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import de.oaa.xxx.games.bdsm.AufgabeAnzeige;
import de.oaa.xxx.games.common.aufgaben.Aufgabe;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.Finisher;
import de.oaa.xxx.games.common.aufgaben.Sperre;
@Service
public class LockGameService {
// public LockGameService(LockGameEntity gamestate) {
//
// }
//
// public String getNextTask() {
//
// }
//
// public String getCurrentTask() {
//
// }
//
// public void applyLock(Werkzeug applyFor, LocalDateTime applyTill, ) {
//
// }
private LockGameEntity gamestate;
private LockGameRepository lockGameRepository;
private LockGameLockRepository lockGameLockRepository;
private AufgabenList aufgabenList;
public LockGameService(LockGameEntity gamestate, LockGameRepository lockGameRepository,
LockGameLockRepository lockGameLockRepository) throws JsonMappingException, JsonProcessingException {
this.gamestate = gamestate;
this.lockGameRepository = lockGameRepository;
this.lockGameLockRepository = lockGameLockRepository;
this.aufgabenList = new ObjectMapper().readValue(gamestate.getAufgaben(), AufgabenList.class);
}
public GameState getGameState() {
return new GameState(gamestate.getGameId(), gamestate.getUserId(), gamestate.getLevel(),
gamestate.getActiveTask(), gamestate.getActiveTaskEnd(), gamestate.getTaskInQueue(),
gamestate.getLockInQueue());
}
public void initNextTask() throws JsonProcessingException {
checkLevel();
String result = null;
if (gamestate.getLevel() <= 5) {
int nextInt = new Random().nextInt(1, 100);
if (nextInt < 25) {
result = findZeitstrafe();
} else {
result = findAufgabe();
}
if (result == null) {
result = "Pause...";
gamestate.setTaskInQueue(result);
Aufgabe aufgabe = new Aufgabe();
aufgabe.setText("Pause...");
aufgabe.setSekundenVon(300);
aufgabe.setSekundenBis(300);
ObjectMapper mapper = new ObjectMapper();
gamestate.setTaskInQueue(mapper.writeValueAsString(aufgabe));
}
lockGameRepository.save(gamestate);
}
}
private String findZeitstrafe() throws JsonProcessingException {
var list = gamestate.getActiveLocks().stream().flatMap(lock -> lock.getLockFor().stream()).toList();
var level = gamestate.getLevel();
while (level > 0) {
var sperren = aufgabenList.getSperren().stream()
.filter(sperre -> sperre.getSperreFuer().stream().noneMatch(item -> list.contains(item))).toList();
if (!sperren.isEmpty()) {
ObjectMapper mapper = new ObjectMapper();
Sperre sperre = sperren.get(new Random().nextInt(sperren.size()));
gamestate.setLockInQueue(mapper.writeValueAsString(sperre));
return sperre.getText();
}
level--;
}
return null;
}
private String findAufgabe() throws JsonProcessingException {
var list = gamestate.getActiveLocks().stream().flatMap(lock -> lock.getLockFor().stream()).toList();
var level = gamestate.getLevel();
while (level > 0) {
var aufgaben = aufgabenList.getAufgaben().stream()
.filter(aufgabe -> aufgabe.getBenoetigtAktiv().stream().noneMatch(item -> list.contains(item)))
.toList();
if (!aufgaben.isEmpty()) {
ObjectMapper mapper = new ObjectMapper();
var aufgabe = aufgaben.get(new Random().nextInt(aufgaben.size()));
gamestate.setTaskInQueue(mapper.writeValueAsString(aufgabe));
return aufgabe.getText();
}
level--;
}
return null;
}
public Integer applyTaskInQueue() throws JsonMappingException, JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
if (gamestate.getTaskInQueue() != null) {
var aufgabe = mapper.readValue(gamestate.getTaskInQueue(), Aufgabe.class);
gamestate.setActiveTask(aufgabe.getText());
gamestate.setTaskInQueue(null);
var time = getAufgabeTime(aufgabe);
gamestate.setActiveTaskEnd(LocalDateTime.now().plusSeconds(time));
return time;
} else if (gamestate.getLockInQueue() != null) {
var lock = mapper.readValue(gamestate.getLockInQueue(), Sperre.class);
gamestate.setActiveTask(lock.getText());
gamestate.setLockInQueue(null);
applyLock(lock);
}
lockGameRepository.save(gamestate);
return 0;
}
private Integer getLockTime(Sperre sperre) {
var time = new Random().nextInt(sperre.getMinutenVon(), sperre.getMinutenBis());
return (int) (time * gamestate.getZeitfaktorZeitstrafen());
}
private Integer getAufgabeTime(Aufgabe aufgabe) {
var time = new Random().nextInt(aufgabe.getSekundenVon(), aufgabe.getSekundenBis());
return (int) (time * gamestate.getZeitfaktorZeitstrafen());
}
protected void checkLevel() {
var aufgabenAufAktuellemLevel = gamestate.getAufgabenAufAktuellemLevel();
if (++aufgabenAufAktuellemLevel >= 1 + gamestate.getAufgabenProLevel()) {
aufgabenAufAktuellemLevel = 0;
gamestate.setLevel(gamestate.getLevel() + 1);
}
gamestate.setAufgabenAufAktuellemLevel(aufgabenAufAktuellemLevel);
}
public AufgabeAnzeige getCurrentTask() {
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setAufgabeText(gamestate.getActiveTask());
anzeige.setAufgabeBis(gamestate.getActiveTaskEnd());
return anzeige;
}
public void applyLock(Sperre lock) {
var entity = new LockGameLockEntity();
entity.setLockGameLockId(UUID.randomUUID());
entity.setGameId(gamestate.getGameId());
entity.setLockFor(lock.getSperreFuer());
entity.setReleaseText(lock.getReleaseText());
entity.setReleaseTime(LocalDateTime.now().plusMinutes(getLockTime(lock)));
lockGameLockRepository.save(entity);
}
public List<String> checkLocks() {
var result = new ArrayList<String>();
for (LockGameLockEntity entity : lockGameLockRepository.findByGameId(gamestate.getGameId())) {
if (entity.getReleaseTime().isAfter(LocalDateTime.now())) {
result.add(entity.getReleaseText());
lockGameLockRepository.delete(entity);
}
}
return result;
}
public List<String> releaseLocks() {
var result = new ArrayList<String>();
for (LockGameLockEntity entity : lockGameLockRepository.findByGameId(gamestate.getGameId())) {
result.add(entity.getReleaseText());
lockGameLockRepository.delete(entity);
}
return result;
}
public Finisher getFinisher() {
return aufgabenList.getFinisher().get(new Random().nextInt(aufgabenList.getFinisher().size()));
}
}

View File

@@ -19,6 +19,9 @@ public class Sperre {
private List<Werkzeug> sperreFuer;
private Integer minutenVon;
private Integer minutenBis;
private Integer level;
private Boolean tempUnlockBeforeRequired;
private Boolean tempUnlockAfterRequired;
private List<Toy> benoetigteToys;
@Override

View File

@@ -50,6 +50,12 @@ public class SperreEntity {
private Integer minutenVon;
@Column
private Integer minutenBis;
@Column
private Integer level;
@Column
private Boolean tempUnlockBeforeRequired;
@Column
private Boolean tempUnlockAfterRequired;
@ManyToMany(cascade = CascadeType.DETACH)
@JoinTable(name = "sperreToy", joinColumns = {@JoinColumn(name = "sperreId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")})
private List<ToyEntity> benoetigteToys;
@@ -67,6 +73,9 @@ public class SperreEntity {
sperre.setKurzText(kurzText);
sperre.setMinutenBis(minutenBis);
sperre.setMinutenVon(minutenVon);
sperre.setLevel(level);
sperre.setTempUnlockBeforeRequired(tempUnlockBeforeRequired);
sperre.setTempUnlockAfterRequired(tempUnlockAfterRequired);
sperre.setReleaseText(releaseText);
sperre.setSperreFuer(sperreFuer != null ? new ArrayList<>(sperreFuer) : new ArrayList<>());
sperre.setText(text);
@@ -82,6 +91,9 @@ public class SperreEntity {
entity.setKurzText(sperre.getKurzText());
entity.setMinutenBis(sperre.getMinutenBis());
entity.setMinutenVon(sperre.getMinutenVon());
entity.setLevel(sperre.getLevel());
entity.setTempUnlockBeforeRequired(sperre.getTempUnlockBeforeRequired());
entity.setTempUnlockAfterRequired(sperre.getTempUnlockAfterRequired());
entity.setReleaseText(sperre.getReleaseText());
entity.setSperreFuer(sperre.getSperreFuer());
entity.setText(sperre.getText());

View File

@@ -49,4 +49,8 @@ public interface AufgabenGruppeRepository extends JpaRepository<AufgabenGruppeEn
/** Alle Gruppen für BDSM-Spielstart (availableIn wird im Frontend hervorgehoben). */
@Query("select g from AufgabenGruppeEntity g where (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listAllWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
/** Nur CHASTITY_ONLY-Gruppen für Spiel-Karte Aufgaben-Set-Auswahl. */
@Query("select g from AufgabenGruppeEntity g where g.availableIn = de.oaa.xxx.games.common.aufgaben.AvailableIn.CHASTITY_ONLY and (g.privateGruppe = false or g.userId = :userId) and (:search is null or g.name like :search)")
List<AufgabenGruppeEntity> listChastityWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable);
}

View File

@@ -2,9 +2,13 @@ package de.oaa.xxx.games.common.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.FinisherEntity;
import java.util.List;
import java.util.UUID;
public interface FinisherRepository extends JpaRepository<FinisherEntity, UUID> {
List<FinisherEntity> findByAufgabenGruppe(AufgabenGruppeEntity gruppe);
}

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>
@@ -530,6 +532,19 @@
<label class="werkzeug-check"><span>Anus</span><input type="checkbox" value="ANUS"></label>
</div>
</div>
<div id="iTempUnlockRow">
<label>Temporäre Öffnungen</label>
<div style="display:flex; flex-direction:column; gap:0.5rem; margin-top:0.5rem;">
<label style="display:flex; align-items:center; gap:0.6rem; font-size:0.85rem; cursor:pointer;">
<input type="checkbox" id="iTempUnlockBefore" style="accent-color:var(--color-primary); width:1rem; height:1rem;">
Temporäre Öffnung <em>vor</em> der Zeitstrafe erforderlich
</label>
<label style="display:flex; align-items:center; gap:0.6rem; font-size:0.85rem; cursor:pointer;">
<input type="checkbox" id="iTempUnlockAfter" style="accent-color:var(--color-primary); width:1rem; height:1rem;">
Temporäre Öffnung <em>nach</em> der Zeitstrafe erforderlich
</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>
@@ -749,7 +764,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 +1101,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 +1121,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 +1131,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 +1378,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 +1386,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 +1399,39 @@
: '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('iTempUnlockRow').style.display = (isZeit && isChastity) ? 'block' : 'none';
document.getElementById('iReleaseTextRow').style.display = isZeit ? 'block' : 'none';
}
function _resetItemFields() {
@@ -1400,6 +1449,8 @@
document.querySelectorAll('#iWerkzeugFinisherPassiv input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iSperreFuer input').forEach(cb => cb.checked = false);
document.querySelectorAll('#iGeschlecht input').forEach(rb => rb.checked = false);
document.getElementById('iTempUnlockBefore').checked = false;
document.getElementById('iTempUnlockAfter').checked = false;
_selectedToys = [];
renderSelectedToys();
document.getElementById('itemModalError').style.display = 'none';
@@ -1449,6 +1500,11 @@
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 : '';
document.getElementById('iTempUnlockBefore').checked = d.tempUnlockBeforeRequired === true;
document.getElementById('iTempUnlockAfter').checked = d.tempUnlockAfterRequired === true;
}
}
const preSelected = (d.benoetigteToys || []).filter(t => t.toyId);
@@ -1480,7 +1536,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 +1662,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 +1681,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 +1697,9 @@
minutenBis: minBis ? parseInt(minBis, 10) : null,
releaseText: document.getElementById('iReleaseText').value.trim() || null,
sperreFuer,
level: zeitLevel,
tempUnlockBeforeRequired: _isChastityMode ? document.getElementById('iTempUnlockBefore').checked : null,
tempUnlockAfterRequired: _isChastityMode ? document.getElementById('iTempUnlockAfter').checked : null,
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

@@ -260,6 +260,33 @@
.radio-group label { display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-size:0.9rem; margin:0; color:var(--color-text); }
.radio-group input[type="radio"] { width:auto; padding:0; margin:0; }
/* ── Spiel-Set Suche ── */
.gs-dropdown {
display:none; position:absolute; top:100%; left:0; right:0; z-index:200;
background:var(--color-card); border:1px solid var(--color-secondary);
border-radius:6px; max-height:200px; overflow-y:auto;
box-shadow:0 4px 14px rgba(0,0,0,0.35); margin-top:2px;
}
.gs-dropdown-item {
padding:0.55rem 0.9rem; cursor:pointer; font-size:0.9rem;
border-bottom:1px solid rgba(255,255,255,0.05);
}
.gs-dropdown-item:last-child { border-bottom:none; }
.gs-dropdown-item:hover { background:rgba(255,255,255,0.07); }
.gs-item-name { font-weight:600; color:var(--color-text); }
.gs-item-desc { font-size:0.78rem; color:var(--color-muted); margin-top:0.1rem; }
.gs-selected {
display:flex; align-items:center; gap:0.6rem;
background:rgba(255,255,255,0.05); border:1px solid var(--color-secondary);
border-radius:6px; padding:0.45rem 0.75rem; margin-top:0.35rem;
font-size:0.88rem; color:var(--color-text);
}
.gs-selected button {
background:none; border:none; color:var(--color-muted);
cursor:pointer; padding:0; margin:0; font-size:1rem; width:auto; line-height:1;
}
.gs-selected button:hover { color:#e74c3c; background:none; }
/* ── Simulation ── */
.sim-bar-track { background:var(--color-secondary); border-radius:6px; height:8px; overflow:hidden; margin:0.5rem 0 0.25rem; }
.sim-bar-fill { height:100%; background:var(--color-primary); border-radius:6px; transition:width 0.1s linear; width:0%; }
@@ -268,47 +295,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 +315,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,43 +418,57 @@
<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>
<!-- Aufgaben (CardLock) nur sichtbar wenn TASK-Karte > 0 -->
<div id="sectionCardTasks" style="display:none;">
<div class="form-section">
<div class="form-section-title">Aufgaben</div>
<div style="margin-bottom:0.65rem;">
<div style="font-size:0.75rem;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--color-muted);margin-bottom:0.45rem;">Wer entscheidet über die Aufgabe?</div>
<div class="radio-group">
<label><input type="radio" name="modalCardTaskMode" value="RANDOM" checked> Zufall</label>
<label><input type="radio" name="modalCardTaskMode" value="KEYHOLDER" > Keyholder*In</label>
<label><input type="radio" name="modalCardTaskMode" value="COMMUNITY" > Community</label>
</div>
</div>
<input type="range" id="sldGameSpieldauer" min="0" max="4" value="2" oninput="updateGameSpieldauer(this.value)" style="width:100%;">
<div class="form-row" style="margin-bottom:0.5rem;">
<label>Aufgaben-Set <span class="required-star">*</span></label>
<select id="fCardTaskSetId" onchange="onTaskSetChange('card')">
<option value="">Kein Aufgaben-Set</option>
</select>
</div>
<button class="btn-add" type="button" onclick="openTaskSetModal(null,'card')">+ Neues Set anlegen</button>
<div id="cardTaskSetPreview" class="task-set-preview"></div>
</div>
</div>
<!-- Aufgaben (CardLock) -->
<div class="form-section">
<div class="form-section-title">Aufgaben (optional)</div>
<div style="margin-bottom:0.65rem;">
<div style="font-size:0.75rem;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--color-muted);margin-bottom:0.45rem;">Wer entscheidet über die Aufgabe?</div>
<div class="radio-group">
<label><input type="radio" name="modalCardTaskMode" value="RANDOM" checked> Zufall</label>
<label><input type="radio" name="modalCardTaskMode" value="KEYHOLDER" > Keyholder*In</label>
<label><input type="radio" name="modalCardTaskMode" value="COMMUNITY" > Community</label>
<!-- Spiel-Karte nur sichtbar wenn GAME_CARD > 0 -->
<div id="sectionGameSetConfig" style="display:none;">
<div class="form-section">
<div class="form-section-title">Spiel-Karte Minispiel</div>
<p style="font-size:0.82rem;color:var(--color-muted);margin:0 0 0.75rem;">Wenn eine Spiel-Karte gezogen wird, startet ein Minispiel mit dem gewählten Chastity-Aufgaben-Set.</p>
<div class="form-row">
<label>Aufgaben-Set (Chastity) <span class="required-star">*</span></label>
<div style="position:relative;">
<input type="text" id="gameSetSearch" placeholder="Name eingeben zum Suchen…"
autocomplete="off" oninput="onGameSetSearch(this.value)" onfocus="onGameSetSearchFocus()">
<div id="gameSetDropdown" class="gs-dropdown"></div>
</div>
<div id="gameSetSelected" class="gs-selected" style="display:none;"></div>
<input type="hidden" id="fGameSetId">
<div class="field-error-msg" id="errGameSet" style="display:none;">Bitte ein Aufgaben-Set für Spiel-Karten auswählen.</div>
</div>
<div class="form-row" style="margin-bottom:0;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem;">
<label for="sldGameSpieldauer" style="margin:0;">Spieldauer</label>
<span id="valGameSpieldauer" style="font-size:0.85rem;color:var(--color-muted);">Mittel</span>
</div>
<input type="range" id="sldGameSpieldauer" min="0" max="4" value="2"
oninput="updateGameSpieldauer(this.value)"
style="width:100%;accent-color:var(--color-primary);">
</div>
</div>
<div class="form-row" style="margin-bottom:0.5rem;">
<label>Aufgaben-Set</label>
<select id="fCardTaskSetId" onchange="onTaskSetChange('card')">
<option value="">Kein Aufgaben-Set</option>
</select>
</div>
<button class="btn-add" type="button" onclick="openTaskSetModal(null,'card')">+ Neues Set anlegen</button>
<div id="cardTaskSetPreview" class="task-set-preview"></div>
</div>
</div>
@@ -719,114 +708,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>
@@ -935,8 +816,114 @@
function syncMinMax(id, val) {
if (id.startsWith('min_')) { const mx = document.getElementById('max_'+id.slice(4)); if (mx && val > (parseInt(mx.value)||0)) mx.value = val; }
else if (id.startsWith('max_')) { const mn = document.getElementById('min_'+id.slice(4)); if (mn && val < (parseInt(mn.value)||0)) mn.value = val; }
if (id === 'min_GAME_CARD' || id === 'max_GAME_CARD') {
if (val > 0) {
// Gegenseitiger Ausschluss: Task-Karten nullen
const minT = document.getElementById('min_TASK'); if (minT) minT.value = 0;
const maxT = document.getElementById('max_TASK'); if (maxT) maxT.value = 0;
checkTaskCardSection();
}
checkGameCardSection();
}
if (id === 'min_TASK' || id === 'max_TASK') {
if (val > 0) {
// Gegenseitiger Ausschluss: Game-Karten nullen
const minG = document.getElementById('min_GAME_CARD'); if (minG) minG.value = 0;
const maxG = document.getElementById('max_GAME_CARD'); if (maxG) maxG.value = 0;
checkGameCardSection();
}
checkTaskCardSection();
}
}
// ── Spiel-Karte: Aufgaben-Set + Spieldauer ──
const GAME_SPIELDAUER = [
{ label: 'Sehr kurz' },
{ label: 'Kurz' },
{ label: 'Mittel' },
{ label: 'Lang' },
{ label: 'Sehr lang' },
];
let _gameSetSearchTimer = null;
let _gameSetResults = [];
function checkGameCardSection() {
const minV = parseInt(document.getElementById('min_GAME_CARD')?.value) || 0;
const maxV = parseInt(document.getElementById('max_GAME_CARD')?.value) || 0;
const sec = document.getElementById('sectionGameSetConfig');
if (sec) sec.style.display = (minV > 0 || maxV > 0) ? '' : 'none';
}
function updateGameSpieldauer(val) {
document.getElementById('valGameSpieldauer').textContent = GAME_SPIELDAUER[+val].label;
}
function onGameSetSearchFocus() {
if (!document.getElementById('fGameSetId').value) onGameSetSearch(document.getElementById('gameSetSearch').value);
}
function onGameSetSearch(value) {
clearTimeout(_gameSetSearchTimer);
if (value.length === 0) {
_gameSetSearchTimer = setTimeout(() => doGameSetSearch(''), 0);
} else if (value.length < 2) {
document.getElementById('gameSetDropdown').style.display = 'none';
} else {
_gameSetSearchTimer = setTimeout(() => doGameSetSearch(value), 300);
}
}
async function doGameSetSearch(search) {
try {
const url = '/gruppe/chastity' + (search ? '?search=' + encodeURIComponent(search) : '');
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
_gameSetResults = data.gruppen || [];
renderGameSetDropdown();
} catch(e) { console.error(e); }
}
function renderGameSetDropdown() {
const dd = document.getElementById('gameSetDropdown');
if (!dd) return;
if (!_gameSetResults.length) { dd.style.display = 'none'; return; }
dd.innerHTML = _gameSetResults.map(g => `
<div class="gs-dropdown-item" onclick="selectGameSet('${esc(g.gruppenId)}','${esc(g.name).replace(/'/g, "\\'")}')">
<div class="gs-item-name">${esc(g.name)}</div>
${g.beschreibung ? `<div class="gs-item-desc">${esc(g.beschreibung)}</div>` : ''}
</div>`).join('');
dd.style.display = 'block';
}
function selectGameSet(id, name, suppressDirty = false) {
document.getElementById('fGameSetId').value = id;
document.getElementById('gameSetSearch').value = '';
document.getElementById('gameSetDropdown').style.display = 'none';
document.getElementById('gameSetSelected').innerHTML =
`<span style="flex:1;">${esc(name)}</span>
<button type="button" onclick="clearGameSet()" title="Auswahl entfernen">✕</button>`;
document.getElementById('gameSetSelected').style.display = 'flex';
document.getElementById('errGameSet').style.display = 'none';
if (!suppressDirty) markDirty();
}
function clearGameSet() {
document.getElementById('fGameSetId').value = '';
document.getElementById('gameSetSearch').value = '';
document.getElementById('gameSetSelected').style.display = 'none';
document.getElementById('gameSetSelected').innerHTML = '';
markDirty();
}
document.addEventListener('click', e => {
const search = document.getElementById('gameSetSearch');
const dd = document.getElementById('gameSetDropdown');
if (dd && search && !search.contains(e.target) && !dd.contains(e.target)) {
dd.style.display = 'none';
}
});
// ── Karten-Info ──
function openCardInfo(cardId) {
const c = CARD_DEFS.find(x => x.id === cardId); if (!c) return;
@@ -1223,51 +1210,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);
@@ -1424,6 +1366,19 @@
tpFromMinutes('pe', template?.pickEveryMinute || 60);
document.getElementById('fAccumulate').checked = template?.accumulatePicks || false;
document.getElementById('fShowRemaining').checked = template?.showRemainingCards || false;
// Spiel-Karte
clearGameSet();
checkGameCardSection();
const gsi = template?.gameSpieldauerIdx ?? 2;
document.getElementById('sldGameSpieldauer').value = gsi;
updateGameSpieldauer(gsi);
if (template?.gameSetId) {
fetch(`/gruppe/${template.gameSetId}`)
.then(r => r.ok ? r.json() : null)
.then(g => { if (g?.name) selectGameSet(template.gameSetId, g.name, true); })
.catch(() => {});
}
}
if (type === 'TIMELOCK') {
@@ -1476,14 +1431,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 +1474,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();
@@ -1589,8 +1530,13 @@
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 (hasGameCards && !document.getElementById('fGameSetId').value) {
document.getElementById('errGameSet').style.display = '';
showModalError('Spiel-Karten konfiguriert, aber kein Aufgaben-Set ausgewählt.');
firstError = firstError || document.getElementById('errGameSet');
} else {
document.getElementById('errGameSet').style.display = 'none';
}
if (firstError) { firstError.scrollIntoView({behavior:'smooth',block:'center'}); return; }
const cardCountsMin={}, cardCountsMax={};
@@ -1609,8 +1555,8 @@
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,
gameSetId: hasGameCards ? (document.getElementById('fGameSetId').value || null) : null,
gameSpieldauerIdx: hasGameCards ? (parseInt(document.getElementById('sldGameSpieldauer').value) || 2) : null,
};
} else {
// TimeLock
@@ -1895,426 +1841,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