Chain-Hit-Planer für Torn – mit API-Sync, einklappbarer Member-Verwaltung, verschiebbarem & größenveränderbarem Fenster
// ==UserScript==
// @name Chain Planer (Torn)
// @namespace https://edelzentrale.de/
// @version 1.3
// @description Chain-Hit-Planer für Torn – mit API-Sync, einklappbarer Member-Verwaltung, verschiebbarem & größenveränderbarem Fenster
// @author xShaYaKaZ
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
/* ============================================================
STORAGE
============================================================ */
const STORAGE_KEY = 'cp_chainPlaner_v1';
function loadState() {
try {
const raw = GM_getValue(STORAGE_KEY, null);
if (raw) {
const p = JSON.parse(raw);
if (!p.members) p.members = [];
if (!p.planner) p.planner = { date: '', members: [], sequence: [], completed: {} };
if (!p.planner.completed) p.planner.completed = {};
return p;
}
} catch (e) { console.error('[ChainPlaner] Ladefehler:', e); }
return { members: [], planner: { date: '', members: [], sequence: [], completed: {} } };
}
function saveState() {
try { GM_setValue(STORAGE_KEY, JSON.stringify(state)); }
catch (e) { console.error('[ChainPlaner] Speicherfehler:', e); }
}
let state = loadState();
const API_KEY_STORE = 'cp_api_key_v1';
function loadApiKey() {
try { return GM_getValue(API_KEY_STORE, ''); } catch (e) { return ''; }
}
function saveApiKey(key) {
try { GM_setValue(API_KEY_STORE, key); } catch (e) {}
}
/* ============================================================
STYLES (alles scoped unter #cp-root / #cp-fab, keine globalen
:root Variablen um Konflikte mit Torn zu vermeiden)
============================================================ */
const style = document.createElement('style');
style.textContent = `
#cp-fab{
position:fixed;left:0;top:0;z-index:2147483000;
width:30px;height:30px;border-radius:50%;border:none;cursor:grab;
background:#ff8c42;color:#1a1208;font-family:'Oswald',sans-serif;
font-weight:700;font-size:8.5px;letter-spacing:.2px;
display:flex;align-items:center;justify-content:center;text-align:center;
box-shadow:0 3px 10px rgba(0,0,0,.45);touch-action:none;user-select:none;
padding:0;line-height:1;
}
#cp-fab:active{cursor:grabbing;transform:scale(.95);}
#cp-root{
--bg:#12151a;--panel:#1a1e25;--panel-alt:#20252d;--panel-hover:#262c35;--border:#2b313b;
--text:#eceae5;--text-dim:#8b919c;--accent:#ff9a52;--accent-dim:#c06a2e;
--accent-2:#3ee2b0;--danger:#ff6b6b;
--font-display:'Oswald',sans-serif;--font-body:'Inter',system-ui,sans-serif;
--font-mono:'Roboto Mono',monospace;
--r-lg:10px;--r-md:7px;--r-sm:4px;
position:fixed;z-index:2147482999;
background:var(--bg);color:var(--text);font-family:var(--font-body);
box-sizing:border-box;
border:1px solid var(--border);border-radius:var(--r-lg);
box-shadow:0 20px 50px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.02);
min-width:300px;min-height:260px;
width:min(420px,92vw);height:min(600px,85vh);
display:none;flex-direction:column;
overflow:hidden;
}
#cp-root *{box-sizing:border-box;}
#cp-root.cp-open{display:flex;}
#cp-root ::-webkit-scrollbar{width:9px;height:9px;}
#cp-root ::-webkit-scrollbar-track{background:transparent;}
#cp-root ::-webkit-scrollbar-thumb{background:var(--border);border-radius:5px;border:2px solid var(--bg);}
#cp-root ::-webkit-scrollbar-thumb:hover{background:var(--accent-dim);}
#cp-root .cp-app{
container-type:inline-size;container-name:cpapp;
flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;
padding:0 12px 20px;
}
#cp-root header.cp-top{
background:linear-gradient(180deg,var(--panel-alt),var(--panel));
display:flex;align-items:center;gap:10px;
padding:11px 12px;border-bottom:1px solid var(--border);
cursor:move;touch-action:none;user-select:none;
flex-shrink:0;position:relative;
}
#cp-root header.cp-top::after{
content:"";position:absolute;left:0;right:0;bottom:-1px;height:1px;
background:linear-gradient(90deg,transparent,var(--accent-dim),transparent);opacity:.5;
}
#cp-resize-handle{
position:absolute;right:2px;bottom:2px;width:16px;height:16px;
cursor:nwse-resize;touch-action:none;z-index:6;opacity:.5;
background:
radial-gradient(circle,var(--text-dim) 1px,transparent 1.2px) 12px 12px/4px 4px no-repeat,
radial-gradient(circle,var(--text-dim) 1px,transparent 1.2px) 12px 4px/4px 4px no-repeat,
radial-gradient(circle,var(--text-dim) 1px,transparent 1.2px) 4px 12px/4px 4px no-repeat;
}
#cp-resize-handle:hover{opacity:.9;}
#cp-root header.cp-top h1{
font-family:var(--font-display);font-weight:600;font-size:18px;
letter-spacing:.4px;margin:0;color:var(--text);text-transform:uppercase;flex:1;
}
#cp-root header.cp-top h1 span{color:var(--accent);}
#cp-root header.cp-top .cp-sub{
font-family:var(--font-mono);font-size:9.5px;color:var(--text-dim);
letter-spacing:1px;text-transform:uppercase;display:none;
}
#cp-close{
background:transparent;border:1px solid var(--border);color:var(--text-dim);
width:28px;height:28px;border-radius:var(--r-sm);font-size:13px;cursor:pointer;flex-shrink:0;
display:flex;align-items:center;justify-content:center;transition:border-color .15s,color .15s;
}
#cp-close:active{border-color:var(--danger);color:var(--danger);}
#cp-root .cp-field{display:flex;flex-direction:column;gap:5px;}
#cp-root .cp-field label{
font-size:10px;text-transform:uppercase;letter-spacing:.8px;
color:var(--text-dim);font-family:var(--font-mono);
}
#cp-root input,#cp-root select{
background:var(--panel-alt);border:1px solid var(--border);color:var(--text);
font-family:var(--font-mono);font-size:16px;padding:9px 10px;border-radius:var(--r-sm);
outline:none;transition:border-color .15s,background .15s;-webkit-appearance:none;
}
#cp-root input:focus,#cp-root select:focus{border-color:var(--accent);background:var(--panel-hover);}
#cp-root input[type="checkbox"]{
width:18px;height:18px;padding:0;flex-shrink:0;accent-color:var(--accent-2);cursor:pointer;
}
#cp-root button.cp-btn{
font-family:var(--font-display);font-size:12px;letter-spacing:.7px;text-transform:uppercase;
background:linear-gradient(180deg,var(--accent),var(--accent-dim));color:#1a1208;border:none;
padding:11px 16px;border-radius:var(--r-sm);cursor:pointer;min-height:40px;
transition:filter .15s,transform .1s;box-shadow:0 2px 6px rgba(0,0,0,.25);
}
#cp-root button.cp-btn:hover{filter:brightness(1.08);}
#cp-root button.cp-btn:active{transform:scale(.97);filter:brightness(.97);}
#cp-root button.cp-btn:disabled{opacity:.35;cursor:not-allowed;filter:none;box-shadow:none;}
#cp-root button.cp-btn.cp-secondary{background:transparent;color:var(--text);border:1px solid var(--border);box-shadow:none;}
#cp-root button.cp-btn.cp-secondary:hover{border-color:var(--accent);color:var(--accent);}
#cp-root button.cp-btn.cp-secondary:active{border-color:var(--accent);color:var(--accent);}
#cp-root button.cp-btn.cp-small{padding:8px 12px;font-size:10.5px;min-height:34px;}
#cp-root .cp-empty{color:var(--text-dim);font-family:var(--font-mono);font-size:12px;padding:18px 0;text-align:center;letter-spacing:.2px;}
#cp-toast{
position:fixed;bottom:80px;left:50%;transform:translateX(-50%) translateY(10px);
background:var(--panel,#1a1e25);border:1px solid var(--accent,#ff9a52);color:var(--text,#eceae5);
font-family:var(--font-mono,monospace);font-size:12px;padding:11px 18px;border-radius:var(--r-md,6px);
opacity:0;transition:opacity .2s,transform .2s;pointer-events:none;z-index:2147483001;
max-width:90vw;text-align:center;box-shadow:0 6px 20px rgba(0,0,0,.4);
}
#cp-toast.cp-show{opacity:1;transform:translateX(-50%) translateY(0);}
#cp-root .cp-planer-topbar{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;margin-bottom:14px;}
#cp-root .cp-planer-grid{display:flex;flex-direction:column;gap:14px;}
@container cpapp (min-width:760px){
#cp-root .cp-planer-grid{display:grid;grid-template-columns:repeat(3,1fr);}
}
@media(min-width:600px){
#cp-root header.cp-top .cp-sub{display:inline;}
}
#cp-root .cp-card2{
background:var(--panel);border:1px solid var(--border);border-radius:var(--r-md);
display:flex;flex-direction:column;overflow:hidden;min-height:220px;
}
#cp-root .cp-card2-head{
font-family:var(--font-display);font-size:11.5px;text-transform:uppercase;letter-spacing:1px;
padding:10px 12px;border-bottom:1px solid var(--border);color:var(--text);
background:var(--panel-alt);display:flex;align-items:center;gap:6px;flex-wrap:wrap;
}
#cp-root .cp-card2-head::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--accent);flex-shrink:0;box-shadow:0 0 6px var(--accent);}
#cp-root .cp-add-row{
display:flex;gap:8px;align-items:stretch;flex-wrap:wrap;
padding:10px 12px;border-bottom:1px solid var(--border);
}
#cp-root .cp-add-row select{flex:1 1 100px;min-width:90px;}
#cp-root .cp-add-row input[type=number]{width:64px;flex-shrink:0;}
#cp-root .cp-mode-toggle{display:flex;border:1px solid var(--border);border-radius:var(--r-sm);overflow:hidden;flex-shrink:0;}
#cp-root .cp-pm-btn{
font-family:var(--font-mono);font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;
background:var(--panel-alt);border:none;color:var(--text-dim);padding:8px 10px;cursor:pointer;min-height:38px;
transition:background .15s,color .15s;
}
#cp-root .cp-pm-btn.cp-active{background:var(--accent);color:#1a1208;}
#cp-root .cp-member-list2,#cp-root .cp-seq-list{
list-style:none;margin:0;padding:9px;
display:flex;flex-direction:column;gap:6px;flex:1;
max-height:60vh;overflow-y:auto;
}
@media(min-width:900px){
#cp-root .cp-member-list2,#cp-root .cp-seq-list{max-height:340px;}
}
#cp-root .cp-member-item2{
display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:var(--panel-alt);
border:1px solid var(--border);border-radius:var(--r-sm);padding:8px 10px;
transition:background .15s,border-color .15s;
}
#cp-root .cp-member-item2:hover{background:var(--panel-hover);}
#cp-root .cp-member-item2 .cp-name{flex:1 1 100px;font-weight:600;font-size:13px;}
#cp-root .cp-member-item2 .cp-mode-badge{
font-family:var(--font-mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.5px;
padding:2px 6px;border-radius:var(--r-sm);
}
#cp-root .cp-mode-badge.cp-normal{color:var(--text-dim);border:1px solid var(--border);}
#cp-root .cp-mode-badge.cp-schnell{color:var(--accent);border:1px solid var(--accent-dim);}
#cp-root .cp-member-item2 input[type=number]{width:60px;font-size:14px;padding:6px;}
#cp-root .cp-del-btn{
background:transparent;border:none;color:var(--danger);cursor:pointer;font-size:16px;
padding:4px 6px;flex-shrink:0;min-width:30px;opacity:.75;transition:opacity .15s;
}
#cp-root .cp-del-btn:hover{opacity:1;}
#cp-root .cp-seq-item{
display:flex;align-items:center;gap:6px;flex-wrap:wrap;background:var(--panel-alt);
border:1px solid var(--border);border-radius:var(--r-sm);padding:7px 8px;
transition:background .15s;
}
#cp-root .cp-seq-item:hover{background:var(--panel-hover);}
#cp-root .cp-reorder{display:flex;flex-direction:column;gap:2px;flex-shrink:0;}
#cp-root .cp-move{
background:var(--panel);border:1px solid var(--border);color:var(--text-dim);
cursor:pointer;font-size:9px;line-height:1;padding:4px 6px;border-radius:3px;min-width:24px;
transition:border-color .15s,color .15s;
}
#cp-root .cp-move:disabled{opacity:.25;cursor:not-allowed;}
#cp-root .cp-move:hover:not(:disabled){border-color:var(--accent);color:var(--accent);}
#cp-root .cp-seq-item .cp-name{flex:1 1 90px;font-weight:600;font-size:12.5px;}
#cp-root .cp-seq-item input[type=number]{width:54px;font-size:14px;padding:6px 5px;}
#cp-root .cp-seq-item .cp-del-btn{font-size:14px;}
#cp-root .cp-split-btn{
background:transparent;border:1px solid var(--border);color:var(--text-dim);
font-family:var(--font-mono);font-size:9.5px;padding:5px 8px;border-radius:3px;cursor:pointer;
letter-spacing:.3px;flex-shrink:0;transition:border-color .15s,color .15s;
}
#cp-root .cp-split-btn:hover{border-color:var(--accent);color:var(--accent);}
#cp-root .cp-alloc{
font-family:var(--font-mono);font-size:9.5px;flex-shrink:0;padding:3px 6px;border-radius:3px;
}
#cp-root .cp-alloc.cp-ok{color:var(--accent-2);border:1px solid rgba(62,226,176,.3);background:rgba(62,226,176,.06);}
#cp-root .cp-alloc.cp-over{color:var(--danger);border:1px solid rgba(255,107,107,.3);background:rgba(255,107,107,.06);}
#cp-root .cp-alloc.cp-partial{color:var(--accent);border:1px solid rgba(255,154,82,.3);background:rgba(255,154,82,.06);}
#cp-root .cp-output{padding:12px 14px;flex:1;overflow-y:auto;font-family:var(--font-mono);font-size:13px;line-height:1.85;max-height:60vh;}
#cp-root .cp-output-line{display:flex;align-items:center;gap:8px;margin-bottom:1px;border-radius:var(--r-sm);transition:background .15s;}
#cp-root .cp-output-line:hover{background:var(--panel-alt);}
#cp-root .cp-output-line label{display:flex;align-items:center;gap:8px;cursor:pointer;flex:1;padding:4px 6px;}
#cp-root .cp-output-line .cp-text{color:var(--text);}
#cp-root .cp-output-line.cp-done .cp-text{color:var(--text-dim);text-decoration:line-through;}
#cp-root .cp-output-date{
font-family:var(--font-display);font-size:12.5px;color:var(--accent);
letter-spacing:.5px;margin-bottom:8px;text-transform:uppercase;
padding-bottom:6px;border-bottom:1px solid var(--border);
}
#cp-root .cp-stamm{background:var(--panel);border:1px solid var(--border);border-radius:var(--r-md);margin:14px 0 18px;overflow:hidden;}
#cp-root .cp-stamm-head{
display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;
padding:12px 14px;background:var(--panel-alt);transition:background .15s;
}
#cp-root .cp-stamm-head:hover{background:var(--panel-hover);}
#cp-root .cp-stamm-head h2{
font-family:var(--font-display);font-size:13px;text-transform:uppercase;
letter-spacing:1px;margin:0;color:var(--text);display:flex;align-items:center;gap:8px;flex:1;
}
#cp-root .cp-stamm-head h2::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--accent);display:inline-block;box-shadow:0 0 6px var(--accent);}
#cp-root .cp-stamm-count{
font-family:var(--font-mono);font-size:10px;color:var(--text-dim);
background:var(--panel);border:1px solid var(--border);border-radius:20px;padding:2px 9px;
}
#cp-root .cp-stamm-chevron{
font-size:11px;color:var(--text-dim);transition:transform .2s;flex-shrink:0;
}
#cp-root .cp-stamm.cp-collapsed .cp-stamm-chevron{transform:rotate(-90deg);}
#cp-root .cp-stamm-body{padding:14px;}
#cp-root .cp-stamm.cp-collapsed .cp-stamm-body{display:none;}
#cp-root .cp-member-add{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;}
#cp-root .cp-member-add input{flex:1 1 160px;}
#cp-root .cp-member-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px;}
#cp-root .cp-member-list li{
display:flex;justify-content:space-between;align-items:center;
background:var(--panel-alt);border:1px solid var(--border);
padding:8px 12px;border-radius:var(--r-sm);transition:background .15s;
}
#cp-root .cp-member-list li:hover{background:var(--panel-hover);}
#cp-root .cp-member-list li .cp-nm{font-weight:600;}
#cp-root .cp-member-list li button{
background:transparent;border:none;color:var(--danger);cursor:pointer;font-size:11.5px;
font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.5px;padding:6px;opacity:.8;
}
#cp-root .cp-member-list li button:hover{opacity:1;}
`;
document.head.appendChild(style);
/* ============================================================
DOM AUFBAU
============================================================ */
const fab = document.createElement('button');
fab.id = 'cp-fab';
fab.textContent = 'CP';
document.body.appendChild(fab);
const root = document.createElement('div');
root.id = 'cp-root';
root.innerHTML = `
<header class="cp-top">
<h1>Chain<span>Planer</span></h1>
<span class="cp-sub">Torn · Hit-Planung</span>
<button id="cp-close" title="Schließen">✕</button>
</header>
<div class="cp-app">
<div class="cp-stamm cp-collapsed" id="cp-stamm">
<div class="cp-stamm-head" id="cp-stamm-head">
<h2>Member verwalten</h2>
<span class="cp-stamm-count" id="cp-stamm-count">0</span>
<span class="cp-stamm-chevron">▾</span>
</div>
<div class="cp-stamm-body">
<div class="cp-member-add">
<input type="password" id="cp-api-key" placeholder="Torn API-Key (Fraktion)">
<button class="cp-btn cp-secondary" id="cp-btn-sync">🔄 Sync Fraktion</button>
</div>
<div id="cp-sync-status" style="font-family:var(--font-mono);font-size:10.5px;color:var(--text-dim);margin-bottom:10px;"></div>
<div class="cp-member-add">
<input type="text" id="cp-inp-new-member" placeholder="Neuer Member-Name (manuell)">
<button class="cp-btn" id="cp-btn-add-member">Hinzufügen</button>
</div>
<ul class="cp-member-list" id="cp-member-list">
<div class="cp-empty">Noch keine Member. Füge oben Member hinzu oder synce die Fraktion.</div>
</ul>
</div>
</div>
<div class="cp-planer-topbar">
<div class="cp-field" style="min-width:150px;">
<label>Chain-Datum</label>
<input type="date" id="cp-planer-date">
</div>
<button class="cp-btn cp-secondary cp-small" id="cp-btn-planer-reset">Planer zurücksetzen</button>
</div>
<div class="cp-planer-grid">
<div class="cp-card2">
<div class="cp-card2-head">Fenster 1 – Mitglieder & Gesamt-Hits</div>
<div class="cp-add-row">
<select id="cp-planer-member-select"></select>
<input type="number" id="cp-planer-hits-input" min="1" max="999" value="10" title="Gesamt-Hits">
<div class="cp-mode-toggle">
<button class="cp-pm-btn cp-active" data-mode="normal">Normal</button>
<button class="cp-pm-btn" data-mode="schnell">⚡ Schnell</button>
</div>
<button class="cp-btn cp-small" id="cp-btn-planer-add">+</button>
</div>
<ul id="cp-planer-member-list" class="cp-member-list2"></ul>
</div>
<div class="cp-card2">
<div class="cp-card2-head">
Fenster 2 – Reihenfolge
<span style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);">(▲▼ · ÷ Split)</span>
</div>
<div id="cp-planer-seq-info" style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim);padding:6px 10px 0;"></div>
<ul id="cp-planer-seq-list" class="cp-seq-list"></ul>
</div>
<div class="cp-card2">
<div class="cp-card2-head" style="justify-content:space-between;">
<span>Fenster 3 – Chain-Ausgabe</span>
<button class="cp-btn cp-secondary cp-small" id="cp-btn-planer-copy">Kopieren</button>
</div>
<div id="cp-planer-output" class="cp-output"></div>
</div>
</div>
</div>
<div id="cp-resize-handle" title="Größe ändern"></div>
`;
document.body.appendChild(root);
const toast = document.createElement('div');
toast.id = 'cp-toast';
document.body.appendChild(toast);
/* ============================================================
STAMMDATEN EIN-/AUSKLAPPEN
============================================================ */
const STAMM_COLLAPSED_KEY = 'cp_stamm_collapsed_v1';
const stammEl = root.querySelector('#cp-stamm');
(function initStammCollapse() {
let collapsed = true;
try {
const raw = GM_getValue(STAMM_COLLAPSED_KEY, null);
if (raw !== null) collapsed = raw === 'true' || raw === true;
} catch (e) {}
stammEl.classList.toggle('cp-collapsed', collapsed);
})();
root.querySelector('#cp-stamm-head').addEventListener('click', () => {
const collapsed = stammEl.classList.toggle('cp-collapsed');
try { GM_setValue(STAMM_COLLAPSED_KEY, collapsed); } catch (e) {}
});
/* ============================================================
POSITION / GRÖSSE - LADEN & SPEICHERN
============================================================ */
const RECT_KEY = 'cp_win_rect_v1';
const FAB_KEY = 'cp_fab_pos_v1';
function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); }
function loadWinRect() {
try {
const raw = GM_getValue(RECT_KEY, null);
if (raw) return JSON.parse(raw);
} catch (e) {}
const w = Math.min(420, window.innerWidth * 0.92);
const h = Math.min(600, window.innerHeight * 0.85);
return { x: Math.round(window.innerWidth - w - 24), y: 70, w: Math.round(w), h: Math.round(h) };
}
function saveWinRect(rect) {
try { GM_setValue(RECT_KEY, JSON.stringify(rect)); } catch (e) {}
}
function applyWinRect(rect) {
root.style.left = rect.x + 'px';
root.style.top = rect.y + 'px';
root.style.width = rect.w + 'px';
root.style.height = rect.h + 'px';
}
function currentWinRect() {
return { x: root.offsetLeft, y: root.offsetTop, w: root.offsetWidth, h: root.offsetHeight };
}
function loadFabPos() {
try {
const raw = GM_getValue(FAB_KEY, null);
if (raw) return JSON.parse(raw);
} catch (e) {}
return { x: window.innerWidth - 46, y: window.innerHeight - 80 };
}
function saveFabPos(pos) {
try { GM_setValue(FAB_KEY, JSON.stringify(pos)); } catch (e) {}
}
function applyFabPos(pos) {
fab.style.left = pos.x + 'px';
fab.style.top = pos.y + 'px';
}
applyWinRect(loadWinRect());
applyFabPos(loadFabPos());
/* ============================================================
OPEN / CLOSE
============================================================ */
function openPanel() {
const rect = currentWinRect();
const w = rect.w || 420, h = rect.h || 600;
root.style.left = clamp(rect.x || 0, 0, Math.max(0, window.innerWidth - w)) + 'px';
root.style.top = clamp(rect.y || 0, 0, Math.max(0, window.innerHeight - h)) + 'px';
root.classList.add('cp-open');
}
function closePanel() {
root.classList.remove('cp-open');
}
fab.addEventListener('click', () => {
if (fab._didDrag) { fab._didDrag = false; return; }
if (root.classList.contains('cp-open')) closePanel(); else openPanel();
});
root.querySelector('#cp-close').addEventListener('click', closePanel);
/* ============================================================
FENSTER VERSCHIEBEN (Titelleiste)
============================================================ */
(function enableWindowDrag() {
const header = root.querySelector('header.cp-top');
let drag = null;
header.addEventListener('pointerdown', e => {
if (e.target.closest('#cp-close')) return;
drag = { startX: e.clientX, startY: e.clientY, startLeft: root.offsetLeft, startTop: root.offsetTop };
header.setPointerCapture(e.pointerId);
e.preventDefault();
});
header.addEventListener('pointermove', e => {
if (!drag) return;
const dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
const nl = clamp(drag.startLeft + dx, 0, window.innerWidth - root.offsetWidth);
const nt = clamp(drag.startTop + dy, 0, window.innerHeight - root.offsetHeight);
root.style.left = nl + 'px';
root.style.top = nt + 'px';
});
function endDrag() {
if (!drag) return;
drag = null;
saveWinRect(currentWinRect());
}
header.addEventListener('pointerup', endDrag);
header.addEventListener('pointercancel', endDrag);
})();
/* ============================================================
FENSTER GRÖSSE ÄNDERN (Ecke unten rechts)
============================================================ */
(function enableWindowResize() {
const handle = root.querySelector('#cp-resize-handle');
let res = null;
handle.addEventListener('pointerdown', e => {
res = { startX: e.clientX, startY: e.clientY, startW: root.offsetWidth, startH: root.offsetHeight };
handle.setPointerCapture(e.pointerId);
e.preventDefault();
e.stopPropagation();
});
handle.addEventListener('pointermove', e => {
if (!res) return;
const dw = e.clientX - res.startX, dh = e.clientY - res.startY;
const maxW = window.innerWidth - root.offsetLeft;
const maxH = window.innerHeight - root.offsetTop;
const nw = clamp(res.startW + dw, 300, maxW);
const nh = clamp(res.startH + dh, 260, maxH);
root.style.width = nw + 'px';
root.style.height = nh + 'px';
});
function endResize() {
if (!res) return;
res = null;
saveWinRect(currentWinRect());
}
handle.addEventListener('pointerup', endResize);
handle.addEventListener('pointercancel', endResize);
})();
/* ============================================================
FAB VERSCHIEBEN
============================================================ */
(function enableFabDrag() {
let drag = null;
const THRESHOLD = 6;
fab.addEventListener('pointerdown', e => {
drag = { startX: e.clientX, startY: e.clientY, startLeft: fab.offsetLeft, startTop: fab.offsetTop, moved: false };
fab.setPointerCapture(e.pointerId);
});
fab.addEventListener('pointermove', e => {
if (!drag) return;
const dx = e.clientX - drag.startX, dy = e.clientY - drag.startY;
if (Math.abs(dx) > THRESHOLD || Math.abs(dy) > THRESHOLD) drag.moved = true;
if (!drag.moved) return;
const nl = clamp(drag.startLeft + dx, 0, window.innerWidth - fab.offsetWidth);
const nt = clamp(drag.startTop + dy, 0, window.innerHeight - fab.offsetHeight);
fab.style.left = nl + 'px';
fab.style.top = nt + 'px';
});
function endDrag() {
if (!drag) return;
if (drag.moved) {
fab._didDrag = true;
saveFabPos({ x: fab.offsetLeft, y: fab.offsetTop });
}
drag = null;
}
fab.addEventListener('pointerup', endDrag);
fab.addEventListener('pointercancel', endDrag);
})();
/* ============================================================
HELPERS
============================================================ */
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('cp-show');
clearTimeout(toast._t);
toast._t = setTimeout(() => toast.classList.remove('cp-show'), 2200);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function newId() { return '_' + Math.random().toString(36).slice(2, 9); }
/* ============================================================
MEMBER-VERWALTUNG (Stammdaten)
============================================================ */
function renderMemberList() {
const ul = root.querySelector('#cp-member-list');
const countBadge = root.querySelector('#cp-stamm-count');
if (countBadge) countBadge.textContent = state.members.length;
if (state.members.length === 0) {
ul.innerHTML = '<div class="cp-empty">Noch keine Member. Füge oben Member hinzu.</div>';
} else {
ul.innerHTML = [...state.members].sort((a, b) => a.localeCompare(b, 'de')).map(name => `
<li>
<span class="cp-nm">${escapeHtml(name)}</span>
<button data-remove="${escapeHtml(name)}">Entfernen</button>
</li>`).join('');
ul.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => {
const name = btn.dataset.remove;
if (!confirm(`"${name}" entfernen?`)) return;
state.members = state.members.filter(m => m !== name);
const removedIds = state.planner.members.filter(m => m.name === name).map(m => m.id);
state.planner.members = state.planner.members.filter(m => m.name !== name);
removedIds.forEach(id => {
state.planner.sequence.filter(s => s.memberId === id).forEach(s => delete state.planner.completed[s.id]);
state.planner.sequence = state.planner.sequence.filter(s => s.memberId !== id);
});
saveState();
renderMemberList();
renderPlanerMembers();
renderPlanerSequence();
renderPlanerOutput();
});
});
}
refreshPlanerSelect();
}
root.querySelector('#cp-btn-add-member').addEventListener('click', addMember);
root.querySelector('#cp-inp-new-member').addEventListener('keydown', e => { if (e.key === 'Enter') addMember(); });
function addMember() {
const inp = root.querySelector('#cp-inp-new-member');
const name = inp.value.trim();
if (!name) { showToast('Bitte einen Namen eingeben.'); return; }
if (state.members.includes(name)) { showToast('Dieser Member existiert bereits.'); return; }
state.members.push(name);
saveState();
inp.value = '';
renderMemberList();
}
/* ============================================================
TORN API - FRAKTIONSMITGLIEDER SYNC
============================================================ */
const apiKeyInput = root.querySelector('#cp-api-key');
const syncStatus = root.querySelector('#cp-sync-status');
const syncBtn = root.querySelector('#cp-btn-sync');
apiKeyInput.value = loadApiKey();
apiKeyInput.addEventListener('change', () => {
saveApiKey(apiKeyInput.value.trim());
});
syncBtn.addEventListener('click', syncFactionMembers);
function syncFactionMembers() {
const key = apiKeyInput.value.trim();
if (!key) { showToast('Bitte zuerst einen Torn API-Key eintragen.'); return; }
saveApiKey(key);
syncBtn.disabled = true;
syncStatus.textContent = 'Synchronisiere Fraktionsmitglieder …';
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.torn.com/faction/?selections=basic&key=${encodeURIComponent(key)}`,
onload: function (res) {
syncBtn.disabled = false;
let data;
try { data = JSON.parse(res.responseText); }
catch (e) { syncStatus.textContent = 'Antwort konnte nicht gelesen werden.'; showToast('API-Antwort ungültig.'); return; }
if (data.error) {
syncStatus.textContent = `API-Fehler: ${data.error.error}`;
showToast(`API-Fehler: ${data.error.error}`);
return;
}
const membersObj = data.members || {};
const names = Object.values(membersObj).map(m => m.name).filter(Boolean);
if (names.length === 0) {
syncStatus.textContent = 'Keine Mitglieder in der API-Antwort gefunden.';
showToast('Keine Mitglieder gefunden.');
return;
}
let added = 0;
names.forEach(n => {
if (!state.members.includes(n)) { state.members.push(n); added++; }
});
saveState();
renderMemberList();
const factionName = data.name ? ` (${data.name})` : '';
syncStatus.textContent = `Zuletzt synct: ${names.length} Member${factionName}, ${added} neu hinzugefügt.`;
showToast(`Sync fertig: ${added} neue Member.`);
},
onerror: function () {
syncBtn.disabled = false;
syncStatus.textContent = 'Netzwerkfehler bei der API-Anfrage.';
showToast('Netzwerkfehler bei der API-Anfrage.');
}
});
}
/* ============================================================
CHAIN PLANER
============================================================ */
let planerMode = 'normal';
function planerEnsure() {
if (!state.planner) state.planner = { date: '', members: [], sequence: [], completed: {} };
if (!state.planner.completed) state.planner.completed = {};
}
function planerSave() { planerEnsure(); saveState(); }
function planerAllocated(memberId) {
return state.planner.sequence.filter(s => s.memberId === memberId).reduce((s, i) => s + i.hits, 0);
}
function refreshPlanerSelect() {
const sel = root.querySelector('#cp-planer-member-select');
const added = new Set(state.planner.members.map(m => m.name));
const available = state.members.filter(n => !added.has(n));
sel.innerHTML = available.length
? available.map(n => `<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`).join('')
: '<option value="">Alle Member bereits hinzugefügt</option>';
sel.disabled = !available.length;
root.querySelector('#cp-btn-planer-add').disabled = !available.length;
}
// ---- Fenster 1 ----
function renderPlanerMembers() {
planerEnsure();
const ul = root.querySelector('#cp-planer-member-list');
refreshPlanerSelect();
if (state.planner.members.length === 0) {
ul.innerHTML = '<div class="cp-empty" style="margin:8px;">Noch keine Member hinzugefügt.</div>';
return;
}
ul.innerHTML = state.planner.members.map(m => {
const alloc = planerAllocated(m.id);
const diff = alloc - m.totalHits;
let cls = 'cp-ok', txt = `${alloc}/${m.totalHits}`;
if (diff > 0) { cls = 'cp-over'; txt = `${alloc}/${m.totalHits} +${diff}`; }
else if (diff < 0) { cls = 'cp-partial'; txt = `${alloc}/${m.totalHits} ${diff}`; }
return `<li class="cp-member-item2" data-mid="${m.id}">
<span class="cp-name">${escapeHtml(m.name)}</span>
<span class="cp-mode-badge ${m.mode === 'schnell' ? 'cp-schnell' : 'cp-normal'}">${m.mode === 'schnell' ? '⚡ Schnell' : 'Normal'}</span>
<input type="number" min="1" max="999" value="${m.totalHits}" data-total-for="${m.id}" title="Gesamt-Hits">
<span class="cp-alloc ${cls}" title="Verteilt / Gesamt">${txt}</span>
<button class="cp-del-btn" data-del-member="${m.id}" title="Entfernen">✕</button>
</li>`;
}).join('');
ul.querySelectorAll('[data-total-for]').forEach(inp => {
inp.addEventListener('change', () => {
const m = state.planner.members.find(m => m.id === inp.dataset.totalFor);
if (m) { m.totalHits = Math.max(1, parseInt(inp.value) || 1); planerSave(); renderPlanerMembers(); }
});
});
ul.querySelectorAll('[data-del-member]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.delMember;
state.planner.members = state.planner.members.filter(m => m.id !== id);
state.planner.sequence.filter(s => s.memberId === id).forEach(s => delete state.planner.completed[s.id]);
state.planner.sequence = state.planner.sequence.filter(s => s.memberId !== id);
planerSave();
renderPlanerMembers();
renderPlanerSequence();
renderPlanerOutput();
});
});
}
// ---- Fenster 2 (Reihenfolge, mobil: ▲▼ statt Drag&Drop) ----
function renderPlanerSequence() {
planerEnsure();
const ul = root.querySelector('#cp-planer-seq-list');
const info = root.querySelector('#cp-planer-seq-info');
const totalHits = state.planner.sequence.reduce((s, i) => s + i.hits, 0);
info.textContent = `${state.planner.sequence.length} Block(s) · ${totalHits} Hit(s) total`;
if (state.planner.sequence.length === 0) {
ul.innerHTML = '<div class="cp-empty" style="margin:8px;">Noch keine Einträge. Füge Member in Fenster 1 hinzu.</div>';
return;
}
const lastIdx = state.planner.sequence.length - 1;
ul.innerHTML = state.planner.sequence.map((item, idx) => {
const member = state.planner.members.find(m => m.id === item.memberId);
const alloc = planerAllocated(item.memberId);
const total = member ? member.totalHits : '?';
const diff = member ? alloc - member.totalHits : 0;
let cls = 'cp-ok';
if (diff > 0) cls = 'cp-over'; else if (diff < 0) cls = 'cp-partial';
return `<li class="cp-seq-item" data-seq-id="${item.id}">
<div class="cp-reorder">
<button class="cp-move" data-move-up="${item.id}" ${idx === 0 ? 'disabled' : ''} title="Nach oben">▲</button>
<button class="cp-move" data-move-down="${item.id}" ${idx === lastIdx ? 'disabled' : ''} title="Nach unten">▼</button>
</div>
<span class="cp-name">${escapeHtml(item.name)}</span>
<input type="number" min="1" max="999" value="${item.hits}" data-seq-hits="${item.id}" title="Hits">
<span class="cp-alloc ${cls}" title="${alloc}/${total} gesamt">${alloc}/${total}</span>
<button class="cp-split-btn" data-split="${item.id}" title="Block aufteilen">÷ Split</button>
<button class="cp-del-btn" data-del-seq="${item.id}" title="Entfernen">✕</button>
</li>`;
}).join('');
// Nach oben
ul.querySelectorAll('[data-move-up]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = state.planner.sequence.findIndex(s => s.id === btn.dataset.moveUp);
if (idx > 0) {
const [moved] = state.planner.sequence.splice(idx, 1);
state.planner.sequence.splice(idx - 1, 0, moved);
planerSave(); renderPlanerSequence(); renderPlanerOutput();
}
});
});
// Nach unten
ul.querySelectorAll('[data-move-down]').forEach(btn => {
btn.addEventListener('click', () => {
const idx = state.planner.sequence.findIndex(s => s.id === btn.dataset.moveDown);
if (idx >= 0 && idx < state.planner.sequence.length - 1) {
const [moved] = state.planner.sequence.splice(idx, 1);
state.planner.sequence.splice(idx + 1, 0, moved);
planerSave(); renderPlanerSequence(); renderPlanerOutput();
}
});
});
// Hits ändern
ul.querySelectorAll('[data-seq-hits]').forEach(inp => {
inp.addEventListener('change', () => {
const item = state.planner.sequence.find(s => s.id === inp.dataset.seqHits);
if (item) { item.hits = Math.max(1, parseInt(inp.value) || 1); planerSave(); renderPlanerMembers(); renderPlanerSequence(); renderPlanerOutput(); }
});
});
// Split
ul.querySelectorAll('[data-split]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.split;
const idx = state.planner.sequence.findIndex(s => s.id === id);
if (idx < 0) return;
const item = state.planner.sequence[idx];
if (item.hits < 2) { showToast('Mindestens 2 Hits nötig zum Aufteilen.'); return; }
const h1 = Math.ceil(item.hits / 2), h2 = item.hits - h1;
item.hits = h1;
state.planner.sequence.splice(idx + 1, 0, { id: newId(), memberId: item.memberId, name: item.name, hits: h2 });
planerSave(); renderPlanerMembers(); renderPlanerSequence(); renderPlanerOutput();
});
});
// Block löschen
ul.querySelectorAll('[data-del-seq]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.delSeq;
state.planner.sequence = state.planner.sequence.filter(s => s.id !== id);
delete state.planner.completed[id];
planerSave(); renderPlanerMembers(); renderPlanerSequence(); renderPlanerOutput();
});
});
}
// ---- Fenster 3 ----
function renderPlanerOutput() {
planerEnsure();
const out = root.querySelector('#cp-planer-output');
const p = state.planner;
if (p.sequence.length === 0) {
out.innerHTML = '<div class="cp-empty" style="margin:0;">Noch keine Chain-Sequenz vorhanden.</div>';
return;
}
let ctr = 1;
const lines = p.sequence.map(item => {
const from = ctr, to = ctr + item.hits - 1; ctr = to + 1;
return { id: item.id, name: item.name, from, to, hits: item.hits, done: !!p.completed[item.id] };
});
const dateStr = p.date
? 'Chain ' + new Date(p.date + 'T00:00:00').toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
: 'Chain (kein Datum)';
out.innerHTML = `<div class="cp-output-date">${escapeHtml(dateStr)}</div>` +
lines.map(line => {
const range = line.hits === 1 ? `${line.from}` : `${line.from}-${line.to}`;
return `<div class="cp-output-line ${line.done ? 'cp-done' : ''}">
<label>
<input type="checkbox" ${line.done ? 'checked' : ''} data-out-cb="${line.id}">
<span class="cp-text">${escapeHtml(line.name + ': ' + range)}${line.done ? ' ✅' : ''}</span>
</label>
</div>`;
}).join('');
out.querySelectorAll('[data-out-cb]').forEach(cb => {
cb.addEventListener('change', () => {
if (cb.checked) p.completed[cb.dataset.outCb] = true; else delete p.completed[cb.dataset.outCb];
planerSave(); renderPlanerOutput();
});
});
}
// ---- Member zu Planer hinzufügen (Fenster 1) ----
root.querySelector('#cp-btn-planer-add').addEventListener('click', () => {
planerEnsure();
const sel = root.querySelector('#cp-planer-member-select');
const hInp = root.querySelector('#cp-planer-hits-input');
const name = sel.value.trim();
const hits = Math.max(1, parseInt(hInp.value) || 1);
if (!name) { showToast('Bitte einen Member auswählen.'); return; }
if (state.planner.members.some(m => m.name === name)) { showToast('Bereits in der Liste.'); return; }
const member = { id: newId(), name, totalHits: hits, mode: planerMode };
state.planner.members.push(member);
const seqItem = { id: newId(), memberId: member.id, name, hits };
if (planerMode === 'schnell') {
const firstActive = state.planner.sequence.findIndex(s => !state.planner.completed[s.id]);
state.planner.sequence.splice(firstActive < 0 ? 0 : firstActive, 0, seqItem);
} else {
state.planner.sequence.push(seqItem);
}
planerSave();
renderPlanerMembers();
renderPlanerSequence();
renderPlanerOutput();
});
// Modus-Umschalter
root.querySelectorAll('.cp-pm-btn').forEach(btn => {
btn.addEventListener('click', () => {
root.querySelectorAll('.cp-pm-btn').forEach(b => b.classList.remove('cp-active'));
btn.classList.add('cp-active');
planerMode = btn.dataset.mode;
});
});
// Datum
root.querySelector('#cp-planer-date').addEventListener('change', e => {
planerEnsure(); state.planner.date = e.target.value; planerSave(); renderPlanerOutput();
});
// Kopieren
root.querySelector('#cp-btn-planer-copy').addEventListener('click', () => {
const p = state.planner;
const dateStr = p.date
? 'Chain ' + new Date(p.date + 'T00:00:00').toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
: 'Chain';
let ctr = 1;
const rows = [dateStr, ...p.sequence.map(item => {
const from = ctr, to = ctr + item.hits - 1; ctr = to + 1;
const range = item.hits === 1 ? `${from}` : `${from}-${to}`;
return `${item.name}: ${range}${p.completed[item.id] ? ' ✅' : ''}`;
})];
const text = rows.join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => showToast('In Zwischenablage kopiert.'))
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
});
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
showToast('In Zwischenablage kopiert.');
} catch (e) {
showToast('Kopieren fehlgeschlagen.');
}
document.body.removeChild(ta);
}
// Reset
root.querySelector('#cp-btn-planer-reset').addEventListener('click', () => {
if (!confirm('Den ganzen Planer zurücksetzen?')) return;
state.planner = { date: '', members: [], sequence: [], completed: {} };
root.querySelector('#cp-planer-date').value = '';
planerSave();
renderPlanerMembers(); renderPlanerSequence(); renderPlanerOutput();
});
/* ============================================================
INIT
============================================================ */
function init() {
planerEnsure();
if (state.planner.date) root.querySelector('#cp-planer-date').value = state.planner.date;
renderMemberList();
renderPlanerMembers();
renderPlanerSequence();
renderPlanerOutput();
}
init();
})();