import { ResourceTracker } from '../core/resource-tracker.js'; import { el, toast } from '../core/dom.js'; import { t as i18nT } from '../core/i18n.js'; import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; const PAGE = 'attacks'; let tracker = null; let root = null; let currentAttack = null; let selectedSection = null; let selectedImageScope = null; let selectedActionName = null; let selectedImages = new Set(); let editMode = false; let imageCache = []; let imageResolver = null; let sortKey = 'name'; let sortDir = 1; const iconCache = new Map(); let disposeSidebarLayout = null; function q(sel, base = root) { return base?.querySelector(sel) || null; } function qa(sel, base = root) { return Array.from(base?.querySelectorAll(sel) || []); } function note(msg, ms = 2200, type = 'info') { toast(String(msg ?? ''), ms, type); } function L(key, vars) { return i18nT(key, vars); } function Lx(key, fallback, vars) { const out = i18nT(key, vars); return out && out !== key ? out : fallback; } function markup() { return `
${L('attacks.sidebar.management')}

${L('attacks.editor.selectAttack')}

${L('attacks.tabs.comments')}

${L('attacks.images.enterEditMode')}
${Lx('attacks.images.density', 'Density')}
S
`; } async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); } async function postJSON(url, body = {}) { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return r.json(); } async function iconFor(name) { if (iconCache.has(name)) return iconCache.get(name); for (const url of [`/actions_icons/${encodeURIComponent(name)}.png`, `/get_status_icon?action=${encodeURIComponent(name)}`]) { try { const r = await fetch(url); if (!r.ok) continue; const b = await r.blob(); const obj = URL.createObjectURL(b); iconCache.set(name, obj); return obj; } catch { } } return '/web/images/attack.png'; } function iconCandidateURLs(actionName) { return [ `/actions_icons/${encodeURIComponent(actionName)}.png`, `/actions_icons/${encodeURIComponent(actionName)}.bmp`, `/get_status_icon?action=${encodeURIComponent(actionName)}`, ]; } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function makePlaceholderIconBlob(actionName) { const size = 128; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#0b0e13'; ctx.fillRect(0, 0, size, size); ctx.lineWidth = 8; ctx.strokeStyle = '#59b6ff'; ctx.beginPath(); ctx.arc(size / 2, size / 2, size / 2 - 8, 0, Math.PI * 2); ctx.stroke(); const initials = (actionName || 'A') .split(/[^A-Za-z0-9]+/) .filter(Boolean) .slice(0, 2) .map((x) => x[0]) .join('') .toUpperCase() || 'A'; ctx.fillStyle = '#59b6ff'; ctx.font = 'bold 56px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(initials, size / 2, size / 2 + 4); return new Promise((resolve) => canvas.toBlob((b) => resolve(b || new Blob([], { type: 'image/png' })), 'image/png')); } async function fetchActionIconBlob(actionName) { for (const url of iconCandidateURLs(actionName)) { try { const r = await fetch(url, { cache: 'no-cache' }); if (r.ok) return await r.blob(); } catch { } } try { const r = await fetch('/web/images/attack.png', { cache: 'no-cache' }); if (r.ok) return await r.blob(); } catch { } return makePlaceholderIconBlob(actionName); } async function hasStatusImage(actionName) { const p = `/images/status/${encodeURIComponent(actionName)}/${encodeURIComponent(actionName)}.bmp`; try { const r = await fetch(p, { cache: 'no-cache' }); return r.ok; } catch { return false; } } async function actionHasCharacterImages(actionName) { try { const data = await getJSON('/get_action_images?action=' + encodeURIComponent(actionName)); const imgs = data?.images || []; if (!Array.isArray(imgs)) return false; const rx = new RegExp(`^${escapeRegExp(actionName)}\\d+\\.(bmp|png|jpe?g|gif|webp)$`, 'i'); return imgs.some((im) => { const n = typeof im === 'string' ? im : (im.name || im.filename || ''); return rx.test(String(n)); }); } catch { return false; } } async function ensureStatusImageFromIcon(actionName) { if (await hasStatusImage(actionName)) return false; const blob = await fetchActionIconBlob(actionName); const fd = new FormData(); fd.append('type', 'action'); fd.append('action_name', actionName); fd.append('status_image', new File([blob], `${actionName}.bmp`, { type: 'image/bmp' })); const r = await fetch('/upload_status_image', { method: 'POST', body: fd }); const d = await r.json(); if (d.status !== 'success') throw new Error(d.message || 'upload_status_image failed'); return true; } async function ensureAtLeastOneCharacterImageFromIcon(actionName) { if (await actionHasCharacterImages(actionName)) return false; const blob = await fetchActionIconBlob(actionName); const fd = new FormData(); fd.append('action_name', actionName); fd.append('character_images', new File([blob], `${actionName}1.png`, { type: blob.type || 'image/png' })); const r = await fetch('/upload_character_images', { method: 'POST', body: fd }); const d = await r.json(); if (d.status !== 'success') throw new Error(d.message || 'upload_character_images failed'); return true; } async function ensureCommentsSection(sectionName, sectionsSet) { if (sectionsSet.has(sectionName)) return false; await postJSON('/save_comments', { section: sectionName, comments: [Lx('attacks.sync.defaultComment', 'Add comment for this action')], }); sectionsSet.add(sectionName); return true; } async function syncMissing() { try { const attacksResp = await getJSON('/get_attacks'); const attacks = Array.isArray(attacksResp) ? attacksResp : (Array.isArray(attacksResp?.attacks) ? attacksResp.attacks : []); const names = attacks.map((a) => a?.name || a?.id).filter(Boolean); if (!names.length) { note(Lx('attacks.sync.none', 'No attacks to sync.'), 2200, 'warning'); return; } const sectionsResp = await getJSON('/get_sections'); const sectionsSet = new Set((sectionsResp?.sections || []).map((x) => String(x))); let createdComments = 0; let createdStatus = 0; let createdChars = 0; for (const name of names) { if (await ensureCommentsSection(name, sectionsSet)) createdComments++; if (await ensureStatusImageFromIcon(name)) createdStatus++; if (await ensureAtLeastOneCharacterImageFromIcon(name)) createdChars++; } note( Lx( 'attacks.sync.done', `Sync done. New comments: ${createdComments}, status images: ${createdStatus}, character images: ${createdChars}.`, { comments: createdComments, status: createdStatus, characters: createdChars }, ), 4200, 'success', ); await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]); if (selectedImageScope) await refreshScope(); } catch (e) { note(`${Lx('attacks.sync.failed', 'Sync Missing failed')}: ${e.message}`, 3200, 'error'); } } async function loadAttacks() { const list = q('#attacks-list'); const hint = q('#empty-attacks-hint'); if (!list || !hint) return; list.innerHTML = ''; try { const data = await getJSON('/get_attacks'); const attacks = (Array.isArray(data) ? data : (data.attacks || [])) .map((a) => ({ name: a.name || a.id || L('common.unknown'), enabled: Number(a.enabled ?? a.b_enabled ?? 0) })) .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); hint.style.display = attacks.length ? 'none' : 'block'; for (const a of attacks) { const li = document.createElement('li'); li.className = 'card'; li.dataset.attackName = a.name; const img = document.createElement('img'); iconFor(a.name).then((u) => { img.src = u; }); const span = document.createElement('span'); span.textContent = a.name; const dot = document.createElement('button'); dot.className = 'enable-dot' + (a.enabled ? ' on' : ''); dot.type = 'button'; tracker.trackEventListener(dot, 'click', async (e) => { e.stopPropagation(); const target = !dot.classList.contains('on'); dot.classList.toggle('on', target); const d = await postJSON('/actions/set_enabled', { action_name: a.name, enabled: target ? 1 : 0 }); if (d.status !== 'success') dot.classList.toggle('on', !target); }); tracker.trackEventListener(li, 'click', () => selectAttack(a.name, li)); li.append(img, span, dot); list.appendChild(li); } } catch { hint.style.display = 'block'; hint.textContent = L('attacks.errors.loadAttacks'); } } async function selectAttack(name, node) { qa('#attacks-list .card').forEach((n) => n.classList.remove('selected')); node?.classList.add('selected'); currentAttack = name; q('#editor-title').textContent = name; const ta = q('#editor-textarea'); ta.disabled = false; const d = await getJSON('/get_attack_content?name=' + encodeURIComponent(name)); ta.value = d?.status === 'success' ? (d.content ?? '') : ''; } function imageSort(list) { const cmpName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) * sortDir; const area = (x) => (x.width || 0) * (x.height || 0); return [...list].sort(sortKey === 'name' ? cmpName : ((a, b) => ((area(a) - area(b)) * sortDir || cmpName(a, b)))); } function syncImageModeClasses() { if (!root) return; root.classList.toggle('edit-mode', !!editMode); root.classList.remove('status-mode', 'static-mode', 'web-mode', 'icons-mode'); if (selectedImageScope === 'action') root.classList.add('status-mode'); if (selectedImageScope === 'static') root.classList.add('static-mode'); if (selectedImageScope === 'web') root.classList.add('web-mode'); if (selectedImageScope === 'icons') root.classList.add('icons-mode'); } function renderImages(items, resolver) { imageCache = items.map((im) => ({ name: typeof im === 'string' ? im : (im.name || im.filename || ''), width: im.width, height: im.height })); imageResolver = resolver; const grid = q('#image-container'); const search = (q('#search-input')?.value || '').toLowerCase().trim(); grid.innerHTML = ''; imageSort(imageCache).filter((x) => !search || x.name.toLowerCase().includes(search)).forEach((im) => { const tile = document.createElement('div'); tile.className = 'image-item'; tile.classList.toggle('selectable', !!editMode); tile.dataset.imageName = im.name; const img = document.createElement('img'); img.src = resolver(im.name); const info = document.createElement('div'); info.className = 'image-info'; info.textContent = im.width && im.height ? `${im.name} (${im.width}x${im.height})` : im.name; const ring = document.createElement('div'); ring.className = 'select-ring'; const tick = document.createElement('div'); tick.className = 'tick-overlay'; tick.textContent = 'OK'; tracker.trackEventListener(tile, 'click', () => { if (!editMode) return; tile.classList.toggle('selected'); if (tile.classList.contains('selected')) selectedImages.add(im.name); else selectedImages.delete(im.name); }); tile.append(img, info, ring, tick); grid.appendChild(tile); }); } async function loadSections() { const ul = q('#section-list'); const hint = q('#empty-comments-hint'); ul.innerHTML = ''; try { const d = await getJSON('/get_sections'); const sections = (d.sections || []).slice().sort((a, b) => String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true })); hint.style.display = sections.length ? 'none' : 'block'; for (const name of sections) { const li = document.createElement('li'); li.className = 'card'; li.dataset.section = name; const img = document.createElement('img'); iconFor(name).then((u) => { img.src = u; }); const span = document.createElement('span'); span.textContent = name; tracker.trackEventListener(li, 'click', async () => { qa('#section-list .card').forEach((n) => n.classList.remove('selected')); li.classList.add('selected'); selectedSection = name; q('#delete-section-btn').disabled = false; q('#section-title').textContent = `${L('attacks.tabs.comments')} - ${name}`; const c = await getJSON('/get_comments?section=' + encodeURIComponent(name)); const ce = q('#comments-editor'); ce.classList.remove('placeholder'); ce.innerHTML = ''; (c.comments || []).forEach((line) => { const div = document.createElement('div'); div.className = 'comment-line'; div.textContent = line || '\u200b'; ce.appendChild(div); }); }); li.append(img, span); ul.appendChild(li); } } catch { hint.style.display = 'block'; } } function addScopeCard(parent, type, name, imgSrc, onClick) { const li = document.createElement('li'); li.className = 'card'; li.dataset.type = type; li.dataset.name = name; const img = document.createElement('img'); img.src = imgSrc; const span = document.createElement('span'); span.textContent = name; tracker.trackEventListener(li, 'click', async () => { selectScope(type, name); await onClick(); }); li.append(img, span); parent.appendChild(li); } async function loadImageScopes() { const actionList = q('#action-list'); actionList.innerHTML = ''; const staticList = q('#library-list'); staticList.innerHTML = ''; const webList = q('#web-images-list'); webList.innerHTML = ''; const iconList = q('#actions-icons-list'); iconList.innerHTML = ''; try { const actions = await getJSON('/get_actions'); (actions.actions || []).forEach((a) => { const li = document.createElement('li'); li.className = 'card'; li.dataset.type = 'action'; li.dataset.name = a.name; const img = document.createElement('img'); iconFor(a.name).then((u) => { img.src = u; }); const span = document.createElement('span'); span.textContent = a.name; tracker.trackEventListener(li, 'click', async () => { selectScope('action', a.name); const d = await getJSON('/get_action_images?action=' + encodeURIComponent(a.name)); if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(a.name)}/${encodeURIComponent(n)}`); }); li.append(img, span); actionList.appendChild(li); }); addScopeCard(staticList, 'static', L('attacks.section.staticImages'), '/web/images/static_icon.png', async () => { const d = await getJSON('/list_static_images_with_dimensions'); if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n)); }); addScopeCard(webList, 'web', L('attacks.section.webImages'), '/web/images/icon-192x192.png', async () => { const d = await getJSON('/list_web_images'); if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n)); }); addScopeCard(iconList, 'icons', L('attacks.section.actionIcons'), '/web/images/attack.png', async () => { const d = await getJSON('/list_actions_icons'); if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n)); }); } catch { note(L('attacks.errors.loadImages'), 2600, 'error'); } } function selectScope(type, name) { qa('#action-list .card, #library-list .card, #web-images-list .card, #actions-icons-list .card').forEach((n) => n.classList.remove('selected')); qa(`[data-type="${type}"][data-name="${name}"]`).forEach((n) => n.classList.add('selected')); selectedImageScope = type; selectedActionName = type === 'action' ? name : null; selectedImages.clear(); syncImageModeClasses(); } async function refreshScope() { if (selectedImageScope === 'action' && selectedActionName) { const d = await getJSON('/get_action_images?action=' + encodeURIComponent(selectedActionName)); if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(selectedActionName)}/${encodeURIComponent(n)}`); } else if (selectedImageScope === 'static') { const d = await getJSON('/list_static_images_with_dimensions'); if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n)); } else if (selectedImageScope === 'web') { const d = await getJSON('/list_web_images'); if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n)); } else if (selectedImageScope === 'icons') { const d = await getJSON('/list_actions_icons'); if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n)); } } async function loadCharacters() { const ul = q('#character-list'); if (!ul) return; ul.innerHTML = ''; const d = await getJSON('/list_characters'); const current = d.current_character; (d.characters || []).forEach((c) => { const li = document.createElement('li'); li.className = 'card'; li.dataset.name = c.name; const img = document.createElement('img'); img.src = '/get_character_icon?character=' + encodeURIComponent(c.name) + '&t=' + Date.now(); img.onerror = () => { img.src = '/web/images/default_character_icon.png'; }; const span = document.createElement('span'); span.textContent = c.name; if (c.name === current) { const ck = document.createElement('span'); ck.textContent = L('common.yes'); li.appendChild(ck); } tracker.trackEventListener(li, 'click', async () => { if (!confirm(L('attacks.confirm.switchCharacter', { name: c.name }))) return; const r = await postJSON('/switch_character', { character_name: c.name }); if (r.status === 'success') { note(L('attacks.toast.characterSwitched'), 1800, 'success'); loadCharacters(); } }); li.append(img, span); ul.appendChild(li); }); } function setPage(page) { qa('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.page === page)); qa('.sidebar-page').forEach((s) => { s.style.display = 'none'; }); qa('.page-content').forEach((p) => p.classList.remove('active')); const sidebar = q(`#${page}-sidebar`); if (sidebar) sidebar.style.display = 'block'; q(`#${page}-page`)?.classList.add('active'); } function bindTabs() { qa('.tab-btn').forEach((btn) => tracker.trackEventListener(btn, 'click', async () => { const page = btn.dataset.page; setPage(page); if (page === 'attacks') await loadAttacks(); if (page === 'comments') await loadSections(); if (page === 'images') await Promise.all([loadImageScopes(), loadCharacters()]); })); } function bindActions() { tracker.trackEventListener(q('#add-attack-btn'), 'click', async () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.py'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('attack_file', f); const r = await fetch('/add_attack', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') { note(L('attacks.toast.attackImported'), 1800, 'success'); await loadAttacks(); await syncMissing(); } }; inp.click(); }); tracker.trackEventListener(q('#remove-attack-btn'), 'click', async () => { if (!currentAttack) return; if (!confirm(L('attacks.confirm.removeAttack', { name: currentAttack }))) return; const d = await postJSON('/remove_attack', { name: currentAttack }); if (d.status === 'success') { currentAttack = null; q('#editor-textarea').value = ''; q('#editor-textarea').disabled = true; q('#editor-title').textContent = L('attacks.editor.selectAttack'); loadAttacks(); } }); tracker.trackEventListener(q('#delete-action-btn'), 'click', async () => { const actionName = currentAttack || selectedActionName; if (!actionName) return note(L('attacks.toast.selectAttackFirst'), 1800, 'warning'); if (!confirm(L('attacks.confirm.deleteAction', { name: actionName }))) return; const d = await postJSON('/action/delete', { action_name: actionName }); if (d.status === 'success') { if (currentAttack === actionName) { currentAttack = null; q('#editor-textarea').value = ''; q('#editor-textarea').disabled = true; q('#editor-title').textContent = L('attacks.editor.selectAttack'); } note(L('attacks.toast.actionDeleted'), 1800, 'success'); await Promise.all([loadAttacks(), loadImageScopes()]); } else { note(d.message || L('common.error'), 2200, 'error'); } }); tracker.trackEventListener(q('#restore-default-actions-btn'), 'click', async () => { if (!confirm(L('attacks.confirm.restoreDefaultsBundle'))) return; const d = await postJSON('/actions/restore_defaults', {}); if (d.status === 'success') { note(L('attacks.toast.defaultsRestored'), 2000, 'success'); currentAttack = null; selectedImageScope = null; selectedActionName = null; selectedImages.clear(); syncImageModeClasses(); await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]); } else { note(d.message || L('common.error'), 2200, 'error'); } }); tracker.trackEventListener(q('#sync-missing-btn'), 'click', async () => { await syncMissing(); }); tracker.trackEventListener(q('#save-attack-btn'), 'click', async () => { if (!currentAttack) return; const d = await postJSON('/save_attack', { name: currentAttack, content: q('#editor-textarea').value }); if (d.status === 'success') note(L('common.saved'), 1500, 'success'); }); tracker.trackEventListener(q('#restore-attack-btn'), 'click', async () => { if (!currentAttack) return; if (!confirm(L('attacks.confirm.restoreAttack', { name: currentAttack }))) return; const d = await postJSON('/restore_attack', { name: currentAttack }); if (d.status === 'success') selectAttack(currentAttack, q(`#attacks-list .card[data-attack-name="${currentAttack}"]`)); }); tracker.trackEventListener(q('#create-character-btn'), 'click', async () => { const name = prompt(L('attacks.prompt.newCharacterName')); if (!name) return; const d = await postJSON('/create_character', { character_name: name }); if (d.status === 'success') { note(L('attacks.toast.characterCreated'), 1800, 'success'); loadCharacters(); } }); tracker.trackEventListener(q('#delete-character-btn'), 'click', async () => { const d = await getJSON('/list_characters'); const deletable = (d.characters || []).filter((x) => x.name !== 'BJORN').map((x) => x.name); if (!deletable.length) return note(L('attacks.toast.noDeletableCharacters'), 1800, 'warning'); const name = prompt(L('attacks.prompt.characterToDelete') + '\n' + deletable.join('\n')); if (!name || !deletable.includes(name)) return; if (!confirm(L('attacks.confirm.deleteCharacter', { name }))) return; const r = await postJSON('/delete_character', { character_name: name }); if (r.status === 'success') { note(L('attacks.toast.characterDeleted'), 1800, 'success'); loadCharacters(); } }); tracker.trackEventListener(q('#add-section-btn'), 'click', async () => { const name = prompt(L('attacks.prompt.newSectionName')); if (!name) return; const d = await postJSON('/save_comments', { section: name, comments: [] }); if (d.status === 'success') loadSections(); }); tracker.trackEventListener(q('#delete-section-btn'), 'click', async () => { if (!selectedSection) return; if (!confirm(L('attacks.confirm.deleteSection', { name: selectedSection }))) return; const d = await postJSON('/delete_comment_section', { section: selectedSection }); if (d.status === 'success') { selectedSection = null; q('#comments-editor').innerHTML = ''; q('#section-title').textContent = L('attacks.tabs.comments'); loadSections(); } }); tracker.trackEventListener(q('#restore-default-btn'), 'click', async () => { if (!confirm(L('attacks.confirm.restoreDefaultComments'))) return; const r = await fetch('/restore_default_comments', { method: 'POST' }); const d = await r.json(); if (d.status === 'success') { note(L('attacks.toast.commentsRestored'), 1800, 'success'); loadSections(); } }); tracker.trackEventListener(q('#save-comments-btn'), 'click', async () => { if (!selectedSection) return note(L('attacks.toast.selectSectionFirst'), 1800, 'warning'); const lines = qa('.comment-line', q('#comments-editor')).map((x) => x.textContent?.trim()).filter(Boolean); const d = await postJSON('/save_comments', { section: selectedSection, comments: lines }); if (d.status === 'success') note(L('attacks.toast.commentsSaved'), 1600, 'success'); }); tracker.trackEventListener(q('#select-all-btn'), 'click', () => { const ce = q('#comments-editor'); if (!ce) return; ce.focus(); const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); range.selectNodeContents(ce); sel.removeAllRanges(); sel.addRange(range); }); tracker.trackEventListener(q('#search-input'), 'input', () => renderImages(imageCache, imageResolver || (() => ''))); tracker.trackEventListener(q('#sort-key'), 'change', (e) => { sortKey = e.target.value; renderImages(imageCache, imageResolver || (() => '')); }); tracker.trackEventListener(q('#sort-dir'), 'click', (e) => { sortDir *= -1; e.target.textContent = sortDir === 1 ? '^' : 'v'; renderImages(imageCache, imageResolver || (() => '')); }); tracker.trackEventListener(q('#density'), 'input', (e) => { const px = Number(e.target.value) || 160; root?.style.setProperty('--tile-min', `${px}px`); try { localStorage.setItem('attacks.tileMin', String(px)); } catch { } }); tracker.trackEventListener(q('#edit-mode-toggle-btn'), 'click', () => { editMode = !editMode; syncImageModeClasses(); q('#edit-mode-toggle-btn').textContent = editMode ? L('attacks.images.exitEditMode') : L('attacks.images.enterEditMode'); if (!editMode) { selectedImages.clear(); qa('.image-item.selected').forEach((x) => x.classList.remove('selected')); } renderImages(imageCache, imageResolver || (() => '')); }); tracker.trackEventListener(q('#rename-image-btn'), 'click', async () => { if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning'); const oldName = Array.from(selectedImages)[0]; const newName = prompt(L('attacks.prompt.newImageName'), oldName); if (!newName || newName === oldName) return; const type = selectedImageScope === 'action' ? 'image' : selectedImageScope; const d = await postJSON('/rename_image', { type, action: selectedActionName, old_name: oldName, new_name: newName }); if (d.status === 'success') { selectedImages.clear(); refreshScope(); } }); tracker.trackEventListener(q('#replace-image-btn'), 'click', async () => { if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning'); const oldName = Array.from(selectedImages)[0]; const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('type', selectedImageScope); fd.append('image_name', oldName); if (selectedImageScope === 'action') fd.append('action', selectedActionName); fd.append('new_image', f); const r = await fetch('/replace_image', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') { selectedImages.clear(); refreshScope(); } }; inp.click(); }); tracker.trackEventListener(q('#resize-images-btn'), 'click', async () => { if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning'); const w = Number(prompt(L('attacks.prompt.resizeWidth'), '100')); const h = Number(prompt(L('attacks.prompt.resizeHeight'), '100')); if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return; const payload = { type: selectedImageScope, action: selectedActionName, image_names: Array.from(selectedImages), width: Math.round(w), height: Math.round(h), }; const d = await postJSON('/resize_images', payload); if (d.status === 'success') { note(L('attacks.toast.imagesResized'), 1800, 'success'); selectedImages.clear(); await refreshScope(); } else { note(d.message || L('common.error'), 2200, 'error'); } }); tracker.trackEventListener(q('#add-characters-btn'), 'click', async () => { if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning'); const inp = document.createElement('input'); inp.type = 'file'; inp.multiple = true; inp.accept = '.bmp,.jpg,.jpeg,.png'; inp.onchange = async () => { const files = Array.from(inp.files || []); if (!files.length) return; const fd = new FormData(); fd.append('action_name', selectedActionName); files.forEach((f) => fd.append('character_images', f)); const r = await fetch('/upload_character_images', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') { note(L('attacks.toast.characterImagesUploaded'), 1800, 'success'); await refreshScope(); } else { note(d.message || L('common.error'), 2200, 'error'); } }; inp.click(); }); tracker.trackEventListener(q('#delete-images-btn'), 'click', async () => { if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning'); if (!confirm(L('attacks.confirm.deleteSelectedImages'))) return; const d = await postJSON('/delete_images', { type: selectedImageScope, action: selectedActionName, image_names: Array.from(selectedImages) }); if (d.status === 'success') { selectedImages.clear(); refreshScope(); } }); tracker.trackEventListener(q('#add-status-image-btn'), 'click', async () => { if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning'); const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('type', 'action'); fd.append('action_name', selectedActionName); fd.append('status_image', f); const r = await fetch('/upload_status_image', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') refreshScope(); }; inp.click(); }); tracker.trackEventListener(q('#add-static-image-btn'), 'click', async () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('static_image', f); const r = await fetch('/upload_static_image', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') refreshScope(); }; inp.click(); }); tracker.trackEventListener(q('#add-web-image-btn'), 'click', async () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('web_image', f); const r = await fetch('/upload_web_image', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') refreshScope(); }; inp.click(); }); tracker.trackEventListener(q('#add-icon-image-btn'), 'click', async () => { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; inp.onchange = async () => { const f = inp.files?.[0]; if (!f) return; const fd = new FormData(); fd.append('icon_image', f); const r = await fetch('/upload_actions_icon', { method: 'POST', body: fd }); const d = await r.json(); if (d.status === 'success') refreshScope(); }; inp.click(); }); } export async function mount(container) { tracker = new ResourceTracker(PAGE); root = el('div', { class: 'attacks-container page-with-sidebar' }); root.innerHTML = markup(); container.appendChild(root); q('.attacks-sidebar')?.classList.add('page-sidebar'); q('.attacks-main')?.classList.add('page-main'); disposeSidebarLayout = initSharedSidebarLayout(root, { sidebarSelector: '.attacks-sidebar', mainSelector: '.attacks-main', storageKey: 'sidebar:attacks', mobileBreakpoint: 900, toggleLabel: Lx('common.menu', 'Menu'), mobileDefaultOpen: true, }); bindTabs(); bindActions(); syncImageModeClasses(); const density = q('#density'); if (density) { let tile = Number(density.value) || 160; try { const saved = Number(localStorage.getItem('attacks.tileMin')); if (Number.isFinite(saved) && saved >= 120 && saved <= 260) tile = saved; } catch { } density.value = String(tile); root.style.setProperty('--tile-min', `${tile}px`); } const ce = q('#comments-editor'); if (ce && !ce.textContent.trim()) { ce.classList.add('placeholder'); ce.textContent = ce.dataset.placeholder || L('attacks.comments.placeholder'); tracker.trackEventListener(ce, 'focus', () => { if (ce.classList.contains('placeholder')) { ce.classList.remove('placeholder'); ce.innerHTML = '

'; } }); } await loadAttacks(); } export function unmount() { for (const v of iconCache.values()) { if (typeof v === 'string' && v.startsWith('blob:')) URL.revokeObjectURL(v); } iconCache.clear(); selectedImages.clear(); if (disposeSidebarLayout) { disposeSidebarLayout(); disposeSidebarLayout = null; } if (tracker) { tracker.cleanupAll(); tracker = null; } root = null; }