import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
import { PRESET_TELEPORTS, getTeleportName } from './teleports-data.js';
import { SUPPORTED_LANGUAGES, getCurrentLanguage, setLanguage, t } from './i18n.js';
// Application State
const state = {
apiUrl: localStorage.getItem('gac_srtool_api_url') || 'http://localhost:21000',
pingIntervalSec: parseInt(localStorage.getItem('gac_srtool_ping_interval') || '10', 10),
isConnected: false,
statusData: null,
avatarsList: [],
currentJsonMode: 'freesr', // 'freesr' or 'persistent'
activeTab: 'tab-dashboard'
};
// UI Elements
const el = {
langSelect: document.getElementById('lang-select'),
statusDot: document.getElementById('status-dot'),
connectionStatusText: document.getElementById('connection-status-text'),
btnQuickPing: document.getElementById('btn-quick-ping'),
btnOpenSettings: document.getElementById('btn-open-settings'),
modalSettings: document.getElementById('modal-settings'),
btnCloseModal: document.getElementById('btn-close-modal'),
btnModalCancel: document.getElementById('btn-modal-cancel'),
btnModalSave: document.getElementById('btn-modal-save'),
modalApiUrl: document.getElementById('modal-api-url'),
// Dashboard
statAvatars: document.getElementById('stat-avatars'),
statLightcones: document.getElementById('stat-lightcones'),
statRelics: document.getElementById('stat-relics'),
lblApiUrl: document.getElementById('lbl-api-url'),
lblPlaneId: document.getElementById('lbl-plane-id'),
lblFloorId: document.getElementById('lbl-floor-id'),
lblCoordinates: document.getElementById('lbl-coordinates'),
lineupContainer: document.getElementById('lineup-container'),
btnQuickMaxAll: document.getElementById('btn-quick-max-all'),
btnQuickWarpExpress: document.getElementById('btn-quick-warp-express'),
btnQuickSyncPersistent: document.getElementById('btn-quick-sync-persistent'),
// Avatars
avatarsGrid: document.getElementById('avatars-grid'),
avatarSearch: document.getElementById('avatar-search'),
avatarFilterRarity: document.getElementById('avatar-filter-rarity'),
btnMaxAllAvatars: document.getElementById('btn-max-all-avatars'),
btnReloadAvatars: document.getElementById('btn-reload-avatars'),
// Ex-Rank
exrankList: document.getElementById('exrank-list'),
btnExrankEnableAll: document.getElementById('btn-exrank-enable-all'),
btnExrankDisableAll: document.getElementById('btn-exrank-disable-all'),
// Teleport
teleportPresetsList: document.getElementById('teleport-presets-list'),
formCustomTeleport: document.getElementById('form-custom-teleport'),
tpPlane: document.getElementById('tp-plane'),
tpFloor: document.getElementById('tp-floor'),
tpEntry: document.getElementById('tp-entry'),
tpX: document.getElementById('tp-x'),
tpY: document.getElementById('tp-y'),
tpZ: document.getElementById('tp-z'),
// JSON
btnSelectFreesr: document.getElementById('btn-select-freesr'),
btnSelectPersistent: document.getElementById('btn-select-persistent'),
btnJsonBeautify: document.getElementById('btn-json-beautify'),
btnJsonDownload: document.getElementById('btn-json-download'),
btnJsonSave: document.getElementById('btn-json-save'),
jsonFileInput: document.getElementById('json-file-input'),
jsonEditor: document.getElementById('json-editor'),
jsonStatusInfo: document.getElementById('json-status-info'),
// Settings
formSettings: document.getElementById('form-settings'),
settingApiUrl: document.getElementById('setting-api-url'),
settingPingInterval: document.getElementById('setting-ping-interval'),
btnResetSettings: document.getElementById('btn-reset-settings'),
toastContainer: document.getElementById('toast-container')
};
// Toast notification helper
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let icon = 'ℹ️';
if (type === 'success') icon = '✅';
if (type === 'error') icon = '❌';
toast.innerHTML = `${icon}${message}`;
el.toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(10px)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3500);
}
// API Fetch Helper
async function apiCall(endpoint, method = 'GET', data = null) {
const cleanBase = state.apiUrl.replace(/\/+$/, '');
const url = `${cleanBase}${endpoint}`;
const options = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
options.signal = controller.signal;
const res = await fetch(url, options);
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Connection timed out');
}
throw err;
}
}
// Update connection status UI
function setConnectionStatus(connected, extraText = '') {
state.isConnected = connected;
if (connected) {
el.statusDot.classList.add('connected');
el.connectionStatusText.textContent = extraText || t('online');
el.connectionStatusText.style.color = 'var(--accent-green)';
} else {
el.statusDot.classList.remove('connected');
el.connectionStatusText.textContent = extraText || t('offline');
el.connectionStatusText.style.color = 'var(--accent-red)';
}
}
// Apply multi-language translations across whole UI
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(elem => {
const key = elem.getAttribute('data-i18n');
elem.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(elem => {
const key = elem.getAttribute('data-i18n-placeholder');
elem.placeholder = t(key);
});
// Re-render localized UI parts
if (state.statusData) {
renderLineup(state.statusData.lineups);
renderExRanks(state.statusData.ex_ranks || []);
}
renderTeleportPresets();
if (state.avatarsList.length > 0) {
renderAvatarsGrid();
}
}
// Language selector initialization
function setupLanguageSelector() {
el.langSelect.innerHTML = '';
SUPPORTED_LANGUAGES.forEach(lang => {
const opt = document.createElement('option');
opt.value = lang.code;
opt.textContent = `${lang.flag} ${lang.name}`;
if (lang.code === getCurrentLanguage()) {
opt.selected = true;
}
el.langSelect.appendChild(opt);
});
el.langSelect.addEventListener('change', (e) => {
const newLang = e.target.value;
setLanguage(newLang);
applyTranslations();
showToast(`Language switched to ${SUPPORTED_LANGUAGES.find(l => l.code === newLang)?.name || newLang}`, 'info');
});
}
// Refresh status from /api/status
async function refreshStatus() {
try {
const data = await apiCall('/api/status');
state.statusData = data;
setConnectionStatus(true);
el.lblApiUrl.textContent = state.apiUrl;
el.statAvatars.textContent = data.avatar_count ?? 0;
el.statLightcones.textContent = data.lightcone_count ?? 0;
el.statRelics.textContent = data.relic_count ?? 0;
if (data.scene) {
el.lblPlaneId.textContent = data.scene.plane_id || '-';
el.lblFloorId.textContent = data.scene.floor_id || '-';
}
if (data.position) {
const { x = 0, y = 0, z = 0 } = data.position;
el.lblCoordinates.textContent = `X: ${x}, Y: ${y}, Z: ${z}`;
}
renderLineup(data.lineups);
renderExRanks(data.ex_ranks || []);
} catch (err) {
setConnectionStatus(false, t('connectFailed'));
}
}
// Render current lineup cards
function renderLineup(lineups) {
if (!lineups || Object.keys(lineups).length === 0) {
el.lineupContainer.innerHTML = `
${t('slotEmpty')}
`;
return;
}
el.lineupContainer.innerHTML = '';
for (let slot = 0; slot < 4; slot++) {
const avatarId = lineups[slot] || 0;
const card = document.createElement('div');
card.className = 'card';
card.style.padding = '0.85rem';
if (avatarId === 0) {
card.innerHTML = `
${t('slot')} ${slot + 1}
${t('slotEmpty')}
`;
} else {
const info = getAvatarInfo(avatarId);
card.innerHTML = `
${t('slot')} ${slot + 1}
${info.name}
${info.element} • ${info.path}
`;
}
el.lineupContainer.appendChild(card);
}
}
// Load and Render Avatars
async function loadAvatars() {
try {
el.avatarsGrid.innerHTML = `${t('checking')}
`;
const res = await apiCall('/api/freesr/avatars');
state.avatarsList = res.avatars || [];
renderAvatarsGrid();
} catch (err) {
el.avatarsGrid.innerHTML = `${err.message}
`;
}
}
function renderAvatarsGrid() {
const searchTerm = el.avatarSearch.value.trim().toLowerCase();
const rarityFilter = el.avatarFilterRarity.value;
const filtered = state.avatarsList.filter(item => {
const meta = getAvatarInfo(item.avatar_id);
const matchesSearch = meta.name.toLowerCase().includes(searchTerm) || String(item.avatar_id).includes(searchTerm);
const matchesRarity = rarityFilter === 'all' || String(meta.rarity) === rarityFilter;
return matchesSearch && matchesRarity;
});
if (filtered.length === 0) {
el.avatarsGrid.innerHTML = 'No matching avatars
';
return;
}
el.avatarsGrid.innerHTML = '';
filtered.forEach(item => {
const meta = getAvatarInfo(item.avatar_id);
const card = document.createElement('div');
card.className = 'card avatar-card';
const pipsHtml = [0, 1, 2, 3, 4, 5, 6].map(rank => `
`).join('');
card.innerHTML = `
★ ${meta.rarity}
${t('eidolonLabel')}
${pipsHtml}
`;
// Eidolon button clicks
card.querySelectorAll('.eidolon-pip').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const avatarId = parseInt(btn.getAttribute('data-avatar'), 10);
const rank = parseInt(btn.getAttribute('data-rank'), 10);
await setAvatarRank(avatarId, rank);
});
});
el.avatarsGrid.appendChild(card);
});
}
// Set avatar rank API
async function setAvatarRank(avatarId, rank) {
try {
await apiCall('/api/avatar/rank', 'POST', { avatar_id: avatarId, rank });
showToast(t('avatarRankSuccess', { id: avatarId, rank }), 'success');
// Update local state and re-render
const target = state.avatarsList.find(a => a.avatar_id === avatarId);
if (target) {
target.rank = rank;
renderAvatarsGrid();
}
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
// Max all avatars API
async function maxAllAvatars() {
if (!confirm(t('confirmMaxAll'))) return;
try {
await apiCall('/api/avatar/max_all', 'POST', {});
showToast(t('maxAllSuccess'), 'success');
loadAvatars();
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
// Render Ex-Ranks
function renderExRanks(exList) {
if (!exList || exList.length === 0) {
el.exrankList.innerHTML = 'No Ex-Rank Avatars
';
return;
}
el.exrankList.innerHTML = '';
exList.forEach(item => {
const card = document.createElement('div');
card.className = 'ex-switch-card';
const localizedMeta = getAvatarInfo(item.avatar_id);
card.innerHTML = `
${localizedMeta.name}
Avatar ID: ${item.avatar_id} • Ex-Rank Skill ID: ${item.ex_rank_id}
`;
card.querySelector('input[type="checkbox"]').addEventListener('change', async (e) => {
const unlocked = e.target.checked;
const avatarId = item.avatar_id;
try {
await apiCall('/api/exrank/toggle', 'POST', { avatar_id: avatarId, unlocked });
const statusText = unlocked ? t('enabled') : t('disabled');
showToast(t('exrankToggleSuccess', { name: localizedMeta.name, status: statusText }), 'success');
} catch (err) {
e.target.checked = !unlocked;
showToast(`Error: ${err.message}`, 'error');
}
});
el.exrankList.appendChild(card);
});
}
// Render Teleport Presets
function renderTeleportPresets() {
el.teleportPresetsList.innerHTML = '';
PRESET_TELEPORTS.forEach(preset => {
const item = document.createElement('div');
item.className = 'card';
item.style.padding = '0.85rem';
item.style.display = 'flex';
item.style.justifyContent = 'space-between';
item.style.alignItems = 'center';
const displayName = getTeleportName(preset);
item.innerHTML = `
${displayName}
Plane: ${preset.plane_id} • Floor: ${preset.floor_id} • Entry: ${preset.entry_id}
`;
item.querySelector('.btn-warp').addEventListener('click', async () => {
await executeTeleport({
plane_id: preset.plane_id,
floor_id: preset.floor_id,
entry_id: preset.entry_id,
x: preset.x,
y: preset.y,
z: preset.z
}, displayName);
});
el.teleportPresetsList.appendChild(item);
});
}
// Execute Teleport API
async function executeTeleport(payload, label = '') {
try {
await apiCall('/api/teleport', 'POST', payload);
showToast(t('warpSuccess', { label: label || `Plane ${payload.plane_id}` }), 'success');
refreshStatus();
} catch (err) {
showToast(t('warpFailed', { err: err.message }), 'error');
}
}
// Load and Switch JSON
async function loadJsonData(mode) {
state.currentJsonMode = mode;
if (mode === 'freesr') {
el.btnSelectFreesr.className = 'btn btn-sm btn-primary active';
el.btnSelectPersistent.className = 'btn btn-sm btn-secondary';
el.jsonStatusInfo.textContent = t('jsonStatusOpening', { name: 'freesr-data.json' });
try {
const res = await apiCall('/api/freesr');
el.jsonEditor.value = JSON.stringify(res.data, null, 2);
} catch (err) {
showToast(`Load failed: ${err.message}`, 'error');
}
} else {
el.btnSelectPersistent.className = 'btn btn-sm btn-primary active';
el.btnSelectFreesr.className = 'btn btn-sm btn-secondary';
el.jsonStatusInfo.textContent = t('jsonStatusOpening', { name: 'persistent' });
try {
const res = await apiCall('/api/persistent');
el.jsonEditor.value = JSON.stringify(res.persistent, null, 2);
} catch (err) {
showToast(`Load failed: ${err.message}`, 'error');
}
}
}
// Save JSON to Server
async function saveJsonData() {
const text = el.jsonEditor.value.trim();
if (!text) {
showToast('No JSON data to save', 'error');
return;
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (err) {
showToast(`${t('jsonInvalid')}: ${err.message}`, 'error');
return;
}
const endpoint = state.currentJsonMode === 'freesr' ? '/api/freesr' : '/api/persistent';
try {
await apiCall(endpoint, 'POST', parsed);
showToast(t('jsonSaveSuccess', { name: state.currentJsonMode }), 'success');
refreshStatus();
} catch (err) {
showToast(`Save failed: ${err.message}`, 'error');
}
}
// Tab Switching
function setupTabs() {
const tabs = document.querySelectorAll('.tab-btn');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(tBtn => tBtn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const targetId = tab.getAttribute('data-tab');
const targetContent = document.getElementById(targetId);
if (targetContent) {
targetContent.classList.add('active');
state.activeTab = targetId;
// On-demand tab refresh
if (targetId === 'tab-avatars' && state.avatarsList.length === 0) {
loadAvatars();
} else if (targetId === 'tab-json') {
loadJsonData(state.currentJsonMode);
}
}
});
});
}
// Event Listeners setup
function setupEvents() {
// Quick Ping & Refresh
el.btnQuickPing.addEventListener('click', () => {
showToast(t('checking'), 'info');
refreshStatus();
});
// Settings Modal
el.btnOpenSettings.addEventListener('click', () => {
el.modalApiUrl.value = state.apiUrl;
el.modalSettings.classList.add('active');
});
el.btnCloseModal.addEventListener('click', () => el.modalSettings.classList.remove('active'));
el.btnModalCancel.addEventListener('click', () => el.modalSettings.classList.remove('active'));
el.btnModalSave.addEventListener('click', () => {
const newUrl = el.modalApiUrl.value.trim();
if (newUrl) {
state.apiUrl = newUrl;
localStorage.setItem('gac_srtool_api_url', newUrl);
el.modalSettings.classList.remove('active');
showToast(t('settingsSaved'), 'info');
refreshStatus();
}
});
// Quick Action Buttons
el.btnQuickMaxAll.addEventListener('click', maxAllAvatars);
el.btnMaxAllAvatars.addEventListener('click', maxAllAvatars);
el.btnQuickWarpExpress.addEventListener('click', () => {
const express = PRESET_TELEPORTS[0];
executeTeleport(express, getTeleportName(express));
});
el.btnQuickSyncPersistent.addEventListener('click', async () => {
try {
await apiCall('/api/persistent');
showToast('Persistent data synced', 'success');
refreshStatus();
} catch (err) {
showToast(`Sync failed: ${err.message}`, 'error');
}
});
// Avatar Search & Filter
el.avatarSearch.addEventListener('input', renderAvatarsGrid);
el.avatarFilterRarity.addEventListener('change', renderAvatarsGrid);
el.btnReloadAvatars.addEventListener('click', loadAvatars);
// Ex-Rank All Controls
el.btnExrankEnableAll.addEventListener('click', async () => {
try {
await apiCall('/api/exrank/toggle', 'POST', { all: true, unlocked: true });
showToast(t('exrankAllSuccess', { status: t('enabled') }), 'success');
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
});
el.btnExrankDisableAll.addEventListener('click', async () => {
try {
await apiCall('/api/exrank/toggle', 'POST', { all: true, unlocked: false });
showToast(t('exrankAllSuccess', { status: t('disabled') }), 'success');
refreshStatus();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
});
// Custom Teleport Form
el.formCustomTeleport.addEventListener('submit', (e) => {
e.preventDefault();
const payload = {
plane_id: parseInt(el.tpPlane.value, 10),
floor_id: parseInt(el.tpFloor.value, 10),
entry_id: parseInt(el.tpEntry.value, 10),
x: el.tpX.value ? parseInt(el.tpX.value, 10) : 0,
y: el.tpY.value ? parseInt(el.tpY.value, 10) : 0,
z: el.tpZ.value ? parseInt(el.tpZ.value, 10) : 0
};
executeTeleport(payload, `Plane ${payload.plane_id}`);
});
// JSON Tab Controls
el.btnSelectFreesr.addEventListener('click', () => loadJsonData('freesr'));
el.btnSelectPersistent.addEventListener('click', () => loadJsonData('persistent'));
el.btnJsonBeautify.addEventListener('click', () => {
try {
const parsed = JSON.parse(el.jsonEditor.value);
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
showToast('JSON beautified', 'info');
} catch (err) {
showToast(t('jsonInvalid'), 'error');
}
});
el.btnJsonSave.addEventListener('click', saveJsonData);
el.btnJsonDownload.addEventListener('click', () => {
const text = el.jsonEditor.value;
const blob = new Blob([text], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${state.currentJsonMode}-${Date.now()}.json`;
a.click();
showToast(`Downloaded ${a.download}`, 'success');
});
el.jsonFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const parsed = JSON.parse(event.target.result);
el.jsonEditor.value = JSON.stringify(parsed, null, 2);
showToast(`Loaded ${file.name}`, 'success');
} catch (err) {
showToast(t('jsonInvalid'), 'error');
}
};
reader.readAsText(file);
});
// Settings Form
el.settingApiUrl.value = state.apiUrl;
el.settingPingInterval.value = state.pingIntervalSec;
el.formSettings.addEventListener('submit', (e) => {
e.preventDefault();
state.apiUrl = el.settingApiUrl.value.trim();
state.pingIntervalSec = parseInt(el.settingPingInterval.value, 10) || 10;
localStorage.setItem('gac_srtool_api_url', state.apiUrl);
localStorage.setItem('gac_srtool_ping_interval', state.pingIntervalSec);
showToast(t('settingsSaved'), 'success');
refreshStatus();
});
el.btnResetSettings.addEventListener('click', () => {
state.apiUrl = 'http://localhost:21000';
state.pingIntervalSec = 10;
el.settingApiUrl.value = state.apiUrl;
el.settingPingInterval.value = state.pingIntervalSec;
localStorage.removeItem('gac_srtool_api_url');
localStorage.removeItem('gac_srtool_ping_interval');
showToast(t('settingsReset'), 'info');
refreshStatus();
});
}
// Background ping loop
function startPingLoop() {
setInterval(() => {
refreshStatus();
}, state.pingIntervalSec * 1000);
}
// Initialize Application
function init() {
setupLanguageSelector();
setupTabs();
setupEvents();
applyTranslations();
refreshStatus();
startPingLoop();
}
// Run on DOM load
window.addEventListener('DOMContentLoaded', init);