Files
FileRise/public/js/main.js

1108 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /js/main.js — light bootstrap
// ---- Toast bridge (global, early, race-proof) ----
(function installToastBridge() {
if (window.__FR_TOAST_BRIDGE__) return;
window.__FR_TOAST_BRIDGE__ = true;
window.__FR_TOAST_Q = window.__FR_TOAST_Q || []; // queued toasts until real toast is ready
window.__REAL_TOAST__ = window.__REAL_TOAST__ || null; // set later once domUtils is loaded
window.__FR_TOAST_FILTER__ = window.__FR_TOAST_FILTER__ || null; // filter hook (auth.js)
window.showToast = function (msgOrKey, duration) {
// Let auth.js (or anyone) rewrite/suppress messages centrally.
try {
if (typeof window.__FR_TOAST_FILTER__ === 'function') {
const out = window.__FR_TOAST_FILTER__(msgOrKey);
if (out === null) return; // suppressed
msgOrKey = out; // rewritten/translated
}
} catch { }
if (typeof window.__REAL_TOAST__ === 'function') {
return window.__REAL_TOAST__(msgOrKey, duration);
}
window.__FR_TOAST_Q.push([msgOrKey, duration]);
};
// Optional: generic event bridge
window.addEventListener('filerise:toast', (e) => {
const { message, duration } = (e && e.detail) || {};
if (message) window.showToast(message, duration);
});
})();
async function ensureToastReady() {
if (window.__REAL_TOAST__) return;
try {
const dom = await import('/js/domUtils.js?v={{APP_QVER}}'); // real toast
const real = dom.showToast || ((m, d) => console.log('TOAST:', m, d));
// (Optional) “false-negative to success” normalizer
const normalized = function (msg, dur) {
try {
const m = (msg || '').toString().toLowerCase();
if (/does not exist|already exist|not found/.test(m) && window.__FR_LAST_OK) {
window.__FR_LAST_OK = false;
return real('Done.', dur);
}
} catch { }
return real(msg, dur);
};
window.__REAL_TOAST__ = normalized;
// Flush anything that queued before domUtils was ready
const q = window.__FR_TOAST_Q || [];
window.__FR_TOAST_Q = [];
q.forEach(([m, d]) => window.__REAL_TOAST__(m, d));
} catch {
window.__REAL_TOAST__ = (m, d) => console.log('TOAST:', m, d);
}
}
function isDemoHost() {
// Handles optional "www." just in case
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
}
function showLoginTip(message) {
const tip = document.getElementById('fr-login-tip');
if (!tip) return;
tip.innerHTML = ''; // clear
if (message) tip.append(document.createTextNode(message));
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
const line = document.createElement('div'); line.style.marginTop = '6px';
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
document.createTextNode(' · pass: '), mk('demo'));
tip.append(line);
}
tip.style.display = 'block'; // reveal without shifting layout
}
async function hideOverlaySmoothly(overlay) {
if (!overlay) return;
try { await document.fonts?.ready; } catch { }
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
overlay.style.display = 'none';
}
function wireModalEnterDefault() {
if (window.__FR_FLAGS.wired.enterDefault) return;
window.__FR_FLAGS.wired.enterDefault = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
// dont hijack multiline inputs or anything explicitly opted-out
const tgt = e.target;
if (tgt && (tgt.tagName === 'TEXTAREA' || tgt.isContentEditable || tgt.closest('[data-no-enter]'))) return;
// pick the topmost visible modal
const modal = Array.from(document.querySelectorAll('.modal')).reverse().find(m => {
const s = getComputedStyle(m);
return s.display !== 'none' && s.visibility !== 'hidden' && s.pointerEvents !== 'none';
});
if (!modal) return;
const btn = modal.querySelector('[data-default]');
if (!btn || btn.disabled) return;
e.preventDefault();
btn.click();
}, true); // capture so we beat other handlers
}
// One-shot guards
window.__FR_FLAGS = window.__FR_FLAGS || {
booted: false,
initialized: false,
domReadyFired: false,
wired: Object.create(null)
};
window.__FR_FLAGS.bootPromise = window.__FR_FLAGS.bootPromise || null;
window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
// ---- Result guard + request coalescer (dedupe) ----
(function installResultGuardAndCoalescer() {
if (window.__FR_FETCH_GUARD_INSTALLED) return;
window.__FR_FETCH_GUARD_INSTALLED = true;
const nativeFetch = window.fetch.bind(window);
window.__FR_LAST_OK = false;
const inFlight = new Map(); // key -> { ts, promise }
function normalizeUrl(u) {
try {
const url = new URL(u, window.location.origin);
// Keep path + stable query ordering
const params = new URLSearchParams(url.search);
const sorted = new URLSearchParams();
[...params.keys()].sort().forEach(k => sorted.set(k, params.get(k)));
return url.pathname + (sorted.toString() ? '?' + sorted.toString() : '');
} catch { return String(u || ''); }
}
async function toStableBody(init) {
const b = init && init.body;
if (!b) return '';
try {
if (typeof b === 'string') {
// try JSON
try {
const j = JSON.parse(b);
// remove volatile fields like csrf
delete j.csrf; delete j.csrf_token; delete j._;
return 'JSON:' + JSON.stringify(j, Object.keys(j).sort());
} catch {
// maybe urlencoded
const p = new URLSearchParams(b);
['csrf', 'csrf_token', '_'].forEach(k => p.delete(k));
return 'FORM:' + [...p.entries()].map(([k, v]) => `${k}=${v}`).sort().join('&');
}
}
} catch { }
return 'B:' + String(b);
}
window.fetch = async (input, init = {}) => {
const method = (init.method || 'GET').toUpperCase();
const urlKey = normalizeUrl(typeof input === 'string' ? input : (input && input.url) || '');
const bodyKey = await toStableBody(init);
const key = method + ' ' + urlKey + ' ' + bodyKey;
const now = Date.now();
const existing = inFlight.get(key);
if (existing && (now - existing.ts) < 800) {
// coalesce: return the same promise
return existing.promise.then(r => r.clone());
}
// 1) Only coalesce mutating methods
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return nativeFetch(input, init);
}
// 2) Only same-origin API calls
const isSameOrigin = (typeof input === 'string')
? input.startsWith('/') || input.startsWith(location.origin)
: new URL(input.url).origin === location.origin;
const urlPath = (typeof input === 'string') ? input : new URL(input.url).pathname;
if (!isSameOrigin || !urlPath.startsWith('/api/')) {
return nativeFetch(input, init);
}
// 3) Never touch downloads/streams
if (urlPath.includes('download') || urlPath.includes('zip')) {
return nativeFetch(input, init);
}
// 4) Kill switch (handy for debugging)
if (window.__FR_DISABLE_COALESCE) {
return nativeFetch(input, init);
}
const p = nativeFetch(input, init).then(async (res) => {
try {
const clone = res.clone();
let okJson = null;
try { okJson = await clone.json(); } catch { }
const okFlag = res.ok && okJson && (
okJson.success === true || okJson.status === 'ok' || okJson.result === 'ok'
);
window.__FR_LAST_OK = !!okFlag;
} catch { }
return res;
}).finally(() => {
// let it linger briefly so very-rapid duplicates still coalesce
setTimeout(() => inFlight.delete(key), 200);
});
inFlight.set(key, { ts: now, promise: p });
return p.then(r => r.clone());
};
// Gentle toast normalizer (compatible with showToast(message, duration))
const origToast = window.showToast;
if (typeof origToast === 'function' && !origToast.__frWrapped) {
const wrapped = function (msg, maybeDuration) {
try {
const m = (msg || '').toString().toLowerCase();
const looksWrong =
/does not exist|already exist|not found/.test(m) &&
window.__FR_LAST_OK === true;
if (looksWrong) {
window.__FR_LAST_OK = false;
// Keep default duration if not numeric
const dur = (typeof maybeDuration === 'number') ? maybeDuration : undefined;
return origToast('Done.', dur);
}
} catch { }
const dur = (typeof maybeDuration === 'number') ? maybeDuration : undefined;
return origToast(msg, dur);
};
wrapped.__frWrapped = true;
window.showToast = wrapped;
}
})();
function bindDragAutoScroll() {
if (window.__FR_FLAGS.wired.dragScroll) return;
window.__FR_FLAGS.wired.dragScroll = true;
const THRESH = 50;
const SPEED = 20;
document.addEventListener('dragover', (e) => {
const y = e && typeof e.clientY === 'number' ? e.clientY : null;
if (y == null) return;
if (y < THRESH) window.scrollBy(0, -SPEED);
else if (y > (window.innerHeight - THRESH)) window.scrollBy(0, SPEED);
}, { passive: true });
}
function bindClickIfMissing(id, fnName) {
const el = document.getElementById(id);
if (!el || el.__bound) return;
el.__bound = true;
el.addEventListener('click', async (e) => {
e.preventDefault();
if (el.dataset.busy === '1') return;
el.dataset.busy = '1';
try {
const acts = await ensureFileActionsLoaded();
if (acts && typeof acts[fnName] === 'function') {
await acts[fnName]();
}
// if API said success but no positive toast happened, give a neutral one
if (window.__FR_LAST_OK === true && typeof window.showToast === 'function') {
window.showToast('Done.', 'success');
window.__FR_LAST_OK = false;
}
} catch (err) {
console.warn(`[wire] ${fnName} error`, err);
} finally {
const modal = el.closest('.modal'); if (modal) modal.style.display = 'none';
setTimeout(() => { el.dataset.busy = '0'; }, 600); // short cooldown
}
}, true);
}
function dispatchLegacyReadyOnce() {
if (window.__FR_FLAGS.domReadyFired) return;
window.__FR_FLAGS.domReadyFired = true;
try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch { }
try { window.dispatchEvent(new Event('load')); } catch { }
}
// -------- username label ( --------
function wireUserNameLabel(state) {
const username = (state && state.username) || localStorage.getItem('username') || '';
const btn = document.getElementById('userDropdownToggle') || document.getElementById('userMenuBtn');
if (!btn) return;
const label = btn.querySelector('.user-name-label');
if (!label) return; // dont inject new DOM
label.textContent = username || '';
}
// -------- DARK MODE (persist + system fallback + a11y labels) --------
function applyDarkMode({ fromSystemChange = false } = {}) {
let stored = null;
try { stored = localStorage.getItem('darkMode'); } catch { }
let isDark = (stored === null)
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
: (stored === '1' || stored === 'true');
const root = document.documentElement;
const body = document.body;
[root, body].forEach(el => {
if (!el) return;
el.classList.toggle('dark-mode', isDark);
el.setAttribute('data-theme', isDark ? 'dark' : 'light');
});
// keep UA chrome & bg consistent post-toggle
const bg = isDark ? '#121212' : '#ffffff';
root.style.backgroundColor = bg;
root.style.colorScheme = isDark ? 'dark' : 'light';
if (body) {
body.style.backgroundColor = bg;
body.style.colorScheme = isDark ? 'dark' : 'light';
}
const mt = document.querySelector('meta[name="theme-color"]');
if (mt) mt.content = bg;
const mcs = document.querySelector('meta[name="color-scheme"]');
if (mcs) mcs.content = isDark ? 'dark light' : 'light dark';
const btn = document.getElementById('darkModeToggle');
const icon = document.getElementById('darkModeIcon');
if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode';
if (btn) {
const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode');
const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode');
const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode'));
btn.classList.toggle('active', isDark);
btn.setAttribute('aria-label', aria);
btn.setAttribute('title', isDark ? ttOff : ttOn);
}
}
function bindDarkMode() {
const btn = document.getElementById('darkModeToggle');
if (btn && !btn.__bound) {
btn.__bound = true;
applyDarkMode(); // apply once on boot
btn.addEventListener('click', () => {
// Toggle relative to current DOM state
const isDarkNext = !(document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode'));
try { localStorage.setItem('darkMode', isDarkNext ? '1' : '0'); } catch { }
applyDarkMode();
});
}
// Listen to system changes only if user has NOT set a preference
if (!window.__FR_FLAGS.wired.sysDarkMO) {
window.__FR_FLAGS.wired.sysDarkMO = true;
let stored = null; try { stored = localStorage.getItem('darkMode'); } catch { }
if (stored === null && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyDarkMode({ fromSystemChange: true });
try { mq.addEventListener('change', handler); } catch { mq.addListener(handler); }
}
}
}
(function () {
// ---------- tiny utils ----------
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
// Safe show/hide that work with both CSS and [hidden]
const unhide = (el) => { if (!el) return; el.removeAttribute('hidden'); el.style.display = ''; };
const hideEl = (el) => { if (!el) return; el.setAttribute('hidden', ''); el.style.display = 'none'; };
const show = (el) => {
if (!el) return;
el.hidden = false; el.classList?.remove('d-none', 'hidden');
el.style.display = 'block'; el.style.visibility = 'visible'; el.style.opacity = '1';
};
const hide = (el) => { if (!el) return; el.style.display = 'none'; };
const setMeta = (name, val) => {
let m = document.querySelector(`meta[name="${name}"]`);
if (!m) { m = document.createElement('meta'); m.name = name; document.head.appendChild(m); }
m.content = val;
};
// ---------- site config / auth ----------
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
} else {
row.removeAttribute('hidden');
row.style.display = '';
}
}
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
if (h1.textContent !== title) h1.textContent = title;
});
mo.observe(h1, { childList: true, characterData: true, subtree: true });
h1.__titleLock = mo;
}
}
}
} catch { }
}
async function readyToReveal() {
// Wait for CSS + fonts so the first revealed frame is fully styled
try { await (window.__CSS_PROMISE__ || Promise.resolve()); } catch { }
try { await document.fonts?.ready; } catch { }
// Give layout one paint to settle
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
}
async function revealAppAndHideOverlay() {
const appRoot = document.getElementById('appRoot');
const overlay = document.getElementById('loadingOverlay');
await readyToReveal();
if (appRoot) appRoot.style.visibility = 'visible';
if (overlay) {
overlay.style.transition = 'opacity .18s ease-out';
overlay.style.opacity = '0';
setTimeout(() => { overlay.style.display = 'none'; }, 220);
}
}
async function loadSiteConfig() {
try {
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
window.__FR_SITE_CFG__ = j || {};
// Early pass: title + login options (skip touching <h1> to avoid flicker)
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
return window.__FR_SITE_CFG__;
} catch {
window.__FR_SITE_CFG__ = {};
applySiteConfig({}, { phase: 'early' });
return null;
}
}
async function primeCsrf() {
try {
const tr = await fetch('/api/auth/token.php', { credentials: 'include' });
const tj = await tr.json().catch(() => ({}));
if (tj?.csrf_token) { setMeta('csrf-token', tj.csrf_token); window.csrfToken = tj.csrf_token; try { localStorage.setItem('csrf', tj.csrf_token); } catch { } }
} catch { }
}
async function checkAuth() {
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
const j = await r.json().catch(() => ({}));
if (j?.csrf_token) {
setMeta('csrf-token', j.csrf_token);
window.csrfToken = j.csrf_token;
try { localStorage.setItem('csrf', j.csrf_token); } catch { }
}
if (typeof j?.isAdmin !== 'undefined') {
try { localStorage.setItem('isAdmin', j.isAdmin ? '1' : '0'); } catch { }
}
if (typeof j?.username !== 'undefined') {
try { localStorage.setItem('username', j.username || ''); } catch { }
}
const setup = !!j?.setup || !!j?.setup_mode || j?.mode === 'setup' || j?.status === 'setup' || !!j?.requires_setup || !!j?.needs_setup;
return { authed: !!j?.authenticated, setup, raw: j };
} catch {
return { authed: false, setup: false, raw: {} };
}
}
// ---- Create dropdown + its two modals ----
function wireCreateDropdown() {
const container = document.getElementById('createDropdown');
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const makeF = document.getElementById('createFileOption');
const makeD = document.getElementById('createFolderOption');
if (!container || !btn || !menu) return;
// Ensure layout basics
if (getComputedStyle(container).position === 'static') container.style.position = 'relative';
btn.style.pointerEvents = 'auto';
menu.style.pointerEvents = 'auto';
menu.style.zIndex = '10010';
// Show/hide button based on live auth flags
const st = (window.__FR_AUTH_STATE || {});
const readOnly = !!st.readOnly || (localStorage.getItem('readOnly') === 'true' || localStorage.getItem('readOnly') === '1');
const disableUpload = !!st.disableUpload || (localStorage.getItem('disableUpload') === 'true' || localStorage.getItem('disableUpload') === '1');
btn.style.display = (!readOnly && !disableUpload) ? 'inline-flex' : 'none';
let justToggledAt = 0;
function openMenu() {
// Beat any CSS with !important
menu.style.setProperty('display', 'block', 'important');
btn.setAttribute('aria-expanded', 'true');
justToggledAt = Date.now();
}
function closeMenu() {
menu.style.setProperty('display', 'none', 'important');
btn.setAttribute('aria-expanded', 'false');
}
function isOpen() {
return getComputedStyle(menu).display !== 'none';
}
if (!btn.__bound) {
btn.__bound = true;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation(); // block bubble-phase global closers
if (isOpen()) closeMenu(); else openMenu();
}, true); // use capture so we run before bubble-phase closers
}
// Close only when clicking truly outside our container.
// Use capture and ignore the click that immediately follows our open() call.
if (!menu.__outside) {
menu.__outside = true;
document.addEventListener('click', (e) => {
// ignore the same-tick/rapid click that opened the menu
if (Date.now() - justToggledAt < 120) return;
if (!container.contains(e.target)) closeMenu();
}, true); // capture to pre-empt other handlers
}
if (!menu.__esc) {
menu.__esc = true;
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) closeMenu();
});
}
const openModal = (id) => {
const m = document.getElementById(id);
if (m) { m.style.display = 'block'; closeMenu(); }
};
if (makeF && !makeF.__bound) { makeF.__bound = true; makeF.addEventListener('click', (e) => { e.preventDefault(); openModal('createFileModal'); }); }
if (makeD && !makeD.__bound) { makeD.__bound = true; makeD.addEventListener('click', (e) => { e.preventDefault(); openModal('createFolderModal'); }); }
}
// ---- Modal cancel safety ----
function bindCancelSafeties() {
const ids = [
'#cancelDeleteFiles', '#cancelCopyFiles', '#cancelMoveFiles', '#cancelDownloadZip', '#cancelDownloadFile',
'#cancelCreateFile', '#cancelMoveFolder', '#cancelRenameFolder', '#cancelDeleteFolder', '#closeRestoreModal'
];
ids.forEach(id => {
const el = document.querySelector(id);
if (el && !el.__safe) {
el.__safe = true;
el.addEventListener('click', (e) => {
e.preventDefault();
const modal = el.closest('.modal');
if (modal) modal.style.display = 'none';
});
}
});
}
function keepCreateDropdownWired() {
if (window.__FR_FLAGS.wired.keepCreateMO) return;
window.__FR_FLAGS.wired.keepCreateMO = true;
const mo = new MutationObserver(() => {
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
if (btn && menu && !btn.__bound) wireCreateDropdown();
});
mo.observe(document.body, { childList: true, subtree: true });
}
// ---- Folder-level helpers: de-dupe selects only when a modal opens ----
function dedupeSelect(el) {
if (!el) return;
const seen = new Set();
const rm = [];
Array.from(el.options).forEach(opt => {
const key = (opt.value || opt.textContent || '').trim();
if (!key) return;
if (seen.has(key)) rm.push(opt); else seen.add(key);
});
rm.forEach(o => o.remove());
}
function dedupeByIdSoon(id) { setTimeout(() => { const el = document.getElementById(id); if (el) dedupeSelect(el); }, 60); }
function wireFolderButtons() {
const open = (id) => { const m = document.getElementById(id); if (m) m.style.display = 'block'; };
const moveBtn = document.getElementById('moveFolderBtn');
if (moveBtn && !moveBtn.__bound) {
moveBtn.__bound = true;
moveBtn.addEventListener('click', (e) => { e.preventDefault(); open('moveFolderModal'); dedupeByIdSoon('moveFolderTarget'); });
}
const shareBtn = document.getElementById('shareFolderBtn');
if (shareBtn && !shareBtn.__bound) {
shareBtn.__bound = true;
shareBtn.addEventListener('click', (e) => {
e.preventDefault();
const shareModal = document.getElementById('shareFolderModal');
if (shareModal) shareModal.style.display = 'block';
else document.dispatchEvent(new CustomEvent('filerise:share-folder', { detail: { folder: window.currentFolder || 'root' } }));
});
}
const moveFilesOpenBtn = document.getElementById('moveSelectedBtn');
if (moveFilesOpenBtn && !moveFilesOpenBtn.__dedupeHook) {
moveFilesOpenBtn.__dedupeHook = true;
moveFilesOpenBtn.addEventListener('click', () => dedupeByIdSoon('moveTargetFolder'));
}
}
// ---- Lift modals above cards, always clickable ----
function liftModals() {
document.querySelectorAll('.modal').forEach(m => { m.style.pointerEvents = 'auto'; m.style.zIndex = '10000'; });
document.querySelectorAll('.modal .modal-content').forEach(c => { c.style.position = 'relative'; c.style.zIndex = '10001'; });
}
// ---- Title fix after first list ----
function updateFileListTitle() {
const el = document.getElementById('fileListTitle');
if (!el) return;
const folder = (window.currentFolder || localStorage.getItem('lastOpenedFolder') || 'root');
const name = folder === 'root' ? '(Root)' : folder;
el.textContent = `Files in ${name}`;
el.setAttribute('data-i18n-key', 'file_list_title');
}
// ---------- SETUP MODE ----------
async function createUserSetup(username, password, isAdmin) {
const csrf = window.csrfToken || localStorage.getItem('csrf') || '';
const headers = { 'Content-Type': 'application/json' };
if (csrf) headers['X-CSRF-Token'] = csrf;
const res = await fetch('/api/addUser.php?setup=1', {
method: 'POST',
credentials: 'include',
headers,
body: JSON.stringify({ username, password, isAdmin: true, admin: true, grant_admin: true })
});
let j = {};
try { j = await res.json(); } catch { }
if (!res.ok || j.error) throw new Error(j.error || `Add user failed (${res.status})`);
return true;
}
function bindSetupAddUser() {
const form = document.getElementById('addUserForm');
if (!form || form.__bound) return;
form.__bound = true;
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const usernameEl = document.getElementById('newUsername');
const passwordEl = document.getElementById('addUserPassword');
const isAdminEl = document.getElementById('isAdmin');
const username = (usernameEl?.value || '').trim();
const password = (passwordEl?.value || '');
const isAdmin = isAdminEl ? !!isAdminEl.checked : true;
if (!username || !password) {
alert('Enter a username and password.');
(username ? passwordEl : usernameEl)?.focus();
return;
}
try {
await primeCsrf();
await createUserSetup(username, password, isAdmin);
const addModal = document.getElementById('addUserModal'); if (addModal) addModal.style.display = 'none';
window.setupMode = false;
window.location.reload();
} catch (e) {
alert(e.message || 'Failed to create user. Check server logs.');
if (!username) usernameEl?.focus();
else if (!password) passwordEl?.focus();
}
}, true);
}
// ---------- pre-auth login ----------
function forceLoginVisible() {
show($('#main'));
show($('#loginForm'));
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
}
function looksLikeTOTP(res, body) {
try {
if (res && (res.headers.get('X-TOTP-Required') === '1' ||
(res.redirected && /[?&]totp_required=1\b/.test(res.url)))) {
return true;
}
if (body && (body.totp_required === true || body.error === 'TOTP_REQUIRED')) {
return true;
}
} catch { }
return false;
}
async function openTotpNow() {
// refresh CSRF for the upcoming /totp_verify call
try { await primeCsrf(); } catch { }
window.pendingTOTP = true;
// reuse the function you already export from auth.js
try {
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch (e) {
console.warn('Could not import auth.js to open TOTP modal:', e);
const m = document.getElementById('totpLoginModal'); if (m) m.style.display = 'block';
}
}
function bindLogin() {
const oidcBtn = $('#oidcLoginBtn');
if (oidcBtn && !oidcBtn.__bound) {
oidcBtn.__bound = true;
oidcBtn.addEventListener('click', () => { window.location.href = '/api/auth/auth.php?oidc=initiate'; });
}
const form = $('#authForm');
if (!form || form.__bound) return;
form.__bound = true;
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const username = ($('#loginUsername') || {}).value || '';
const password = ($('#loginPassword') || {}).value || '';
const remember = !!(document.getElementById('rememberMeCheckbox') || {}).checked;
await primeCsrf();
const csrf = window.csrfToken || localStorage.getItem('csrf') || '';
// After showing the login form in the not-authed branch
(async () => {
const qp = new URLSearchParams(location.search);
if (qp.get('totp_required') === '1') {
try { await primeCsrf(); } catch { }
window.pendingTOTP = true;
try {
const auth = await import('/js/auth.js?v={{APP_QVER}}');
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
} catch (e) {
console.warn('openTOTPLoginModal import failed', e);
}
}
})();
// JSON first
try {
const r = await fetch('/api/auth/auth.php', {
method: 'POST', credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/json', 'Accept': 'application/json' }, csrf ? { 'X-CSRF-Token': csrf } : {}),
body: JSON.stringify({ username: String(username).trim(), password: String(password).trim(), remember_me: !!remember })
});
const j = await r.clone().json().catch(() => ({}));
// TOTP step-up?
if (looksLikeTOTP(r, j)) { await openTotpNow(); return; }
if (j && (j.authenticated || j.success || j.status === 'ok' || j.result === 'ok')) return afterLogin();
} catch { }
// fallback form
try {
const p = new URLSearchParams();
p.set('username', username); p.set('password', password); p.set('remember_me', remember ? '1' : '0');
const r2 = await fetch('/api/auth/auth.php', {
method: 'POST', credentials: 'include',
headers: Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, csrf ? { 'X-CSRF-Token': csrf } : {}),
body: p.toString()
});
const j2 = await r2.clone().json().catch(() => ({}));
// TOTP step-up on fallback too
if (looksLikeTOTP(r2, j2)) { await openTotpNow(); return; }
if (j2 && (j2.authenticated || j2.success || j2.status === 'ok' || j2.result === 'ok')) return afterLogin();
} catch { }
alert('Login failed');
});
}
function afterLogin() {
const start = Date.now();
(function poll() {
checkAuth().then(({ authed }) => {
if (authed) { window.location.reload(); return; }
if (Date.now() - start < 5000) return setTimeout(poll, 200);
alert('Login session not established');
}).catch(() => setTimeout(poll, 250));
})();
}
// ---------- SETUP MODE (no flicker) ----------
async function bootSetupWizard() {
const overlay = document.getElementById('loadingOverlay'); if (overlay) overlay.remove();
const wrap = document.querySelector('.main-wrapper'); if (wrap) wrap.style.display = '';
const login = document.getElementById('loginForm'); if (login) login.style.display = 'none';
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
(document.getElementById('mainOperations') || {}).style && (document.getElementById('mainOperations').style.display = 'none');
(document.getElementById('uploadFileForm') || {}).style && (document.getElementById('uploadFileForm').style.display = 'none');
(document.getElementById('fileListContainer') || {}).style && (document.getElementById('fileListContainer').style.display = 'none');
window.setupMode = true;
await primeCsrf();
try { await import('/js/adminPanel.js?v={{APP_QVER}}'); } catch { }
try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch { }
const addModal = document.getElementById('addUserModal'); if (addModal) addModal.style.display = 'block';
const lu = document.getElementById('loginUsername'); if (lu) { lu.removeAttribute('autofocus'); lu.disabled = true; }
const lp = document.getElementById('loginPassword'); if (lp) lp.disabled = true;
document.querySelectorAll('[autofocus]').forEach(el => el.removeAttribute('autofocus'));
bindSetupAddUser();
}
// ---------- HEAVY BOOT ----------
async function bootHeavy() {
if (window.__FR_FLAGS.bootPromise) return window.__FR_FLAGS.bootPromise;
window.__FR_FLAGS.bootPromise = (async () => {
if (window.__FR_FLAGS.booted) return; // no-op if somehow set
window.__FR_FLAGS.booted = true;
ensureToastReady();
// show chrome
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
try {
// 0) refresh auth snapshot (once)
let state = {};
try {
const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' });
state = await r.json();
if (state && state.username) localStorage.setItem('username', state.username);
if (typeof state.isAdmin !== 'undefined') localStorage.setItem('isAdmin', state.isAdmin ? '1' : '0');
window.__FR_AUTH_STATE = state;
} catch { }
// authed → heavy boot path
document.body.classList.add('authed');
// 1) i18n (safe)
// i18n: honor saved language first, then apply translations
try {
const i18n = await import('/js/i18n.js?v={{APP_QVER}}').catch(() => import('/js/i18n.js'));
let saved = 'en';
try { saved = localStorage.getItem('language') || 'en'; } catch { }
if (typeof i18n.setLocale === 'function') { await i18n.setLocale(saved); }
if (typeof i18n.applyTranslations === 'function') { i18n.applyTranslations(); }
try { document.documentElement.setAttribute('lang', saved); } catch { }
} catch { }
// 2) core app — **initialize exactly once** (this calls initUpload/initFileActions/loadFolderTree/etc.)
const app = await import('/js/appCore.js?v={{APP_QVER}}');
if (!window.__FR_FLAGS.initialized) {
if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken();
if (typeof app.initializeApp === 'function') app.initializeApp();
const darkBtn = document.getElementById('darkModeToggle');
if (darkBtn) {
darkBtn.removeAttribute('hidden');
darkBtn.style.setProperty('display', 'inline-flex', 'important'); // beats any CSS
darkBtn.style.visibility = ''; // just in case
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
document.head.appendChild(link);
window.__FR_FLAGS.initialized = true;
try {
if (!sessionStorage.getItem('__fr_welcomed')) {
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
const safe = String(name).replace(/[\r\n<>]/g, '').trim().slice(0, 60);
window.showToast(safe ? `Welcome back, ${safe}!` : 'Welcome!', 3000);
sessionStorage.setItem('__fr_welcomed', '1'); // prevent repeats on reload
}
} catch { }
}
// 3) auth/header bits — pass real state so “Admin Panel” shows up
if (!window.__FR_FLAGS.wired.auth) {
try {
const auth = await import('/js/auth.js?v={{APP_QVER}}');
auth.updateLoginOptionsUIFromStorage && auth.updateLoginOptionsUIFromStorage();
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
// bind ALL the admin / change-password buttons once
if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') {
try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); }
window.__FR_FLAGS.wired.authInit = true;
}
} catch (e) {
console.warn('[auth] import failed', e);
}
wireUserNameLabel(state);
window.__FR_FLAGS.wired.auth = true;
}
// 4) legacy ready **only once** (prevents loops)
dispatchLegacyReadyOnce();
// 5) first file list — once (initializeApp doesnt fetch list)
if (!window.__FR_FLAGS.wired.firstList) {
try {
const flv = await import('/js/fileListView.js?v={{APP_QVER}}');
window.currentFolder ||= 'root';
if (typeof flv.loadFileList === 'function') await flv.loadFileList(window.currentFolder);
const list = document.getElementById('fileListContainer'); if (list) list.style.display = '';
updateFileListTitle();
} catch { }
window.__FR_FLAGS.wired.firstList = true;
}
// 6) light UI wiring — once each (no confirm bindings here; your modules own them)
if (!window.__FR_FLAGS.wired.dark) { bindDarkMode(); window.__FR_FLAGS.wired.dark = true; }
if (!window.__FR_FLAGS.wired.create) { wireCreateDropdown(); window.__FR_FLAGS.wired.create = true; }
if (!window.__FR_FLAGS.wired.folder) { wireFolderButtons(); window.__FR_FLAGS.wired.folder = true; }
if (!window.__FR_FLAGS.wired.lift) { liftModals(); window.__FR_FLAGS.wired.lift = true; }
if (!window.__FR_FLAGS.wired.cancel) { bindCancelSafeties(); window.__FR_FLAGS.wired.cancel = true; }
if (!window.__FR_FLAGS.wired.dragScroll) { bindDragAutoScroll(); window.__FR_FLAGS.wired.dragScroll = true; }
wireModalEnterDefault();
} catch (e) {
console.error('[main] heavy boot failed', e);
alert('Failed to load app');
} finally {
if (ov) ov.style.display = 'none';
window.setupMode = false;
}
})();
return window.__FR_FLAGS.bootPromise;
}
// ---------- entry (no flicker: decide state BEFORE showing login) ----------
document.addEventListener('DOMContentLoaded', async () => {
if (window.__FR_FLAGS.entryStarted) return;
window.__FR_FLAGS.entryStarted = true;
// Always start clean
document.body.classList.remove('authed');
const overlay = document.getElementById('loadingOverlay');
const wrap = document.querySelector('.main-wrapper'); // app shell
const mainEl = document.getElementById('main'); // contains loginForm
const login = document.getElementById('loginForm');
bindDarkMode();
await loadSiteConfig();
const { authed, setup } = await checkAuth();
if (setup) {
// Setup wizard runs inside app shell
unhide(wrap);
hideEl(login);
await bootSetupWizard();
await revealAppAndHideOverlay();
return;
}
if (authed) {
// Authenticated path: show app, hide login
document.body.classList.add('authed');
unhide(wrap); // works whether CSS or [hidden] was used
hideEl(login);
await bootHeavy();
await revealAppAndHideOverlay();
requestAnimationFrame(() => {
const pre = document.getElementById('pretheme-css');
if (pre) pre.remove();
});
return;
}
// ---- NOT AUTHED: show only the login view ----
hideEl(wrap); // ensure app shell stays hidden while logged out
unhide(mainEl);
unhide(login);
if (login) login.style.display = '';
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
// keep app cards inert while logged out (no layout poke)
['uploadCard', 'folderManagementCard'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.setAttribute('aria-hidden', 'true');
try { el.inert = true; } catch { }
});
bindLogin();
wireCreateDropdown();
keepCreateDropdownWired();
wireModalEnterDefault();
showLoginTip('Please log in to continue');
if (overlay) overlay.style.display = 'none';
}, { once: true });
})();
// --- Mobile switcher + PWA SW (mobile-only) ---
(() => {
// keep it simple + robust
const qs = new URLSearchParams(location.search);
const hasFrAppHint = qs.get('frapp') === '1';
const isStandalone =
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
(typeof navigator.standalone === 'boolean' && navigator.standalone);
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
const isMobileish =
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
const shouldLoadSwitcher =
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
// expose a flag to inspect later
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
if (shouldLoadSwitcher) {
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
.then(() => {
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
sessionStorage.setItem('frx_opened_once', '1');
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
}
})
.catch(err => console.info('[FileRise] switcher import failed:', err));
}
// SW only for web (https or localhost), never in Capacitor
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
});
}
})();