// /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;
// don’t 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; // don’t 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
correct early (no visual flicker)
document.title = title;
// --- Header logo (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const customLogoUrl = branding.customLogoUrl || "";
const logoImg = document.querySelector('.header-logo img');
if (logoImg) {
if (customLogoUrl) {
logoImg.setAttribute('src', customLogoUrl);
logoImg.setAttribute('alt', 'Site logo');
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
logoImg.setAttribute('alt', 'FileRise');
}
}
} catch (e) {
// non-fatal; ignore branding issues
}
// --- Header colors (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const root = document.documentElement;
const light = branding.headerBgLight || '';
const dark = branding.headerBgDark || '';
if (light) root.style.setProperty('--header-bg-light', light);
else root.style.removeProperty('--header-bg-light');
if (dark) root.style.setProperty('--header-bg-dark', dark);
else root.style.removeProperty('--header-bg-dark');
} catch (e) {
// non-fatal
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
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 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 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 doesn’t 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' });
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
(() => {
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
const qp = new URLSearchParams(location.search);
if (onlyOIDC && qp.get('noauto') !== '1') {
const btn = document.getElementById('oidcLoginBtn');
if (btn) setTimeout(() => btn.click(), 250);
}
})();
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(() => { });
});
}
})();