378 lines
15 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
},
|
|
|
|
// === 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(); });
|