Compare commits

..

4 Commits

Author SHA1 Message Date
Akurosia Kamo
c3f18b38f5 small fix, stay on playner page on refresh and translate planner 2026-05-22 12:35:05 +02:00
xziino
84064669d3 Planner: uniquePlanName beim Kopieren anwenden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:29:32 +02:00
xziino
be9d050036 Planner: Ordner-Struktur, Rechtsklick-Remove, Duplikat-Schutz
- Ordner CRUD (erstellen, umbenennen, löschen) in der Plan-Sidebar
- Pläne per 📁-Button in Ordner verschieben (Picker-Dropdown)
- Ordner auf-/zuklappbar mit Chevron-Animation
- Rechtsklick auf Ability-Badge entfernt die Zuweisung
- uniquePlanName(): Duplikat-Namen beim manuellen Erstellen blockieren
- Import-Modal: Name wird direkt mit eindeutigem Namen vorbefüllt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:25:44 +02:00
xziino
5010da4ddb Fix: auth_start_href() durch direkte Links auf auth/start.php ersetzen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 11:20:17 +02:00
6 changed files with 359 additions and 28 deletions

View File

@ -8,7 +8,7 @@ $state = bin2hex(random_bytes(16));
$_SESSION['pkce_verifier'] = $verifier; $_SESSION['pkce_verifier'] = $verifier;
$_SESSION['oauth_state'] = $state; $_SESSION['oauth_state'] = $state;
$_SESSION['oauth_return'] = safe_return_path($_GET['return'] ?? ($_SERVER['HTTP_REFERER'] ?? null)); $_SESSION['oauth_return'] = null;
$params = http_build_query([ $params = http_build_query([
'response_type' => 'code', 'response_type' => 'code',

View File

@ -416,3 +416,96 @@
.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-shield.ability-chip--active { background: rgba(74,158,255,.18); border-color: rgba(74,158,255,.6); color: var(--blue); }
.ability-chip--other-job { opacity: 0.45; } .ability-chip--other-job { opacity: 0.45; }
/* ── Folder Sidebar ──────────────────────────────────────────────────────────── */
.folder-section { margin-bottom: 2px; }
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border-radius: var(--r);
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.folder-row:hover { background: var(--bg2); }
.folder-chevron {
font-size: 9px;
color: var(--t3);
transition: transform 0.15s;
display: inline-block;
width: 10px;
flex-shrink: 0;
}
.folder-chevron.open { transform: rotate(90deg); }
.folder-name-text {
font-size: 13px;
color: var(--t1);
font-weight: 500;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-name-input {
font-size: 13px !important;
padding: 2px 6px !important;
flex: 1;
width: auto !important;
min-width: 80px;
}
.folder-count {
font-size: 11px;
color: var(--t3);
background: var(--bg3);
border-radius: 10px;
padding: 1px 6px;
flex-shrink: 0;
}
.folder-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s;
}
.folder-row:hover .folder-actions { opacity: 1; }
.folder-plans { padding-left: 10px; }
.folder-plans.collapsed { display: none; }
.folder-empty {
font-size: 12px;
color: var(--t3);
padding: 5px 10px;
font-style: italic;
}
/* ── Folder Picker Dropdown ──────────────────────────────────────────────────── */
.folder-picker {
background: var(--bgcard);
border: 1px solid var(--borderem);
border-radius: var(--r);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
min-width: 160px;
overflow: hidden;
}
.folder-picker-option {
padding: 7px 14px;
font-size: 13px;
color: var(--t2);
cursor: pointer;
transition: background 0.1s;
white-space: nowrap;
}
.folder-picker-option:hover { background: var(--bg2); color: var(--t1); }
.folder-picker-option.active { color: var(--gold); }

View File

@ -2,14 +2,33 @@
const PLANNER_KEY = 'ff14-planner-plans'; const PLANNER_KEY = 'ff14-planner-plans';
const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan'; const PLANNER_ACTIVE_KEY = 'ff14-planner-active-plan';
const FOLDERS_KEY = 'ff14-planner-folders';
function loadPlans() { function loadPlans() {
try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); } try { return JSON.parse(localStorage.getItem(PLANNER_KEY) || '[]'); }
catch { return []; } catch { return []; }
} }
function savePlans(plans) { localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); }
function savePlans(plans) { function loadFolders() {
localStorage.setItem(PLANNER_KEY, JSON.stringify(plans)); try { return JSON.parse(localStorage.getItem(FOLDERS_KEY) || '[]'); }
catch { return []; }
}
function saveFolders(f) { localStorage.setItem(FOLDERS_KEY, JSON.stringify(f)); }
function createFolder(name) {
const folder = { id: crypto.randomUUID(), name: name.trim() || 'Neuer Ordner', createdAt: Date.now(), updatedAt: Date.now() };
const all = loadFolders(); all.push(folder); saveFolders(all); return folder;
}
function updateFolder(id, changes) {
const all = loadFolders(), idx = all.findIndex(f => f.id === id);
if (idx === -1) return;
all[idx] = { ...all[idx], ...changes, updatedAt: Date.now() };
saveFolders(all);
}
function deleteFolder(id) {
saveFolders(loadFolders().filter(f => f.id !== id));
savePlans(loadPlans().map(p => p.folderId === id ? { ...p, folderId: null, updatedAt: Date.now() } : p));
} }
// ── CRUD ────────────────────────────────────────────────────────────────────── // ── CRUD ──────────────────────────────────────────────────────────────────────
@ -22,6 +41,7 @@ function createPlan(name) {
updatedAt: Date.now(), updatedAt: Date.now(),
source: null, source: null,
mitigationNames: {}, mitigationNames: {},
folderId: null,
jobComposition: Array(8).fill(''), jobComposition: Array(8).fill(''),
mechanics: [] mechanics: []
}; };
@ -48,6 +68,14 @@ function deletePlan(id) {
savePlans(loadPlans().filter(p => p.id !== id)); savePlans(loadPlans().filter(p => p.id !== id));
} }
function uniquePlanName(baseName) {
const names = new Set(loadPlans().map(p => p.name));
if (!names.has(baseName)) return baseName;
let i = 2;
while (names.has(`${baseName} ${i}`)) i++;
return `${baseName} ${i}`;
}
function copyPlan(id) { function copyPlan(id) {
const all = loadPlans(); const all = loadPlans();
const orig = all.find(p => p.id === id); const orig = all.find(p => p.id === id);
@ -55,7 +83,7 @@ function copyPlan(id) {
const copy = { const copy = {
...JSON.parse(JSON.stringify(orig)), ...JSON.parse(JSON.stringify(orig)),
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: orig.name + ' (Kopie)', name: uniquePlanName(orig.name + ' (Kopie)'),
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now() updatedAt: Date.now()
}; };
@ -66,7 +94,8 @@ function copyPlan(id) {
// ── State ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
let activePlanId = null; let activePlanId = null;
let collapsedFolders = new Set();
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@ -111,30 +140,68 @@ function sameMechanic(existing, incoming, source) {
// ── Rendering: Plan List ────────────────────────────────────────────────────── // ── Rendering: Plan List ──────────────────────────────────────────────────────
function renderPlanList() { function planItemHtml(p) {
const el = document.getElementById('plan-list'); return `
if (!el) return;
const plans = loadPlans();
if (plans.length === 0) {
el.innerHTML = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
return;
}
el.innerHTML = plans.map(p => `
<div class="plan-item${p.id === activePlanId ? ' active' : ''}" data-id="${escHtml(p.id)}"> <div class="plan-item${p.id === activePlanId ? ' active' : ''}" data-id="${escHtml(p.id)}">
<div class="plan-item-info"> <div class="plan-item-info">
<div class="plan-item-name">${escHtml(p.name)}</div> <div class="plan-item-name">${escHtml(p.name)}</div>
<div class="plan-item-meta">${p.mechanics.length} Mechaniken &middot; ${fmtDate(p.updatedAt)}</div> <div class="plan-item-meta">${p.mechanics.length} Mechaniken &middot; ${fmtDate(p.updatedAt)}</div>
</div> </div>
<div class="plan-item-actions"> <div class="plan-item-actions">
<button class="plan-btn plan-btn-move" data-id="${escHtml(p.id)}" title="In Ordner verschieben">📁</button>
<button class="plan-btn plan-btn-copy" data-id="${escHtml(p.id)}" title="Kopieren"></button> <button class="plan-btn plan-btn-copy" data-id="${escHtml(p.id)}" title="Kopieren"></button>
<button class="plan-btn plan-btn-danger plan-btn-delete" data-id="${escHtml(p.id)}" title="Löschen"></button> <button class="plan-btn plan-btn-danger plan-btn-delete" data-id="${escHtml(p.id)}" title="Löschen"></button>
</div> </div>
</div> </div>`;
`).join(''); }
function renderPlanList() {
const el = document.getElementById('plan-list');
if (!el) return;
const plans = loadPlans();
const folders = loadFolders();
if (plans.length === 0 && folders.length === 0) {
el.innerHTML = '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
return;
}
const byFolder = new Map();
const ungrouped = [];
for (const p of plans) {
const fid = p.folderId ?? null;
if (fid && folders.some(f => f.id === fid)) {
if (!byFolder.has(fid)) byFolder.set(fid, []);
byFolder.get(fid).push(p);
} else {
ungrouped.push(p);
}
}
let html = folders.map(folder => {
const folderPlans = byFolder.get(folder.id) ?? [];
const collapsed = collapsedFolders.has(folder.id);
return `
<div class="folder-section" data-folder-id="${escHtml(folder.id)}">
<div class="folder-row" data-folder-id="${escHtml(folder.id)}">
<span class="folder-chevron${collapsed ? '' : ' open'}"></span>
<span class="folder-name-text">${escHtml(folder.name)}</span>
<span class="folder-count">${folderPlans.length}</span>
<div class="folder-actions">
<button class="plan-btn folder-rename-btn" data-id="${escHtml(folder.id)}" title="Umbenennen"></button>
<button class="plan-btn plan-btn-danger folder-delete-btn" data-id="${escHtml(folder.id)}" title="Ordner löschen"></button>
</div>
</div>
<div class="folder-plans${collapsed ? ' collapsed' : ''}">
${folderPlans.map(p => planItemHtml(p)).join('') || '<div class="folder-empty">Leer</div>'}
</div>
</div>`;
}).join('') + ungrouped.map(p => planItemHtml(p)).join('');
el.innerHTML = html || '<div class="plan-list-empty">Noch keine Pläne vorhanden</div>';
// Plan item: open
el.querySelectorAll('.plan-item').forEach(row => { el.querySelectorAll('.plan-item').forEach(row => {
row.addEventListener('click', e => { row.addEventListener('click', e => {
if (e.target.closest('.plan-item-actions')) return; if (e.target.closest('.plan-item-actions')) return;
@ -142,6 +209,15 @@ function renderPlanList() {
}); });
}); });
// Plan item: move to folder
el.querySelectorAll('.plan-btn-move').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
showFolderPicker(btn.dataset.id, btn);
});
});
// Plan item: copy
el.querySelectorAll('.plan-btn-copy').forEach(btn => { el.querySelectorAll('.plan-btn-copy').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -150,6 +226,7 @@ function renderPlanList() {
}); });
}); });
// Plan item: delete
el.querySelectorAll('.plan-btn-delete').forEach(btn => { el.querySelectorAll('.plan-btn-delete').forEach(btn => {
btn.addEventListener('click', e => { btn.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
@ -157,10 +234,63 @@ function renderPlanList() {
if (!plan) return; if (!plan) return;
if (!confirm(`Plan "${plan.name}" löschen?`)) return; if (!confirm(`Plan "${plan.name}" löschen?`)) return;
deletePlan(btn.dataset.id); deletePlan(btn.dataset.id);
if (activePlanId === btn.dataset.id) { if (activePlanId === btn.dataset.id) { activePlanId = null; renderPlanDetail(null); }
activePlanId = null; renderPlanList();
renderPlanDetail(null); });
} });
// Folder row: toggle collapse
el.querySelectorAll('.folder-row').forEach(row => {
row.addEventListener('click', e => {
if (e.target.closest('.folder-actions')) return;
const fid = row.dataset.folderId;
if (collapsedFolders.has(fid)) collapsedFolders.delete(fid);
else collapsedFolders.add(fid);
renderPlanList();
});
});
// Folder: rename
el.querySelectorAll('.folder-rename-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const fid = btn.dataset.id;
const folder = loadFolders().find(f => f.id === fid);
if (!folder) return;
const span = el.querySelector(`.folder-row[data-folder-id="${fid}"] .folder-name-text`);
if (!span) return;
const input = document.createElement('input');
input.className = 'folder-name-input';
input.value = folder.name;
span.replaceWith(input);
input.focus(); input.select();
let saved = false;
const save = () => {
if (saved) return; saved = true;
const name = input.value.trim();
if (name) updateFolder(fid, { name });
renderPlanList();
};
input.addEventListener('blur', save);
input.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); save(); }
if (ev.key === 'Escape') { saved = true; renderPlanList(); }
});
});
});
// Folder: delete
el.querySelectorAll('.folder-delete-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const folder = loadFolders().find(f => f.id === btn.dataset.id);
if (!folder) return;
const count = (byFolder.get(btn.dataset.id) ?? []).length;
const msg = count > 0
? `Ordner "${folder.name}" löschen? Die ${count} enthaltenen Pläne werden nicht gelöscht.`
: `Ordner "${folder.name}" löschen?`;
if (!confirm(msg)) return;
deleteFolder(btn.dataset.id);
renderPlanList(); renderPlanList();
}); });
}); });
@ -360,6 +490,14 @@ function initMechanicClicks(planId) {
if (!card) return; if (!card) return;
showAbilityModal(planId, card.dataset.mechanicId); showAbilityModal(planId, card.dataset.mechanicId);
}); });
list.addEventListener('contextmenu', e => {
const badge = e.target.closest('.badge-assign');
if (!badge) return;
e.preventDefault();
e.stopPropagation();
const removeBtn = badge.querySelector('.badge-remove');
if (removeBtn) removeAssignment(planId, removeBtn.dataset.mechanicId, removeBtn.dataset.ability);
});
} }
// ── Rename ──────────────────────────────────────────────────────────────────── // ── Rename ────────────────────────────────────────────────────────────────────
@ -424,15 +562,105 @@ function initNewPlanForm() {
cancel.addEventListener('click', () => { form.style.display = 'none'; }); cancel.addEventListener('click', () => { form.style.display = 'none'; });
const clearErr = () => {
input.style.borderColor = '';
document.getElementById('planner-new-name-err')?.remove();
};
const doCreate = () => { const doCreate = () => {
const name = input.value.trim(); const name = input.value.trim();
if (!name) { input.focus(); return; } if (!name) { input.focus(); return; }
if (loadPlans().some(p => p.name === name)) {
input.style.borderColor = 'var(--red)';
let err = document.getElementById('planner-new-name-err');
if (!err) {
err = document.createElement('div');
err.id = 'planner-new-name-err';
err.style.cssText = 'font-size:11px;color:var(--red);margin-top:3px';
input.insertAdjacentElement('afterend', err);
}
err.textContent = 'Name bereits vergeben';
input.focus();
return;
}
clearErr();
const plan = createPlan(name); const plan = createPlan(name);
form.style.display = 'none'; form.style.display = 'none';
renderPlanList(); renderPlanList();
openPlan(plan.id); openPlan(plan.id);
}; };
input.addEventListener('input', clearErr);
save.addEventListener('click', doCreate);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') doCreate();
if (e.key === 'Escape') { clearErr(); form.style.display = 'none'; }
});
}
// ── Folder Picker ─────────────────────────────────────────────────────────────
let folderPickerCleanup = null;
function closeFolderPicker() {
document.getElementById('folder-picker')?.remove();
if (folderPickerCleanup) { folderPickerCleanup(); folderPickerCleanup = null; }
}
function showFolderPicker(planId, anchorEl) {
closeFolderPicker();
const plan = getPlan(planId);
const folders = loadFolders();
if (!plan) return;
if (folders.length === 0) return;
const rect = anchorEl.getBoundingClientRect();
const picker = document.createElement('div');
picker.id = 'folder-picker';
picker.className = 'folder-picker';
picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;left:${rect.left}px;z-index:9999`;
picker.innerHTML = [{ id: null, name: 'Kein Ordner' }, ...folders].map(f => `
<div class="folder-picker-option${(plan.folderId ?? null) === f.id ? ' active' : ''}" data-fid="${escHtml(f.id ?? '')}">
${f.id === null ? '— ' : '📁 '}${escHtml(f.name)}
</div>`).join('');
document.body.appendChild(picker);
picker.querySelectorAll('.folder-picker-option').forEach(opt => {
opt.addEventListener('click', e => {
e.stopPropagation();
updatePlan(planId, { folderId: opt.dataset.fid || null });
closeFolderPicker();
renderPlanList();
});
});
const onOutside = e => { if (!picker.contains(e.target)) closeFolderPicker(); };
setTimeout(() => document.addEventListener('click', onOutside), 0);
folderPickerCleanup = () => document.removeEventListener('click', onOutside);
}
// ── New Folder Form ───────────────────────────────────────────────────────────
function initNewFolderForm() {
const btn = document.getElementById('planner-new-folder-btn');
const form = document.getElementById('planner-new-folder-form');
const input = document.getElementById('planner-new-folder-name');
const save = document.getElementById('planner-new-folder-save');
const cancel = document.getElementById('planner-new-folder-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; }
createFolder(name);
form.style.display = 'none';
renderPlanList();
};
save.addEventListener('click', doCreate); save.addEventListener('click', doCreate);
input.addEventListener('keydown', e => { input.addEventListener('keydown', e => {
if (e.key === 'Enter') doCreate(); if (e.key === 'Enter') doCreate();
@ -718,7 +946,7 @@ function doImport(data, withMitigations, whereMode, mergeId, newName) {
const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() }; const source = { reportCode, fightId, fightName, fightStart, fightEnd, language: plannerLanguage() };
if (whereMode === 'new') { if (whereMode === 'new') {
const plan = createPlan(newName || fightName || 'Importierter Plan'); const plan = createPlan(uniquePlanName(newName || fightName || 'Importierter Plan'));
return updatePlan(plan.id, { return updatePlan(plan.id, {
mechanics, mechanics,
source, source,
@ -942,9 +1170,9 @@ let pendingImportData = null;
function showImportModal(data) { function showImportModal(data) {
pendingImportData = data; pendingImportData = data;
// Pre-fill name // Pre-fill name — auto-unique so the user sees immediately what name will be used
const nameInput = document.getElementById('import-plan-name'); const nameInput = document.getElementById('import-plan-name');
if (nameInput) { nameInput.value = data.fightName || ''; } if (nameInput) { nameInput.value = uniquePlanName(data.fightName || 'Importierter Plan'); }
// Populate merge dropdown // Populate merge dropdown
const planSelect = document.getElementById('import-plan-select'); const planSelect = document.getElementById('import-plan-select');
@ -1049,6 +1277,7 @@ window.plannerTab = {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initNewPlanForm(); initNewPlanForm();
initNewFolderForm();
initImportModal(); initImportModal();
initAbilityModal(); initAbilityModal();
activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY); activePlanId = localStorage.getItem(PLANNER_ACTIVE_KEY);

View File

@ -21,7 +21,7 @@
<?php endif; ?> <?php endif; ?>
</p> </p>
<a href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" class="btn btn-gold btn-login"> <a href="auth/start.php" class="btn btn-gold btn-login">
<?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?> <?= $tokenExpired ? 'Reconnect to FFLogs' : 'Connect to FFLogs' ?>
</a> </a>

View File

@ -23,7 +23,7 @@
</select> </select>
</div> </div>
<button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button> <button class="btn btn-gold" type="submit" style="align-self:flex-end">Fetch</button>
<a class="btn" href="<?= htmlspecialchars(auth_start_href(), ENT_QUOTES) ?>" style="align-self:flex-end;text-decoration:none">Reconnect</a> <a class="btn" href="auth/start.php" style="align-self:flex-end;text-decoration:none">Reconnect</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -4,9 +4,18 @@
<div class="plan-sidebar"> <div class="plan-sidebar">
<div class="plan-sidebar-header"> <div class="plan-sidebar-header">
<div class="card-title">Pläne</div> <div class="card-title">Pläne</div>
<button id="planner-new-folder-btn" class="btn btn-sm" title="Neuer Ordner">+ Ordner</button>
<button id="planner-new-btn" class="btn btn-sm btn-gold">+ Neu</button> <button id="planner-new-btn" class="btn btn-sm btn-gold">+ Neu</button>
</div> </div>
<div id="planner-new-folder-form" class="plan-new-form" style="display:none">
<input type="text" id="planner-new-folder-name" placeholder="Ordner-Name…">
<div class="plan-new-actions">
<button id="planner-new-folder-save" class="btn btn-sm btn-gold">Erstellen</button>
<button id="planner-new-folder-cancel" class="btn btn-sm">Abbrechen</button>
</div>
</div>
<div id="planner-new-form" class="plan-new-form" style="display:none"> <div id="planner-new-form" class="plan-new-form" style="display:none">
<input type="text" id="planner-new-name" placeholder="Plan-Name…"> <input type="text" id="planner-new-name" placeholder="Plan-Name…">
<div class="plan-new-actions"> <div class="plan-new-actions">