2344 lines
86 KiB
JavaScript
2344 lines
86 KiB
JavaScript
import { AVATAR_METADATA, getAvatarInfo } from './avatars-data.js';
|
||
import { LIGHTCONE_METADATA, RELIC_SET_METADATA, getLightconeInfo, getRelicSetInfo } from './equipment-data.js';
|
||
import { SUPPORTED_LANGUAGES, TRANSLATIONS, getCurrentLanguage, setLanguage, t, translateElement, translatePath } from './i18n.js';
|
||
|
||
// Application State
|
||
const state = {
|
||
serverMode: localStorage.getItem('gac_srtool_mode') || 'gac', // 'gac' | 'firefly' | 'robin'
|
||
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: [],
|
||
fullFreesrData: null,
|
||
persistentData: null,
|
||
currentJsonMode: 'freesr',
|
||
activeTab: 'tab-dashboard',
|
||
selectedLineupSlot: null,
|
||
selectedAvatarDetail: null,
|
||
creativeMode: localStorage.getItem('gac_srtool_creative_mode') === 'true'
|
||
};
|
||
|
||
// UI Elements Map
|
||
const el = {
|
||
serverModeSelect: document.getElementById('server-mode-select'),
|
||
settingServerMode: document.getElementById('setting-server-mode'),
|
||
lblActiveMode: document.getElementById('lbl-active-mode'),
|
||
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'),
|
||
toggleCreativeMode: document.getElementById('toggle-creative-mode'),
|
||
badgeCreativeMode: document.getElementById('badge-creative-mode'),
|
||
btnQuickMaxAll: document.getElementById('btn-quick-max-all'),
|
||
btnQuickSyncPersistent: document.getElementById('btn-quick-sync-persistent'),
|
||
|
||
// Avatars
|
||
avatarsGrid: document.getElementById('avatars-grid'),
|
||
avatarSearch: document.getElementById('avatar-search'),
|
||
avatarFilterRarity: document.getElementById('avatar-filter-rarity'),
|
||
avatarFilterElement: document.getElementById('avatar-filter-element'),
|
||
avatarFilterPath: document.getElementById('avatar-filter-path'),
|
||
btnMaxAllAvatars: document.getElementById('btn-max-all-avatars'),
|
||
btnReloadAvatars: document.getElementById('btn-reload-avatars'),
|
||
|
||
// Equipment
|
||
lightconesList: document.getElementById('lightcones-list'),
|
||
relicsList: document.getElementById('relics-list'),
|
||
badgeTotalLc: document.getElementById('badge-total-lc'),
|
||
badgeTotalRelics: document.getElementById('badge-total-relics'),
|
||
btnOpenAddLc: document.getElementById('btn-open-add-lc'),
|
||
modalAddLc: document.getElementById('modal-add-lc'),
|
||
btnCloseAddLcModal: document.getElementById('btn-close-add-lc-modal'),
|
||
btnCancelAddLc: document.getElementById('btn-cancel-add-lc'),
|
||
formAddLc: document.getElementById('form-add-lc'),
|
||
addLcSelect: document.getElementById('add-lc-select'),
|
||
addLcLevel: document.getElementById('add-lc-level'),
|
||
addLcPromotion: document.getElementById('add-lc-promotion'),
|
||
addLcRank: document.getElementById('add-lc-rank'),
|
||
addLcAvatar: document.getElementById('add-lc-avatar'),
|
||
btnOpenAddRelic: document.getElementById('btn-open-add-relic'),
|
||
modalAddRelic: document.getElementById('modal-add-relic'),
|
||
btnCloseAddRelicModal: document.getElementById('btn-close-add-relic-modal'),
|
||
btnCancelAddRelic: document.getElementById('btn-cancel-add-relic'),
|
||
formAddRelic: document.getElementById('form-add-relic'),
|
||
addRelicSet: document.getElementById('add-relic-set'),
|
||
addRelicSlot: document.getElementById('add-relic-slot'),
|
||
addRelicLevel: document.getElementById('add-relic-level'),
|
||
addRelicMainAffix: document.getElementById('add-relic-main-affix'),
|
||
addRelicAvatar: document.getElementById('add-relic-avatar'),
|
||
|
||
// Persistent Tab
|
||
tabBtnPersistent: document.getElementById('tab-btn-persistent'),
|
||
formPersistentChars: document.getElementById('form-persistent-chars'),
|
||
persistentMcSelect: document.getElementById('persistent-mc-select'),
|
||
persistentMarchSelect: document.getElementById('persistent-march-select'),
|
||
toggleSwGlobal: document.getElementById('toggle-sw-global'),
|
||
toggleCastoriceGlobal: document.getElementById('toggle-castorice-global'),
|
||
toggleAhaGlobal: document.getElementById('toggle-aha-global'),
|
||
btnSavePersistentToggles: document.getElementById('btn-save-persistent-toggles'),
|
||
|
||
// Ex-Rank
|
||
tabBtnExrank: document.getElementById('tab-btn-exrank'),
|
||
exrankList: document.getElementById('exrank-list'),
|
||
btnExrankEnableAll: document.getElementById('btn-exrank-enable-all'),
|
||
btnExrankDisableAll: document.getElementById('btn-exrank-disable-all'),
|
||
|
||
// JSON Tab
|
||
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'),
|
||
|
||
// Character Detail Modal
|
||
modalAvatarDetail: document.getElementById('modal-avatar-detail'),
|
||
btnCloseAvatarModal: document.getElementById('btn-close-avatar-modal'),
|
||
modalCharTitle: document.getElementById('modal-char-title'),
|
||
modalCharImg: document.getElementById('modal-char-img'),
|
||
modalCharName: document.getElementById('modal-char-name'),
|
||
modalCharMeta: document.getElementById('modal-char-meta'),
|
||
modalCharLevel: document.getElementById('modal-char-level'),
|
||
modalCharPromotion: document.getElementById('modal-char-promotion'),
|
||
modalCharEidolonBtns: document.getElementById('modal-char-eidolon-btns'),
|
||
btnModalQuickE0: document.getElementById('btn-modal-quick-e0'),
|
||
btnModalQuickE6: document.getElementById('btn-modal-quick-e6'),
|
||
btnModalMaxSkills: document.getElementById('btn-modal-max-skills'),
|
||
btnModalSaveAvatar: document.getElementById('btn-modal-save-avatar'),
|
||
|
||
// Skills Info Steppers (Inspired by srtools.punklorde.org/skills-info)
|
||
valSkillBasic: document.getElementById('val-skill-basic'),
|
||
rangeSkillBasic: document.getElementById('range-skill-basic'),
|
||
btnStepDecBasic: document.getElementById('btn-step-dec-basic'),
|
||
btnStepIncBasic: document.getElementById('btn-step-inc-basic'),
|
||
btnSkillMaxBasic: document.getElementById('btn-skill-max-basic'),
|
||
|
||
valSkillSkill: document.getElementById('val-skill-skill'),
|
||
rangeSkillSkill: document.getElementById('range-skill-skill'),
|
||
btnStepDecSkill: document.getElementById('btn-step-dec-skill'),
|
||
btnStepIncSkill: document.getElementById('btn-step-inc-skill'),
|
||
btnSkillMaxSkill: document.getElementById('btn-skill-max-skill'),
|
||
|
||
valSkillUltra: document.getElementById('val-skill-ultra'),
|
||
rangeSkillUltra: document.getElementById('range-skill-ultra'),
|
||
btnStepDecUltra: document.getElementById('btn-step-dec-ultra'),
|
||
btnStepIncUltra: document.getElementById('btn-step-inc-ultra'),
|
||
btnSkillMaxUltra: document.getElementById('btn-skill-max-ultra'),
|
||
|
||
valSkillTalent: document.getElementById('val-skill-talent'),
|
||
rangeSkillTalent: document.getElementById('range-skill-talent'),
|
||
btnStepDecTalent: document.getElementById('btn-step-dec-talent'),
|
||
btnStepIncTalent: document.getElementById('btn-step-inc-talent'),
|
||
btnSkillMaxTalent: document.getElementById('btn-skill-max-talent'),
|
||
|
||
checkTraceTechnique: document.getElementById('check-trace-technique'),
|
||
checkTraceA2: document.getElementById('check-trace-a2'),
|
||
checkTraceA4: document.getElementById('check-trace-a4'),
|
||
checkTraceA6: document.getElementById('check-trace-a6'),
|
||
checkTraceStats: document.getElementById('check-trace-stats'),
|
||
|
||
btnModalEnergy50: document.getElementById('btn-modal-energy-50'),
|
||
btnModalEnergy100: document.getElementById('btn-modal-energy-100'),
|
||
rangeCharEnergy: document.getElementById('range-char-energy'),
|
||
valCharEnergy: document.getElementById('val-char-energy'),
|
||
|
||
modalLcId: document.getElementById('modal-lc-id'),
|
||
modalLcPreview: document.getElementById('modal-lc-preview'),
|
||
modalLcLevel: document.getElementById('modal-lc-level'),
|
||
btnModalMaxLc: document.getElementById('btn-modal-max-lc'),
|
||
modalLcRankBtns: document.getElementById('modal-lc-rank-btns'),
|
||
|
||
// Lineup Picker Modal
|
||
modalLineupPicker: document.getElementById('modal-lineup-picker'),
|
||
btnClosePickerModal: document.getElementById('btn-close-picker-modal'),
|
||
modalPickerTitle: document.getElementById('modal-picker-title'),
|
||
pickerSearch: document.getElementById('picker-search'),
|
||
pickerAvatarsGrid: document.getElementById('picker-avatars-grid'),
|
||
|
||
toastContainer: document.getElementById('toast-container')
|
||
};
|
||
|
||
// Toast notification helper
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icons = {
|
||
info: 'ℹ️',
|
||
success: '✅',
|
||
warning: '⚠️',
|
||
error: '❌'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<span>${icons[type] || 'ℹ️'}</span>
|
||
<span style="flex: 1; word-break: break-word;">${message}</span>
|
||
`;
|
||
|
||
el.toastContainer.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transform = 'translateX(100%)';
|
||
toast.style.transition = 'all 0.3s ease';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 4000);
|
||
}
|
||
|
||
// Low-level API caller
|
||
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(), 6000);
|
||
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)';
|
||
}
|
||
}
|
||
|
||
// Helper to get element CSS class
|
||
// Canonical element & path maps (handles English and all 13 supported languages)
|
||
const CANONICAL_ELEMENTS = {
|
||
physical: 'Physical', fire: 'Fire', ice: 'Ice', lightning: 'Lightning',
|
||
wind: 'Wind', quantum: 'Quantum', imaginary: 'Imaginary', thunder: 'Lightning'
|
||
};
|
||
|
||
const CANONICAL_PATHS = {
|
||
destruction: 'Destruction', hunt: 'Hunt', 'the hunt': 'Hunt', erudition: 'Erudition',
|
||
harmony: 'Harmony', nihility: 'Nihility', preservation: 'Preservation',
|
||
abundance: 'Abundance', remembrance: 'Remembrance', elation: 'Elation'
|
||
};
|
||
|
||
// Dynamically index all translated element and path strings across languages
|
||
for (const langKey in TRANSLATIONS) {
|
||
const tr = TRANSLATIONS[langKey];
|
||
if (tr.elements) {
|
||
for (const [enKey, locVal] of Object.entries(tr.elements)) {
|
||
if (locVal) CANONICAL_ELEMENTS[String(locVal).trim().toLowerCase()] = enKey;
|
||
}
|
||
}
|
||
if (tr.paths) {
|
||
for (const [enKey, locVal] of Object.entries(tr.paths)) {
|
||
if (locVal) CANONICAL_PATHS[String(locVal).trim().toLowerCase()] = (enKey === 'The Hunt' ? 'Hunt' : enKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
function normalizeElement(elem) {
|
||
if (!elem) return 'Physical';
|
||
return CANONICAL_ELEMENTS[String(elem).trim().toLowerCase()] || elem;
|
||
}
|
||
|
||
function normalizePath(p) {
|
||
if (!p) return 'Destruction';
|
||
return CANONICAL_PATHS[String(p).trim().toLowerCase()] || (p === 'The Hunt' ? 'Hunt' : p);
|
||
}
|
||
|
||
function getElementClass(elem) {
|
||
const canonical = normalizeElement(elem);
|
||
const map = {
|
||
Fire: 'elem-fire',
|
||
Ice: 'elem-ice',
|
||
Lightning: 'elem-lightning',
|
||
Wind: 'elem-wind',
|
||
Physical: 'elem-physical',
|
||
Quantum: 'elem-quantum',
|
||
Imaginary: 'elem-imaginary'
|
||
};
|
||
return map[canonical] || 'elem-physical';
|
||
}
|
||
|
||
// Render Element Icon HTML (Official Star Rail Game Icon)
|
||
function getElementIconHtml(element, size = 20) {
|
||
if (!element) return '';
|
||
const canonical = normalizeElement(element);
|
||
const elemClass = getElementClass(canonical);
|
||
return `<span class="icon-badge element-icon-badge ${elemClass}" title="${element}">
|
||
<img src="assets/elements/${canonical}.png"
|
||
alt="${element}"
|
||
style="width: ${size}px; height: ${size}px;"
|
||
onerror="if(this.src.indexOf('StarRailRes')===-1){this.src='https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/element/${canonical === 'Lightning' ? 'Thunder' : canonical}.png';}">
|
||
</span>`;
|
||
}
|
||
|
||
// Render Path Icon HTML (Official Star Rail Game Icon)
|
||
function getPathIconHtml(path, size = 20) {
|
||
if (!path) return '';
|
||
const canonical = normalizePath(path);
|
||
return `<span class="icon-badge path-icon-badge" title="${path}">
|
||
<img src="assets/paths/${canonical}.png"
|
||
alt="${path}"
|
||
style="width: ${size}px; height: ${size}px;"
|
||
onerror="if(this.src.indexOf('StarRailRes')===-1){this.src='https://raw.githubusercontent.com/Mar-7th/StarRailRes/master/icon/path/${canonical}.png';}">
|
||
</span>`;
|
||
}
|
||
|
||
// Update filter dropdown options to current language
|
||
function updateFilterDropdownTranslations() {
|
||
if (el.avatarFilterElement) {
|
||
const selected = el.avatarFilterElement.value;
|
||
const elements = ['Fire', 'Ice', 'Lightning', 'Wind', 'Physical', 'Quantum', 'Imaginary'];
|
||
if (el.avatarFilterElement.options[0]) {
|
||
el.avatarFilterElement.options[0].textContent = t('filterAllElements') || 'ทุกธาตุ';
|
||
}
|
||
elements.forEach((elemKey, idx) => {
|
||
if (el.avatarFilterElement.options[idx + 1]) {
|
||
el.avatarFilterElement.options[idx + 1].textContent = translateElement(elemKey);
|
||
}
|
||
});
|
||
el.avatarFilterElement.value = selected;
|
||
}
|
||
|
||
if (el.avatarFilterPath) {
|
||
const selected = el.avatarFilterPath.value;
|
||
const paths = ['Destruction', 'Hunt', 'Erudition', 'Harmony', 'Nihility', 'Preservation', 'Abundance', 'Remembrance', 'Elation'];
|
||
if (el.avatarFilterPath.options[0]) {
|
||
el.avatarFilterPath.options[0].textContent = t('filterAllPaths') || 'ทุกประเภท';
|
||
}
|
||
paths.forEach((pathKey, idx) => {
|
||
if (el.avatarFilterPath.options[idx + 1]) {
|
||
el.avatarFilterPath.options[idx + 1].textContent = translatePath(pathKey);
|
||
}
|
||
});
|
||
el.avatarFilterPath.value = selected;
|
||
}
|
||
|
||
const dict = TRANSLATIONS[getCurrentLanguage()] || TRANSLATIONS.en || TRANSLATIONS.th;
|
||
if (el.persistentMcSelect && dict.tbOptions) {
|
||
const curVal = el.persistentMcSelect.value;
|
||
Array.from(el.persistentMcSelect.options).forEach(opt => {
|
||
if (dict.tbOptions[opt.value]) {
|
||
opt.textContent = dict.tbOptions[opt.value];
|
||
}
|
||
});
|
||
el.persistentMcSelect.value = curVal;
|
||
}
|
||
|
||
if (el.persistentMarchSelect && dict.marchOptions) {
|
||
const curVal = el.persistentMarchSelect.value;
|
||
Array.from(el.persistentMarchSelect.options).forEach(opt => {
|
||
if (dict.marchOptions[opt.value]) {
|
||
opt.textContent = dict.marchOptions[opt.value];
|
||
}
|
||
});
|
||
el.persistentMarchSelect.value = curVal;
|
||
}
|
||
}
|
||
|
||
// Apply multi-language translations across UI
|
||
function applyTranslations() {
|
||
document.querySelectorAll('[data-i18n]').forEach(elem => {
|
||
const key = elem.getAttribute('data-i18n');
|
||
const val = t(key);
|
||
if (val) elem.textContent = val;
|
||
});
|
||
|
||
document.querySelectorAll('[data-i18n-placeholder]').forEach(elem => {
|
||
const key = elem.getAttribute('data-i18n-placeholder');
|
||
const val = t(key);
|
||
if (val) elem.placeholder = val;
|
||
});
|
||
|
||
// Update dynamic filter dropdown options
|
||
updateFilterDropdownTranslations();
|
||
|
||
// Update connection status text with new language
|
||
setConnectionStatus(state.isConnected);
|
||
|
||
renderLineup(state.statusData?.lineups);
|
||
if (state.statusData?.ex_ranks) {
|
||
renderExRanks(state.statusData.ex_ranks);
|
||
}
|
||
if (state.avatarsList.length > 0) {
|
||
renderAvatarsGrid();
|
||
}
|
||
loadEquipment();
|
||
}
|
||
|
||
// Setup Language Selector
|
||
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: ${SUPPORTED_LANGUAGES.find(l => l.code === newLang)?.name || newLang}`, 'info');
|
||
});
|
||
}
|
||
|
||
// Server Mode Setup
|
||
function setupServerModes() {
|
||
const updateModeDisplay = (mode) => {
|
||
state.serverMode = mode;
|
||
localStorage.setItem('gac_srtool_mode', mode);
|
||
if (el.serverModeSelect) el.serverModeSelect.value = mode;
|
||
if (el.settingServerMode) el.settingServerMode.value = mode;
|
||
|
||
if (mode === 'gac') {
|
||
if (el.lblActiveMode) el.lblActiveMode.textContent = 'GaC-hkrpg-go';
|
||
if (el.tabBtnPersistent) el.tabBtnPersistent.style.display = '';
|
||
if (el.tabBtnExrank) el.tabBtnExrank.style.display = '';
|
||
if (el.btnQuickSyncPersistent) el.btnQuickSyncPersistent.style.display = '';
|
||
if (el.btnSelectPersistent) el.btnSelectPersistent.style.display = '';
|
||
} else {
|
||
if (el.lblActiveMode) el.lblActiveMode.textContent = mode === 'firefly' ? 'FireflyGo' : 'RobinSR';
|
||
if (el.tabBtnPersistent) el.tabBtnPersistent.style.display = 'none';
|
||
if (el.tabBtnExrank) el.tabBtnExrank.style.display = 'none';
|
||
if (el.btnQuickSyncPersistent) el.btnQuickSyncPersistent.style.display = 'none';
|
||
if (el.btnSelectPersistent) el.btnSelectPersistent.style.display = 'none';
|
||
|
||
// If user is currently viewing a hidden tab, auto switch back to dashboard
|
||
if (state.activeTab === 'tab-persistent' || state.activeTab === 'tab-exrank') {
|
||
const dashboardBtn = document.querySelector('.tab-btn[data-tab="tab-dashboard"]');
|
||
if (dashboardBtn) dashboardBtn.click();
|
||
}
|
||
|
||
// If user is viewing persistent JSON, switch back to freesr
|
||
if (state.currentJsonMode === 'persistent') {
|
||
loadJsonData('freesr');
|
||
}
|
||
}
|
||
};
|
||
|
||
if (el.serverModeSelect) {
|
||
el.serverModeSelect.addEventListener('change', (e) => {
|
||
updateModeDisplay(e.target.value);
|
||
showToast(t('toastServerSwitched', { mode: el.lblActiveMode ? el.lblActiveMode.textContent : e.target.value }), 'info');
|
||
refreshStatus();
|
||
});
|
||
}
|
||
|
||
if (el.settingServerMode) {
|
||
el.settingServerMode.addEventListener('change', (e) => {
|
||
updateModeDisplay(e.target.value);
|
||
showToast(t('toastServerSwitched', { mode: el.lblActiveMode ? el.lblActiveMode.textContent : e.target.value }), 'info');
|
||
refreshStatus();
|
||
});
|
||
}
|
||
|
||
updateModeDisplay(state.serverMode);
|
||
}
|
||
|
||
// Initialize Avatars Database: Populates all known avatars from metadata
|
||
function initAvatarsList() {
|
||
if (state.avatarsList.length > 0) return;
|
||
const list = [];
|
||
for (const aidStr in AVATAR_METADATA) {
|
||
const aid = parseInt(aidStr, 10);
|
||
list.push({
|
||
avatar_id: aid,
|
||
level: 80,
|
||
promotion: 6,
|
||
rank: 6
|
||
});
|
||
}
|
||
list.sort((a, b) => b.avatar_id - a.avatar_id);
|
||
state.avatarsList = list;
|
||
}
|
||
|
||
// Default lineup matching our persistent server data
|
||
const DEFAULT_LINEUP = { 0: 1511, 1: 1506, 2: 1502, 3: 1503 };
|
||
|
||
// Refresh status from server
|
||
async function refreshStatus() {
|
||
try {
|
||
let data;
|
||
if (state.serverMode === 'gac') {
|
||
data = await apiCall('/api/status');
|
||
} else {
|
||
const freesr = await apiCall('/api/freesr').catch(() => null);
|
||
if (freesr && freesr.data) {
|
||
data = {
|
||
status: 200,
|
||
avatar_count: Object.keys(freesr.data.avatars || {}).length,
|
||
lightcone_count: (freesr.data.lightcones || []).length,
|
||
relic_count: (freesr.data.relics || []).length,
|
||
lineups: freesr.data.lineups || freesr.data.battle_config?.custom_battle_lineup || DEFAULT_LINEUP,
|
||
scene: freesr.data.scene,
|
||
position: freesr.data.position,
|
||
ex_ranks: []
|
||
};
|
||
} else {
|
||
throw new Error('Server not reachable');
|
||
}
|
||
}
|
||
|
||
state.statusData = data;
|
||
setConnectionStatus(true);
|
||
|
||
el.lblApiUrl.textContent = state.apiUrl;
|
||
el.statAvatars.textContent = data.avatar_count ?? state.avatarsList.length;
|
||
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 || DEFAULT_LINEUP);
|
||
renderExRanks(data.ex_ranks || []);
|
||
loadAvatars();
|
||
} catch (err) {
|
||
setConnectionStatus(false, t('offline'));
|
||
el.lblApiUrl.textContent = state.apiUrl;
|
||
el.statAvatars.textContent = state.avatarsList.length;
|
||
// Keep rendering with current or default lineup so UI is always functional
|
||
renderLineup(state.statusData?.lineups || DEFAULT_LINEUP);
|
||
loadAvatars();
|
||
}
|
||
}
|
||
|
||
// Render party lineup cards
|
||
function renderLineup(lineups) {
|
||
if (el.toggleCreativeMode) {
|
||
el.toggleCreativeMode.checked = state.creativeMode;
|
||
}
|
||
if (el.badgeCreativeMode) {
|
||
el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none';
|
||
}
|
||
|
||
el.lineupContainer.innerHTML = '';
|
||
let currentLineup = lineups || state.statusData?.lineups || DEFAULT_LINEUP;
|
||
|
||
// Handle array format if provided
|
||
if (Array.isArray(currentLineup)) {
|
||
const mapped = {};
|
||
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
|
||
currentLineup = mapped;
|
||
}
|
||
|
||
// Determine slots to render
|
||
let slotKeys = [];
|
||
if (state.creativeMode) {
|
||
slotKeys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
|
||
if (slotKeys.length === 0) slotKeys = [0];
|
||
} else {
|
||
slotKeys = [0, 1, 2, 3];
|
||
}
|
||
|
||
slotKeys.forEach(slot => {
|
||
const avatarId = currentLineup[slot] || currentLineup[String(slot)] || 0;
|
||
const slotCard = document.createElement('div');
|
||
slotCard.className = `lineup-slot-card ${avatarId ? 'filled' : ''}`;
|
||
slotCard.title = t('slotTooltip');
|
||
|
||
const removeSlotBtnHtml = state.creativeMode ? `
|
||
<button type="button" class="btn-lineup-remove-slot" title="${t('deleteItem') || 'ลบ'}" data-slot="${slot}">✕</button>
|
||
` : '';
|
||
|
||
if (avatarId === 0) {
|
||
slotCard.innerHTML = `
|
||
<div class="lineup-slot-num">Slot ${slot + 1}</div>
|
||
${removeSlotBtnHtml}
|
||
<div style="font-size: 2.2rem; color: var(--text-muted); opacity: 0.5;">➕</div>
|
||
<div style="font-weight: 700; color: var(--text-muted); margin-top: 0.5rem;">${t('slotEmpty')}</div>
|
||
`;
|
||
} else {
|
||
const info = getAvatarInfo(avatarId);
|
||
slotCard.innerHTML = `
|
||
<div class="lineup-slot-num">Slot ${slot + 1}</div>
|
||
${removeSlotBtnHtml}
|
||
<img class="lineup-slot-img"
|
||
src="./assets/avatars/${avatarId}.png"
|
||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${avatarId}.png';}else{this.style.display='none';}"
|
||
alt="${info.name}">
|
||
<div style="font-weight: 800; font-size: 1rem; color: var(--text-highlight); margin-top: 0.35rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%;">${info.name}</div>
|
||
<div style="display: flex; gap: 0.4rem; margin-top: 0.4rem; justify-content: center; align-items: center;">
|
||
${getElementIconHtml(info.element, 20)}
|
||
${getPathIconHtml(info.path, 20)}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
slotCard.addEventListener('click', (e) => {
|
||
if (e.target.closest('.btn-lineup-remove-slot')) {
|
||
e.stopPropagation();
|
||
removeLineupSlot(slot);
|
||
return;
|
||
}
|
||
openLineupPicker(slot);
|
||
});
|
||
|
||
el.lineupContainer.appendChild(slotCard);
|
||
});
|
||
|
||
// In Creative Mode: render "+ เพิ่ม Slot" button card
|
||
if (state.creativeMode) {
|
||
const addSlotCard = document.createElement('div');
|
||
addSlotCard.className = 'lineup-slot-card lineup-add-slot-card';
|
||
addSlotCard.title = t('btnAddSlot') || '+ เพิ่ม Slot';
|
||
addSlotCard.innerHTML = `
|
||
<div style="font-size: 2.2rem; color: var(--accent-green); line-height: 1;">➕</div>
|
||
<div style="font-weight: 700; color: var(--accent-green); margin-top: 0.5rem;">${t('btnAddSlot') || '+ เพิ่ม Slot'}</div>
|
||
`;
|
||
addSlotCard.addEventListener('click', () => {
|
||
addLineupSlot();
|
||
});
|
||
el.lineupContainer.appendChild(addSlotCard);
|
||
}
|
||
}
|
||
|
||
// Add extra slot in Creative Mode
|
||
async function addLineupSlot() {
|
||
state.statusData = state.statusData || {};
|
||
let currentLineup = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
|
||
if (Array.isArray(currentLineup)) {
|
||
const mapped = {};
|
||
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
|
||
currentLineup = mapped;
|
||
}
|
||
const keys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
|
||
const nextSlot = keys.length > 0 ? Math.max(...keys) + 1 : 0;
|
||
currentLineup[nextSlot] = 0;
|
||
state.statusData.lineups = currentLineup;
|
||
|
||
if (state.fullFreesrData) {
|
||
state.fullFreesrData.lineups = currentLineup;
|
||
if (state.fullFreesrData.battle_config) {
|
||
state.fullFreesrData.battle_config.custom_battle_lineup = currentLineup;
|
||
}
|
||
}
|
||
|
||
if (state.isConnected) {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/persistent', 'POST', { lineups: currentLineup }).catch(() => {});
|
||
} else {
|
||
await saveFreesrChangesToServer().catch(() => {});
|
||
}
|
||
}
|
||
|
||
renderLineup(currentLineup);
|
||
openLineupPicker(nextSlot);
|
||
}
|
||
|
||
// Remove slot in Creative Mode
|
||
async function removeLineupSlot(slotIndex) {
|
||
state.statusData = state.statusData || {};
|
||
let currentLineup = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
|
||
if (Array.isArray(currentLineup)) {
|
||
const mapped = {};
|
||
currentLineup.forEach((id, idx) => { mapped[idx] = id; });
|
||
currentLineup = mapped;
|
||
}
|
||
|
||
const keys = Object.keys(currentLineup).map(k => parseInt(k, 10)).sort((a, b) => a - b);
|
||
const newLineup = {};
|
||
let newIdx = 0;
|
||
for (const k of keys) {
|
||
if (k !== slotIndex) {
|
||
newLineup[newIdx] = currentLineup[k];
|
||
newIdx++;
|
||
}
|
||
}
|
||
|
||
if (Object.keys(newLineup).length === 0) {
|
||
newLineup[0] = 0;
|
||
}
|
||
|
||
state.statusData.lineups = newLineup;
|
||
|
||
if (state.fullFreesrData) {
|
||
state.fullFreesrData.lineups = newLineup;
|
||
if (state.fullFreesrData.battle_config) {
|
||
state.fullFreesrData.battle_config.custom_battle_lineup = newLineup;
|
||
}
|
||
}
|
||
|
||
if (state.isConnected) {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/persistent', 'POST', { lineups: newLineup }).catch(() => {});
|
||
} else {
|
||
await saveFreesrChangesToServer().catch(() => {});
|
||
}
|
||
}
|
||
|
||
renderLineup(newLineup);
|
||
const toastMsg = t('toastSlotRemoved', { slot: slotIndex + 1 }) || `ลบ Slot ${slotIndex + 1} เรียบร้อยแล้ว`;
|
||
showToast(toastMsg, 'info');
|
||
}
|
||
|
||
// Lineup Picker Modal
|
||
function openLineupPicker(slotIndex) {
|
||
state.selectedLineupSlot = slotIndex;
|
||
el.modalPickerTitle.textContent = `${t('pickerTitle')} - Slot ${slotIndex + 1}`;
|
||
el.pickerSearch.value = '';
|
||
renderPickerGrid();
|
||
el.modalLineupPicker.classList.add('active');
|
||
}
|
||
|
||
function renderPickerGrid() {
|
||
const search = el.pickerSearch.value.trim().toLowerCase();
|
||
el.pickerAvatarsGrid.innerHTML = '';
|
||
|
||
// Option to remove character from slot
|
||
const removeCard = document.createElement('div');
|
||
removeCard.className = 'picker-remove-card';
|
||
removeCard.innerHTML = `
|
||
<span style="font-size: 1.15rem; line-height: 1;">❌</span>
|
||
<span>${t('removeFromTeam')}</span>
|
||
`;
|
||
removeCard.addEventListener('click', async () => {
|
||
await assignAvatarToLineup(state.selectedLineupSlot, 0);
|
||
el.modalLineupPicker.classList.remove('active');
|
||
});
|
||
el.pickerAvatarsGrid.appendChild(removeCard);
|
||
|
||
// Available avatars (sorted descending by avatar_id)
|
||
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
|
||
sortedAvatars.forEach(item => {
|
||
const meta = getAvatarInfo(item.avatar_id);
|
||
if (search && !meta.name.toLowerCase().includes(search) && !String(item.avatar_id).includes(search)) {
|
||
return;
|
||
}
|
||
|
||
const card = document.createElement('div');
|
||
card.className = `picker-card-item card-rarity-${meta.rarity}`;
|
||
|
||
card.innerHTML = `
|
||
<div class="picker-avatar-img-wrap">
|
||
<img class="picker-avatar-img"
|
||
src="./assets/avatars/${item.avatar_id}.png"
|
||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${item.avatar_id}.png';}else{this.style.display='none';}"
|
||
alt="${meta.name}">
|
||
</div>
|
||
<div class="picker-avatar-info">
|
||
<div class="picker-avatar-name" title="${meta.name}">${meta.name}</div>
|
||
<div class="picker-avatar-subinfo">
|
||
${getElementIconHtml(meta.element, 18)}
|
||
${getPathIconHtml(meta.path, 18)}
|
||
<span style="font-weight: 600; margin-left: 0.2rem;">Lv. ${item.level} • E${item.rank}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
card.addEventListener('click', async () => {
|
||
await assignAvatarToLineup(state.selectedLineupSlot, item.avatar_id);
|
||
el.modalLineupPicker.classList.remove('active');
|
||
});
|
||
|
||
el.pickerAvatarsGrid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
// Assign avatar to lineup slot
|
||
async function assignAvatarToLineup(slot, avatarId) {
|
||
try {
|
||
state.statusData = state.statusData || {};
|
||
const lineups = { ...(state.statusData.lineups || DEFAULT_LINEUP) };
|
||
|
||
// In Normal Mode: Prevent duplicates by clearing other slots that had this avatar
|
||
if (!state.creativeMode && avatarId !== 0) {
|
||
for (const s in lineups) {
|
||
if (parseInt(s, 10) !== slot && lineups[s] === avatarId) {
|
||
lineups[s] = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
lineups[slot] = avatarId;
|
||
state.statusData.lineups = lineups;
|
||
|
||
if (state.fullFreesrData) {
|
||
state.fullFreesrData.lineups = lineups;
|
||
if (state.fullFreesrData.battle_config) {
|
||
state.fullFreesrData.battle_config.custom_battle_lineup = lineups;
|
||
}
|
||
}
|
||
|
||
if (state.isConnected) {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/persistent', 'POST', { lineups });
|
||
} else {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
}
|
||
|
||
renderLineup(lineups);
|
||
const name = avatarId ? getAvatarInfo(avatarId).name : t('slotEmpty');
|
||
showToast(t('toastSlotUpdated', { slot: slot + 1, name }), 'success');
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Load and Render Avatars
|
||
async function loadAvatars() {
|
||
initAvatarsList();
|
||
if (state.isConnected) {
|
||
try {
|
||
const res = await apiCall('/api/freesr/avatars');
|
||
if (res && res.avatars && res.avatars.length > 0) {
|
||
res.avatars.forEach(serverAv => {
|
||
const found = state.avatarsList.find(a => a.avatar_id === serverAv.avatar_id);
|
||
if (found) {
|
||
found.level = serverAv.level;
|
||
found.promotion = serverAv.promotion;
|
||
found.rank = serverAv.rank;
|
||
}
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn('Live avatar list fetch failed, using local list:', err);
|
||
}
|
||
}
|
||
state.avatarsList.sort((a, b) => b.avatar_id - a.avatar_id);
|
||
renderAvatarsGrid();
|
||
}
|
||
|
||
function renderAvatarsGrid() {
|
||
const searchTerm = el.avatarSearch.value.trim().toLowerCase();
|
||
const rarityFilter = el.avatarFilterRarity.value;
|
||
const elementFilter = el.avatarFilterElement.value;
|
||
const pathFilter = el.avatarFilterPath ? el.avatarFilterPath.value : 'all';
|
||
|
||
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;
|
||
const elemCanonical = normalizeElement(meta.rawElement || meta.element);
|
||
const matchesElement = elementFilter === 'all' || elemCanonical === normalizeElement(elementFilter);
|
||
const pathCanonical = normalizePath(meta.rawPath || meta.path);
|
||
const matchesPath = pathFilter === 'all' || pathCanonical === normalizePath(pathFilter);
|
||
return matchesSearch && matchesRarity && matchesElement && matchesPath;
|
||
});
|
||
|
||
filtered.sort((a, b) => b.avatar_id - a.avatar_id);
|
||
|
||
if (filtered.length === 0) {
|
||
el.avatarsGrid.innerHTML = `<div style="grid-column: 1/-1; text-align: center; color: var(--text-muted); padding: 2rem;">${t('noAvatarsFound')}</div>`;
|
||
return;
|
||
}
|
||
|
||
el.avatarsGrid.innerHTML = '';
|
||
filtered.forEach(item => {
|
||
const meta = getAvatarInfo(item.avatar_id);
|
||
const card = document.createElement('div');
|
||
card.className = `card avatar-card card-rarity-${meta.rarity}`;
|
||
|
||
card.innerHTML = `
|
||
<span class="avatar-badge-rarity rarity-${meta.rarity}">★ ${meta.rarity}</span>
|
||
<div class="avatar-header-info">
|
||
<div class="avatar-image-container">
|
||
<img class="avatar-img"
|
||
src="./assets/avatars/${item.avatar_id}.png"
|
||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${item.avatar_id}.png';}else{this.style.display='none';this.nextElementSibling.style.display='flex';}"
|
||
alt="${meta.name}">
|
||
<div class="avatar-icon-placeholder" style="display: none;">${meta.name.charAt(0)}</div>
|
||
</div>
|
||
<div class="avatar-meta-details">
|
||
<div class="avatar-name" title="${meta.name}">${meta.name}</div>
|
||
<div class="avatar-subinfo">
|
||
${getElementIconHtml(meta.element, 20)}
|
||
${getPathIconHtml(meta.path, 20)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="avatar-card-footer">
|
||
<div class="avatar-status-pill">
|
||
<span class="avatar-lvl">Lv. ${item.level}</span>
|
||
<span class="avatar-eidolon ${item.rank === 6 ? 'e6' : ''}">E${item.rank}</span>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-gold btn-card-quick-max" title="ปรับ Max E6 ทันที">⭐ MAX</button>
|
||
</div>
|
||
`;
|
||
|
||
// Quick Max Button
|
||
const btnQuickMax = card.querySelector('.btn-card-quick-max');
|
||
btnQuickMax.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
await maxSingleAvatar(item.avatar_id);
|
||
});
|
||
|
||
// Clicking card opens full character build modal
|
||
card.addEventListener('click', () => {
|
||
openAvatarDetailModal(item);
|
||
});
|
||
|
||
el.avatarsGrid.appendChild(card);
|
||
});
|
||
}
|
||
|
||
// Quick Max Single Avatar (Lv 80 + Prom 6 + E6 + Max Traces)
|
||
async function maxSingleAvatar(avatarId) {
|
||
const target = state.avatarsList.find(a => a.avatar_id === avatarId);
|
||
if (target) {
|
||
target.level = 80;
|
||
target.promotion = 6;
|
||
target.rank = 6;
|
||
}
|
||
if (state.fullFreesrData?.avatars?.[avatarId]) {
|
||
const av = state.fullFreesrData.avatars[avatarId];
|
||
av.level = 80;
|
||
av.promotion = 6;
|
||
av.data.rank = 6;
|
||
if (av.data.skills) {
|
||
for (const k in av.data.skills) av.data.skills[k] = 10;
|
||
}
|
||
}
|
||
|
||
renderAvatarsGrid();
|
||
|
||
if (state.isConnected) {
|
||
try {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/avatar/rank', 'POST', { avatar_id: avatarId, rank: 6 });
|
||
await apiCall('/api/freesr/avatars', 'POST', { avatar_id: avatarId, level: 80, promotion: 6, rank: 6 });
|
||
} else {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
showToast(t('toastCharMaxed', { name: getAvatarInfo(avatarId).name }), 'success');
|
||
} catch (err) {
|
||
showToast(t('toastCharMaxed', { name: getAvatarInfo(avatarId).name }), 'info');
|
||
}
|
||
} else {
|
||
showToast(t('toastCharMaxed', { name: getAvatarInfo(avatarId).name }), 'info');
|
||
}
|
||
}
|
||
|
||
// Active Lightcone Superimposition Rank in Modal (1 - 5)
|
||
let selectedLcRank = 5;
|
||
|
||
// Modal Sub-tabs Handler
|
||
function setupModalSubtabs() {
|
||
const tabBtns = document.querySelectorAll('.modal-subtab-btn');
|
||
tabBtns.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
tabBtns.forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.modal-subtab-content').forEach(c => {
|
||
c.style.display = 'none';
|
||
c.classList.remove('active');
|
||
});
|
||
btn.classList.add('active');
|
||
const targetId = btn.getAttribute('data-modaltab');
|
||
const targetContent = document.getElementById(targetId);
|
||
if (targetContent) {
|
||
targetContent.style.display = 'block';
|
||
targetContent.classList.add('active');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateLcRankPips(rank) {
|
||
selectedLcRank = rank;
|
||
document.querySelectorAll('.lc-super-pip').forEach(btn => {
|
||
const r = parseInt(btn.getAttribute('data-rank'), 10);
|
||
if (r === rank) {
|
||
btn.classList.add('active');
|
||
} else {
|
||
btn.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateModalLcPreview(lcId) {
|
||
if (!el.modalLcPreview) return;
|
||
const id = parseInt(lcId, 10);
|
||
if (!id) {
|
||
el.modalLcPreview.innerHTML = '';
|
||
return;
|
||
}
|
||
const info = getLightconeInfo(id);
|
||
el.modalLcPreview.innerHTML = `
|
||
<span style="color: var(--accent-cyan); font-weight: 700;">${info.name}</span>
|
||
<span class="avatar-badge-rarity rarity-${info.rarity}" style="position: static; font-size: 0.7rem; padding: 0.1rem 0.4rem; margin-left: 0.4rem;">★ ${info.rarity}</span>
|
||
`;
|
||
}
|
||
|
||
// Character Detail / Build Modal (Inspired by srtools.punklorde.org/skills-info)
|
||
function openAvatarDetailModal(avatarItem) {
|
||
state.selectedAvatarDetail = avatarItem;
|
||
const aid = avatarItem.avatar_id;
|
||
const meta = getAvatarInfo(aid);
|
||
|
||
el.modalCharTitle.textContent = `${t('modalTitleCharEdit')}: ${meta.name} (ID: ${aid})`;
|
||
el.modalCharImg.src = `./assets/avatars/${aid}.png`;
|
||
el.modalCharImg.onerror = function() {
|
||
this.src = `https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${aid}.png`;
|
||
};
|
||
el.modalCharName.textContent = meta.name;
|
||
el.modalCharMeta.innerHTML = `
|
||
${getElementIconHtml(meta.element, 24)}
|
||
${getPathIconHtml(meta.path, 24)}
|
||
<span class="avatar-badge-rarity rarity-${meta.rarity}" style="position: static;">★ ${meta.rarity}</span>
|
||
`;
|
||
|
||
// Reset to first sub-tab (Skills & Traces)
|
||
document.querySelectorAll('.modal-subtab-btn').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.modal-subtab-content').forEach(c => {
|
||
c.style.display = 'none';
|
||
c.classList.remove('active');
|
||
});
|
||
const firstTabBtn = document.querySelector('.modal-subtab-btn[data-modaltab="modaltab-skills"]');
|
||
if (firstTabBtn) firstTabBtn.classList.add('active');
|
||
const firstTabContent = document.getElementById('modaltab-skills');
|
||
if (firstTabContent) {
|
||
firstTabContent.style.display = 'block';
|
||
firstTabContent.classList.add('active');
|
||
}
|
||
|
||
// Basic Status
|
||
el.modalCharLevel.value = avatarItem.level || 80;
|
||
el.modalCharPromotion.value = avatarItem.promotion || 6;
|
||
renderModalEidolonPips(avatarItem.rank ?? 6);
|
||
|
||
// Load avatar data from fullFreesrData if available
|
||
const av = state.fullFreesrData?.avatars?.[aid];
|
||
const skills = av?.data?.skills || {};
|
||
const anchorSkills = av?.data?.skills_by_anchor_type || {};
|
||
|
||
// 1. Basic Attack: anchor 1 or aid + '001' (1-6)
|
||
const basicVal = anchorSkills['1'] ?? skills[`${aid}001`] ?? 6;
|
||
if (el.rangeSkillBasic && el.valSkillBasic) {
|
||
el.rangeSkillBasic.value = Math.min(6, Math.max(1, basicVal));
|
||
el.valSkillBasic.textContent = el.rangeSkillBasic.value;
|
||
}
|
||
|
||
// 2. Skill: anchor 2 or aid + '002' (1-10)
|
||
const skillVal = anchorSkills['2'] ?? skills[`${aid}002`] ?? 10;
|
||
if (el.rangeSkillSkill && el.valSkillSkill) {
|
||
el.rangeSkillSkill.value = Math.min(10, Math.max(1, skillVal));
|
||
el.valSkillSkill.textContent = el.rangeSkillSkill.value;
|
||
}
|
||
|
||
// 3. Ultimate: anchor 3 or aid + '003' (1-10)
|
||
const ultraVal = anchorSkills['3'] ?? skills[`${aid}003`] ?? 10;
|
||
if (el.rangeSkillUltra && el.valSkillUltra) {
|
||
el.rangeSkillUltra.value = Math.min(10, Math.max(1, ultraVal));
|
||
el.valSkillUltra.textContent = el.rangeSkillUltra.value;
|
||
}
|
||
|
||
// 4. Talent: anchor 4 or aid + '004' (1-10)
|
||
const talentVal = anchorSkills['4'] ?? skills[`${aid}004`] ?? 10;
|
||
if (el.rangeSkillTalent && el.valSkillTalent) {
|
||
el.rangeSkillTalent.value = Math.min(10, Math.max(1, talentVal));
|
||
el.valSkillTalent.textContent = el.rangeSkillTalent.value;
|
||
}
|
||
|
||
// Traces & Technique
|
||
if (el.checkTraceTechnique) {
|
||
el.checkTraceTechnique.checked = (anchorSkills['5'] ?? skills[`${aid}007`] ?? 1) > 0;
|
||
}
|
||
if (el.checkTraceA2) {
|
||
el.checkTraceA2.checked = (anchorSkills['6'] ?? skills[`${aid}101`] ?? 1) > 0;
|
||
}
|
||
if (el.checkTraceA4) {
|
||
el.checkTraceA4.checked = (anchorSkills['7'] ?? skills[`${aid}102`] ?? 1) > 0;
|
||
}
|
||
if (el.checkTraceA6) {
|
||
el.checkTraceA6.checked = (anchorSkills['8'] ?? skills[`${aid}103`] ?? 1) > 0;
|
||
}
|
||
if (el.checkTraceStats) {
|
||
el.checkTraceStats.checked = (anchorSkills['9'] ?? skills[`${aid}201`] ?? 1) > 0;
|
||
}
|
||
|
||
// Ultimate Energy
|
||
const maxSp = av?.sp_max ?? 140;
|
||
const currentSp = av?.sp_value ?? maxSp;
|
||
if (el.rangeCharEnergy && el.valCharEnergy) {
|
||
el.rangeCharEnergy.max = maxSp;
|
||
el.rangeCharEnergy.value = currentSp;
|
||
el.valCharEnergy.textContent = `${currentSp}/${maxSp}`;
|
||
}
|
||
|
||
// Lightcone
|
||
const equippedLc = (state.fullFreesrData?.lightcones || []).find(lc => lc.equip_avatar === aid);
|
||
if (equippedLc) {
|
||
if (el.modalLcId) el.modalLcId.value = equippedLc.item_id || '';
|
||
if (el.modalLcLevel) el.modalLcLevel.value = equippedLc.level || 80;
|
||
selectedLcRank = equippedLc.rank || 5;
|
||
updateModalLcPreview(equippedLc.item_id);
|
||
} else {
|
||
if (el.modalLcId) el.modalLcId.value = '';
|
||
if (el.modalLcLevel) el.modalLcLevel.value = 80;
|
||
selectedLcRank = 5;
|
||
updateModalLcPreview(null);
|
||
}
|
||
updateLcRankPips(selectedLcRank);
|
||
|
||
el.modalAvatarDetail.classList.add('active');
|
||
}
|
||
|
||
function renderModalEidolonPips(currentRank) {
|
||
el.modalCharEidolonBtns.innerHTML = '';
|
||
for (let r = 0; r <= 6; r++) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = `eidolon-pip modal-eidolon-pip ${currentRank >= r ? 'active' : ''}`;
|
||
btn.textContent = `E${r}`;
|
||
btn.title = `Eidolon ${r}`;
|
||
btn.addEventListener('click', () => {
|
||
if (state.selectedAvatarDetail) {
|
||
state.selectedAvatarDetail.rank = r;
|
||
}
|
||
renderModalEidolonPips(r);
|
||
});
|
||
el.modalCharEidolonBtns.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
// Set avatar rank (Used by both card pips and modal)
|
||
async function setAvatarRank(avatarId, rank) {
|
||
const target = state.avatarsList.find(a => a.avatar_id === avatarId);
|
||
if (target) {
|
||
target.rank = rank;
|
||
}
|
||
if (state.fullFreesrData?.avatars?.[avatarId]) {
|
||
state.fullFreesrData.avatars[avatarId].data.rank = rank;
|
||
}
|
||
|
||
renderAvatarsGrid();
|
||
|
||
if (state.selectedAvatarDetail && state.selectedAvatarDetail.avatar_id === avatarId) {
|
||
state.selectedAvatarDetail.rank = rank;
|
||
renderModalEidolonPips(rank);
|
||
}
|
||
|
||
if (state.isConnected) {
|
||
try {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/avatar/rank', 'POST', { avatar_id: avatarId, rank });
|
||
} else {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
showToast(t('avatarRankSuccess', { id: avatarId, rank }), 'success');
|
||
} catch (err) {
|
||
console.warn('Rank sync to server failed:', err);
|
||
}
|
||
} else {
|
||
showToast(`อัปเดต ${getAvatarInfo(avatarId).name} เป็น E${rank} (บันทึกในเครื่อง)`, 'info');
|
||
}
|
||
}
|
||
|
||
// Max Skills & Traces for character
|
||
function maxSkillsForAvatar() {
|
||
if (el.rangeSkillBasic && el.valSkillBasic) {
|
||
el.rangeSkillBasic.value = 6;
|
||
el.valSkillBasic.textContent = 6;
|
||
}
|
||
if (el.rangeSkillSkill && el.valSkillSkill) {
|
||
el.rangeSkillSkill.value = 10;
|
||
el.valSkillSkill.textContent = 10;
|
||
}
|
||
if (el.rangeSkillUltra && el.valSkillUltra) {
|
||
el.rangeSkillUltra.value = 10;
|
||
el.valSkillUltra.textContent = 10;
|
||
}
|
||
if (el.rangeSkillTalent && el.valSkillTalent) {
|
||
el.rangeSkillTalent.value = 10;
|
||
el.valSkillTalent.textContent = 10;
|
||
}
|
||
if (el.checkTraceTechnique) el.checkTraceTechnique.checked = true;
|
||
if (el.checkTraceA2) el.checkTraceA2.checked = true;
|
||
if (el.checkTraceA4) el.checkTraceA4.checked = true;
|
||
if (el.checkTraceA6) el.checkTraceA6.checked = true;
|
||
if (el.checkTraceStats) el.checkTraceStats.checked = true;
|
||
|
||
showToast(t('toastAllSkillsMaxed'), 'info');
|
||
}
|
||
|
||
// Max All Build For Current Avatar (Lv.80, Prom 6, E6, Max Skills, Max Traces, 100% Energy, LC Lv.80 S5)
|
||
async function maxAllBuildForCurrentAvatar() {
|
||
if (!state.selectedAvatarDetail) return;
|
||
const aid = state.selectedAvatarDetail.avatar_id;
|
||
|
||
// Level & Eidolon
|
||
el.modalCharLevel.value = 80;
|
||
el.modalCharPromotion.value = 6;
|
||
state.selectedAvatarDetail.level = 80;
|
||
state.selectedAvatarDetail.promotion = 6;
|
||
state.selectedAvatarDetail.rank = 6;
|
||
renderModalEidolonPips(6);
|
||
|
||
// Skills & Traces
|
||
maxSkillsForAvatar();
|
||
|
||
// Energy 100%
|
||
if (el.rangeCharEnergy && el.valCharEnergy) {
|
||
el.rangeCharEnergy.value = el.rangeCharEnergy.max;
|
||
el.valCharEnergy.textContent = `${el.rangeCharEnergy.max}/${el.rangeCharEnergy.max}`;
|
||
}
|
||
|
||
// Lightcone
|
||
if (el.modalLcLevel) el.modalLcLevel.value = 80;
|
||
updateLcRankPips(5);
|
||
|
||
// Save changes
|
||
await saveAvatarDetails();
|
||
}
|
||
|
||
// Save avatar details from modal
|
||
async function saveAvatarDetails() {
|
||
if (!state.selectedAvatarDetail) return;
|
||
const aid = state.selectedAvatarDetail.avatar_id;
|
||
const level = parseInt(el.modalCharLevel.value, 10) || 80;
|
||
const promotion = parseInt(el.modalCharPromotion.value, 10) || 6;
|
||
const rank = state.selectedAvatarDetail.rank ?? 6;
|
||
|
||
state.selectedAvatarDetail.level = level;
|
||
state.selectedAvatarDetail.promotion = promotion;
|
||
state.selectedAvatarDetail.rank = rank;
|
||
|
||
const target = state.avatarsList.find(a => a.avatar_id === aid);
|
||
if (target) {
|
||
target.level = level;
|
||
target.promotion = promotion;
|
||
target.rank = rank;
|
||
}
|
||
|
||
if (!state.fullFreesrData) {
|
||
state.fullFreesrData = { avatars: {}, lightcones: [], relics: [] };
|
||
}
|
||
if (!state.fullFreesrData.avatars) {
|
||
state.fullFreesrData.avatars = {};
|
||
}
|
||
if (!state.fullFreesrData.avatars[aid]) {
|
||
state.fullFreesrData.avatars[aid] = {
|
||
owner_uid: 0,
|
||
avatar_id: aid,
|
||
level,
|
||
promotion,
|
||
techniques: [],
|
||
sp_value: parseInt(el.rangeCharEnergy?.value, 10) || 140,
|
||
sp_max: parseInt(el.rangeCharEnergy?.max, 10) || 140,
|
||
data: {
|
||
rank,
|
||
skills: {},
|
||
skills_by_anchor_type: {}
|
||
}
|
||
};
|
||
}
|
||
|
||
const av = state.fullFreesrData.avatars[aid];
|
||
av.level = level;
|
||
av.promotion = promotion;
|
||
av.data = av.data || {};
|
||
av.data.rank = rank;
|
||
if (el.rangeCharEnergy) {
|
||
av.sp_value = parseInt(el.rangeCharEnergy.value, 10) || av.sp_max || 140;
|
||
}
|
||
|
||
// Extract skills values
|
||
const basicVal = parseInt(el.rangeSkillBasic?.value, 10) || 6;
|
||
const skillVal = parseInt(el.rangeSkillSkill?.value, 10) || 10;
|
||
const ultraVal = parseInt(el.rangeSkillUltra?.value, 10) || 10;
|
||
const talentVal = parseInt(el.rangeSkillTalent?.value, 10) || 10;
|
||
const techVal = el.checkTraceTechnique?.checked ? 1 : 0;
|
||
const a2Val = el.checkTraceA2?.checked ? 1 : 0;
|
||
const a4Val = el.checkTraceA4?.checked ? 1 : 0;
|
||
const a6Val = el.checkTraceA6?.checked ? 1 : 0;
|
||
const statVal = el.checkTraceStats?.checked ? 1 : 0;
|
||
|
||
av.data.skills = av.data.skills || {};
|
||
av.data.skills[`${aid}001`] = basicVal;
|
||
av.data.skills[`${aid}002`] = skillVal;
|
||
av.data.skills[`${aid}003`] = ultraVal;
|
||
av.data.skills[`${aid}004`] = talentVal;
|
||
av.data.skills[`${aid}007`] = techVal;
|
||
av.data.skills[`${aid}101`] = a2Val;
|
||
av.data.skills[`${aid}102`] = a4Val;
|
||
av.data.skills[`${aid}103`] = a6Val;
|
||
for (let i = 1; i <= 10; i++) {
|
||
const pad = i < 10 ? `0${i}` : `${i}`;
|
||
av.data.skills[`${aid}2${pad}`] = statVal;
|
||
}
|
||
|
||
av.data.skills_by_anchor_type = {
|
||
"1": basicVal,
|
||
"2": skillVal,
|
||
"3": ultraVal,
|
||
"4": talentVal,
|
||
"5": techVal,
|
||
"6": a2Val,
|
||
"7": a4Val,
|
||
"8": a6Val,
|
||
"9": statVal,
|
||
"10": statVal,
|
||
"11": statVal,
|
||
"12": statVal,
|
||
"13": statVal,
|
||
"14": statVal,
|
||
"15": statVal,
|
||
"16": statVal,
|
||
"17": statVal,
|
||
"18": statVal
|
||
};
|
||
|
||
// Lightcone update
|
||
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones || [];
|
||
const lcId = parseInt(el.modalLcId?.value, 10);
|
||
const lcLevel = parseInt(el.modalLcLevel?.value, 10) || 80;
|
||
const lcRank = selectedLcRank || 5;
|
||
const lcProm = lcLevel > 70 ? 6 : (lcLevel > 60 ? 5 : (lcLevel > 50 ? 4 : (lcLevel > 40 ? 3 : (lcLevel > 20 ? 1 : 0))));
|
||
|
||
const existingLcIndex = state.fullFreesrData.lightcones.findIndex(lc => lc.equip_avatar === aid);
|
||
if (lcId && lcId > 0) {
|
||
if (existingLcIndex !== -1) {
|
||
state.fullFreesrData.lightcones[existingLcIndex].item_id = lcId;
|
||
state.fullFreesrData.lightcones[existingLcIndex].level = lcLevel;
|
||
state.fullFreesrData.lightcones[existingLcIndex].rank = lcRank;
|
||
state.fullFreesrData.lightcones[existingLcIndex].promotion = lcProm;
|
||
} else {
|
||
state.fullFreesrData.lightcones.push({
|
||
internal_uid: state.fullFreesrData.lightcones.length,
|
||
item_id: lcId,
|
||
equip_avatar: aid,
|
||
level: lcLevel,
|
||
rank: lcRank,
|
||
promotion: lcProm
|
||
});
|
||
}
|
||
} else if (existingLcIndex !== -1) {
|
||
state.fullFreesrData.lightcones[existingLcIndex].equip_avatar = null;
|
||
}
|
||
|
||
renderAvatarsGrid();
|
||
loadEquipment();
|
||
el.modalAvatarDetail.classList.remove('active');
|
||
|
||
if (state.isConnected) {
|
||
try {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/freesr/avatars', 'POST', { avatar_id: aid, level, promotion, rank });
|
||
await saveFreesrChangesToServer();
|
||
} else {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'success');
|
||
} catch (err) {
|
||
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'info');
|
||
}
|
||
} else {
|
||
showToast(t('toastCharSaved', { name: getAvatarInfo(aid).name }), 'success');
|
||
}
|
||
}
|
||
|
||
// Max all avatars
|
||
async function maxAllAvatars() {
|
||
if (!confirm(t('confirmMaxAll'))) return;
|
||
state.avatarsList.forEach(a => {
|
||
a.level = 80;
|
||
a.promotion = 6;
|
||
a.rank = 6;
|
||
});
|
||
|
||
if (state.fullFreesrData?.avatars) {
|
||
for (const aid in state.fullFreesrData.avatars) {
|
||
const av = state.fullFreesrData.avatars[aid];
|
||
av.level = 80;
|
||
av.promotion = 6;
|
||
av.data.rank = 6;
|
||
if (av.data.skills) {
|
||
for (const k in av.data.skills) av.data.skills[k] = 10;
|
||
}
|
||
}
|
||
}
|
||
|
||
renderAvatarsGrid();
|
||
|
||
if (state.isConnected) {
|
||
try {
|
||
if (state.serverMode === 'gac') {
|
||
await apiCall('/api/avatar/max_all', 'POST', {});
|
||
} else {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
showToast(t('maxAllSuccess'), 'success');
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`ปรับในเครื่องสำเร็จ แต่ส่งเซิร์ฟเวอร์ไม่ได้: ${err.message}`, 'warning');
|
||
}
|
||
} else {
|
||
showToast('ปรับ Max Lv.80, เลื่อนขั้น 6, E6 ตัวละครทั้งหมดเรียบร้อย (โหมดออฟไลน์)', 'success');
|
||
}
|
||
}
|
||
|
||
// Push local freesr changes to server
|
||
async function saveFreesrChangesToServer() {
|
||
if (!state.fullFreesrData) return;
|
||
if (state.serverMode === 'firefly' || state.serverMode === 'robin') {
|
||
try {
|
||
await apiCall('/srtools', 'POST', { data: state.fullFreesrData });
|
||
} catch (e) {
|
||
await apiCall('/api/freesr', 'POST', state.fullFreesrData);
|
||
}
|
||
} else {
|
||
await apiCall('/api/freesr', 'POST', state.fullFreesrData);
|
||
}
|
||
}
|
||
|
||
// Load Equipment (Lightcones & Relics)
|
||
async function loadEquipment() {
|
||
try {
|
||
let lcs = [];
|
||
let relics = [];
|
||
|
||
if (state.fullFreesrData) {
|
||
lcs = state.fullFreesrData.lightcones || [];
|
||
relics = state.fullFreesrData.relics || [];
|
||
} else if (state.isConnected) {
|
||
const res = await apiCall('/api/freesr');
|
||
if (res && res.data) {
|
||
state.fullFreesrData = res.data;
|
||
lcs = res.data.lightcones || [];
|
||
relics = res.data.relics || [];
|
||
}
|
||
}
|
||
|
||
el.badgeTotalLc.textContent = t('itemCount', { n: lcs.length });
|
||
el.badgeTotalRelics.textContent = t('itemCount', { n: relics.length });
|
||
|
||
// Render Lightcones
|
||
el.lightconesList.innerHTML = '';
|
||
if (lcs.length === 0) {
|
||
el.lightconesList.innerHTML = `<div style="color: var(--text-muted); text-align: center; padding: 1.5rem;">${t('noLcData')}</div>`;
|
||
} else {
|
||
lcs.slice(0, 100).forEach(lc => {
|
||
const item = document.createElement('div');
|
||
item.className = 'card';
|
||
item.style.padding = '0.75rem';
|
||
item.style.display = 'flex';
|
||
item.style.justifyContent = 'space-between';
|
||
item.style.alignItems = 'center';
|
||
|
||
const lcInfo = getLightconeInfo(lc.item_id);
|
||
const equipped = lc.equip_avatar ? getAvatarInfo(lc.equip_avatar).name : t('notEquipped');
|
||
item.innerHTML = `
|
||
<div>
|
||
<div style="font-weight: 700; font-size: 0.95rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">
|
||
<span>${lcInfo.name}</span>
|
||
<span class="avatar-badge-rarity rarity-${lcInfo.rarity}" style="position: static; font-size: 0.7rem; padding: 0.1rem 0.4rem;">★ ${lcInfo.rarity}</span>
|
||
<span style="font-size: 0.8rem; color: var(--text-muted);">(ID: ${lc.item_id})</span>
|
||
</div>
|
||
<div style="font-size: 0.775rem; color: var(--text-muted); margin-top: 0.2rem;">
|
||
Lv. ${lc.level} • S${lc.rank} • ${t('equippedBy')}: <strong style="color: var(--accent-cyan);">${equipped}</strong>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button type="button" class="btn btn-sm btn-danger btn-delete-lc" data-uid="${lc.internal_uid}" title="${t('deleteItem')}">🗑️</button>
|
||
</div>
|
||
`;
|
||
el.lightconesList.appendChild(item);
|
||
});
|
||
|
||
// Bind delete LC buttons
|
||
el.lightconesList.querySelectorAll('.btn-delete-lc').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const uid = parseInt(e.currentTarget.getAttribute('data-uid'), 10);
|
||
if (state.fullFreesrData?.lightcones) {
|
||
state.fullFreesrData.lightcones = state.fullFreesrData.lightcones.filter(l => l.internal_uid !== uid);
|
||
await saveFreesrChangesToServer();
|
||
loadEquipment();
|
||
showToast(t('toastItemDeleted'), 'info');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Render Relics
|
||
el.relicsList.innerHTML = '';
|
||
if (relics.length === 0) {
|
||
el.relicsList.innerHTML = `<div style="color: var(--text-muted); text-align: center; padding: 1.5rem;">${t('noRelicData')}</div>`;
|
||
} else {
|
||
relics.slice(0, 100).forEach(r => {
|
||
const item = document.createElement('div');
|
||
item.className = 'card';
|
||
item.style.padding = '0.75rem';
|
||
item.style.display = 'flex';
|
||
item.style.justifyContent = 'space-between';
|
||
item.style.alignItems = 'center';
|
||
|
||
const setId = r.relic_set_id || r.relic_id || r.item_id;
|
||
const relicInfo = getRelicSetInfo(setId);
|
||
const equipped = r.equip_avatar ? getAvatarInfo(r.equip_avatar).name : t('notEquipped');
|
||
item.innerHTML = `
|
||
<div>
|
||
<div style="font-weight: 700; font-size: 0.95rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">
|
||
<span>${relicInfo.name}</span>
|
||
<span style="font-size: 0.8rem; color: var(--text-muted);">(ID: ${r.relic_id || setId})</span>
|
||
</div>
|
||
<div style="font-size: 0.775rem; color: var(--text-muted); margin-top: 0.2rem;">
|
||
Lv. ${r.level} • ${t('equippedBy')}: <strong style="color: var(--accent-cyan);">${equipped}</strong>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button type="button" class="btn btn-sm btn-danger btn-delete-relic" data-uid="${r.internal_uid}" title="${t('deleteItem')}">🗑️</button>
|
||
</div>
|
||
`;
|
||
el.relicsList.appendChild(item);
|
||
});
|
||
|
||
// Bind delete Relic buttons
|
||
el.relicsList.querySelectorAll('.btn-delete-relic').forEach(btn => {
|
||
btn.addEventListener('click', async (e) => {
|
||
const uid = parseInt(e.currentTarget.getAttribute('data-uid'), 10);
|
||
if (state.fullFreesrData?.relics) {
|
||
state.fullFreesrData.relics = state.fullFreesrData.relics.filter(r => r.internal_uid !== uid);
|
||
await saveFreesrChangesToServer();
|
||
loadEquipment();
|
||
showToast(t('toastItemDeleted'), 'info');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn('Equipment loading error:', err);
|
||
}
|
||
}
|
||
|
||
// Open Add Light Cone Modal
|
||
function openAddLcModal() {
|
||
if (!el.modalAddLc) return;
|
||
el.addLcSelect.innerHTML = '';
|
||
const lcIds = Object.keys(LIGHTCONE_METADATA).sort((a, b) => parseInt(b, 10) - parseInt(a, 10));
|
||
lcIds.forEach(id => {
|
||
const meta = getLightconeInfo(id);
|
||
const opt = document.createElement('option');
|
||
opt.value = id;
|
||
opt.textContent = `★${meta.rarity} [${meta.path || 'LC'}] ${meta.name} (ID: ${id})`;
|
||
el.addLcSelect.appendChild(opt);
|
||
});
|
||
|
||
el.addLcAvatar.innerHTML = `<option value="0">${t('unassigned')}</option>`;
|
||
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
|
||
sortedAvatars.forEach(av => {
|
||
const info = getAvatarInfo(av.avatar_id);
|
||
const opt = document.createElement('option');
|
||
opt.value = av.avatar_id;
|
||
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
|
||
el.addLcAvatar.appendChild(opt);
|
||
});
|
||
|
||
el.addLcLevel.value = 80;
|
||
el.addLcPromotion.value = 6;
|
||
el.addLcRank.value = 5;
|
||
|
||
el.modalAddLc.classList.add('active');
|
||
}
|
||
|
||
// Open Add Relic Modal
|
||
function openAddRelicModal() {
|
||
if (!el.modalAddRelic) return;
|
||
el.addRelicSet.innerHTML = '';
|
||
const setIds = Object.keys(RELIC_SET_METADATA).sort((a, b) => parseInt(b, 10) - parseInt(a, 10));
|
||
setIds.forEach(setId => {
|
||
const meta = getRelicSetInfo(setId);
|
||
const opt = document.createElement('option');
|
||
opt.value = setId;
|
||
opt.textContent = `${meta.name} (Set ID: ${setId})`;
|
||
el.addRelicSet.appendChild(opt);
|
||
});
|
||
|
||
el.addRelicAvatar.innerHTML = `<option value="0">${t('unassigned')}</option>`;
|
||
const sortedAvatars = [...state.avatarsList].sort((a, b) => b.avatar_id - a.avatar_id);
|
||
sortedAvatars.forEach(av => {
|
||
const info = getAvatarInfo(av.avatar_id);
|
||
const opt = document.createElement('option');
|
||
opt.value = av.avatar_id;
|
||
opt.textContent = `${info.name} (ID: ${av.avatar_id})`;
|
||
el.addRelicAvatar.appendChild(opt);
|
||
});
|
||
|
||
el.addRelicSlot.value = "1";
|
||
el.addRelicLevel.value = 15;
|
||
updateRelicMainAffixes("1");
|
||
|
||
el.modalAddRelic.classList.add('active');
|
||
}
|
||
|
||
function updateRelicMainAffixes(slot) {
|
||
if (!el.addRelicMainAffix) return;
|
||
el.addRelicMainAffix.innerHTML = '';
|
||
|
||
const affixesBySlot = {
|
||
"1": [{ id: 1, name: "HP (Flat)" }],
|
||
"2": [{ id: 1, name: "ATK (Flat)" }],
|
||
"3": [
|
||
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
|
||
{ id: 4, name: "CRIT Rate%" }, { id: 5, name: "CRIT DMG%" },
|
||
{ id: 6, name: "Outgoing Healing Boost%" }, { id: 7, name: "Effect Hit Rate%" }
|
||
],
|
||
"4": [
|
||
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
|
||
{ id: 4, name: "SPD" }
|
||
],
|
||
"5": [
|
||
{ id: 1, name: "HP%" }, { id: 2, name: "ATK%" }, { id: 3, name: "DEF%" },
|
||
{ id: 4, name: "Physical DMG Boost%" }, { id: 5, name: "Fire DMG Boost%" },
|
||
{ id: 6, name: "Ice DMG Boost%" }, { id: 7, name: "Lightning DMG Boost%" },
|
||
{ id: 8, name: "Wind DMG Boost%" }, { id: 9, name: "Quantum DMG Boost%" },
|
||
{ id: 10, name: "Imaginary DMG Boost%" }
|
||
],
|
||
"6": [
|
||
{ id: 1, name: "Break Effect%" }, { id: 2, name: "Energy Regeneration Rate%" },
|
||
{ id: 3, name: "HP%" }, { id: 4, name: "ATK%" }, { id: 5, name: "DEF%" }
|
||
]
|
||
};
|
||
|
||
const list = affixesBySlot[slot] || [{ id: 1, name: "Main Stat" }];
|
||
list.forEach(a => {
|
||
const opt = document.createElement('option');
|
||
opt.value = a.id;
|
||
opt.textContent = a.name;
|
||
el.addRelicMainAffix.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function handleAddLcSubmit(e) {
|
||
e.preventDefault();
|
||
const itemId = parseInt(el.addLcSelect.value, 10);
|
||
const level = parseInt(el.addLcLevel.value, 10) || 80;
|
||
const promotion = parseInt(el.addLcPromotion.value, 10) || 6;
|
||
const rank = parseInt(el.addLcRank.value, 10) || 5;
|
||
const equipAvatar = parseInt(el.addLcAvatar.value, 10) || 0;
|
||
|
||
if (!state.fullFreesrData) {
|
||
state.fullFreesrData = { lightcones: [], relics: [], avatars: {} };
|
||
}
|
||
if (!state.fullFreesrData.lightcones) {
|
||
state.fullFreesrData.lightcones = [];
|
||
}
|
||
|
||
if (equipAvatar !== 0) {
|
||
state.fullFreesrData.lightcones.forEach(lc => {
|
||
if (lc.equip_avatar === equipAvatar) lc.equip_avatar = 0;
|
||
});
|
||
}
|
||
|
||
const maxUid = state.fullFreesrData.lightcones.reduce((m, lc) => Math.max(m, lc.internal_uid || 0), 0);
|
||
const newLc = {
|
||
level,
|
||
item_id: itemId,
|
||
equip_avatar: equipAvatar,
|
||
rank,
|
||
promotion,
|
||
internal_uid: maxUid + 1
|
||
};
|
||
|
||
state.fullFreesrData.lightcones.unshift(newLc);
|
||
await saveFreesrChangesToServer();
|
||
loadEquipment();
|
||
el.modalAddLc.classList.remove('active');
|
||
showToast(t('toastLcAdded'), 'success');
|
||
}
|
||
|
||
async function handleAddRelicSubmit(e) {
|
||
e.preventDefault();
|
||
const setId = parseInt(el.addRelicSet.value, 10);
|
||
const slot = parseInt(el.addRelicSlot.value, 10) || 1;
|
||
const level = parseInt(el.addRelicLevel.value, 10) || 15;
|
||
const mainAffixId = parseInt(el.addRelicMainAffix.value, 10) || 1;
|
||
const equipAvatar = parseInt(el.addRelicAvatar.value, 10) || 0;
|
||
|
||
const relicId = 60000 + (setId * 10) + slot;
|
||
|
||
if (!state.fullFreesrData) {
|
||
state.fullFreesrData = { lightcones: [], relics: [], avatars: {} };
|
||
}
|
||
if (!state.fullFreesrData.relics) {
|
||
state.fullFreesrData.relics = [];
|
||
}
|
||
|
||
if (equipAvatar !== 0) {
|
||
state.fullFreesrData.relics.forEach(r => {
|
||
if (r.equip_avatar === equipAvatar && (r.relic_id % 10 === slot)) {
|
||
r.equip_avatar = 0;
|
||
}
|
||
});
|
||
}
|
||
|
||
const maxUid = state.fullFreesrData.relics.reduce((m, r) => Math.max(m, r.internal_uid || 0), 0);
|
||
const newRelic = {
|
||
level,
|
||
relic_id: relicId,
|
||
main_affix_id: mainAffixId,
|
||
sub_affixes: [
|
||
{ sub_affix_id: 1, cnt: 2, step: 1 },
|
||
{ sub_affix_id: 2, cnt: 2, step: 1 },
|
||
{ sub_affix_id: 3, cnt: 3, step: 2 },
|
||
{ sub_affix_id: 4, cnt: 4, step: 2 }
|
||
],
|
||
internal_uid: maxUid + 1,
|
||
equip_avatar: equipAvatar
|
||
};
|
||
|
||
state.fullFreesrData.relics.unshift(newRelic);
|
||
await saveFreesrChangesToServer();
|
||
loadEquipment();
|
||
el.modalAddRelic.classList.remove('active');
|
||
showToast(t('toastRelicAdded'), 'success');
|
||
}
|
||
|
||
// Load Persistent Save Settings (Exclusive to GaC-hkrpg-go)
|
||
async function loadPersistentSettings() {
|
||
if (state.serverMode !== 'gac') return;
|
||
try {
|
||
const res = await apiCall('/api/persistent');
|
||
if (!res || !res.data) return;
|
||
state.persistentData = res.data;
|
||
|
||
if (res.data.main_character) {
|
||
el.persistentMcSelect.value = res.data.main_character;
|
||
}
|
||
if (res.data.march_type) {
|
||
el.persistentMarchSelect.value = res.data.march_type;
|
||
}
|
||
|
||
el.toggleSwGlobal.checked = res.data.enable_sw_global !== false;
|
||
el.toggleCastoriceGlobal.checked = res.data.enable_castorice_global !== false;
|
||
if (el.toggleAhaGlobal) el.toggleAhaGlobal.checked = res.data.enable_aha_global !== false;
|
||
} catch (err) {
|
||
console.warn('Persistent settings loading error:', err);
|
||
}
|
||
}
|
||
|
||
// Render Ex-Rank Cards
|
||
function renderExRanks(exList) {
|
||
if (!el.exrankList) return;
|
||
el.exrankList.innerHTML = '';
|
||
if (!exList || exList.length === 0) {
|
||
el.exrankList.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 2rem;">ไม่มีข้อมูล Ex-Rank</div>';
|
||
return;
|
||
}
|
||
|
||
exList.forEach(ex => {
|
||
const item = document.createElement('div');
|
||
item.className = 'card exrank-item';
|
||
item.style.padding = '1.25rem';
|
||
item.style.display = 'flex';
|
||
item.style.justifyContent = 'space-between';
|
||
item.style.alignItems = 'center';
|
||
item.style.flexWrap = 'wrap';
|
||
item.style.gap = '1rem';
|
||
|
||
const info = getAvatarInfo(ex.avatar_id);
|
||
|
||
item.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: 1rem;">
|
||
<div class="avatar-image-container" style="width: 52px; height: 52px;">
|
||
<img class="avatar-img"
|
||
src="./assets/avatars/${ex.avatar_id}.png"
|
||
onerror="if(this.src.indexOf('jsdelivr')===-1){this.src='https://cdn.jsdelivr.net/gh/Mar-7th/StarRailRes@master/icon/avatar/${ex.avatar_id}.png';}else{this.style.display='none';}"
|
||
alt="${info.name}">
|
||
</div>
|
||
<div>
|
||
<div style="font-weight: 800; font-size: 1.05rem;">${info.name}</div>
|
||
<div style="display: flex; gap: 0.4rem; margin-top: 0.35rem; align-items: center;">
|
||
${getElementIconHtml(info.element, 20)}
|
||
${getPathIconHtml(info.path, 20)}
|
||
<span style="font-size: 0.775rem; color: var(--text-muted); margin-left: 0.25rem;">Ex-Rank ID: ${ex.ex_rank_id}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-sm ${ex.unlocked ? 'btn-success' : 'btn-secondary'} btn-toggle-ex"
|
||
data-avatar="${ex.avatar_id}"
|
||
data-unlocked="${ex.unlocked}">
|
||
${ex.unlocked ? '⚡ เปิดใช้งานแล้ว' : '🔒 ปิดการใช้งาน'}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
item.querySelector('.btn-toggle-ex').addEventListener('click', async (e) => {
|
||
const aid = parseInt(e.target.getAttribute('data-avatar'), 10);
|
||
const cur = e.target.getAttribute('data-unlocked') === 'true';
|
||
await toggleSingleExRank(aid, !cur);
|
||
});
|
||
|
||
el.exrankList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function toggleSingleExRank(avatarId, unlock) {
|
||
try {
|
||
await apiCall('/api/exrank/toggle', 'POST', { avatar_id: avatarId, unlocked: unlock });
|
||
showToast(unlock ? 'ปลดล็อค Ex-Rank สำเร็จ!' : 'ปิดใช้งาน Ex-Rank เรียบร้อย', 'success');
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`Error: ${err.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// Load and Switch JSON Tab Data
|
||
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 = 'กำลังโหลด freesr-data.json...';
|
||
try {
|
||
if (state.fullFreesrData) {
|
||
el.jsonEditor.value = JSON.stringify(state.fullFreesrData, null, 2);
|
||
el.jsonStatusInfo.textContent = 'freesr-data.json (Loaded from memory)';
|
||
} else {
|
||
const res = await apiCall('/api/freesr');
|
||
state.fullFreesrData = res.data;
|
||
el.jsonEditor.value = JSON.stringify(res.data, null, 2);
|
||
el.jsonStatusInfo.textContent = 'freesr-data.json (Loaded from server)';
|
||
}
|
||
} catch (err) {
|
||
el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`;
|
||
if (!el.jsonEditor.value) {
|
||
el.jsonEditor.value = JSON.stringify({ avatars: {}, lightcones: [], relics: [], battle_config: {} }, null, 2);
|
||
}
|
||
}
|
||
} else {
|
||
el.btnSelectFreesr.className = 'btn btn-sm btn-secondary';
|
||
el.btnSelectPersistent.className = 'btn btn-sm btn-primary active';
|
||
el.jsonStatusInfo.textContent = 'กำลังโหลด persistent...';
|
||
try {
|
||
if (state.persistentData) {
|
||
el.jsonEditor.value = JSON.stringify(state.persistentData, null, 2);
|
||
el.jsonStatusInfo.textContent = 'persistent (Loaded from memory)';
|
||
} else {
|
||
const res = await apiCall('/api/persistent');
|
||
state.persistentData = res.data;
|
||
el.jsonEditor.value = JSON.stringify(res.data, null, 2);
|
||
el.jsonStatusInfo.textContent = 'persistent (Loaded from server)';
|
||
}
|
||
} catch (err) {
|
||
el.jsonStatusInfo.textContent = `Offline: สามารถพิมพ์หรือวาง JSON เองได้`;
|
||
if (!el.jsonEditor.value) {
|
||
el.jsonEditor.value = JSON.stringify({ lineups: DEFAULT_LINEUP, main_character: "FemaleHarmony", march_type: "MarchHunt" }, null, 2);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Save JSON Tab Data
|
||
async function saveJsonData() {
|
||
try {
|
||
const parsed = JSON.parse(el.jsonEditor.value);
|
||
if (state.currentJsonMode === 'freesr') {
|
||
state.fullFreesrData = parsed;
|
||
if (state.isConnected) {
|
||
await saveFreesrChangesToServer();
|
||
}
|
||
// Sync loaded characters to local list
|
||
if (parsed.avatars) {
|
||
for (const aid in parsed.avatars) {
|
||
const av = parsed.avatars[aid];
|
||
const found = state.avatarsList.find(a => a.avatar_id === parseInt(aid, 10));
|
||
if (found) {
|
||
found.level = av.level || 80;
|
||
found.promotion = av.promotion || 6;
|
||
found.rank = av.data?.rank ?? 6;
|
||
}
|
||
}
|
||
renderAvatarsGrid();
|
||
}
|
||
showToast('บันทึก freesr-data.json เรียบร้อย', 'success');
|
||
} else {
|
||
state.persistentData = parsed;
|
||
if (state.isConnected && state.serverMode === 'gac') {
|
||
await apiCall('/api/persistent', 'POST', parsed);
|
||
}
|
||
if (parsed.lineups) {
|
||
state.statusData = state.statusData || {};
|
||
state.statusData.lineups = parsed.lineups;
|
||
renderLineup(parsed.lineups);
|
||
}
|
||
showToast('บันทึกไฟล์ persistent เรียบร้อย', 'success');
|
||
}
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`เกิดข้อผิดพลาด: ${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') {
|
||
renderAvatarsGrid();
|
||
} else if (targetId === 'tab-equipment') {
|
||
loadEquipment();
|
||
} else if (targetId === 'tab-persistent') {
|
||
loadPersistentSettings();
|
||
} else if (targetId === 'tab-json') {
|
||
loadJsonData(state.currentJsonMode);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Setup Event Listeners
|
||
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'), 'success');
|
||
refreshStatus();
|
||
}
|
||
});
|
||
|
||
// Character Detail Modal Controls
|
||
el.btnCloseAvatarModal.addEventListener('click', () => el.modalAvatarDetail.classList.remove('active'));
|
||
document.getElementById('btn-modal-cancel-avatar')?.addEventListener('click', () => el.modalAvatarDetail.classList.remove('active'));
|
||
el.btnModalSaveAvatar.addEventListener('click', saveAvatarDetails);
|
||
el.btnModalMaxSkills.addEventListener('click', maxSkillsForAvatar);
|
||
document.getElementById('btn-modal-max-all-char')?.addEventListener('click', maxAllBuildForCurrentAvatar);
|
||
|
||
// Steppers for 4 Skills (Basic, Skill, Ultra, Talent)
|
||
const setupSkillStepper = (rangeEl, valEl, decBtn, incBtn, maxBtn, maxVal) => {
|
||
if (!rangeEl || !valEl) return;
|
||
rangeEl.addEventListener('input', () => { valEl.textContent = rangeEl.value; });
|
||
decBtn?.addEventListener('click', () => {
|
||
let cur = parseInt(rangeEl.value, 10);
|
||
if (cur > parseInt(rangeEl.min, 10)) {
|
||
rangeEl.value = cur - 1;
|
||
valEl.textContent = rangeEl.value;
|
||
}
|
||
});
|
||
incBtn?.addEventListener('click', () => {
|
||
let cur = parseInt(rangeEl.value, 10);
|
||
if (cur < parseInt(rangeEl.max, 10)) {
|
||
rangeEl.value = cur + 1;
|
||
valEl.textContent = rangeEl.value;
|
||
}
|
||
});
|
||
maxBtn?.addEventListener('click', () => {
|
||
rangeEl.value = maxVal;
|
||
valEl.textContent = maxVal;
|
||
});
|
||
};
|
||
|
||
setupSkillStepper(el.rangeSkillBasic, el.valSkillBasic, el.btnStepDecBasic, el.btnStepIncBasic, el.btnSkillMaxBasic, 6);
|
||
setupSkillStepper(el.rangeSkillSkill, el.valSkillSkill, el.btnStepDecSkill, el.btnStepIncSkill, el.btnSkillMaxSkill, 10);
|
||
setupSkillStepper(el.rangeSkillUltra, el.valSkillUltra, el.btnStepDecUltra, el.btnStepIncUltra, el.btnSkillMaxUltra, 10);
|
||
setupSkillStepper(el.rangeSkillTalent, el.valSkillTalent, el.btnStepDecTalent, el.btnStepIncTalent, el.btnSkillMaxTalent, 10);
|
||
|
||
// Ultimate Energy Controls
|
||
el.rangeCharEnergy?.addEventListener('input', () => {
|
||
el.valCharEnergy.textContent = `${el.rangeCharEnergy.value}/${el.rangeCharEnergy.max}`;
|
||
});
|
||
el.btnModalEnergy50?.addEventListener('click', () => {
|
||
const half = Math.floor(parseInt(el.rangeCharEnergy.max, 10) * 0.5);
|
||
el.rangeCharEnergy.value = half;
|
||
el.valCharEnergy.textContent = `${half}/${el.rangeCharEnergy.max}`;
|
||
});
|
||
el.btnModalEnergy100?.addEventListener('click', () => {
|
||
el.rangeCharEnergy.value = el.rangeCharEnergy.max;
|
||
el.valCharEnergy.textContent = `${el.rangeCharEnergy.max}/${el.rangeCharEnergy.max}`;
|
||
});
|
||
|
||
// Lightcone Controls in Modal
|
||
document.querySelectorAll('.lc-super-pip').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const r = parseInt(btn.getAttribute('data-rank'), 10);
|
||
updateLcRankPips(r);
|
||
});
|
||
});
|
||
el.btnModalMaxLc?.addEventListener('click', () => {
|
||
if (el.modalLcLevel) el.modalLcLevel.value = 80;
|
||
updateLcRankPips(5);
|
||
showToast(t('modalBtnMaxLc'), 'info');
|
||
});
|
||
|
||
el.modalLcId?.addEventListener('input', (e) => {
|
||
updateModalLcPreview(e.target.value);
|
||
});
|
||
|
||
// Quick Preset Buttons for Character Modal
|
||
document.querySelectorAll('.btn-lvl-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const lvl = parseInt(btn.getAttribute('data-lvl'), 10);
|
||
el.modalCharLevel.value = lvl;
|
||
if (lvl > 70) el.modalCharPromotion.value = 6;
|
||
else if (lvl > 60) el.modalCharPromotion.value = 5;
|
||
else if (lvl > 50) el.modalCharPromotion.value = 4;
|
||
else if (lvl > 40) el.modalCharPromotion.value = 3;
|
||
else if (lvl > 20) el.modalCharPromotion.value = 1;
|
||
else el.modalCharPromotion.value = 0;
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.btn-prom-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
el.modalCharPromotion.value = btn.getAttribute('data-prom');
|
||
});
|
||
});
|
||
|
||
el.btnModalQuickE0?.addEventListener('click', () => {
|
||
if (state.selectedAvatarDetail) state.selectedAvatarDetail.rank = 0;
|
||
renderModalEidolonPips(0);
|
||
});
|
||
|
||
el.btnModalQuickE6?.addEventListener('click', () => {
|
||
if (state.selectedAvatarDetail) state.selectedAvatarDetail.rank = 6;
|
||
renderModalEidolonPips(6);
|
||
});
|
||
|
||
// Lineup Picker Modal Controls
|
||
el.btnClosePickerModal.addEventListener('click', () => el.modalLineupPicker.classList.remove('active'));
|
||
el.pickerSearch.addEventListener('input', renderPickerGrid);
|
||
|
||
// Creative Mode Toggle
|
||
if (el.toggleCreativeMode) {
|
||
el.toggleCreativeMode.checked = state.creativeMode;
|
||
el.toggleCreativeMode.addEventListener('change', (e) => {
|
||
state.creativeMode = e.target.checked;
|
||
localStorage.setItem('gac_srtool_creative_mode', state.creativeMode);
|
||
if (el.badgeCreativeMode) {
|
||
el.badgeCreativeMode.style.display = state.creativeMode ? 'inline-flex' : 'none';
|
||
}
|
||
renderLineup(state.statusData?.lineups);
|
||
const title = t('lblCreativeMode') || 'โหมดสร้างสรรค์';
|
||
showToast(`${title}: ${state.creativeMode ? 'ON' : 'OFF'}`, 'info');
|
||
});
|
||
}
|
||
|
||
// Quick Action Buttons on Dashboard
|
||
el.btnQuickMaxAll.addEventListener('click', maxAllAvatars);
|
||
el.btnMaxAllAvatars.addEventListener('click', maxAllAvatars);
|
||
|
||
el.btnQuickSyncPersistent.addEventListener('click', async () => {
|
||
try {
|
||
await apiCall('/api/persistent');
|
||
showToast('ซิงค์ข้อมูล Persistent เรียบร้อย', 'success');
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`Sync failed: ${err.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// Persistent Form Submit
|
||
el.formPersistentChars.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const mc = el.persistentMcSelect.value;
|
||
const march = el.persistentMarchSelect.value;
|
||
try {
|
||
await apiCall('/api/persistent', 'POST', {
|
||
main_character: mc,
|
||
march_type: march
|
||
});
|
||
showToast('บันทึกการตั้งค่าตัวละครสำเร็จ!', 'success');
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`เกิดข้อผิดพลาด: ${err.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// Persistent Toggles
|
||
el.btnSavePersistentToggles.addEventListener('click', async () => {
|
||
const sw = el.toggleSwGlobal.checked;
|
||
const castorice = el.toggleCastoriceGlobal.checked;
|
||
const aha = el.toggleAhaGlobal ? el.toggleAhaGlobal.checked : true;
|
||
try {
|
||
await apiCall('/api/persistent', 'POST', {
|
||
enable_sw_global: sw,
|
||
enable_castorice_global: castorice,
|
||
enable_aha_global: aha
|
||
});
|
||
showToast(t('btnSaveGlobalSwitch') || 'บันทึกสวิตช์ Global เรียบร้อย!', 'success');
|
||
refreshStatus();
|
||
} catch (err) {
|
||
showToast(`เกิดข้อผิดพลาด: ${err.message}`, 'error');
|
||
}
|
||
});
|
||
|
||
// Equipment Add Modal Controls
|
||
if (el.btnOpenAddLc) {
|
||
el.btnOpenAddLc.addEventListener('click', openAddLcModal);
|
||
}
|
||
if (el.btnCloseAddLcModal) {
|
||
el.btnCloseAddLcModal.addEventListener('click', () => el.modalAddLc.classList.remove('active'));
|
||
}
|
||
if (el.btnCancelAddLc) {
|
||
el.btnCancelAddLc.addEventListener('click', () => el.modalAddLc.classList.remove('active'));
|
||
}
|
||
if (el.formAddLc) {
|
||
el.formAddLc.addEventListener('submit', handleAddLcSubmit);
|
||
}
|
||
|
||
if (el.btnOpenAddRelic) {
|
||
el.btnOpenAddRelic.addEventListener('click', openAddRelicModal);
|
||
}
|
||
if (el.btnCloseAddRelicModal) {
|
||
el.btnCloseAddRelicModal.addEventListener('click', () => el.modalAddRelic.classList.remove('active'));
|
||
}
|
||
if (el.btnCancelAddRelic) {
|
||
el.btnCancelAddRelic.addEventListener('click', () => el.modalAddRelic.classList.remove('active'));
|
||
}
|
||
if (el.addRelicSlot) {
|
||
el.addRelicSlot.addEventListener('change', (e) => updateRelicMainAffixes(e.target.value));
|
||
}
|
||
if (el.formAddRelic) {
|
||
el.formAddRelic.addEventListener('submit', handleAddRelicSubmit);
|
||
}
|
||
|
||
// Avatar Search & Filters
|
||
el.avatarSearch.addEventListener('input', renderAvatarsGrid);
|
||
el.avatarFilterRarity.addEventListener('change', renderAvatarsGrid);
|
||
el.avatarFilterElement.addEventListener('change', renderAvatarsGrid);
|
||
el.avatarFilterPath?.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');
|
||
}
|
||
});
|
||
|
||
// 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 สวยงามแล้ว', '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(`ดาวน์โหลด ${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);
|
||
|
||
// If freesr-data.json was uploaded
|
||
if (parsed.avatars) {
|
||
state.fullFreesrData = parsed;
|
||
for (const aid in parsed.avatars) {
|
||
const av = parsed.avatars[aid];
|
||
const found = state.avatarsList.find(a => a.avatar_id === parseInt(aid, 10));
|
||
if (found) {
|
||
found.level = av.level || 80;
|
||
found.promotion = av.promotion || 6;
|
||
found.rank = av.data?.rank ?? 6;
|
||
}
|
||
}
|
||
renderAvatarsGrid();
|
||
}
|
||
|
||
// If persistent or has lineups
|
||
if (parsed.lineups) {
|
||
state.statusData = state.statusData || {};
|
||
state.statusData.lineups = parsed.lineups;
|
||
renderLineup(parsed.lineups);
|
||
}
|
||
|
||
showToast(`โหลดไฟล์ ${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();
|
||
});
|
||
|
||
// Modal Backdrop Click to close
|
||
[el.modalAvatarDetail, el.modalLineupPicker, el.modalSettings].forEach(modal => {
|
||
if (modal) {
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
modal.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ESC Key to close any active modal
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
document.querySelectorAll('.modal-overlay.active').forEach(m => m.classList.remove('active'));
|
||
}
|
||
});
|
||
}
|
||
|
||
// Background Ping Loop
|
||
function startPingLoop() {
|
||
setInterval(() => {
|
||
refreshStatus();
|
||
}, state.pingIntervalSec * 1000);
|
||
}
|
||
|
||
// Initialize Application
|
||
function init() {
|
||
initAvatarsList();
|
||
setupServerModes();
|
||
setupLanguageSelector();
|
||
setupTabs();
|
||
setupModalSubtabs();
|
||
setupEvents();
|
||
applyTranslations();
|
||
refreshStatus();
|
||
renderAvatarsGrid();
|
||
startPingLoop();
|
||
}
|
||
|
||
// Run on DOM load
|
||
window.addEventListener('DOMContentLoaded', init);
|