Planer: Meine-Spells-View + Lila Personal-Farbe + Sidebar-Layout + Gantt-Scrollbar

- js/planner.js:
  - Mechaniken-Card: View-Toggle 'Mechaniken' / 'Meine Spells'
  - renderMySpellsHtml(): Assignments pro Mechanik gruppiert (1 Zeile statt 1 Zeile/Ability)
  - initMySpells(): Toggle-Logik, Job-Dropdown persistent (localStorage), Kopieren-Button
  - Personal-Mits lila (myspells-type--personal / badge-assign-personal) in beiden Views
  - Legende: Personal-Eintrag ergänzt
- css/planner.css:
  - .view-toggle-btns / .view-toggle-btn: Toggle-Button-Styles
  - .myspells-*: Cheatsheet-Layout (Grid, Badges, Farbkodierung)
  - .badge-assign-personal: Lila für Personal-Mits in Mechaniken-Übersicht
  - .info-legend-dot--personal: Lila Dot in Legende
  - .planner-info-panel: border-bottom statt border-top (jetzt oben in Sidebar)
  - .timeline-scroll: dunkler Custom-Scrollbar (webkit + scrollbar-color)
- templates/tab-planner.php: Info-Panel über Pläne-Sektion verschoben
- templates/topbar.php: Planer-Icon 📋 -> ☰

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
xziino 2026-05-24 17:12:28 +02:00
parent 1a91d1af0e
commit fce55c5bb6
4 changed files with 272 additions and 16 deletions

View File

@ -320,9 +320,10 @@
font-size: 13px;
color: var(--t2);
}
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
.badge-assign-buff { background: rgba(200,168,75,.08); border-color: rgba(200,168,75,.4); color: var(--gold); }
.badge-assign-debuff { background: rgba(224,92,92,.08); border-color: rgba(224,92,92,.4); color: var(--red); }
.badge-assign-shield { background: rgba(74,158,255,.08); border-color: rgba(74,158,255,.4); color: var(--blue); }
.badge-assign-personal { background: rgba(177,112,255,.08); border-color: rgba(177,112,255,.4); color: #dbc7ff; }
.badge-assign--missing-job {
border-style: dashed;
@ -485,9 +486,10 @@
}
.ability-chip:hover { background: var(--bg3); color: var(--t1); }
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-buff.ability-chip--active { background: rgba(200,168,75,.18); border-color: rgba(200,168,75,.6); color: var(--gold); }
.ability-chip.badge-assign-debuff.ability-chip--active { background: rgba(224,92,92,.18); border-color: rgba(224,92,92,.6); color: var(--red); }
.ability-chip.badge-assign-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip.badge-assign-personal.ability-chip--active { background: rgba(177,112,255,.18); border-color: rgba(177,112,255,.6); color: #dbc7ff; }
.ability-chip--other-job { opacity: 0.45; }
@ -526,9 +528,9 @@
/* ── Info Panel ──────────────────────────────────────────────────────────────── */
.planner-info-panel {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.info-section { margin-bottom: 12px; }
@ -560,9 +562,10 @@
border-radius: 2px;
flex-shrink: 0;
}
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--buff { background: rgba(200,168,75,.8); }
.info-legend-dot--debuff { background: var(--red); }
.info-legend-dot--shield { background: var(--blue); }
.info-legend-dot--personal { background: rgba(177,112,255,.9); }
.info-legend-label {
font-size: 12px;
@ -754,6 +757,8 @@
overflow-y: hidden;
border: 1px solid var(--border);
background: var(--bg1);
scrollbar-color: var(--border) var(--bg1);
scrollbar-width: thin;
max-width: 100%;
width: 100%;
cursor: grab;
@ -764,6 +769,11 @@
cursor: grabbing;
}
.timeline-scroll::-webkit-scrollbar { height: 6px; }
.timeline-scroll::-webkit-scrollbar-track { background: var(--bg1); }
.timeline-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.timeline-scroll::-webkit-scrollbar-thumb:hover { background: var(--t3); }
.timeline-grid {
width: calc(180px + var(--timeline-width));
display: grid;
@ -1210,6 +1220,124 @@
font-size: 12px;
}
/* ── View Toggle ─────────────────────────────────────────────────────────── */
.view-toggle-btns {
display: flex;
gap: 4px;
}
.view-toggle-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--r);
background: transparent;
color: var(--t2);
font-size: 12px;
font-family: var(--font-b);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.view-toggle-btn:hover {
color: var(--t1);
border-color: var(--t3);
}
.view-toggle-btn.active {
background: var(--gold);
border-color: var(--gold);
color: #000;
font-weight: 600;
}
/* ── Meine Spells ────────────────────────────────────────────────────────── */
.myspells-controls {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.myspells-controls select {
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: var(--r);
background: var(--bg2);
color: var(--t1);
font-size: 13px;
cursor: pointer;
}
.myspells-row {
display: grid;
grid-template-columns: 48px 180px 1fr;
align-items: center;
gap: 10px;
padding: 7px 4px;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 13px;
}
.myspells-abilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.myspells-row:last-child {
border-bottom: none;
}
.myspells-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--gold);
font-size: 12px;
}
.myspells-mechanic {
color: var(--t2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.myspells-ability {
display: flex;
align-items: center;
gap: 5px;
font-weight: 500;
font-size: 12px;
color: var(--t1);
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--r);
padding: 3px 8px 3px 4px;
white-space: nowrap;
}
.myspells-ability.myspells-type--debuff { color: var(--orange); border-color: rgba(255,140,0,0.3); }
.myspells-ability.myspells-type--shield { color: var(--blue); border-color: rgba(88,166,255,0.3); }
.myspells-ability.myspells-type--personal { color: #dbc7ff; border-color: rgba(177,112,255,0.4); }
.myspells-icon {
width: 20px;
height: 20px;
object-fit: contain;
flex-shrink: 0;
}
.myspells-empty {
padding: 24px 0;
text-align: center;
color: var(--t3);
font-size: 13px;
}
@media (max-width: 980px) {
.planner-layout {
grid-template-columns: 1fr;

View File

@ -461,10 +461,26 @@ function renderPlanDetail(plan) {
<div class="card">
<div class="card-title-row">
<div class="card-title">Mechaniken</div>
<div class="view-toggle-btns">
<button class="view-toggle-btn active" data-view="mechanics">Mechaniken</button>
<button class="view-toggle-btn" data-view="myspells"> Meine Spells</button>
</div>
</div>
<div id="mechanic-list">
${renderMechanicListHtml(plan)}
</div>
<div id="myspells-panel" style="display:none">
<div class="myspells-controls">
<select id="myspells-job-select">
<option value=""> Job wählen </option>
${(plan.jobComposition ?? []).filter(Boolean).filter((j, i, a) => a.indexOf(j) === i).map(j =>
`<option value="${escHtml(j)}">${escHtml(j)}</option>`
).join('')}
</select>
<button id="myspells-copy-btn" class="btn btn-sm" title="Als Text kopieren"> Kopieren</button>
</div>
<div id="myspells-list"></div>
</div>
</div>
`;
@ -478,10 +494,118 @@ function renderPlanDetail(plan) {
initTimelineOptions(plan.id);
initTimeline(plan.id);
initMechanicClicks(plan.id);
initMySpells(plan.id);
renderInfoPanel(plan);
ensureActionMetaLoaded().then(() => refreshMechanicList(plan.id));
}
// ── Meine Spells ─────────────────────────────────────────────────────────────
function renderMySpellsHtml(plan, job) {
if (!job) return '<div class="myspells-empty">Job auswählen um Assignments zu sehen.</div>';
const mechanics = visiblePlanMechanics(plan);
const rows = [];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
rows.push({ mechanic, assignments: mine });
}
if (!rows.length) {
return `<div class="myspells-empty">Keine Assignments für ${escHtml(job)} in diesem Plan.</div>`;
}
return rows.map(({ mechanic, assignments }) => {
const time = escHtml(fmtTimestamp(mechanic.timestamp));
const mechName = escHtml(mechanic.name);
const abilities = assignments.map(a => {
const iconSrc = abilityIcon(a.ability);
const icon = iconSrc
? `<img src="${escHtml(iconSrc)}" class="myspells-icon" alt="">`
: '';
const name = escHtml(assignmentAbilityName(a, plan));
const isPersonal = TIMELINE_PERSONAL_ABILITIES.has(a.ability);
const typeClass = a.buffType === 'debuff' ? 'myspells-type--debuff'
: a.buffType === 'shield' ? 'myspells-type--shield'
: isPersonal ? 'myspells-type--personal'
: 'myspells-type--buff';
return `<span class="myspells-ability ${typeClass}">${icon}${name}</span>`;
}).join('');
return `
<div class="myspells-row">
<span class="myspells-time">${time}</span>
<span class="myspells-mechanic">${mechName}</span>
<div class="myspells-abilities">${abilities}</div>
</div>`;
}).join('');
}
function mySpellsPlainText(plan, job) {
if (!job) return '';
const mechanics = visiblePlanMechanics(plan);
const lines = [`Meine Spells — ${job} (${plan.name})`, '─'.repeat(36)];
for (const mechanic of mechanics) {
const mine = (mechanic.assignments ?? []).filter(a => a.job === job);
if (!mine.length) continue;
const time = fmtTimestamp(mechanic.timestamp);
const names = mine.map(a => assignmentAbilityName(a, plan)).join(', ');
lines.push(`${time.padEnd(6)} ${mechanic.name.padEnd(22)}${names}`);
}
return lines.join('\n');
}
function initMySpells(planId) {
const viewBtns = document.querySelectorAll('.view-toggle-btn');
const mechList = document.getElementById('mechanic-list');
const myPanel = document.getElementById('myspells-panel');
const jobSelect = document.getElementById('myspells-job-select');
const myList = document.getElementById('myspells-list');
const copyBtn = document.getElementById('myspells-copy-btn');
if (!mechList || !myPanel || !jobSelect || !myList) return;
viewBtns.forEach(btn => {
btn.addEventListener('click', () => {
viewBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const view = btn.dataset.view;
mechList.style.display = view === 'mechanics' ? '' : 'none';
myPanel.style.display = view === 'myspells' ? '' : 'none';
if (view === 'myspells') {
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value || '');
}
});
});
// Gespeicherten Job wiederherstellen
const savedJob = localStorage.getItem('ff14-planner-myspells-job') ?? '';
if (savedJob && [...jobSelect.options].some(o => o.value === savedJob)) {
jobSelect.value = savedJob;
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, savedJob);
}
jobSelect.addEventListener('change', () => {
localStorage.setItem('ff14-planner-myspells-job', jobSelect.value);
const plan = loadPlans().find(p => p.id === planId);
if (plan) myList.innerHTML = renderMySpellsHtml(plan, jobSelect.value);
});
copyBtn?.addEventListener('click', () => {
const plan = loadPlans().find(p => p.id === planId);
if (!plan) return;
const text = mySpellsPlainText(plan, jobSelect.value);
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '✓ Kopiert';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
}).catch(() => {
copyBtn.textContent = '✗ Fehler';
setTimeout(() => { copyBtn.innerHTML = '⎘ Kopieren'; }, 1800);
});
});
}
function avgNonTankMaxHp(plan) {
const roster = plan.playerRoster ?? [];
const jobComp = plan.jobComposition ?? [];
@ -585,8 +709,9 @@ function renderMechanicListHtml(plan) {
const assignHtml = sorted.length === 0
? '<span class="mechanic-no-assign">Keine Zuweisung</span>'
: sorted.map(a => {
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield'
const cls = a.buffType === 'debuff' ? 'badge-assign-debuff'
: a.buffType === 'shield' ? 'badge-assign-shield'
: TIMELINE_PERSONAL_ABILITIES.has(a.ability) ? 'badge-assign-personal'
: 'badge-assign-buff';
const isMissing = !!a.job && !activeJobSet.has(a.job);
const icon = abilityIcon(a.ability);
@ -1910,6 +2035,7 @@ function renderInfoPanel(plan) {
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--buff"></span><span class="info-legend-label">Mitigation</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--debuff"></span><span class="info-legend-label">Debuff</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--shield"></span><span class="info-legend-label">Schild</span></div>
<div class="info-legend-row"><span class="info-legend-dot info-legend-dot--personal"></span><span class="info-legend-label">Personal</span></div>
</div>
</div>`;

View File

@ -2,6 +2,9 @@
<!-- Left: Plan list sidebar -->
<div class="plan-sidebar">
<div id="planner-info-panel" class="planner-info-panel"></div>
<div class="plan-sidebar-header">
<div class="card-title">Pläne</div>
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
@ -26,7 +29,6 @@
<div id="plan-list"></div>
<div id="planner-info-panel" class="planner-info-panel"></div>
</div>
<!-- Right: Plan detail -->

View File

@ -3,7 +3,7 @@
<nav class="tabs">
<button class="tab active" data-tab="report"> Report</button>
<button class="tab" data-tab="analysis"> Analyse</button>
<button class="tab" data-tab="planner">📋 Planer</button>
<button class="tab" data-tab="planner"> Planer</button>
</nav>
<div class="topbar-user">Token gültig bis: <?= date('Y-m-d H:i:s', $_SESSION['token_expires']) ?></div>
</header>