release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
This commit is contained in:
@@ -170,9 +170,9 @@ async function safeJson(res) {
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
body.dark-mode .form-control::placeholder { color:#888; }
|
||||
.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
.dark-mode .form-control::placeholder { color:#888; }
|
||||
|
||||
.section-header {
|
||||
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
|
||||
@@ -181,8 +181,8 @@ async function safeJson(res) {
|
||||
.section-header:first-of-type { margin-top:0; }
|
||||
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
||||
.section-header .material-icons { transition:transform .3s; color:#444; }
|
||||
body.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
body.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
|
||||
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
|
||||
|
||||
@@ -193,7 +193,7 @@ async function safeJson(res) {
|
||||
border:2px solid transparent; transition:all .3s;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
|
||||
body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||
.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||
|
||||
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
|
||||
|
||||
@@ -210,7 +210,7 @@ async function safeJson(res) {
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
body.dark-mode .folder-access-list { border-color:#555; }
|
||||
.dark-mode .folder-access-list { border-color:#555; }
|
||||
|
||||
.folder-access-header,
|
||||
.folder-access-row {
|
||||
@@ -228,7 +228,7 @@ async function safeJson(res) {
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||
}
|
||||
body.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||
.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||
|
||||
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||
.folder-access-row:last-child { border-bottom: none; }
|
||||
@@ -257,8 +257,8 @@ async function safeJson(res) {
|
||||
color: #2064ff;
|
||||
margin-left: 6px;
|
||||
}
|
||||
body.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
|
||||
body.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
|
||||
.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
|
||||
.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||
@@ -274,7 +274,7 @@ async function safeJson(res) {
|
||||
/* nicer thin scrollbar (supported browsers) */
|
||||
.folder-cell::-webkit-scrollbar{ height:8px; }
|
||||
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
|
||||
body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||
.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||
|
||||
/* Badge now doesn't clip; let the wrapper handle scroll */
|
||||
.folder-badge{
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Promote any preloaded core CSS
|
||||
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
|
||||
.some(s => s.getAttribute('href') === href)) return;
|
||||
const sheet = document.createElement('link');
|
||||
sheet.rel = 'stylesheet';
|
||||
sheet.href = href;
|
||||
document.head.appendChild(sheet);
|
||||
});
|
||||
// /public/js/defer-css.js
|
||||
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
|
||||
(function () {
|
||||
if (window.__CSS_PROMISE__) return;
|
||||
|
||||
var loads = [];
|
||||
|
||||
// Optionally load non-critical icon/extra font CSS after first paint:
|
||||
const extra = document.createElement('link');
|
||||
extra.rel = 'stylesheet';
|
||||
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
|
||||
document.head.appendChild(extra);
|
||||
});
|
||||
// Promote <link rel="preload" as="style"> IN-PLACE
|
||||
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
|
||||
for (var i = 0; i < preloads.length; i++) {
|
||||
var l = preloads[i];
|
||||
// resolve when it finishes loading as a stylesheet
|
||||
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
|
||||
l.rel = 'stylesheet';
|
||||
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
|
||||
l.removeAttribute('as'); // keep some engines happy about "used" preload
|
||||
}
|
||||
|
||||
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
|
||||
var styles = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (var j = 0; j < styles.length; j++) {
|
||||
var s = styles[j];
|
||||
if (s.sheet) continue; // already applied
|
||||
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
|
||||
}
|
||||
|
||||
// Safari quirk: nudge layout so promoted sheets apply immediately
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
window.__CSS_PROMISE__ = Promise.all(loads);
|
||||
})();
|
||||
@@ -2,6 +2,7 @@
|
||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
|
||||
// thresholds for editor behavior
|
||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||
@@ -14,7 +15,7 @@ const CM_BASE = "/vendor/codemirror/5.65.5/";
|
||||
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
||||
|
||||
const CORE = {
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
js: coreUrl("codemirror.min.js"),
|
||||
css: coreUrl("codemirror.min.css"),
|
||||
themeCss: coreUrl("theme/material-darker.min.css"),
|
||||
};
|
||||
@@ -22,30 +23,30 @@ const CORE = {
|
||||
// Which mode file to load for a given name/mime
|
||||
const MODE_URL = {
|
||||
// core/common
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||
|
||||
// meta / combos
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||
|
||||
// docs / data
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||
|
||||
// shells
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||
|
||||
// languages
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||
};
|
||||
|
||||
// Mode dependency graph
|
||||
@@ -201,23 +202,37 @@ export function editFile(fileName, folder) {
|
||||
if (existingEditor) existingEditor.remove();
|
||||
|
||||
const folderUsed = folder || window.currentFolder || "root";
|
||||
const folderPath = folderUsed === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
||||
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||
|
||||
fetch(fileUrl, { method: "HEAD" })
|
||||
.then(response => {
|
||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
||||
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||
async function probeSize(url) {
|
||||
try {
|
||||
const h = await fetch(url, { method: "HEAD", credentials: "include" });
|
||||
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
|
||||
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
|
||||
} catch { }
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-0" },
|
||||
credentials: "include"
|
||||
});
|
||||
// Content-Range: bytes 0-0/12345
|
||||
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
|
||||
const m = cr && cr.match(/\/(\d+)\s*$/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
} catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
probeSize(fileUrl)
|
||||
.then(sizeBytes => {
|
||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||
throw new Error("File too large.");
|
||||
}
|
||||
return response;
|
||||
return fetch(fileUrl, { credentials: "include" });
|
||||
})
|
||||
.then(() => fetch(fileUrl))
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||
@@ -269,8 +284,8 @@ export function editFile(fileName, folder) {
|
||||
// Keep buttons responsive even before editor exists
|
||||
const decBtn = document.getElementById("decreaseFont");
|
||||
const incBtn = document.getElementById("increaseFont");
|
||||
decBtn.addEventListener("click", () => {});
|
||||
incBtn.addEventListener("click", () => {});
|
||||
decBtn.addEventListener("click", () => { });
|
||||
incBtn.addEventListener("click", () => { });
|
||||
|
||||
// Theme + mode selection
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// fileMenu.js
|
||||
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
|
||||
import { previewFile } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||
@@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) {
|
||||
});
|
||||
menu.appendChild(menuItem);
|
||||
});
|
||||
|
||||
|
||||
menu.style.left = x + "px";
|
||||
menu.style.top = y + "px";
|
||||
menu.style.display = "block";
|
||||
|
||||
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
if (menuRect.bottom > viewportHeight) {
|
||||
@@ -62,7 +62,7 @@ export function hideFileContextMenu() {
|
||||
|
||||
export function fileListContextMenuHandler(e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
let row = e.target.closest("tr");
|
||||
if (row) {
|
||||
const checkbox = row.querySelector(".file-checkbox");
|
||||
@@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) {
|
||||
updateRowHighlight(checkbox);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
||||
|
||||
|
||||
let menuItems = [
|
||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
||||
@@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) {
|
||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
||||
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
|
||||
];
|
||||
|
||||
|
||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
||||
menuItems.push({
|
||||
label: t("extract_zip"),
|
||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (selected.length > 1) {
|
||||
menuItems.push({
|
||||
label: t("tag_selected"),
|
||||
@@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) {
|
||||
}
|
||||
else if (selected.length === 1) {
|
||||
const file = fileData.find(f => f.name === selected[0]);
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("preview"),
|
||||
action: () => {
|
||||
const folder = window.currentFolder || "root";
|
||||
const folderPath = folder === "root"
|
||||
? "uploads/"
|
||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name);
|
||||
previewFile(buildPreviewUrl(folder, file.name), file.name);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (canEditFile(file.name)) {
|
||||
menuItems.push({
|
||||
label: t("edit"),
|
||||
action: () => { editFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("rename"),
|
||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
||||
});
|
||||
|
||||
|
||||
menuItems.push({
|
||||
label: t("tag_file"),
|
||||
action: () => { openTagModal(file); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
||||
}
|
||||
|
||||
@@ -140,7 +137,7 @@ export function bindFileListContextMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
document.addEventListener("click", function (e) {
|
||||
const menu = document.getElementById("fileContextMenu");
|
||||
if (menu && menu.style.display === "block") {
|
||||
hideFileContextMenu();
|
||||
@@ -148,9 +145,9 @@ document.addEventListener("click", function(e) {
|
||||
});
|
||||
|
||||
// Rebind context menu after file table render.
|
||||
(function() {
|
||||
(function () {
|
||||
const originalRenderFileTable = window.renderFileTable;
|
||||
window.renderFileTable = function(folder) {
|
||||
window.renderFileTable = function (folder) {
|
||||
originalRenderFileTable(folder);
|
||||
bindFileListContextMenu();
|
||||
};
|
||||
|
||||
@@ -3,6 +3,12 @@ import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||
|
||||
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
|
||||
export function buildPreviewUrl(folder, name) {
|
||||
const f = (!folder || folder === '') ? 'root' : String(folder);
|
||||
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
export function openShareModal(file, folder) {
|
||||
// Remove any existing modal
|
||||
const existing = document.getElementById("shareModal");
|
||||
@@ -92,10 +98,10 @@ export function openShareModal(file, folder) {
|
||||
|
||||
if (sel.value === "custom") {
|
||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
unit = document.getElementById("customExpirationUnit").value;
|
||||
} else {
|
||||
value = parseInt(sel.value, 10);
|
||||
unit = "minutes";
|
||||
unit = "minutes";
|
||||
}
|
||||
|
||||
const password = document.getElementById("sharePassword").value;
|
||||
@@ -115,20 +121,20 @@ export function openShareModal(file, folder) {
|
||||
password
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
|
||||
document.getElementById("shareLinkInput").value = url;
|
||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||
} else {
|
||||
showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share"));
|
||||
});
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
|
||||
document.getElementById("shareLinkInput").value = url;
|
||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||
} else {
|
||||
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
showToast(t("error_generating_share"));
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to clipboard
|
||||
@@ -272,10 +278,7 @@ export function previewFile(fileUrl, fileName) {
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
@@ -355,10 +358,7 @@ export function previewFile(fileUrl, fileName) {
|
||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
|
||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
||||
modal.querySelector("h4").textContent = newFile.name;
|
||||
img.src = ((window.currentFolder === "root")
|
||||
? "uploads/"
|
||||
: "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/")
|
||||
+ encodeURIComponent(newFile.name) + "?t=" + new Date().getTime();
|
||||
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
|
||||
// Reset transforms.
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
@@ -416,26 +416,26 @@ export function previewFile(fileUrl, fileName) {
|
||||
}
|
||||
} else {
|
||||
// Handle non-image file previews.
|
||||
if (extension === "pdf") {
|
||||
// build a cache‐busted URL
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||
|
||||
// open in a new tab (avoids CSP frame-ancestors)
|
||||
window.open(urlWithTs, "_blank");
|
||||
|
||||
// tear down the just-created modal
|
||||
const modal = document.getElementById("filePreviewModal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
// stop further preview logic
|
||||
return;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
if (extension === "pdf") {
|
||||
// build a cache‐busted URL
|
||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||
|
||||
// open in a new tab (avoids CSP frame-ancestors)
|
||||
window.open(urlWithTs, "_blank");
|
||||
|
||||
// tear down the just-created modal
|
||||
const modal = document.getElementById("filePreviewModal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
// stop further preview logic
|
||||
return;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
||||
const video = document.createElement("video");
|
||||
video.src = fileUrl;
|
||||
video.controls = true;
|
||||
video.className = "image-modal-img";
|
||||
|
||||
|
||||
const progressKey = 'videoProgress-' + fileUrl;
|
||||
video.addEventListener("loadedmetadata", () => {
|
||||
const savedTime = localStorage.getItem(progressKey);
|
||||
|
||||
@@ -67,32 +67,25 @@ function isDemoHost() {
|
||||
}
|
||||
|
||||
function showLoginTip(message) {
|
||||
const form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
|
||||
let tip = document.getElementById('fr-login-tip');
|
||||
if (!tip) {
|
||||
tip = document.createElement('div');
|
||||
tip.id = 'fr-login-tip';
|
||||
tip.className = 'alert alert-info'; // fine even without Bootstrap
|
||||
tip.style.marginTop = '8px';
|
||||
form.prepend(tip);
|
||||
}
|
||||
|
||||
// Clear & rebuild so we can add the demo hint cleanly
|
||||
tip.textContent = '';
|
||||
tip.append(document.createTextNode(message || ''));
|
||||
|
||||
if (isDemoHost()) {
|
||||
const line = document.createElement('div');
|
||||
line.style.marginTop = '6px';
|
||||
const mk = (txt) => { const k = document.createElement('code'); k.textContent = txt; return k; };
|
||||
line.append(
|
||||
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||
document.createTextNode(' · pass: '), mk('demo')
|
||||
);
|
||||
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() {
|
||||
@@ -322,7 +315,6 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem('darkMode'); } catch { }
|
||||
|
||||
// If no stored pref, fall back to system
|
||||
let isDark = (stored === null)
|
||||
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
: (stored === '1' || stored === 'true');
|
||||
@@ -336,15 +328,26 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
||||
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);
|
||||
@@ -381,6 +384,9 @@ function bindDarkMode() {
|
||||
// ---------- 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');
|
||||
@@ -394,28 +400,88 @@ function bindDarkMode() {
|
||||
};
|
||||
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg) {
|
||||
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;
|
||||
const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = 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 disableForm = !!lo.disableFormLogin;
|
||||
const disableOIDC = !!lo.disableOIDCLogin;
|
||||
const disableBasic = !!lo.disableBasicAuth;
|
||||
|
||||
const row = $('#loginForm'); if (row) row.style.display = disableForm ? 'none' : '';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
|
||||
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(() => ({})); applySiteConfig(j);
|
||||
} catch { applySiteConfig({}); }
|
||||
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 {
|
||||
@@ -665,7 +731,6 @@ function bindDarkMode() {
|
||||
function forceLoginVisible() {
|
||||
show($('#main'));
|
||||
show($('#loginForm'));
|
||||
hide($('.main-wrapper'));
|
||||
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
|
||||
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
|
||||
}
|
||||
@@ -809,8 +874,7 @@ function bindDarkMode() {
|
||||
window.__FR_FLAGS.booted = true;
|
||||
ensureToastReady();
|
||||
// show chrome
|
||||
const wrap = document.querySelector('.main-wrapper'); if (wrap) { wrap.hidden = false; wrap.classList?.remove('d-none', 'hidden'); wrap.style.display = 'block'; }
|
||||
const lf = document.getElementById('loginForm'); if (lf) lf.style.display = 'none';
|
||||
|
||||
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
|
||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
|
||||
|
||||
@@ -825,6 +889,9 @@ function bindDarkMode() {
|
||||
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 {
|
||||
@@ -840,10 +907,20 @@ function bindDarkMode() {
|
||||
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;
|
||||
|
||||
// Show "Welcome back, <username>!" only once per tab-session
|
||||
try {
|
||||
if (!sessionStorage.getItem('__fr_welcomed')) {
|
||||
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
|
||||
@@ -864,7 +941,7 @@ function bindDarkMode() {
|
||||
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
|
||||
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
|
||||
|
||||
// ⬇️ bind ALL the admin / change-password buttons once
|
||||
// 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;
|
||||
@@ -913,36 +990,71 @@ function bindDarkMode() {
|
||||
|
||||
// ---------- 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) { await bootSetupWizard(); return; }
|
||||
if (authed) { await bootHeavy(); return; }
|
||||
if (setup) {
|
||||
// Setup wizard runs inside app shell
|
||||
unhide(wrap);
|
||||
hideEl(login);
|
||||
await bootSetupWizard();
|
||||
await revealAppAndHideOverlay();
|
||||
|
||||
// login view
|
||||
show(document.querySelector('#main'));
|
||||
show(document.querySelector('#loginForm'));
|
||||
(document.querySelector('.header-buttons') || {}).style && (document.querySelector('.header-buttons').style.visibility = 'hidden');
|
||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none';
|
||||
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.style.display = 'none';
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
try { el.inert = true; } catch { }
|
||||
});
|
||||
|
||||
bindLogin();
|
||||
wireCreateDropdown();
|
||||
keepCreateDropdownWired();
|
||||
wireModalEnterDefault();
|
||||
showLoginTip('Please log in to continue');
|
||||
|
||||
}, { once: true }); // <— important
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}, { once: true });
|
||||
})();
|
||||
Reference in New Issue
Block a user