Create unique folders without a crashing prompt, then focus the name for editing; also fix folder association type and UTF-8 MTP string encoding.
765 lines
27 KiB
JavaScript
765 lines
27 KiB
JavaScript
'use strict';
|
||
|
||
// ── Bridge ────────────────────────────────────────────────────────────────────
|
||
let _reqId = 0;
|
||
const _pending = {};
|
||
function call(action, params = {}) {
|
||
return new Promise(resolve => {
|
||
const id = ++_reqId;
|
||
_pending[id] = resolve;
|
||
try {
|
||
window.webkit.messageHandlers.bridge.postMessage({id, action, ...params});
|
||
} catch(e) {
|
||
setTimeout(() => { if (_pending[id]) { _pending[id](null); delete _pending[id]; } }, 100);
|
||
}
|
||
});
|
||
}
|
||
window.bridgeResponse = function(id, data) {
|
||
if (_pending[id]) { _pending[id](data); delete _pending[id]; }
|
||
};
|
||
|
||
// ── i18n ──────────────────────────────────────────────────────────────────────
|
||
const I18N = {
|
||
lang: 'en',
|
||
strings: {},
|
||
fallback: {},
|
||
};
|
||
|
||
function t(key, vars = {}) {
|
||
let s = I18N.strings[key] ?? I18N.fallback[key] ?? key;
|
||
for (const [k, v] of Object.entries(vars))
|
||
s = s.replaceAll(`{${k}}`, String(v));
|
||
return s;
|
||
}
|
||
|
||
function tPlural(oneKey, manyKey, n, extra = {}) {
|
||
return t(n === 1 ? oneKey : manyKey, { n, ...extra });
|
||
}
|
||
|
||
function localeCandidates(preferred) {
|
||
const out = [];
|
||
const add = (c) => { if (c && !out.includes(c)) out.push(c); };
|
||
for (const raw of preferred || []) {
|
||
const tag = String(raw || '').replace(/_/g, '-');
|
||
if (!tag) continue;
|
||
add(tag);
|
||
const parts = tag.split('-');
|
||
if (parts[0] === 'zh') {
|
||
const script = (parts[1] || '').toLowerCase();
|
||
if (script.startsWith('hant') || script === 'tw' || script === 'hk' || script === 'mo')
|
||
add('zh-Hant');
|
||
else
|
||
add('zh-Hans');
|
||
add('zh');
|
||
} else {
|
||
add(parts[0]);
|
||
}
|
||
}
|
||
add('en');
|
||
return out;
|
||
}
|
||
|
||
async function fetchLang(code) {
|
||
const pack = await call('get_lang_pack', { code });
|
||
return (pack && typeof pack === 'object') ? pack : null;
|
||
}
|
||
|
||
async function initI18n() {
|
||
// Native injects packs at document start (fetch() is blocked on file://).
|
||
if (window.__OMNI_I18N__ && typeof window.__OMNI_I18N__ === 'object') {
|
||
const boot = window.__OMNI_I18N__;
|
||
I18N.lang = boot.lang || 'en';
|
||
I18N.fallback = boot.fallback || {};
|
||
I18N.strings = boot.strings || { ...I18N.fallback };
|
||
} else {
|
||
I18N.fallback = (await fetchLang('en')) || {};
|
||
I18N.strings = { ...I18N.fallback };
|
||
|
||
let preferred = await call('get_preferred_languages');
|
||
if (!Array.isArray(preferred)) preferred = ['en'];
|
||
|
||
for (const code of localeCandidates(preferred)) {
|
||
if (code === 'en') {
|
||
I18N.lang = 'en';
|
||
break;
|
||
}
|
||
const pack = await fetchLang(code);
|
||
if (pack) {
|
||
I18N.lang = code;
|
||
I18N.strings = { ...I18N.fallback, ...pack };
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
document.documentElement.lang = I18N.lang;
|
||
applyStaticI18n();
|
||
}
|
||
|
||
function applyStaticI18n() {
|
||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.dataset.i18n);
|
||
});
|
||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||
el.title = t(el.dataset.i18nTitle);
|
||
});
|
||
document.title = t('app.title');
|
||
}
|
||
|
||
window.omniApplyI18n = function(payload) {
|
||
if (!payload || typeof payload !== 'object') return;
|
||
window.__OMNI_I18N__ = payload;
|
||
I18N.lang = payload.lang || 'en';
|
||
I18N.fallback = payload.fallback || {};
|
||
I18N.strings = payload.strings || { ...I18N.fallback };
|
||
document.documentElement.lang = I18N.lang;
|
||
applyStaticI18n();
|
||
_remoteFingerprint = '';
|
||
if (typeof render === 'function') render();
|
||
};
|
||
|
||
async function confirmDialog(message) {
|
||
const result = await call('confirm', {
|
||
message,
|
||
ok: t('confirm.delete_btn'),
|
||
cancel: t('confirm.cancel_btn'),
|
||
});
|
||
return result === true || result === 'true';
|
||
}
|
||
|
||
let _pendingRenameHandle = null;
|
||
let _inlineRenameActive = false;
|
||
|
||
async function createFolderInline() {
|
||
if (!S.connected || _inlineRenameActive) return;
|
||
const result = await call('create_folder', {
|
||
defaultName: t('prompt.new_folder_default'),
|
||
});
|
||
if (!result || result.handle == null) return;
|
||
_pendingRenameHandle = String(result.handle);
|
||
_remoteFingerprint = '';
|
||
renderRemoteFiles();
|
||
}
|
||
|
||
function startInlineRename(handleKey) {
|
||
const key = String(handleKey);
|
||
const row = document.querySelector(`#remote-files .frow[data-key="${CSS.escape(key)}"]`);
|
||
if (!row) {
|
||
_pendingRenameHandle = key;
|
||
return;
|
||
}
|
||
if (row.querySelector('.fname-edit')) return;
|
||
_pendingRenameHandle = null;
|
||
|
||
const textEl = row.querySelector('.fname-text');
|
||
if (!textEl) return;
|
||
const oldName = textEl.textContent;
|
||
const className = textEl.className;
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'text';
|
||
input.className = 'fname-edit';
|
||
input.value = oldName;
|
||
input.setAttribute('spellcheck', 'false');
|
||
textEl.replaceWith(input);
|
||
_inlineRenameActive = true;
|
||
|
||
input.focus();
|
||
input.select();
|
||
|
||
let finished = false;
|
||
const finish = async (commit) => {
|
||
if (finished) return;
|
||
finished = true;
|
||
_inlineRenameActive = false;
|
||
const newName = input.value.trim();
|
||
if (commit && newName && newName !== oldName) {
|
||
await call('rename_remote', { handle: Number(key), name: newName });
|
||
}
|
||
_remoteFingerprint = '';
|
||
renderRemoteFiles();
|
||
};
|
||
|
||
input.addEventListener('keydown', e => {
|
||
e.stopPropagation();
|
||
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
||
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
||
});
|
||
input.addEventListener('blur', () => finish(true));
|
||
input.addEventListener('mousedown', e => e.stopPropagation());
|
||
input.addEventListener('click', e => e.stopPropagation());
|
||
input.addEventListener('dblclick', e => e.stopPropagation());
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const S = {
|
||
connected: false, deviceName: '',
|
||
storages: [], activeStorageId: 0,
|
||
remoteNavStack: [],
|
||
remoteFiles: [], remoteRefreshing: false,
|
||
transfers: [],
|
||
statusKey: '', statusArgs: [], statusTimer: 0,
|
||
selRemote: new Set(),
|
||
lastRemoteIdx: -1,
|
||
sortKey: 'name', // name | size | date
|
||
sortAsc: true,
|
||
};
|
||
|
||
let _remoteFingerprint = '';
|
||
|
||
function fingerprintFiles(files) {
|
||
return files.map(f => f.path || f.handle || f.name).join('\0')
|
||
+ '\0' + I18N.lang + '\0' + S.sortKey + '\0' + (S.sortAsc ? 'a' : 'd');
|
||
}
|
||
|
||
function sortedRemoteFiles() {
|
||
const files = (S.remoteFiles || []).slice();
|
||
const dir = S.sortAsc ? 1 : -1;
|
||
const key = S.sortKey;
|
||
files.sort((a, b) => {
|
||
if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
|
||
let cmp = 0;
|
||
if (key === 'size') {
|
||
cmp = (a.size || 0) - (b.size || 0);
|
||
} else if (key === 'date') {
|
||
cmp = String(a.date || '').localeCompare(String(b.date || ''));
|
||
} else {
|
||
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
|
||
sensitivity: 'base', numeric: true,
|
||
});
|
||
}
|
||
if (cmp === 0) {
|
||
cmp = String(a.name || '').localeCompare(String(b.name || ''), undefined, {
|
||
sensitivity: 'base', numeric: true,
|
||
});
|
||
}
|
||
return cmp * dir;
|
||
});
|
||
return files;
|
||
}
|
||
|
||
function setSortColumn(key) {
|
||
if (S.sortKey === key) S.sortAsc = !S.sortAsc;
|
||
else {
|
||
S.sortKey = key;
|
||
S.sortAsc = key !== 'size'; // size defaults to largest-first feels odd; use ascending
|
||
}
|
||
_remoteFingerprint = '';
|
||
renderRemoteFiles();
|
||
}
|
||
|
||
async function poll() {
|
||
try {
|
||
const data = await call('get_state');
|
||
if (data) {
|
||
const sr = S.selRemote;
|
||
const lri = S.lastRemoteIdx;
|
||
Object.assign(S, data);
|
||
S.selRemote = sr;
|
||
S.lastRemoteIdx = lri;
|
||
render();
|
||
}
|
||
} catch(e) {}
|
||
setTimeout(poll, 400);
|
||
}
|
||
|
||
function render() {
|
||
renderDeviceBar();
|
||
renderRemoteToolbar();
|
||
renderRemoteFiles();
|
||
renderTransfers();
|
||
renderStatusBar();
|
||
updateDropZones();
|
||
}
|
||
|
||
function updateDropZones() {
|
||
const rem = document.getElementById('panel-remote')?.getBoundingClientRect();
|
||
if (!rem) return;
|
||
call('update_drop_zones', {
|
||
remote: {x: rem.left, y: rem.top, w: rem.width, h: rem.height},
|
||
});
|
||
}
|
||
|
||
function esc(s) {
|
||
if (s == null) return '';
|
||
const d = document.createElement('div');
|
||
d.textContent = String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function fileColor(f) {
|
||
if (f.isDir) return 'color-dir';
|
||
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||
if (['nsp','xci','nsz','xcz'].includes(ext)) return 'color-game';
|
||
if (ext === 'nro') return 'color-hb';
|
||
return 'color-file';
|
||
}
|
||
|
||
function fileTableHeader() {
|
||
const ind = (key) => {
|
||
if (S.sortKey !== key) return '<span class="sort-ind"></span>';
|
||
return `<span class="sort-ind active">${S.sortAsc ? '▲' : '▼'}</span>`;
|
||
};
|
||
return `<thead><tr>
|
||
<th class="sortable" data-sort="name">${esc(t('col.name'))}${ind('name')}</th>
|
||
<th class="col-size sortable" data-sort="size">${esc(t('col.size'))}${ind('size')}</th>
|
||
<th class="col-date sortable" data-sort="date">${esc(t('col.modified'))}${ind('date')}</th>
|
||
</tr></thead>`;
|
||
}
|
||
|
||
function buildFileRows(files, selSet, idKey) {
|
||
return files.map((f, i) => {
|
||
const key = String(f[idKey] != null ? f[idKey] : (f.path || f.name));
|
||
const sel = selSet.has(key) ? ' selected' : '';
|
||
const cc = fileColor(f);
|
||
return `<tr class="frow${sel}" data-idx="${i}" data-key="${esc(key)}" data-is-dir="${f.isDir ? '1' : '0'}">
|
||
<td class="col-name"><div class="fname-cell">
|
||
<span class="dot ${cc}"></span>
|
||
<span class="fname-text ${cc}">${esc(f.name)}</span>
|
||
</div></td>
|
||
<td class="col-size">${f.isDir ? '' : esc(f.sizeStr)}</td>
|
||
<td class="col-date">${esc(f.date)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function applySelectionToDOM(container, selSet) {
|
||
container.querySelectorAll('.frow').forEach(r => {
|
||
r.classList.toggle('selected', selSet.has(r.dataset.key));
|
||
});
|
||
}
|
||
|
||
function selectionKeys(files, idKey) {
|
||
return files.map(f => String(f[idKey] != null ? f[idKey] : (f.path || f.name)));
|
||
}
|
||
|
||
function attachMarqueeSelect(el, selSet) {
|
||
let active = false, startX = 0, startY = 0, box = null, additive = false, moved = false;
|
||
|
||
const finish = () => {
|
||
if (!active) return;
|
||
const clearOnClick = !moved && !additive;
|
||
active = false;
|
||
if (box) { box.remove(); box = null; }
|
||
window.removeEventListener('mousemove', onMove);
|
||
window.removeEventListener('mouseup', finish);
|
||
if (clearOnClick) {
|
||
selSet.clear();
|
||
applySelectionToDOM(el, selSet);
|
||
S.lastRemoteIdx = -1;
|
||
}
|
||
};
|
||
|
||
const onMove = e => {
|
||
if (!active) return;
|
||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||
if (!moved && Math.hypot(dx, dy) < 4) return;
|
||
moved = true;
|
||
window.omniClearSelection();
|
||
const x1 = Math.min(startX, e.clientX), y1 = Math.min(startY, e.clientY);
|
||
const x2 = Math.max(startX, e.clientX), y2 = Math.max(startY, e.clientY);
|
||
Object.assign(box.style, {
|
||
left: x1 + 'px', top: y1 + 'px',
|
||
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
|
||
});
|
||
if (!additive) selSet.clear();
|
||
el.querySelectorAll('.frow').forEach(row => {
|
||
const r = row.getBoundingClientRect();
|
||
const hit = r.right >= x1 && r.left <= x2 && r.bottom >= y1 && r.top <= y2;
|
||
if (hit) selSet.add(row.dataset.key);
|
||
});
|
||
applySelectionToDOM(el, selSet);
|
||
};
|
||
|
||
el.addEventListener('mousedown', e => {
|
||
if (e.button !== 0 || e.target.closest('.frow') || e.target.closest('thead')) return;
|
||
e.preventDefault();
|
||
active = true;
|
||
moved = false;
|
||
additive = e.metaKey || e.shiftKey;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
box = document.createElement('div');
|
||
box.className = 'marquee-box';
|
||
document.body.appendChild(box);
|
||
window.addEventListener('mousemove', onMove);
|
||
window.addEventListener('mouseup', finish);
|
||
});
|
||
}
|
||
|
||
function renderRemoteFiles() {
|
||
const el = document.getElementById('remote-files');
|
||
const empty = document.getElementById('remote-empty-state');
|
||
|
||
// Don't clobber an in-progress inline rename.
|
||
if (_inlineRenameActive) return;
|
||
|
||
if (!S.connected) {
|
||
if (empty) empty.style.display = '';
|
||
el.querySelectorAll('table,.loading,.empty-state:not(#remote-empty-state)')
|
||
.forEach(n => n.remove());
|
||
_remoteFingerprint = '';
|
||
return;
|
||
}
|
||
if (empty) empty.style.display = 'none';
|
||
|
||
if (S.remoteRefreshing) {
|
||
el.innerHTML = `<div class="loading">${esc(t('loading'))}</div>`;
|
||
_remoteFingerprint = '__loading__' + I18N.lang;
|
||
return;
|
||
}
|
||
|
||
const fp = fingerprintFiles(S.remoteFiles);
|
||
if (fp !== _remoteFingerprint) {
|
||
_remoteFingerprint = fp;
|
||
if (!S.remoteFiles.length) {
|
||
el.innerHTML = `<div class="empty-state">
|
||
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" class="empty-icon">
|
||
<path d="M6 38V14a2 2 0 012-2h12l4 4h16a2 2 0 012 2v20a2 2 0 01-2 2H8a2 2 0 01-2-2z"/>
|
||
</svg>
|
||
<div class="empty-title">${esc(t('empty.folder'))}</div></div>`;
|
||
} else {
|
||
const files = sortedRemoteFiles();
|
||
el.innerHTML = `<table class="ftable">${fileTableHeader()}
|
||
<tbody>${buildFileRows(files, S.selRemote, 'handle')}</tbody></table>`;
|
||
}
|
||
attachRemoteEvents(el);
|
||
} else {
|
||
applySelectionToDOM(el, S.selRemote);
|
||
}
|
||
|
||
if (_pendingRenameHandle) {
|
||
const handle = _pendingRenameHandle;
|
||
requestAnimationFrame(() => startInlineRename(handle));
|
||
}
|
||
}
|
||
|
||
function attachRemoteEvents(el) {
|
||
const files = sortedRemoteFiles();
|
||
const keys = selectionKeys(files, 'handle');
|
||
|
||
el.querySelectorAll('th.sortable').forEach(th => {
|
||
th.addEventListener('click', e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setSortColumn(th.dataset.sort);
|
||
});
|
||
});
|
||
|
||
el.querySelectorAll('.frow').forEach((row, i) => {
|
||
const f = files[i];
|
||
const key = keys[i];
|
||
|
||
row.addEventListener('click', e => {
|
||
handleRowClick(e, i, key, S.selRemote, keys, 'lastRemoteIdx');
|
||
applySelectionToDOM(el, S.selRemote);
|
||
});
|
||
|
||
row.addEventListener('dblclick', () => {
|
||
if (f.isDir) call('navigate_remote', {handle: f.handle, storageId: f.storageId, name: f.name});
|
||
});
|
||
|
||
row.addEventListener('contextmenu', e => { e.preventDefault(); showRemoteCtxMenu(f, e); });
|
||
});
|
||
|
||
attachMarqueeSelect(el, S.selRemote);
|
||
|
||
el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); });
|
||
el.addEventListener('dragleave', e => { if (!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); });
|
||
el.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
el.classList.remove('drag-over');
|
||
for (const file of (e.dataTransfer.files || [])) {
|
||
const p = file.path || '';
|
||
if (p) call('start_upload', {srcPath: p});
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleRowClick(e, idx, key, selSet, allKeys, lastIdxProp) {
|
||
if (e.shiftKey && S[lastIdxProp] >= 0) {
|
||
const lo = Math.min(idx, S[lastIdxProp]);
|
||
const hi = Math.max(idx, S[lastIdxProp]);
|
||
if (!e.metaKey) selSet.clear();
|
||
allKeys.slice(lo, hi + 1).forEach(k => selSet.add(k));
|
||
} else if (e.metaKey) {
|
||
if (selSet.has(key)) selSet.delete(key); else selSet.add(key);
|
||
S[lastIdxProp] = idx;
|
||
} else {
|
||
selSet.clear();
|
||
selSet.add(key);
|
||
S[lastIdxProp] = idx;
|
||
}
|
||
}
|
||
|
||
function renderRemoteToolbar() {
|
||
const sel = document.getElementById('storage-sel');
|
||
if (S.storages && S.storages.length) {
|
||
const prev = sel.value;
|
||
sel.innerHTML = S.storages.map(s =>
|
||
`<option value="${s.id}">${esc(s.name)}${s.freeStr ? ' (' + s.freeStr + ')' : ''}</option>`
|
||
).join('');
|
||
sel.value = String(S.activeStorageId || prev);
|
||
sel.style.display = '';
|
||
} else {
|
||
sel.style.display = 'none';
|
||
}
|
||
const stack = S.remoteNavStack || [];
|
||
const crumb = document.getElementById('remote-breadcrumb');
|
||
crumb.innerHTML = stack.slice(1).map((e, i) => {
|
||
const absIdx = i + 1;
|
||
return absIdx === stack.length - 1
|
||
? `<span class="crumb-cur">${esc(e.label)}</span>`
|
||
: `<span class="crumb-link" data-idx="${absIdx}">${esc(e.label)}</span><span class="crumb-sep">›</span>`;
|
||
}).join('');
|
||
crumb.querySelectorAll('.crumb-link').forEach(el =>
|
||
el.addEventListener('click', () => call('navigate_remote_to', {index: +el.dataset.idx})));
|
||
document.getElementById('btn-remote-up').disabled = stack.length <= 1;
|
||
document.getElementById('btn-new-folder').disabled = !S.connected;
|
||
}
|
||
|
||
function renderDeviceBar() {
|
||
const badge = document.getElementById('conn-badge');
|
||
const status = document.getElementById('device-status-text');
|
||
if (S.connected) {
|
||
const name = S.deviceName || t('device.nintendo_switch');
|
||
badge.className = 'badge connected';
|
||
badge.textContent = name;
|
||
status.textContent = t('device.connected', { name });
|
||
} else {
|
||
badge.className = 'badge disconnected';
|
||
badge.textContent = t('badge.no_device');
|
||
status.textContent = t('device.waiting');
|
||
}
|
||
}
|
||
|
||
function renderTransfers() {
|
||
const list = document.getElementById('transfers-list');
|
||
const label = document.getElementById('transfers-label');
|
||
const cancelAll= document.getElementById('btn-cancel-all');
|
||
const panel = document.getElementById('transfers-panel');
|
||
|
||
const active = (S.transfers || []).filter(t => t.state <= 1);
|
||
label.textContent = active.length
|
||
? tPlural('transfers.count', 'transfers.count_plural', active.length)
|
||
: t('transfers');
|
||
cancelAll.style.display = active.length ? '' : 'none';
|
||
cancelAll.textContent = t('btn.cancel_all');
|
||
|
||
if (!S.transfers || !S.transfers.length) { list.innerHTML = ''; panel.style.display = 'none'; return; }
|
||
panel.style.display = '';
|
||
|
||
const stateNames = {
|
||
0: t('transfer.queued'),
|
||
2: t('transfer.done'),
|
||
3: t('transfer.failed'),
|
||
4: t('transfer.cancelled'),
|
||
};
|
||
list.innerHTML = S.transfers.map(tx => {
|
||
const dir = tx.direction === 0 ? t('transfer.dl') : t('transfer.up');
|
||
const dc = tx.direction === 0 ? 'dir-dl' : 'dir-ul';
|
||
const pct = ((tx.progress || 0) * 100).toFixed(0);
|
||
const showBar = tx.state <= 1;
|
||
const statText = tx.state === 1 ? esc(tx.speedStr)
|
||
: tx.state === 3 ? '⚠ ' + esc(tx.error || t('transfer.failed'))
|
||
: (stateNames[tx.state] || '');
|
||
return `<div class="txfer-item">
|
||
<div class="txfer-row">
|
||
<span class="txfer-dir ${dc}">${esc(dir)}</span>
|
||
<span class="txfer-name" title="${esc(tx.filename)}">${esc(tx.filename)}</span>
|
||
<span class="txfer-stat">${statText}</span>
|
||
${showBar ? `<button class="txfer-cancel" data-id="${esc(tx.id)}">×</button>` : ''}
|
||
</div>
|
||
${showBar ? `<div class="pbar"><div class="pbar-fill" style="width:${pct}%"></div></div>
|
||
<div class="txfer-meta">${pct}%${tx.etaStr ? ' · ' + tx.etaStr : ''}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
list.querySelectorAll('.txfer-cancel').forEach(btn =>
|
||
btn.addEventListener('click', () => call('cancel_transfer', {id: btn.dataset.id})));
|
||
}
|
||
|
||
function statusArgsMap(args) {
|
||
const a = args || [];
|
||
return {
|
||
name: a[0] || '',
|
||
error: a[0] || '',
|
||
label: a[0] || '',
|
||
n: a[0] || '',
|
||
};
|
||
}
|
||
|
||
function renderStatusBar() {
|
||
const rf = (S.remoteFiles || []).length;
|
||
document.getElementById('sb-left').textContent = S.connected
|
||
? tPlural('items.count', 'items.count_plural', rf)
|
||
: '';
|
||
document.getElementById('sb-right').textContent = '';
|
||
let center = '';
|
||
const skipStatus = S.statusKey === 'status.connected' || S.statusKey === 'status.disconnected';
|
||
if (S.statusTimer > 0 && S.statusKey && !skipStatus) {
|
||
center = t(S.statusKey, statusArgsMap(S.statusArgs));
|
||
} else if (S.connected) {
|
||
const act = (S.transfers || []).filter(tx => tx.state === 1);
|
||
if (act.length) {
|
||
const spd = act.reduce((s, tx) => s + (tx.speedBps || 0), 0);
|
||
const speed = spd > 1048576
|
||
? (spd/1048576).toFixed(0) + ' MB/s'
|
||
: (spd/1024).toFixed(0) + ' KB/s';
|
||
center = t('status.active', { n: act.length, speed });
|
||
}
|
||
}
|
||
document.getElementById('sb-center').textContent = center;
|
||
}
|
||
|
||
let _ctxFile = null;
|
||
let _ctxMode = 'file'; // 'file' | 'blank'
|
||
|
||
function positionCtxMenu(e) {
|
||
const m = document.getElementById('ctx-menu');
|
||
m.style.left = e.clientX + 'px';
|
||
m.style.top = e.clientY + 'px';
|
||
m.classList.remove('hidden');
|
||
}
|
||
|
||
function showRemoteCtxMenu(file, e) {
|
||
_ctxFile = file;
|
||
_ctxMode = 'file';
|
||
if (!S.selRemote.has(String(file.handle))) {
|
||
S.selRemote.clear();
|
||
S.selRemote.add(String(file.handle));
|
||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||
}
|
||
document.getElementById('ctx-new-folder').style.display = '';
|
||
document.getElementById('ctx-sep-delete').style.display = '';
|
||
document.getElementById('ctx-delete').style.display = '';
|
||
positionCtxMenu(e);
|
||
}
|
||
|
||
function showBlankCtxMenu(e) {
|
||
if (!S.connected) return;
|
||
_ctxFile = null;
|
||
_ctxMode = 'blank';
|
||
document.getElementById('ctx-new-folder').style.display = '';
|
||
document.getElementById('ctx-sep-delete').style.display = 'none';
|
||
document.getElementById('ctx-delete').style.display = 'none';
|
||
positionCtxMenu(e);
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
if (e.target.closest('#ctx-menu')) return;
|
||
document.getElementById('ctx-menu').classList.add('hidden');
|
||
});
|
||
document.addEventListener('contextmenu', e => {
|
||
if (e.target.closest('.frow')) return;
|
||
if (e.target.closest('#remote-files') && S.connected) {
|
||
e.preventDefault();
|
||
showBlankCtxMenu(e);
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
document.getElementById('ctx-menu').classList.add('hidden');
|
||
});
|
||
|
||
window.omniClearSelection = function() {
|
||
const sel = window.getSelection();
|
||
if (sel && sel.rangeCount) sel.removeAllRanges();
|
||
};
|
||
|
||
function exportDragFilesForRow(row) {
|
||
if (!S.connected || !row) return [];
|
||
const files = sortedRemoteFiles();
|
||
const f = files[+row.dataset.idx];
|
||
if (!f) return [];
|
||
const key = String(f.handle);
|
||
const dragFiles = S.selRemote.has(key)
|
||
? files.filter(rf => S.selRemote.has(String(rf.handle)))
|
||
: [f];
|
||
return dragFiles.map(df => ({
|
||
handle: df.handle,
|
||
storageId: df.storageId,
|
||
name: df.name,
|
||
size: df.size || 0,
|
||
isDir: !!df.isDir,
|
||
}));
|
||
}
|
||
|
||
document.addEventListener('selectstart', e => {
|
||
if (e.target.closest('.file-list')) e.preventDefault();
|
||
}, true);
|
||
|
||
document.addEventListener('mousedown', e => {
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest('.fname-edit')) return;
|
||
const row = e.target.closest('#remote-files .frow');
|
||
if (!row) return;
|
||
e.preventDefault();
|
||
const files = exportDragFilesForRow(row);
|
||
if (!files.length) return;
|
||
call('prepare_export_drag', { files, x: e.clientX, y: e.clientY });
|
||
}, true);
|
||
|
||
document.addEventListener('dragstart', e => {
|
||
if (e.target.closest('.file-list .frow')) e.preventDefault();
|
||
}, true);
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await initI18n();
|
||
|
||
document.getElementById('btn-remote-up') .addEventListener('click', () => call('navigate_remote_up'));
|
||
document.getElementById('btn-remote-refresh').addEventListener('click', () => call('refresh_remote'));
|
||
document.getElementById('btn-new-folder') .addEventListener('click', () => createFolderInline());
|
||
document.getElementById('btn-scan') .addEventListener('click', () => call('reconnect'));
|
||
document.getElementById('btn-open-picker') .addEventListener('click', () => call('open_file_picker'));
|
||
document.getElementById('btn-cancel-all') .addEventListener('click', () => call('cancel_all'));
|
||
document.getElementById('storage-sel') .addEventListener('change', e => call('set_storage', {storageId: +e.target.value}));
|
||
|
||
const bindCtx = (id, fn) => {
|
||
document.getElementById(id).addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
document.getElementById('ctx-menu').classList.add('hidden');
|
||
fn();
|
||
});
|
||
};
|
||
|
||
bindCtx('ctx-new-folder', () => createFolderInline());
|
||
|
||
bindCtx('ctx-delete', async () => {
|
||
if (!_ctxFile || _ctxMode !== 'file') return;
|
||
const multi = S.selRemote.size > 1 && S.selRemote.has(String(_ctxFile.handle));
|
||
const message = multi
|
||
? t('confirm.delete_items', { n: S.selRemote.size })
|
||
: t('confirm.delete', { label: `"${_ctxFile.name}"` });
|
||
if (await confirmDialog(message)) {
|
||
const handles = multi
|
||
? S.remoteFiles.filter(f => S.selRemote.has(String(f.handle))).map(f => f.handle)
|
||
: [_ctxFile.handle];
|
||
handles.forEach(h => call('delete_remote', {handle: h}));
|
||
}
|
||
document.getElementById('ctx-menu').classList.add('hidden');
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.matches('input,textarea,select')) return;
|
||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||
S.selRemote.forEach(h => call('delete_remote', {handle: +h}));
|
||
}
|
||
if (e.metaKey && e.key === 'r') {
|
||
e.preventDefault();
|
||
call('refresh_remote');
|
||
}
|
||
if (e.metaKey && e.key === 'a') {
|
||
e.preventDefault();
|
||
S.remoteFiles.forEach(f => S.selRemote.add(String(f.handle)));
|
||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||
}
|
||
if (e.key === 'Escape') {
|
||
S.selRemote.clear();
|
||
applySelectionToDOM(document.getElementById('remote-files'), S.selRemote);
|
||
}
|
||
});
|
||
|
||
poll();
|
||
updateDropZones();
|
||
window.addEventListener('resize', updateDropZones);
|
||
});
|