From ea00268227e3b7fb9ef549e7da9b602cada8ff5b Mon Sep 17 00:00:00 2001 From: xziino Date: Fri, 22 May 2026 08:26:33 +0200 Subject: [PATCH] Add Planner tab: localStorage plan CRUD and basic UI shell Steps 1+2 of the planner roadmap: data model, create/rename/copy/delete plans, read-only mechanic timeline, two-column layout mirroring the analysis tab style. Co-Authored-By: Claude Sonnet 4.6 --- css/planner.css | 220 +++++++++++++++++++++++++ js/planner.js | 333 ++++++++++++++++++++++++++++++++++++++ js/tabs.js | 1 + templates/page.php | 6 + templates/tab-planner.php | 31 ++++ templates/topbar.php | 1 + 6 files changed, 592 insertions(+) create mode 100644 css/planner.css create mode 100644 js/planner.js create mode 100644 templates/tab-planner.php diff --git a/css/planner.css b/css/planner.css new file mode 100644 index 0000000..33f27bb --- /dev/null +++ b/css/planner.css @@ -0,0 +1,220 @@ +/* ── Planner Layout ──────────────────────────────────────────────────────────── */ +.planner-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 16px; + align-items: start; +} + +/* ── Plan Sidebar ────────────────────────────────────────────────────────────── */ +.plan-sidebar { + background: var(--bgcard); + border: 1px solid var(--border); + border-radius: var(--rl); + padding: 18px; + position: sticky; + top: 74px; +} + +.plan-sidebar-header { + display: flex; + align-items: center; + margin-bottom: 14px; +} +.plan-sidebar-header .card-title { margin-bottom: 0; flex: 1; } + +.plan-new-form { + background: var(--bg2); + border: 1px solid var(--borderem); + border-radius: var(--r); + padding: 10px; + margin-bottom: 12px; +} +.plan-new-form input { + margin-bottom: 8px; + font-size: 14px; + padding: 6px 10px; +} +.plan-new-actions { + display: flex; + gap: 6px; +} + +.plan-list-empty { + font-size: 13px; + color: var(--t3); + text-align: center; + padding: 24px 0; +} + +/* ── Plan Item ───────────────────────────────────────────────────────────────── */ +.plan-item { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px; + border-radius: var(--r); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.12s; + margin-bottom: 4px; +} +.plan-item:hover { background: var(--bg2); border-color: var(--border); } +.plan-item.active { background: var(--bg2); border-color: var(--gold); } + +.plan-item-info { flex: 1; min-width: 0; } + +.plan-item-name { + font-size: 14px; + color: var(--t1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.plan-item.active .plan-item-name { color: var(--gold); } + +.plan-item-meta { + font-size: 12px; + color: var(--t3); + margin-top: 2px; +} + +.plan-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s; +} +.plan-item:hover .plan-item-actions { opacity: 1; } + +.plan-btn { + background: none; + border: 1px solid var(--borderem); + border-radius: var(--r); + color: var(--t2); + font-size: 13px; + padding: 2px 7px; + cursor: pointer; + transition: all 0.12s; + line-height: 1.6; +} +.plan-btn:hover { background: var(--bg3); color: var(--t1); } +.plan-btn.plan-btn-danger { color: var(--red); border-color: rgba(224,92,92,0.3); } +.plan-btn.plan-btn-danger:hover { background: var(--redbg); } + +/* ── Plan Detail ─────────────────────────────────────────────────────────────── */ +.plan-detail-header { + display: flex; + flex-direction: column; + gap: 5px; +} + +.plan-name-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.plan-name-text { + font-family: var(--font-d); + font-size: 18px; + color: var(--gold); + letter-spacing: 0.04em; +} + +.plan-name-input { + font-size: 18px !important; + font-family: var(--font-d) !important; + padding: 2px 8px !important; + width: auto !important; + min-width: 200px; + color: var(--gold) !important; +} + +.plan-detail-meta { + font-size: 13px; + color: var(--t3); +} + +/* ── Job Slots Placeholder ───────────────────────────────────────────────────── */ +.job-slots-placeholder { + padding: 20px; + text-align: center; + color: var(--t3); + font-size: 13px; + background: var(--bg2); + border: 1px dashed var(--border); + border-radius: var(--r); +} + +/* ── Mechanic Cards ──────────────────────────────────────────────────────────── */ +.mechanic-card { + display: grid; + grid-template-columns: 52px 1fr; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid var(--border); + align-items: start; +} +.mechanic-card:last-child { border-bottom: none; } + +.mechanic-time { + font-family: var(--font-d); + font-size: 14px; + color: var(--gold); + letter-spacing: 0.03em; + padding-top: 3px; +} + +.mechanic-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mechanic-phase { + font-size: 11px; + color: var(--blue); + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.mechanic-name { + font-size: 15px; + color: var(--t1); + font-weight: 500; +} + +.mechanic-dmg { + font-size: 13px; + color: var(--t2); +} + +.mechanic-assignments { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 2px; +} + +.mechanic-no-assign { + font-size: 12px; + color: var(--t3); +} + +.badge-assign { + background: var(--bg2); + border: 1px solid var(--borderem); + border-radius: var(--r); + padding: 2px 8px; + font-size: 12px; + color: var(--t2); +} + +.mechanic-notes { + font-size: 12px; + color: var(--t3); + font-style: italic; + margin-top: 2px; +} diff --git a/js/planner.js b/js/planner.js new file mode 100644 index 0000000..c7549e5 --- /dev/null +++ b/js/planner.js @@ -0,0 +1,333 @@ +// ── Storage ─────────────────────────────────────────────────────────────────── + +const PLANNER_KEY = 'ff14-planner-plans'; + +function loadPlans() { + try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } + catch { return []; } +} + +function savePlans(plans) { + localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); +} + +// ── CRUD ────────────────────────────────────────────────────────────────────── + +function createPlan(name) { + const plan = { + id: crypto.randomUUID(), + name: name.trim() || 'Unbenannter Plan', + createdAt: Date.now(), + updatedAt: Date.now(), + source: null, + jobComposition: Array(8).fill(''), + mechanics: [] + }; + const all = loadPlans(); + all.push(plan); + savePlans(all); + return plan; +} + +function getPlan(id) { + return loadPlans().find(p => p.id === id) ?? null; +} + +function updatePlan(id, changes) { + const all = loadPlans(); + const idx = all.findIndex(p => p.id === id); + if (idx === -1) return null; + all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() }; + savePlans(all); + return all[idx]; +} + +function deletePlan(id) { + savePlans(loadPlans().filter(p => p.id !== id)); +} + +function copyPlan(id) { + const all = loadPlans(); + const orig = all.find(p => p.id === id); + if (!orig) return null; + const copy = { + ...JSON.parse(JSON.stringify(orig)), + id: crypto.randomUUID(), + name: orig.name + ' (Kopie)', + createdAt: Date.now(), + updatedAt: Date.now() + }; + all.push(copy); + savePlans(all); + return copy; +} + +// ── State ───────────────────────────────────────────────────────────────────── + +let activePlanId = null; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function fmtDate(ts) { + return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function fmtTimestamp(ms) { + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function fmtNumber(n) { + return Number(n).toLocaleString('de-DE'); +} + +// ── Rendering: Plan List ────────────────────────────────────────────────────── + +function renderPlanList() { + const el = document.getElementById('plan-list'); + if (!el) return; + + const plans = loadPlans(); + + if (plans.length === 0) { + el.innerHTML = '
Noch keine Pläne vorhanden
'; + return; + } + + el.innerHTML = plans.map(p => ` +
+
+
${escHtml(p.name)}
+
${p.mechanics.length} Mechaniken · ${fmtDate(p.updatedAt)}
+
+
+ + +
+
+ `).join(''); + + el.querySelectorAll('.plan-item').forEach(row => { + row.addEventListener('click', e => { + if (e.target.closest('.plan-item-actions')) return; + openPlan(row.dataset.id); + }); + }); + + el.querySelectorAll('.plan-btn-copy').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const copy = copyPlan(btn.dataset.id); + if (copy) { renderPlanList(); openPlan(copy.id); } + }); + }); + + el.querySelectorAll('.plan-btn-delete').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const plan = getPlan(btn.dataset.id); + if (!plan) return; + if (!confirm(`Plan "${plan.name}" löschen?`)) return; + deletePlan(btn.dataset.id); + if (activePlanId === btn.dataset.id) { + activePlanId = null; + renderPlanDetail(null); + } + renderPlanList(); + }); + }); +} + +// ── Rendering: Plan Detail ──────────────────────────────────────────────────── + +function renderPlanDetail(plan) { + const noplan = document.getElementById('planner-no-plan'); + const content = document.getElementById('plan-content'); + if (!noplan || !content) return; + + if (!plan) { + noplan.style.display = ''; + content.style.display = 'none'; + return; + } + + noplan.style.display = 'none'; + content.style.display = ''; + + content.innerHTML = ` +
+
+
+ ${escHtml(plan.name)} + +
+
Erstellt ${fmtDate(plan.createdAt)} · ${plan.mechanics.length} Mechaniken
+
+
+ +
+
Jobaufstellung
+
+ Jobaufstellung wird in einem späteren Schritt konfigurierbar +
+
+ +
+
+
Mechaniken
+
+ ${renderMechanicList(plan)} +
+ `; + + document.getElementById('plan-name-edit-btn')?.addEventListener('click', () => { + startRename(plan.id, plan.name); + }); +} + +function renderMechanicList(plan) { + if (plan.mechanics.length === 0) { + return ` +
+
📋
+

Noch keine Mechaniken

+

+ Importiere einen Log aus dem Analyse-Tab +

+
+ `; + } + + return plan.mechanics.map(m => ` +
+
${escHtml(fmtTimestamp(m.timestamp))}
+
+ ${m.phase ? `
${escHtml(m.phase)}
` : ''} +
${escHtml(m.name)}
+ ${m.unmitigatedDamage + ? `
${fmtNumber(m.unmitigatedDamage)} unmitigiert
` + : '' + } +
+ ${m.assignments.length === 0 + ? 'Keine Zuweisung' + : m.assignments.map(a => + `${escHtml(a.job)} · ${escHtml(a.ability)}` + ).join('') + } +
+ ${m.notes ? `
${escHtml(m.notes)}
` : ''} +
+
+ `).join(''); +} + +// ── Rename ──────────────────────────────────────────────────────────────────── + +function startRename(id, currentName) { + const display = document.getElementById('plan-name-display'); + const editBtn = document.getElementById('plan-name-edit-btn'); + if (!display) return; + + display.innerHTML = ``; + const input = document.getElementById('plan-name-input'); + input.focus(); + input.select(); + if (editBtn) editBtn.style.display = 'none'; + + let saved = false; + const save = () => { + if (saved) return; + saved = true; + const newName = input.value.trim() || currentName; + updatePlan(id, { name: newName }); + renderPlanList(); + openPlan(id); + }; + + input.addEventListener('keydown', e => { + if (e.key === 'Enter') save(); + if (e.key === 'Escape') { + saved = true; + display.innerHTML = `${escHtml(currentName)}`; + if (editBtn) editBtn.style.display = ''; + } + }); + input.addEventListener('blur', save); +} + +// ── Open plan ───────────────────────────────────────────────────────────────── + +function openPlan(id) { + activePlanId = id; + renderPlanList(); + renderPlanDetail(getPlan(id)); +} + +// ── New plan form ───────────────────────────────────────────────────────────── + +function initNewPlanForm() { + const btn = document.getElementById('planner-new-btn'); + const form = document.getElementById('planner-new-form'); + const input = document.getElementById('planner-new-name'); + const save = document.getElementById('planner-new-save'); + const cancel = document.getElementById('planner-new-cancel'); + if (!btn) return; + + btn.addEventListener('click', () => { + form.style.display = ''; + input.value = ''; + input.focus(); + }); + + cancel.addEventListener('click', () => { form.style.display = 'none'; }); + + const doCreate = () => { + const name = input.value.trim(); + if (!name) { input.focus(); return; } + const plan = createPlan(name); + form.style.display = 'none'; + renderPlanList(); + openPlan(plan.id); + }; + + save.addEventListener('click', doCreate); + input.addEventListener('keydown', e => { + if (e.key === 'Enter') doCreate(); + if (e.key === 'Escape') form.style.display = 'none'; + }); +} + +// ── window.plannerTab (hooks for other tabs) ────────────────────────────────── + +window.plannerTab = { + onTabOpen() { + renderPlanList(); + if (activePlanId) { + openPlan(activePlanId); + } else { + renderPlanDetail(null); + } + }, + + importFromAnalysis(aoeEvents, _refEvents, _options) { + // Schritt 3 — noch nicht implementiert + console.log('[Planner] importFromAnalysis — not yet implemented', aoeEvents); + } +}; + +// ── Init ────────────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + initNewPlanForm(); + renderPlanList(); + renderPlanDetail(null); +}); diff --git a/js/tabs.js b/js/tabs.js index 0f889ce..1f0dd88 100644 --- a/js/tabs.js +++ b/js/tabs.js @@ -13,6 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { if (btn) btn.classList.add('active'); if (name === 'analysis') window.analysisTab?.onTabOpen?.(); + if (name === 'planner') window.plannerTab?.onTabOpen?.(); } tabs.forEach(btn => btn.addEventListener('click', () => showTab(btn.dataset.tab))); diff --git a/templates/page.php b/templates/page.php index 883cf8e..5210e42 100644 --- a/templates/page.php +++ b/templates/page.php @@ -8,6 +8,7 @@ + @@ -28,11 +29,16 @@ + + + diff --git a/templates/tab-planner.php b/templates/tab-planner.php new file mode 100644 index 0000000..98109ca --- /dev/null +++ b/templates/tab-planner.php @@ -0,0 +1,31 @@ +
+ + +
+
+
Pläne
+ +
+ + + +
+
+ + +
+
+
📋
+

Kein Plan ausgewählt

+

Erstelle einen neuen Plan oder wähle einen bestehenden aus

+
+ +
+ +
diff --git a/templates/topbar.php b/templates/topbar.php index b81cb22..0457f39 100644 --- a/templates/topbar.php +++ b/templates/topbar.php @@ -3,6 +3,7 @@
Token gültig bis: