mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-10 14:42:04 +00:00
- Implemented methods for fetching AI stats, training history, and recent experiences. - Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling. - Included helper methods for querying the database and sending JSON responses. - Integrated model metadata extraction for visualization purposes.
954 lines
40 KiB
JavaScript
954 lines
40 KiB
JavaScript
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 `
|
|
<div class="attacks-sidebar">
|
|
<div class="sidehead">
|
|
<div class="sidetitle">${L('attacks.sidebar.management')}</div>
|
|
<div class="spacer"></div>
|
|
<button class="btn" id="hideSidebar" data-hide-sidebar="1" type="button">${Lx('common.hide', 'Hide')}</button>
|
|
</div>
|
|
<div class="tabs-container">
|
|
<button class="tab-btn active" data-page="attacks">${L('attacks.tabs.attacks')}</button>
|
|
<button class="tab-btn" data-page="comments">${L('attacks.tabs.comments')}</button>
|
|
<button class="tab-btn" data-page="images">${L('attacks.tabs.images')}</button>
|
|
</div>
|
|
|
|
<div id="attacks-sidebar" class="sidebar-page" style="display:block">
|
|
<ul class="unified-list" id="attacks-list"></ul>
|
|
<div class="hero-btn">
|
|
<button class="btn" id="add-attack-btn">${L('attacks.btn.addAttack')}</button>
|
|
<button class="btn danger" id="remove-attack-btn">${L('attacks.btn.removeAttack')}</button>
|
|
<button class="btn danger" id="delete-action-btn">${L('attacks.btn.deleteAction')}</button>
|
|
<button class="btn" id="sync-missing-btn">${Lx('attacks.btn.syncMissing', 'Sync Missing')}</button>
|
|
<button class="btn danger" id="restore-default-actions-btn">${L('attacks.btn.restoreDefaultsBundle')}</button>
|
|
</div>
|
|
<div id="empty-attacks-hint" style="display:none;opacity:.8;margin-top:8px">${L('attacks.empty.noAttacks')}</div>
|
|
</div>
|
|
|
|
<div id="comments-sidebar" class="sidebar-page" style="display:none">
|
|
<ul class="unified-list" id="section-list"></ul>
|
|
<div class="hero-btn">
|
|
<button class="btn" id="add-section-btn">${L('attacks.btn.addSection')}</button>
|
|
<button class="btn danger" id="delete-section-btn" disabled>${L('attacks.btn.deleteSection')}</button>
|
|
<button class="btn danger" id="restore-default-btn">${L('attacks.btn.restoreDefault')}</button>
|
|
</div>
|
|
<div id="empty-comments-hint" style="display:none;opacity:.8;margin-top:8px">${L('attacks.empty.noComments')}</div>
|
|
</div>
|
|
|
|
<div id="images-sidebar" class="sidebar-page" style="display:none">
|
|
<h3 style="margin:8px 0">${L('attacks.section.characters')}</h3>
|
|
<ul class="unified-list" id="character-list"></ul>
|
|
<div class="chips" style="margin:8px 0 16px 0">
|
|
<button class="btn" id="create-character-btn">${L('attacks.btn.createCharacter')}</button>
|
|
<button class="btn danger" id="delete-character-btn">${L('attacks.btn.deleteCharacter')}</button>
|
|
</div>
|
|
<h3 style="margin:8px 0">${L('attacks.section.statusImages')}</h3>
|
|
<ul class="unified-list" id="action-list"></ul>
|
|
<h3 style="margin:8px 0">${L('attacks.section.staticImages')}</h3>
|
|
<ul class="unified-list" id="library-list"></ul>
|
|
<h3 style="margin:8px 0">${L('attacks.section.webImages')}</h3>
|
|
<ul class="unified-list" id="web-images-list"></ul>
|
|
<h3 style="margin:8px 0">${L('attacks.section.actionIcons')}</h3>
|
|
<ul class="unified-list" id="actions-icons-list"></ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="attacks-main">
|
|
<div id="attacks-page" class="page-content active">
|
|
<div class="editor-textarea-container">
|
|
<div class="editor-header">
|
|
<h2 id="editor-title" style="margin:0">${L('attacks.editor.selectAttack')}</h2>
|
|
<div class="editor-buttons">
|
|
<button class="btn" id="save-attack-btn">${L('common.save')}</button>
|
|
<button class="btn" id="restore-attack-btn">${L('attacks.btn.restoreDefault')}</button>
|
|
</div>
|
|
</div>
|
|
<textarea id="editor-textarea" class="editor-textarea" disabled></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="comments-page" class="page-content">
|
|
<div class="buttons-container">
|
|
<h2 id="section-title" style="margin:0 0 10px 0">${L('attacks.tabs.comments')}</h2>
|
|
<button class="btn" id="select-all-btn">${L('common.selectAll')}</button>
|
|
<button class="btn" id="save-comments-btn">${L('common.save')}</button>
|
|
</div>
|
|
<div class="comments-container">
|
|
<div class="comments-editor" id="comments-editor" contenteditable="true" data-placeholder="${L('attacks.comments.placeholder')}" role="textbox" aria-multiline="true"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="images-page" class="page-content">
|
|
<div class="actions-bar">
|
|
<span class="chip" id="edit-mode-toggle-btn">${L('attacks.images.enterEditMode')}</span>
|
|
<select id="sort-key" class="select">
|
|
<option value="name">${L('attacks.images.sortName')}</option>
|
|
<option value="dim">${L('attacks.images.sortDimensions')}</option>
|
|
</select>
|
|
<button id="sort-dir" class="sort-toggle">^</button>
|
|
<div class="range-wrap" title="${Lx('attacks.images.gridDensity', 'Grid density')}">
|
|
<span>${Lx('attacks.images.density', 'Density')}</span>
|
|
<input id="density" type="range" min="120" max="260" value="160" class="range">
|
|
</div>
|
|
<div class="field"><span class="icon">S</span><input id="search-input" class="input" placeholder="${L('attacks.images.search')}"></div>
|
|
<button id="rename-image-btn" class="edit-only">${L('attacks.images.rename')}</button>
|
|
<button id="replace-image-btn" class="edit-only">${L('attacks.images.replace')}</button>
|
|
<button id="resize-images-btn" class="edit-only">${L('attacks.images.resizeSelected')}</button>
|
|
<button id="add-characters-btn" class="status-only">${L('attacks.images.addCharacters')}</button>
|
|
<button id="delete-images-btn" class="edit-only danger">${L('attacks.images.deleteSelected')}</button>
|
|
<button id="add-status-image-btn">${L('attacks.images.addStatus')}</button>
|
|
<button id="add-static-image-btn">${L('attacks.images.addStatic')}</button>
|
|
<button id="add-web-image-btn">${L('attacks.images.addWeb')}</button>
|
|
<button id="add-icon-image-btn">${L('attacks.images.addIcon')}</button>
|
|
</div>
|
|
<div class="image-container" id="image-container"></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 = '<div class="comment-line"><br></div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|