Files
apxtri/mainapp/mynation/js/app.js
T
2026-10-12 13:51:47 +00:00

378 lines
15 KiB
JavaScript

/**
* mynation — PWA Nation Explorer for apxtri
*
* Dock: browse nations, explore their towns and tribes.
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
*
* INSPECT MODE: screen badge + hover data-action.
* ALL selectors use [data-action]; every clickable element has data-action attribute.
*/
const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev';
const LG = {
loaded: {},
get current() {
return Auth.getLang() || 'en';
},
fallback: {
'app.title': 'My Nation - apxtri',
'app.back': 'Back',
'app.save': 'Save',
'screen.home.title': 'My Nation',
'screen.home.subtitle': 'Explore the network of towns and tribes',
'home.select_placeholder': 'Select a nation...',
'home.explore_btn': 'Explore',
'home.loading_nations': 'Loading nations...',
'home.error_load': 'Failed to load nations',
'home.no_towns': 'No towns found in this nation',
'home.explore_error': 'Error exploring nation: {{message}}',
'home.visit_town': 'Visit town',
'home.directory_btn': 'Town Directory',
'dir.title': 'Town Directory',
'dir.resolve_label': 'Resolve an address (alias@town, tribe.town.nation, town.nation)',
'dir.resolve_btn': 'Resolve',
'dir.refresh': 'Refresh',
'dir.list_title': 'Network directory (distributed /etc/hosts)',
'dir.empty': 'Directory is empty.',
'dir.load_error': 'Failed to load the directory: {{message}}',
'dir.resolve_error': 'Resolution failed: {{message}}',
'dir.not_found': 'Address not found in the directory.',
'dir.status_on': 'online',
'dir.status_off': 'offline',
'nav.town': 'Town',
'nav.tribes': 'Tribes',
'nav.nation': 'Nation',
'nav.identities': 'Identities',
'drawer.language': 'Language',
'drawer.theme': 'Dark theme',
'drawer.not_connected': 'Not connected',
'drawer.connected': 'Connected',
'notify.lang_unavailable': 'Language "{{lg}}" not available',
'notify.network_error': 'Network error: {{message}}'
},
t: function (key, vars) {
var str = LG.loaded[key] || LG.fallback[key] || key;
if (vars) {
Object.keys(vars).forEach(function (k) {
str = str.replace('{{' + k + '}}', vars[k]);
str = str.replace('{' + k + '}', vars[k]);
});
}
return str;
},
set: async function (lg) {
if (lg === 'en') {
LG.loaded = {};
Auth.setLang('en');
LG.apply();
return;
}
try {
var res = await axios.get('static/lg/' + lg + '.json', { validateStatus: function () { return true; } });
if (res.status === 200) {
LG.loaded = res.data;
Auth.setLang(lg);
LG.apply();
} else {
App.notify(LG.t('notify.lang_unavailable', { lg: lg }), true);
}
} catch (err) {
App.notify(LG.t('notify.lang_unavailable', { lg: lg }), true);
}
},
apply: function () {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
var val = LG.t(el.dataset.i18n);
if (val && val !== el.dataset.i18n) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
if (el.placeholder !== undefined) el.placeholder = val;
} else {
el.innerHTML = val;
}
}
});
document.documentElement.lang = LG.current;
}
};
var App = {
// === STATE ===
currentScreen: null, // tracks the visible screen name
nations: [], // list of nation IDs
// === INIT ===
async init() {
Log.debug('App.init() starting');
Auth.initAxios();
Auth.initTheme();
await LocalDB.init('mainapp', 'mynation');
var lang = Auth.getLang();
await LG.set(lang);
await Menu.init();
// Track current screen by wrapping Menu.showScreen
var origShowScreen = Menu.showScreen.bind(Menu);
Menu.showScreen = function (name) {
App.currentScreen = name;
origShowScreen(name);
};
Menu.showScreen('home');
await this.loadNations();
this.bindEvents();
this.setLoading(false);
Log.debug('App.init() complete');
},
// === NOTIFY ===
notify: function (msg, isError) {
Notify.show(msg, isError);
},
// === LOADING ===
setLoading: function (show) {
var overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('opacity-0', 'pointer-events-none');
} else {
overlay.classList.add('opacity-0');
setTimeout(function () { overlay.classList.add('pointer-events-none'); }, 300);
}
},
// === LOAD NATIONS ===
async loadNations() {
var select = document.getElementById('nation-select');
var errorEl = document.getElementById('error');
try {
var res = await axios.get('/api/apxtri/nations/list', { validateStatus: function () { return true; } });
if (res.data.status === 200 && res.data.data?.lst_nationid) {
this.nations = res.data.data.lst_nationid;
this.nations.forEach(function (id) {
var opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
});
}
} catch (e) {
errorEl.textContent = LG.t('home.error_load');
errorEl.classList.remove('hidden');
Log.error('loadNations error:', e.message || e);
}
},
// === EXPLORE NATION ===
async explore() {
var nationId = document.getElementById('nation-select').value;
if (!nationId) return;
var results = document.getElementById('results');
var loading = document.getElementById('loading');
var errorEl = document.getElementById('error');
var info = document.getElementById('nation-info');
var exploreBtn = document.getElementById('btn-explore');
results.innerHTML = '';
errorEl.classList.add('hidden');
loading.classList.remove('hidden');
exploreBtn.disabled = true;
try {
var res = await axios.get('/api/apxtri/nations/explore/' + nationId, { validateStatus: function () { return true; } });
loading.classList.add('hidden');
exploreBtn.disabled = false;
if (res.data.status !== 200) {
errorEl.textContent = res.data.msg || LG.t('home.explore_error', { message: 'unknown' });
errorEl.classList.remove('hidden');
return;
}
var data = res.data.data;
var towns = data.towns || {};
var townIds = Object.keys(towns);
info.textContent = nationId + ' \u00b7 ' + townIds.length + ' town' + (townIds.length > 1 ? 's' : '');
if (townIds.length === 0) {
results.innerHTML = '<div class="text-center opacity-50 py-8">' + LG.t('home.no_towns') + '</div>';
return;
}
var html = '';
for (var i = 0; i < townIds.length; i++) {
var townId = townIds[i];
var entry = towns[townId];
var town = entry.town || {};
var tribes = entry.tribes || [];
var dns = Array.isArray(town.dns) ? town.dns[0] : (town.dns || '');
var status = town.status || 'unknown';
var isLocal = townId === APP_TRIBE;
var statusBadge = status === 'chain' || status === 'elected'
? '<span class="badge badge-success badge-xs">online</span>'
: '<span class="badge badge-error badge-xs">offline</span>';
html += '<div class="card bg-base-100 shadow-sm">';
html += '<div class="card-body p-4">';
// Header with town name and status
html += '<div class="flex items-center justify-between">';
html += '<div class="flex items-center gap-2 min-w-0">';
html += '<div class="avatar placeholder">';
html += '<div class="bg-' + (isLocal ? 'success' : 'secondary') + ' text-' + (isLocal ? 'success' : 'secondary') + '-content rounded-full w-8 h-8 flex items-center justify-center font-bold text-xs">';
html += townId.charAt(0).toUpperCase();
html += '</div>';
html += '</div>';
html += '<div class="min-w-0">';
html += '<h3 class="font-medium truncate">' + townId + '</h3>';
html += '</div>';
html += '</div>';
html += '<div class="flex items-center gap-1 shrink-0">';
html += statusBadge;
if (isLocal) {
html += '<span class="badge badge-ghost badge-xs">local</span>';
}
html += '</div>';
html += '</div>';
// Meta info
if (dns) {
html += '<div class="text-xs opacity-50 mt-1">' + dns + '</div>';
}
// Tribes
if (tribes.length > 0) {
html += '<div class="mt-2">';
html += '<span class="text-xs opacity-60">Tribes:</span>';
html += '<div class="flex flex-wrap gap-1 mt-1">';
for (var j = 0; j < tribes.length; j++) {
var t = tribes[j];
var tClass = isLocal ? 'badge-success' : 'badge-secondary';
html += '<span class="badge badge-xs ' + tClass + ' badge-outline">' + t + '</span>';
}
html += '</div></div>';
}
// Visit link for remote towns
if (dns && !isLocal) {
html += '<div class="mt-3"><a href="https://' + dns + '/" class="btn btn-xs btn-ghost text-primary" target="_blank" data-action="visit-town"><svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>' + LG.t('home.visit_town') + '</a></div>';
}
html += '</div>';
html += '</div>';
}
results.innerHTML = html;
} catch (e) {
loading.classList.add('hidden');
exploreBtn.disabled = false;
errorEl.textContent = LG.t('home.explore_error', { message: e.message || e });
errorEl.classList.remove('hidden');
Log.error('explore error:', e.message || e);
}
},
// === TOWN DIRECTORY (M1) ===
async loadDirectory() {
var listEl = document.getElementById('dir-list');
listEl.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
try {
var res = await axios.get('/api/apxtri/nations/directory', { validateStatus: function () { return true; } });
if (res.data.status !== 200) {
listEl.innerHTML = '<p class="opacity-60">' + App._esc(res.data.msg || LG.t('dir.load_error', { message: 'unknown' })) + '</p>';
return;
}
var entries = res.data.data.entries || [];
if (!entries.length) {
listEl.innerHTML = '<p class="opacity-60">' + LG.t('dir.empty') + '</p>';
return;
}
listEl.innerHTML = entries.map(function (e) {
var online = e.status === 'chain' || e.status === 'elected';
var badge = online
? '<span class="badge badge-success badge-xs">' + LG.t('dir.status_on') + '</span>'
: '<span class="badge badge-error badge-xs">' + LG.t('dir.status_off') + '</span>';
var meta = [e.domain, e.IP ? e.IP : '', e.port ? ':' + e.port : '', 'h=' + e.height].filter(Boolean).join(' \u00b7 ');
return '<div class="flex items-center justify-between bg-base-200 rounded-lg px-2 py-1">' +
'<div class="flex items-center gap-2 min-w-0"><span class="font-medium shrink-0">' + App._esc(e.townId) + '</span>' +
'<span class="truncate opacity-60">' + App._esc(meta || '') + '</span></div>' + badge + '</div>';
}).join('');
} catch (e) {
listEl.innerHTML = '<p class="opacity-60">' + LG.t('dir.load_error', { message: App._esc(e.message) }) + '</p>';
}
},
async resolveAddress() {
var inp = document.getElementById('dir-address');
var result = document.getElementById('dir-result');
var address = (inp.value || '').trim();
if (!address) return;
result.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
try {
var res = await axios.get('/api/apxtri/nations/resolve/' + encodeURIComponent(address), { validateStatus: function () { return true; } });
if (res.data.status !== 200) {
result.innerHTML = '<p class="opacity-60">' + App._esc(res.data.msg || LG.t('dir.not_found')) + '</p>';
return;
}
var d = res.data.data;
var e = d.entry || {};
var online = e.status === 'chain' || e.status === 'elected';
result.innerHTML = '<div class="bg-base-200 rounded-lg p-2">' +
'<div class="flex items-center justify-between"><span class="font-medium">' + App._esc(d.address) + '</span>' +
'<span class="badge badge-ghost badge-xs">' + App._esc(d.kind) + '</span></div>' +
'<div class="mt-1 opacity-70">town: ' + App._esc(e.townId) + ' \u00b7 ' + App._esc(e.domain || '') +
(e.IP ? ' \u00b7 ' + App._esc(e.IP) + (e.port ? ':' + e.port : '') : '') +
' \u00b7 h=' + e.height + '</div>' +
'<div class="mt-1">' + (online ? LG.t('dir.status_on') : LG.t('dir.status_off')) + '</div>' +
(e.publicKey ? '<div class="mt-1 break-all opacity-50">' + App._esc(String(e.publicKey).slice(0, 60)) + '…</div>' : '') +
'</div>';
} catch (e2) {
result.innerHTML = '<p class="opacity-60">' + LG.t('dir.resolve_error', { message: App._esc(e2.message) }) + '</p>';
}
},
openDirectory() {
this.loadDirectory();
Menu.showScreen('directory');
},
_esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
},
// === EVENT BINDING ===
bindEvents: function () {
Log.debug('bindEvents()');
// Explore button
document.querySelector('[data-action="explore-nation"]')?.addEventListener('click', function () {
App.explore();
});
// Nation select enable/disable
document.getElementById('nation-select')?.addEventListener('change', function (e) {
document.getElementById('btn-explore').disabled = !e.target.value;
});
// Keyboard: Enter on nation-select triggers explore
document.getElementById('nation-select')?.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !document.getElementById('btn-explore').disabled) {
App.explore();
}
});
// Directory screen
document.querySelector('[data-action="open-directory"]')?.addEventListener('click', function () { App.openDirectory(); });
document.querySelector('[data-action="dir-back"]')?.addEventListener('click', function () { Menu.showScreen('home'); });
document.querySelector('[data-action="dir-refresh"]')?.addEventListener('click', function () { App.loadDirectory(); });
document.querySelector('[data-action="dir-resolve"]')?.addEventListener('click', function () { App.resolveAddress(); });
document.getElementById('dir-address')?.addEventListener('keydown', function (e) { if (e.key === 'Enter') App.resolveAddress(); });
}
};
// Init on DOM ready
document.addEventListener('DOMContentLoaded', function () { App.init(); });