Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 |
143
CHANGELOG.md
@@ -1,5 +1,148 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/9/2025 (v1.9.0)
|
||||
|
||||
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||
|
||||
feat(ui): modern folder tree
|
||||
|
||||
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
|
||||
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
|
||||
- Breadcrumb tweaks (› separators), hover/selected polish
|
||||
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
|
||||
|
||||
feat(api): add /api/folder/isEmpty.php via controller/model
|
||||
|
||||
- public/api/folder/isEmpty.php delegates to FolderController::stats()
|
||||
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
|
||||
- Releases PHP session lock early to avoid parallel-request pileups
|
||||
|
||||
perf: cap concurrent “isEmpty” requests + timeouts
|
||||
|
||||
- Small concurrency limiter + fetch timeouts
|
||||
- In-memory result & inflight caches for fewer network hits
|
||||
|
||||
fix(state): preserve user expand/collapse choices
|
||||
|
||||
- Respect saved folderTreeState; don’t auto-expand unopened nodes
|
||||
- Only show ancestors for visibility when navigating (no unwanted persists)
|
||||
|
||||
security: tighten .htaccess while enabling WebDAV
|
||||
|
||||
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
|
||||
- AcceptPathInfo On; keep path-aware dotfile denial
|
||||
|
||||
refactor: move count logic to model; thin controller action
|
||||
|
||||
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
|
||||
|
||||
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.13)
|
||||
|
||||
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
|
||||
|
||||
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
|
||||
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
|
||||
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
|
||||
- header dock: show dock whenever icons exist (and on collapse); hide when empty
|
||||
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
|
||||
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
|
||||
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
|
||||
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.12)
|
||||
|
||||
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||
|
||||
- auth (public/js/main.js)
|
||||
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||
- Minor cleanup (SW register catch spacing).
|
||||
|
||||
- drag & drop (public/js/dragAndDrop.js)
|
||||
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||
|
||||
- styles (public/css/styles.css)
|
||||
- Header drop zone fills remaining space and right-aligns its icons.
|
||||
|
||||
UX:
|
||||
|
||||
- OIDC button reliably appears when form/basic are disabled.
|
||||
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.11)
|
||||
|
||||
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||
|
||||
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/8/2025 (v1.8.10)
|
||||
|
||||
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||
|
||||
UI/UX — Media modal
|
||||
|
||||
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||
|
||||
Video/Image controls
|
||||
|
||||
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||
|
||||
Progress badges (list & modal)
|
||||
|
||||
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||
|
||||
Drag & drop
|
||||
|
||||
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||
|
||||
Editor & ONLYOFFICE
|
||||
|
||||
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||
|
||||
Assets & polish
|
||||
|
||||
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||
|
||||
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.9)
|
||||
|
||||
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||
|
||||
- adminPanel.js:
|
||||
- Masked inputs without a saved value now start with data-replace="1".
|
||||
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/7/2025 (v1.8.8)
|
||||
|
||||
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||
|
||||
@@ -29,8 +29,7 @@ New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **Pow
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||
**Dark mode:**
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -33,6 +33,10 @@ define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||
define('ONLYOFFICE_DEBUG', true);
|
||||
*/
|
||||
|
||||
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||
}
|
||||
|
||||
// Encryption helpers
|
||||
function encryptData($data, $encryptionKey)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
Options -Indexes -Multiviews
|
||||
DirectoryIndex index.html
|
||||
|
||||
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||
AcceptPathInfo On
|
||||
|
||||
# ---------------- Security: dotfiles ----------------
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
@@ -24,10 +27,14 @@ RewriteRule - - [L]
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
|
||||
# 2) Deny direct access to PHP outside /api/
|
||||
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteRule \.php$ - [F]
|
||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||
# - allow /api/*.php (API endpoints)
|
||||
# - allow /api.php (ReDoc/spec page)
|
||||
# - allow /webdav.php (SabreDAV front)
|
||||
RewriteCond %{REQUEST_URI} !^/api/ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
|
||||
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
|
||||
RewriteRule \.php$ - [F,L]
|
||||
|
||||
# 3) Never redirect local/dev hosts
|
||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||
|
||||
30
public/api/folder/isEmpty.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/folder/isEmpty.php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
// Snapshot then release session lock so parallel requests don’t block
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
// Input
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
||||
|
||||
// Delegate to controller (model handles ACL + path safety)
|
||||
$result = FolderController::stats($folder, $user, $perms);
|
||||
|
||||
// Always return a compact JSON object like before
|
||||
echo json_encode([
|
||||
'folders' => (int)($result['folders'] ?? 0),
|
||||
'files' => (int)($result['files'] ?? 0),
|
||||
]);
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -141,7 +141,15 @@ body {
|
||||
}#userDropdownToggle {
|
||||
border-radius: 4px !important;
|
||||
padding: 6px 10px !important;
|
||||
}.header-buttons button:hover {
|
||||
}
|
||||
|
||||
#headerDropArea.header-drop-zone{
|
||||
display: flex;
|
||||
justify-content: flex-end; /* buttons to the right */
|
||||
align-items: center;
|
||||
min-height: 40px; /* so the label has room */
|
||||
}
|
||||
.header-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
@@ -1124,7 +1132,7 @@ body {
|
||||
border-radius: 4px;
|
||||
}.folder-tree {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
padding-left: 5px;
|
||||
margin: 0;
|
||||
}.folder-tree.collapsed {
|
||||
display: none;
|
||||
@@ -1141,7 +1149,7 @@ body {
|
||||
text-align: right;
|
||||
}.folder-indent-placeholder {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
width: 5px;
|
||||
}#folderTreeContainer {
|
||||
display: block;
|
||||
}.folder-option {
|
||||
@@ -1524,7 +1532,16 @@ body {
|
||||
.drag-header.active {
|
||||
width: 350px;
|
||||
height: 750px;
|
||||
}.main-column {
|
||||
}
|
||||
/* Fixed-width sidebar (always 350px) */
|
||||
#sidebarDropArea{
|
||||
width: 350px;
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
flex: 0 0 350px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.main-column {
|
||||
flex: 1;
|
||||
transition: margin-left 0.3s ease;
|
||||
}#uploadFolderRow {
|
||||
@@ -1592,8 +1609,8 @@ body {
|
||||
}#sidebarDropArea,
|
||||
#uploadFolderRow {
|
||||
background-color: transparent;
|
||||
|
||||
}.dark-mode #sidebarDropArea,
|
||||
}
|
||||
.dark-mode #sidebarDropArea,
|
||||
.dark-mode #uploadFolderRow {
|
||||
background-color: transparent;
|
||||
}.dark-mode #sidebarDropArea.highlight,
|
||||
@@ -1607,8 +1624,6 @@ body {
|
||||
border: none !important;
|
||||
}.dragging:focus {
|
||||
outline: none;
|
||||
}#sidebarDropArea > .card {
|
||||
margin-bottom: 1rem;
|
||||
}.card {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
@@ -1705,8 +1720,9 @@ body {
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
}.header-drop-zone.drag-active:empty::before {
|
||||
content: "Drop";
|
||||
content: "Drop Zone";
|
||||
font-size: 10px;
|
||||
padding-right: 6px;
|
||||
color: #aaa;
|
||||
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
||||
#fileList tbody tr.clickable-row {
|
||||
@@ -1919,12 +1935,12 @@ body {
|
||||
color: #fff;
|
||||
}
|
||||
.status-badge.watched {
|
||||
border-color: rgba(34,197,94,.35); /* green-ish */
|
||||
border-color: rgba(34,197,94,.45); /* green-ish */
|
||||
background: rgba(34,197,94,.15);
|
||||
}
|
||||
.status-badge.progress {
|
||||
border-color: rgba(250,204,21,.35); /* amber-ish */
|
||||
background: rgba(250,204,21,.15);
|
||||
border-color: rgba(234,88,12,.55); /* amber-ish */
|
||||
background: rgba(234,88,12,.18);
|
||||
}
|
||||
#downloadProgressModal .modal-body,
|
||||
#downloadProgressModal .rise-modal-body,
|
||||
@@ -1940,3 +1956,170 @@ body {
|
||||
}
|
||||
|
||||
#downloadProgressBarOuter { height: 10px; }
|
||||
|
||||
/* ===== FileRise Folder Tree: unified, crisp, aligned ===== */
|
||||
|
||||
/* Knobs (size, spacing, colors) */
|
||||
#folderTreeContainer {
|
||||
/* Colors (used in BOTH themes) */
|
||||
--filr-folder-front: #f6b84e; /* front/lip */
|
||||
--filr-folder-back: #ffd36e; /* back body */
|
||||
--filr-folder-stroke:#a87312; /* outline */
|
||||
--filr-paper-fill: #ffffff; /* paper */
|
||||
--filr-paper-stroke: #b2c2db; /* paper edges/lines */
|
||||
|
||||
/* Size & spacing */
|
||||
--row-h: 28px; /* row height */
|
||||
--twisty: 24px; /* chevron hit-area size */
|
||||
--twisty-gap: -5px; /* gap between chevron and row content */
|
||||
--icon-size: 24px; /* 22–26 look good */
|
||||
--icon-gap: 6px; /* space between icon and label */
|
||||
--indent: 10px; /* subtree indent */
|
||||
}
|
||||
|
||||
/* Keep the same yellow/orange in dark mode; boost paper contrast a touch */
|
||||
.dark-mode #folderTreeContainer {
|
||||
--filr-folder-front: #f6b84e;
|
||||
--filr-folder-back: #ffd36e;
|
||||
--filr-folder-stroke:#a87312;
|
||||
--filr-paper-fill: #ffffff;
|
||||
--filr-paper-stroke: #d0def7; /* brighter so it pops on dark */
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||
|
||||
/* visible “row” for each node */
|
||||
#folderTreeContainer .folder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
padding-left: calc(var(--twisty) + var(--twisty-gap));
|
||||
}
|
||||
|
||||
/* children indent */
|
||||
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||
|
||||
/* ---------- Chevron toggle (twisty) ---------- */
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid transparent; border-radius: 6px;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle::before {
|
||||
content: "▸"; /* closed */
|
||||
font-size: calc(var(--twisty) * 0.8);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
|
||||
> .folder-row > button.folder-toggle::before { content: "▾"; }
|
||||
|
||||
/* root row (it's a <div>) */
|
||||
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
|
||||
|
||||
#folderTreeContainer .folder-row > button.folder-toggle:hover {
|
||||
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
|
||||
}
|
||||
|
||||
/* spacer for leaves so labels align with parents that have a button */
|
||||
#folderTreeContainer .folder-row > .folder-spacer {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty); display: inline-block;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: var(--row-h);
|
||||
line-height: 1.2; /* avoids baseline weirdness */
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
gap: var(--icon-gap);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transform: translateY(0.5px); /* tiny optical nudge for text */
|
||||
}
|
||||
|
||||
/* ---------- Icon box (size & alignment) ---------- */
|
||||
|
||||
#folderTreeContainer .folder-icon {
|
||||
flex: 0 0 var(--icon-size);
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(0.5px); /* tiny optical nudge for SVG */
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
/* ---------- Crisp colors & strokes for the SVG parts ---------- */
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: 1.1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
#folderTreeContainer .folder-icon .paper {
|
||||
fill: var(--filr-paper-fill);
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5; /* thick so it reads at 24px */
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .paper-fold {
|
||||
fill: var(--filr-paper-stroke);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .paper-line {
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
fill: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
/* subtle highlight along lip to add depth */
|
||||
#folderTreeContainer .folder-icon .lip-highlight {
|
||||
stroke: #ffffff;
|
||||
stroke-opacity: .35;
|
||||
stroke-width: 0.9;
|
||||
fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
/* ---------- Hover / Selected ---------- */
|
||||
|
||||
#folderTreeContainer .folder-option:hover {
|
||||
background: rgba(122,179,255,.14);
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-option.selected {
|
||||
background: rgba(122,179,255,.24);
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,17 +3,24 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||
<meta name="theme-color" content="#0b5ed7">
|
||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||
<style id="pretheme-css">
|
||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||
</style>
|
||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||
|
||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||
|
||||
<!-- Critical CSS -->
|
||||
<!-- Critical CSS -->
|
||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||
|
||||
@@ -58,7 +58,7 @@ function wireHeaderTitleLive() {
|
||||
|
||||
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
||||
const type = isSecret ? 'password' : 'text';
|
||||
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
|
||||
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"';
|
||||
const replaceBtn = hasValue
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||
: '';
|
||||
@@ -1070,11 +1070,15 @@ function handleSave() {
|
||||
const idEl = document.getElementById("oidcClientId");
|
||||
const scEl = document.getElementById("oidcClientSecret");
|
||||
|
||||
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
|
||||
payload.oidc.clientId = idEl.value.trim();
|
||||
const idVal = idEl?.value.trim() || '';
|
||||
const secVal = scEl?.value.trim() || '';
|
||||
const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet
|
||||
const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet
|
||||
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
|
||||
payload.oidc.clientId = idVal;
|
||||
}
|
||||
if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
|
||||
payload.oidc.clientSecret = scEl.value.trim();
|
||||
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
|
||||
payload.oidc.clientSecret = secVal;
|
||||
}
|
||||
|
||||
const ooSecretEl = document.getElementById("ooJwtSecret");
|
||||
|
||||
@@ -2,124 +2,163 @@
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = event.currentTarget;
|
||||
let fileNames = [];
|
||||
|
||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
||||
if (selectedCheckboxes.length > 1) {
|
||||
selectedCheckboxes.forEach(chk => {
|
||||
const parentRow = chk.closest("tr");
|
||||
if (parentRow) {
|
||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
||||
if (cell) {
|
||||
let rawName = cell.textContent.trim();
|
||||
const tagContainer = cell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
/* ---------------- helpers ---------------- */
|
||||
function getRowEl(el) {
|
||||
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||
}
|
||||
function getNameFromAny(el) {
|
||||
const row = getRowEl(el);
|
||||
if (!row) return null;
|
||||
// 1) canonical
|
||||
const n = row.getAttribute('data-file-name');
|
||||
if (n) return n;
|
||||
// 2) filename-only span
|
||||
const span = row.querySelector('.filename-text');
|
||||
if (span) return span.textContent.trim();
|
||||
return null;
|
||||
}
|
||||
function getSelectedFileNames() {
|
||||
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||
// de-dup just in case
|
||||
return Array.from(new Set(names));
|
||||
}
|
||||
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||
const wrap = document.createElement('div');
|
||||
Object.assign(wrap.style, {
|
||||
display: 'inline-flex',
|
||||
maxWidth: '420px',
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#333',
|
||||
color: '#fff',
|
||||
border: '1px solid #555',
|
||||
borderRadius: '6px',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
} else {
|
||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
||||
if (fileNameCell) {
|
||||
let rawName = fileNameCell.textContent.trim();
|
||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
||||
if (tagContainer) {
|
||||
const tagText = tagContainer.innerText.trim();
|
||||
if (rawName.endsWith(tagText)) {
|
||||
rawName = rawName.slice(0, -tagText.length).trim();
|
||||
}
|
||||
}
|
||||
fileNames.push(rawName);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileNames.length === 0) return;
|
||||
|
||||
const dragData = fileNames.length === 1
|
||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
||||
|
||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
|
||||
let dragImage = document.createElement("div");
|
||||
dragImage.style.display = "inline-flex";
|
||||
dragImage.style.width = "auto";
|
||||
dragImage.style.maxWidth = "fit-content";
|
||||
dragImage.style.padding = "6px 10px";
|
||||
dragImage.style.backgroundColor = "#333";
|
||||
dragImage.style.color = "#fff";
|
||||
dragImage.style.border = "1px solid #555";
|
||||
dragImage.style.borderRadius = "4px";
|
||||
dragImage.style.alignItems = "center";
|
||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "material-icons";
|
||||
icon.textContent = "insert_drive_file";
|
||||
icon.style.marginRight = "4px";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
||||
dragImage.appendChild(icon);
|
||||
dragImage.appendChild(label);
|
||||
|
||||
document.body.appendChild(dragImage);
|
||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(dragImage);
|
||||
}, 0);
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons';
|
||||
icon.textContent = iconName;
|
||||
const label = document.createElement('span');
|
||||
// trim long single-name labels
|
||||
const txt = String(labelText || '');
|
||||
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||
wrap.appendChild(icon);
|
||||
wrap.appendChild(label);
|
||||
document.body.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ---------------- drag start (rows/cards) ---------------- */
|
||||
export function fileDragStartHandler(event) {
|
||||
const row = getRowEl(event.currentTarget);
|
||||
if (!row) return;
|
||||
|
||||
// Use current selection if present; otherwise drag just this row’s file
|
||||
let names = getSelectedFileNames();
|
||||
if (names.length === 0) {
|
||||
const single = getNameFromAny(row);
|
||||
if (single) names = [single];
|
||||
}
|
||||
if (names.length === 0) return;
|
||||
|
||||
const sourceFolder = window.currentFolder || 'root';
|
||||
const payload = { files: names, sourceFolder };
|
||||
|
||||
// primary payload
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
// fallback (lets some environments read something human)
|
||||
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||
|
||||
// nicer drag image
|
||||
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||
// clean up the ghost as soon as the browser has captured it
|
||||
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||
}
|
||||
|
||||
/* ---------------- folder targets ---------------- */
|
||||
export function folderDragOverHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add("drop-hover");
|
||||
event.currentTarget.classList.add('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDragLeaveHandler(event) {
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
}
|
||||
|
||||
export function folderDropHandler(event) {
|
||||
export async function folderDropHandler(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove("drop-hover");
|
||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
||||
let dragData;
|
||||
event.currentTarget.classList.remove('drop-hover');
|
||||
|
||||
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||
|| 'root';
|
||||
|
||||
// parse drag payload
|
||||
let dragData = null;
|
||||
try {
|
||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
||||
} catch (e) {
|
||||
console.error("Invalid drag data");
|
||||
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||
dragData = JSON.parse(raw);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!dragData) {
|
||||
showToast('Invalid drag data.');
|
||||
return;
|
||||
}
|
||||
if (!dragData || !dragData.fileName) return;
|
||||
fetch("/api/file/moveFiles.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
|
||||
// normalize names
|
||||
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||
: dragData.fileName ? [dragData.fileName]
|
||||
: [];
|
||||
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||
|
||||
if (names.length === 0) {
|
||||
showToast('No files to move.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||
if (dropFolder === sourceFolder) {
|
||||
showToast('Source and destination are the same.');
|
||||
return;
|
||||
}
|
||||
|
||||
// POST move
|
||||
try {
|
||||
const res = await fetch('/api/file/moveFiles.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': window.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: dragData.sourceFolder,
|
||||
files: [dragData.fileName],
|
||||
source: sourceFolder,
|
||||
files: names,
|
||||
destination: dropFolder
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
||||
loadFileList(dragData.sourceFolder);
|
||||
} else {
|
||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error moving file via drop:", error);
|
||||
showToast("Error moving file.");
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (res.ok && data && data.success) {
|
||||
const msg = (names.length === 1)
|
||||
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||
showToast(msg);
|
||||
// Refresh whatever view the user is currently looking at
|
||||
loadFileList(window.currentFolder || sourceFolder);
|
||||
} else {
|
||||
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||
showToast('Error moving file(s): ' + err);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error moving file(s):', e);
|
||||
showToast('Error moving file(s).');
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
|
||||
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||
|
||||
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
|
||||
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||
|
||||
async function fetchOnlyOfficeCapsOnce() {
|
||||
if (__ooCaps.fetched) return __ooCaps;
|
||||
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
|
||||
const j = await r.json();
|
||||
__ooCaps.enabled = !!j.enabled;
|
||||
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||
}
|
||||
} catch { /* ignore; keep defaults */ }
|
||||
__ooCaps.fetched = true;
|
||||
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(fileName) {
|
||||
|
||||
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||
|
||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||
let src =
|
||||
srcFromConfig ||
|
||||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
|
||||
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
|
||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||
await loadScriptOnce(src);
|
||||
}
|
||||
|
||||
async function openOnlyOffice(fileName, folder) {
|
||||
let editor; // make visible to the whole function
|
||||
|
||||
try {
|
||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
|
||||
const text = await resp.text();
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(text); } catch {
|
||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||
}
|
||||
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||
|
||||
// Must be absolute
|
||||
const docUrl = cfg?.document?.url;
|
||||
const cbUrl = cfg?.editorConfig?.callbackUrl;
|
||||
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
|
||||
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
|
||||
}
|
||||
|
||||
// Load DocsAPI if needed
|
||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||
|
||||
// Modal
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'ooEditorModal';
|
||||
modal.classList.add('modal', 'editor-modal');
|
||||
modal.setAttribute('tabindex', '-1');
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<h3 class="editor-title">
|
||||
${t("editing")}: ${escapeHTML(fileName)}
|
||||
</h3>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||
</div>
|
||||
<div class="editor-body" style="flex:1;min-height:200px">
|
||||
<div id="oo-editor" style="width:100%;height:100%"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
modal.focus();
|
||||
|
||||
// We’ll fill this after wiring the toggle, so destroy() can unhook it
|
||||
let removeThemeListener = () => {};
|
||||
|
||||
const destroy = () => {
|
||||
try { editor?.destroyEditor?.(); } catch {}
|
||||
try { removeThemeListener(); } catch {}
|
||||
try { modal.remove(); } catch {}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
|
||||
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
|
||||
|
||||
// Let DS request closing
|
||||
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
|
||||
|
||||
// Initial theme
|
||||
const isDark =
|
||||
document.documentElement.classList.contains('dark-mode') ||
|
||||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
|
||||
cfg.editorConfig = cfg.editorConfig || {};
|
||||
cfg.editorConfig.customization = Object.assign(
|
||||
{},
|
||||
cfg.editorConfig.customization,
|
||||
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
|
||||
);
|
||||
|
||||
// Launch editor
|
||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||
|
||||
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
const onDarkToggle = () => {
|
||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||
if (editor && typeof editor.setTheme === 'function') {
|
||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||
}
|
||||
};
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', onDarkToggle);
|
||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[ONLYOFFICE] failed to open:', e);
|
||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||
}
|
||||
}
|
||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||
|
||||
|
||||
// ---- script/css single-load with timeout guards ----
|
||||
const _loadedScripts = new Set();
|
||||
const _loadedCss = new Set();
|
||||
let _corePromise = null;
|
||||
|
||||
function loadScriptOnce(url) {
|
||||
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (_loadedScripts.has(url)) return resolve();
|
||||
const s = document.createElement("script");
|
||||
const timer = setTimeout(() => {
|
||||
try { s.remove(); } catch { }
|
||||
reject(new Error(`Timeout loading: ${url}`));
|
||||
}, timeoutMs);
|
||||
s.src = url;
|
||||
s.async = true;
|
||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
||||
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
@@ -240,7 +143,6 @@ async function ensureCore() {
|
||||
async function loadSingleMode(name) {
|
||||
const rel = MODE_URL[name];
|
||||
if (!rel) return;
|
||||
// prepend base if needed
|
||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||
await loadScriptOnce(url);
|
||||
}
|
||||
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
|
||||
}
|
||||
|
||||
// Public helper for callers (we keep your existing function name in use):
|
||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
||||
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||
// ==== /CodeMirror lazy loader ===============================================
|
||||
|
||||
// ---- OO preconnect / prewarm ----
|
||||
function injectOOPreconnect(origin) {
|
||||
try {
|
||||
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||
document.head.appendChild(make('dns-prefetch'));
|
||||
document.head.appendChild(make('preconnect'));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||
let src = srcFromConfig;
|
||||
if (!src) {
|
||||
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||
} else {
|
||||
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||
}
|
||||
}
|
||||
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||
try {
|
||||
console.time('oo:api.js');
|
||||
await loadScriptOnce(src);
|
||||
} catch (e) {
|
||||
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
console.timeEnd('oo:api.js');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||
const OO_WARM_MS = 300;
|
||||
|
||||
function ensureOoModalCss() {
|
||||
const prev = document.getElementById('ooEditorModalCss');
|
||||
if (prev) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ooEditorModalCss';
|
||||
style.textContent = `
|
||||
#ooEditorModal{
|
||||
--oo-header-h: 40px;
|
||||
--oo-header-pad-v: 12px;
|
||||
--oo-header-pad-h: 18px;
|
||||
--oo-logo-h: 26px; /* tweak logo size */
|
||||
}
|
||||
|
||||
#ooEditorModal{
|
||||
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||
background:var(--oo-modal-bg,#111)!important;
|
||||
}
|
||||
|
||||
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||
#ooEditorModal .editor-header{
|
||||
position:relative; display:flex; align-items:center; gap:12px;
|
||||
min-height:var(--oo-header-h);
|
||||
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||
border-bottom:1px solid rgba(0,0,0,.15);
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-logo{
|
||||
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||
display:block; user-select:none; -webkit-user-drag:none;
|
||||
}
|
||||
|
||||
#ooEditorModal .editor-title{
|
||||
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||
flex:1 1 auto;
|
||||
}
|
||||
|
||||
/* Your scoped close button style */
|
||||
#ooEditorModal .editor-close-btn{
|
||||
position:absolute; top:5px; right:10px;
|
||||
display:flex; justify-content:center; align-items:center;
|
||||
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||
transition:all .3s ease-in-out;
|
||||
}
|
||||
#ooEditorModal .editor-close-btn:hover{
|
||||
color:#fff; background-color:#ff4d4d;
|
||||
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||
}
|
||||
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||
|
||||
#ooEditorModal .editor-body{
|
||||
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||
}
|
||||
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||
|
||||
#ooEditorModal .oo-warm-overlay{
|
||||
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||
}
|
||||
|
||||
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Theme-aware background so there’s no white/gray edge
|
||||
function applyModalBg(modal){
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||
|| (isDark ? '#121212' : '#ffffff');
|
||||
modal.style.setProperty('--oo-modal-bg', bg);
|
||||
}
|
||||
|
||||
function lockPageScroll(on){
|
||||
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||
}
|
||||
|
||||
function ensureOoFullscreenModal(){
|
||||
ensureOoModalCss();
|
||||
let modal = document.getElementById('ooEditorModal');
|
||||
if (!modal){
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'ooEditorModal';
|
||||
modal.innerHTML = `
|
||||
<div class="editor-header">
|
||||
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||
<h3 class="editor-title"></h3>
|
||||
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div id="oo-editor"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
} else {
|
||||
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||
// ensure logo exists and is placed before title when reusing
|
||||
const header = modal.querySelector('.editor-header');
|
||||
if (!header.querySelector('.editor-logo')){
|
||||
const img = document.createElement('img');
|
||||
img.className = 'editor-logo';
|
||||
img.src = '/assets/logo.svg';
|
||||
img.alt = 'FileRise logo';
|
||||
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||
} else {
|
||||
// make sure order is logo -> title
|
||||
const logo = header.querySelector('.editor-logo');
|
||||
const title = header.querySelector('.editor-title');
|
||||
if (logo.nextElementSibling !== title){
|
||||
header.insertBefore(logo, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
applyModalBg(modal);
|
||||
modal.style.display = 'flex';
|
||||
modal.focus();
|
||||
lockPageScroll(true);
|
||||
return modal;
|
||||
}
|
||||
|
||||
// Overlay lives INSIDE the modal body
|
||||
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||
if (!modal) return;
|
||||
const body = modal.querySelector('.editor-body');
|
||||
let ov = body.querySelector('.oo-warm-overlay');
|
||||
if (on){
|
||||
if (!ov){
|
||||
ov = document.createElement('div');
|
||||
ov.className = 'oo-warm-overlay';
|
||||
ov.textContent = label;
|
||||
body.appendChild(ov);
|
||||
}
|
||||
} else if (ov){
|
||||
ov.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||
async function warmDocServerOnce(cfg){
|
||||
let host = null, warmEditor = null;
|
||||
try{
|
||||
host = document.createElement('div');
|
||||
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||
Object.assign(host.style, {
|
||||
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||
});
|
||||
document.body.appendChild(host);
|
||||
|
||||
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||
|
||||
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||
}catch{} finally{
|
||||
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||
try{ host?.remove(); }catch{}
|
||||
}
|
||||
}
|
||||
|
||||
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||
async function openOnlyOffice(fileName, folder){
|
||||
let editor = null;
|
||||
let removeThemeListener = () => {};
|
||||
let cfg = null;
|
||||
let userClosed = false;
|
||||
|
||||
// Build our full-screen modal
|
||||
const modal = ensureOoFullscreenModal();
|
||||
const titleEl = modal.querySelector('.editor-title');
|
||||
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||
|
||||
const destroy = (removeModal = true) => {
|
||||
try { editor?.destroyEditor?.(); } catch {}
|
||||
try { removeThemeListener(); } catch {}
|
||||
if (removeModal) { try { modal.remove(); } catch {} }
|
||||
lockPageScroll(false);
|
||||
};
|
||||
const onClose = () => { userClosed = true; destroy(true); };
|
||||
|
||||
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||
|
||||
try{
|
||||
// 1) Fetch config
|
||||
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||
const resp = await fetch(url, { credentials: 'include' });
|
||||
const text = await resp.text();
|
||||
|
||||
try { cfg = JSON.parse(text); } catch {
|
||||
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||
}
|
||||
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||
|
||||
// 2) Preconnect + load DocsAPI
|
||||
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||
|
||||
// 3) Theme + base events
|
||||
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||
cfg.editorConfig = cfg.editorConfig || {};
|
||||
cfg.editorConfig.customization = Object.assign(
|
||||
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||
);
|
||||
cfg.events.onRequestClose = () => onClose();
|
||||
|
||||
// 4) Warm EVERY click
|
||||
if (ALWAYS_WARM_OO && !userClosed){
|
||||
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||
await warmDocServerOnce(cfg);
|
||||
if (userClosed) return;
|
||||
}
|
||||
|
||||
// 5) Launch visible editor in full-screen modal
|
||||
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||
|
||||
// Live theme switching + keep modal bg in sync
|
||||
const darkToggle = document.getElementById('darkModeToggle');
|
||||
const onDarkToggle = () => {
|
||||
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||
if (editor && typeof editor.setTheme === 'function') {
|
||||
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||
}
|
||||
applyModalBg(modal);
|
||||
};
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', onDarkToggle);
|
||||
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||
}
|
||||
}catch(e){
|
||||
console.error('[ONLYOFFICE] failed to open:', e);
|
||||
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||
destroy(true);
|
||||
}
|
||||
}
|
||||
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||
|
||||
// ==== Editor (CodeMirror) path =============================================
|
||||
|
||||
function getModeForFile(fileName) {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
|
||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||
|
||||
const cmOptions = {
|
||||
const cm = window.CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
{
|
||||
lineNumbers: !forcePlainText,
|
||||
mode: initialMode,
|
||||
theme,
|
||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||
lineWrapping: false
|
||||
};
|
||||
|
||||
const editor = window.CodeMirror.fromTextArea(
|
||||
document.getElementById("fileEditor"),
|
||||
cmOptions
|
||||
}
|
||||
);
|
||||
window.currentEditor = editor;
|
||||
window.currentEditor = cm;
|
||||
|
||||
setTimeout(adjustEditorSize, 50);
|
||||
observeModalResize(modal);
|
||||
|
||||
// Font controls (now that editor exists)
|
||||
let currentFontSize = 14;
|
||||
const wrapper = editor.getWrapperElement();
|
||||
const wrapper = cm.getWrapperElement();
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
|
||||
decBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
});
|
||||
incBtn.addEventListener("click", function () {
|
||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||
wrapper.style.fontSize = currentFontSize + "px";
|
||||
editor.refresh();
|
||||
cm.refresh();
|
||||
});
|
||||
|
||||
// Save
|
||||
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
|
||||
// Theme switch
|
||||
function updateEditorTheme() {
|
||||
const isDark = document.body.classList.contains("dark-mode");
|
||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
||||
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||
}
|
||||
const toggle = document.getElementById("darkModeToggle");
|
||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
|
||||
if (!canceled && !forcePlainText) {
|
||||
const nn = normalizeModeName(desiredMode);
|
||||
if (nn && isModeRegistered(nn)) {
|
||||
editor.setOption("mode", desiredMode);
|
||||
cm.setOption("mode", desiredMode);
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// If the mode truly fails to load, we just stay in plain text
|
||||
});
|
||||
}).catch(() => { /* stay in plain text */ });
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
|
||||
@@ -182,7 +182,7 @@ function makeBadge(state) {
|
||||
el.classList.add('watched');
|
||||
el.textContent = (t('watched') || t('viewed') || 'Watched');
|
||||
el.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
el.style.background = 'rgba(34,197,94,.12)';
|
||||
el.style.background = 'rgba(34,197,94,.15)';
|
||||
el.style.color = '#22c55e';
|
||||
return el;
|
||||
}
|
||||
@@ -191,9 +191,9 @@ function makeBadge(state) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
el.classList.add('progress');
|
||||
el.textContent = `${pct}%`;
|
||||
el.style.borderColor = 'rgba(245,158,11,.45)';
|
||||
el.style.background = 'rgba(245,158,11,.12)';
|
||||
el.style.color = '#f59e0b';
|
||||
el.style.borderColor = 'rgba(234,88,12,.55)';
|
||||
el.style.background = 'rgba(234,88,12,.18)';
|
||||
el.style.color = '#ea580c';
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,21 @@ export function openShareModal(file, folder) {
|
||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
|
||||
const TXT_RE = /\.(txt|rtf|md|log)$/i;
|
||||
|
||||
function getIconForFile(name) {
|
||||
const lower = (name || '').toLowerCase();
|
||||
if (IMG_RE.test(lower)) return 'image';
|
||||
if (VID_RE.test(lower)) return 'ondemand_video';
|
||||
if (AUD_RE.test(lower)) return 'audiotrack';
|
||||
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
|
||||
if (ARCH_RE.test(lower)) return 'archive';
|
||||
if (CODE_RE.test(lower)) return 'code';
|
||||
if (TXT_RE.test(lower)) return 'description';
|
||||
return 'insert_drive_file';
|
||||
}
|
||||
|
||||
function ensureMediaModal() {
|
||||
let overlay = document.getElementById("filePreviewModal");
|
||||
@@ -152,109 +167,166 @@ function ensureMediaModal() {
|
||||
const navFg = '#fff';
|
||||
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||
|
||||
// fixed top bar; pad-right to avoid overlap with absolute close “×”
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content media-modal" style="
|
||||
position: relative;
|
||||
max-width: 92vw;
|
||||
max-height: 92vh;
|
||||
width: 92vw;
|
||||
max-height: 92vh;
|
||||
height: 92vh;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
background: ${panelBg};
|
||||
color: ${textCol};
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
display:flex; flex-direction:column;
|
||||
">
|
||||
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);">
|
||||
<!-- filename badge (top-left) -->
|
||||
<div class="media-title-badge" style="
|
||||
position:absolute; top:8px; left:12px; max-width:60vw;
|
||||
padding:4px 10px; border-radius:10px;
|
||||
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'};
|
||||
color: ${isDark ? '#fff' : '#111'};
|
||||
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;">
|
||||
<!-- Top bar -->
|
||||
<div class="media-topbar" style="
|
||||
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
|
||||
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
|
||||
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
|
||||
background:${panelBg};
|
||||
">
|
||||
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||
<span class="material-icons title-icon" style="
|
||||
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
|
||||
insert_drive_file
|
||||
</span>
|
||||
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
|
||||
</div>
|
||||
|
||||
<!-- top-right actions row (aligned with your X at top:10px) -->
|
||||
<div class="media-actions-bar" style="
|
||||
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
|
||||
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
|
||||
<span class="status-chip" style="
|
||||
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
||||
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span>
|
||||
<div class="action-group" style="display:flex; gap:6px;"></div>
|
||||
border:1px solid transparent; background:transparent; color:inherit;"></span>
|
||||
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- your absolute close X -->
|
||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">×</span>
|
||||
|
||||
<!-- centered media -->
|
||||
<!-- Stage -->
|
||||
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
|
||||
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||
|
||||
<!-- high-contrast prev/next -->
|
||||
<!-- prev/next = rounded rectangles with centered glyphs -->
|
||||
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
||||
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
||||
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
||||
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
||||
height:56px; min-width:48px; padding:0 14px;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; line-height:0;
|
||||
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
||||
</div>
|
||||
|
||||
<!-- Absolute close “×” (like original), themed + hover behavior -->
|
||||
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
|
||||
position:absolute; top:8px; right:10px; z-index:1002;
|
||||
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
|
||||
">×</span>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// theme the close “×” for visibility + hover rules that match your site:
|
||||
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||
function paintCloseBase() {
|
||||
closeBtn.style.backgroundColor = 'transparent';
|
||||
closeBtn.style.color = '#e11d48'; // base red X
|
||||
closeBtn.style.boxShadow = 'none';
|
||||
}
|
||||
function onCloseHoverEnter() {
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
closeBtn.style.backgroundColor = '#ef4444'; // red fill
|
||||
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
|
||||
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
|
||||
}
|
||||
function onCloseHoverLeave() { paintCloseBase(); }
|
||||
paintCloseBase();
|
||||
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
|
||||
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
|
||||
|
||||
function closeModal() {
|
||||
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||
overlay.remove();
|
||||
}
|
||||
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
|
||||
closeBtn.addEventListener("click", closeModal);
|
||||
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function setTitle(overlay, name) {
|
||||
const el = overlay.querySelector('.media-title-badge');
|
||||
if (el) el.textContent = name || '';
|
||||
const textEl = overlay.querySelector('.title-text');
|
||||
const iconEl = overlay.querySelector('.title-icon');
|
||||
if (textEl) {
|
||||
textEl.textContent = name || '';
|
||||
textEl.setAttribute('title', name || '');
|
||||
}
|
||||
if (iconEl) {
|
||||
iconEl.textContent = getIconForFile(name);
|
||||
// keep the icon legible in both themes
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
||||
}
|
||||
}
|
||||
|
||||
function makeMI(name, title) {
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
function makeTopIcon(name, title) {
|
||||
const b = document.createElement('button');
|
||||
b.className = `material-icons ${name}`;
|
||||
b.textContent = name; // Material Icons font
|
||||
b.className = 'material-icons';
|
||||
b.textContent = name;
|
||||
b.title = title;
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
|
||||
Object.assign(b.style, {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,.25)",
|
||||
border: "1px solid rgba(255,255,255,.25)",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
fontSize: "20px",
|
||||
padding: "0",
|
||||
borderRadius: "8px",
|
||||
color: "#fff",
|
||||
lineHeight: "1"
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
|
||||
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
lineHeight: '1',
|
||||
color: dark ? '#f5f5f5' : '#111',
|
||||
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
|
||||
});
|
||||
|
||||
b.addEventListener('mouseenter', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
|
||||
});
|
||||
b.addEventListener('mouseleave', () => {
|
||||
const darkNow = document.documentElement.classList.contains('dark-mode');
|
||||
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
|
||||
});
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
function setNavVisibility(overlay, showPrev, showNext) {
|
||||
const prev = overlay.querySelector('.nav-left');
|
||||
const next = overlay.querySelector('.nav-right');
|
||||
prev.style.display = showPrev ? 'inline-flex' : 'none';
|
||||
next.style.display = showNext ? 'inline-flex' : 'none';
|
||||
prev.style.display = showPrev ? 'flex' : 'none';
|
||||
next.style.display = showNext ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
function setRowWatchedBadge(name, watched) {
|
||||
@@ -280,8 +352,8 @@ function setRowWatchedBadge(name, watched) {
|
||||
export function previewFile(fileUrl, fileName) {
|
||||
const overlay = ensureMediaModal();
|
||||
const container = overlay.querySelector(".file-preview-container");
|
||||
const actionWrap = overlay.querySelector(".media-actions-bar .action-group");
|
||||
const statusChip = overlay.querySelector(".media-actions-bar .status-chip");
|
||||
const actionWrap = overlay.querySelector(".media-right .action-group");
|
||||
const statusChip = overlay.querySelector(".media-right .status-chip");
|
||||
|
||||
// replace nav buttons to clear old listeners
|
||||
let prevBtn = overlay.querySelector('.nav-left');
|
||||
@@ -320,10 +392,11 @@ export function previewFile(fileUrl, fileName) {
|
||||
img.dataset.rotate = 0;
|
||||
container.appendChild(img);
|
||||
|
||||
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
// topbar-aligned, theme-aware icons
|
||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
actionWrap.appendChild(zoomInBtn);
|
||||
actionWrap.appendChild(zoomOutBtn);
|
||||
actionWrap.appendChild(rotateLeft);
|
||||
@@ -405,14 +478,11 @@ export function previewFile(fileUrl, fileName) {
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
const markBtn = document.createElement('button');
|
||||
const clearBtn = document.createElement('button');
|
||||
markBtn.className = 'btn btn-sm btn-success';
|
||||
clearBtn.className = 'btn btn-sm btn-secondary';
|
||||
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
|
||||
clearBtn.textContent = t("clear_progress") || "Clear progress";
|
||||
actionWrap.appendChild(markBtn);
|
||||
actionWrap.appendChild(clearBtn);
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
@@ -453,15 +523,14 @@ export function previewFile(fileUrl, fileName) {
|
||||
if (!statusChip) return;
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtn.style.display = 'none';
|
||||
clearBtn.style.display = '';
|
||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// In progress
|
||||
@@ -469,18 +538,20 @@ export function previewFile(fileUrl, fileName) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
|
||||
statusChip.style.background = 'rgba(250,204,21,.15)';
|
||||
statusChip.style.color = '#facc15';
|
||||
markBtn.style.display = '';
|
||||
clearBtn.style.display = '';
|
||||
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtn.style.display = '';
|
||||
clearBtn.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
@@ -494,8 +565,8 @@ export function previewFile(fileUrl, fileName) {
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
@@ -528,14 +599,14 @@ setFileProgressBadge(nm, seconds, duration);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtn.onclick = async () => {
|
||||
markBtnIcon.onclick = async () => {
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
clearBtn.onclick = async () => {
|
||||
clearBtnIcon.onclick = async () => {
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
|
||||
@@ -86,7 +86,7 @@ export function getParentFolder(folder) {
|
||||
Breadcrumb Functions
|
||||
----------------------*/
|
||||
|
||||
function setControlEnabled(el, enabled) {
|
||||
function setControlEnabled(el, enabled) {
|
||||
if (!el) return;
|
||||
if ('disabled' in el) el.disabled = !enabled;
|
||||
el.classList.toggle('disabled', !enabled);
|
||||
@@ -143,7 +143,7 @@ function breadcrumbClickHandler(e) {
|
||||
|
||||
updateBreadcrumbTitle(folder);
|
||||
applyFolderCapabilities(folder);
|
||||
expandTreePath(folder);
|
||||
expandTreePath(folder, { persist: false, includeLeaf: false });
|
||||
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
|
||||
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
|
||||
if (target) target.classList.add("selected");
|
||||
@@ -208,7 +208,7 @@ function breadcrumbDropHandler(e) {
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
@@ -287,59 +287,217 @@ async function checkUserFolderPermission() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- SVG icons + icon helpers ----------------
|
||||
const _nonEmptyCache = new Map();
|
||||
|
||||
/** Return inline SVG string for either an empty folder or folder-with-paper */
|
||||
/* ----------------------
|
||||
Folder icon (SVG + fetch + cache)
|
||||
----------------------*/
|
||||
|
||||
// Crisp emoji-like folder (empty / with paper)
|
||||
function folderSVG(kind = 'empty') {
|
||||
return `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<!-- Angled back body -->
|
||||
<path class="folder-back"
|
||||
d="M3 7.4h7.6l1.6 1.8H20.3c1.1 0 2 .9 2 2v7.6c0 1.1-.9 2-2 2H5
|
||||
c-1.1 0-2-.9-2-2V9.4c0-1.1.9-2 2-2z"/>
|
||||
|
||||
${kind === 'paper'
|
||||
? `
|
||||
<!-- Paper raised so it peeks above the lip -->
|
||||
<rect class="paper" x="6.1" y="5.7" width="11.8" height="10.8" rx="1.2"/>
|
||||
<!-- Bigger fold -->
|
||||
<path class="paper-fold" d="M18.0 5.7h-3.2l3.2 3.2z"/>
|
||||
<!-- Content lines -->
|
||||
<path class="paper-line" d="M7.7 8.2h8.3"/>
|
||||
<path class="paper-line" d="M7.7 9.8h7.2"/>
|
||||
<path class="paper-line" d="M7.7 11.3h6.0"/>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<!-- Front lip (angled) -->
|
||||
<path class="folder-front"
|
||||
d="M2.3 10.1H10.9l2.0-2.1h7.4c.94 0 1.7.76 1.7 1.7v7.3c0 .94-.76 1.7-1.7 1.7H4
|
||||
c-.94 0-1.7-.76-1.7-1.7v-6.9z"/>
|
||||
|
||||
<!-- Subtle highlight along the lip to add depth -->
|
||||
<path class="lip-highlight"
|
||||
d="M3.3 10.2H11.2l1.7-1.8h7.0"
|
||||
/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const _folderCountCache = new Map();
|
||||
const _inflightCounts = new Map();
|
||||
|
||||
// --- tiny fetch helper with timeout
|
||||
function fetchJSONWithTimeout(url, ms = 3000) {
|
||||
const ctrl = new AbortController();
|
||||
const tid = setTimeout(() => ctrl.abort(), ms);
|
||||
return fetch(url, { credentials: 'include', signal: ctrl.signal })
|
||||
.then(r => r.ok ? r.json() : { folders: 0, files: 0 })
|
||||
.catch(() => ({ folders: 0, files: 0 }))
|
||||
.finally(() => clearTimeout(tid));
|
||||
}
|
||||
|
||||
// --- simple concurrency limiter (prevents 100 simultaneous requests)
|
||||
const MAX_CONCURRENT_COUNT_REQS = 6;
|
||||
let _activeCountReqs = 0;
|
||||
const _countReqQueue = [];
|
||||
|
||||
function _runCount(url) {
|
||||
return new Promise(resolve => {
|
||||
const start = () => {
|
||||
_activeCountReqs++;
|
||||
fetchJSONWithTimeout(url, 2500)
|
||||
.then(resolve)
|
||||
.finally(() => {
|
||||
_activeCountReqs--;
|
||||
const next = _countReqQueue.shift();
|
||||
if (next) next();
|
||||
});
|
||||
};
|
||||
if (_activeCountReqs < MAX_CONCURRENT_COUNT_REQS) start();
|
||||
else _countReqQueue.push(start);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFolderCounts(folder) {
|
||||
if (_folderCountCache.has(folder)) return _folderCountCache.get(folder);
|
||||
if (_inflightCounts.has(folder)) return _inflightCounts.get(folder);
|
||||
|
||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`;
|
||||
const p = _runCount(url).then(data => {
|
||||
const result = {
|
||||
folders: Number(data?.folders || 0),
|
||||
files: Number(data?.files || 0),
|
||||
};
|
||||
_folderCountCache.set(folder, result);
|
||||
_inflightCounts.delete(folder);
|
||||
return result;
|
||||
});
|
||||
|
||||
_inflightCounts.set(folder, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function setFolderIconForOption(optEl, kind) {
|
||||
const iconEl = optEl.querySelector('.folder-icon');
|
||||
if (!iconEl) return;
|
||||
iconEl.dataset.kind = kind;
|
||||
iconEl.innerHTML = folderSVG(kind);
|
||||
}
|
||||
|
||||
function ensureFolderIcon(folder) {
|
||||
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||||
if (!opt) return;
|
||||
// Set a neutral default first so layout is stable
|
||||
setFolderIconForOption(opt, 'empty');
|
||||
|
||||
fetchFolderCounts(folder).then(({ folders, files }) => {
|
||||
setFolderIconForOption(opt, (folders + files) > 0 ? 'paper' : 'empty');
|
||||
});
|
||||
}
|
||||
/** Set a folder row’s icon to 'empty' or 'paper' */
|
||||
function setFolderIcon(folderPath, kind) {
|
||||
const iconEl = document.querySelector(`.folder-option[data-folder="${folderPath}"] .folder-icon`);
|
||||
if (!iconEl) return;
|
||||
if (iconEl.dataset.icon === kind) return;
|
||||
iconEl.dataset.icon = kind;
|
||||
iconEl.innerHTML = folderSVG(kind);
|
||||
}
|
||||
|
||||
/** Fast local heuristic: mark 'paper' if we can see any subfolders under this LI */
|
||||
function markNonEmptyIfHasChildren(folderPath) {
|
||||
const option = document.querySelector(`.folder-option[data-folder="${folderPath}"]`);
|
||||
if (!option) return false;
|
||||
const li = option.closest('li[role="treeitem"]');
|
||||
const childUL = li ? li.querySelector(':scope > ul') : null;
|
||||
const hasChildNodes = !!(childUL && childUL.querySelector('li'));
|
||||
if (hasChildNodes) { setFolderIcon(folderPath, 'paper'); _nonEmptyCache.set(folderPath, true); }
|
||||
return hasChildNodes;
|
||||
}
|
||||
|
||||
/** ACL-aware check for files: call a tiny stats endpoint (see part C) */
|
||||
async function fetchFolderNonEmptyACL(folderPath) {
|
||||
if (_nonEmptyCache.has(folderPath)) return _nonEmptyCache.get(folderPath);
|
||||
const { folders, files } = await fetchFolderCounts(folderPath);
|
||||
const nonEmpty = (folders + files) > 0;
|
||||
_nonEmptyCache.set(folderPath, nonEmpty);
|
||||
return nonEmpty;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
DOM Building Functions for Folder Tree
|
||||
----------------------*/
|
||||
function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
|
||||
const state = loadFolderTreeState();
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
|
||||
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}" role="group">`;
|
||||
|
||||
for (const folder in tree) {
|
||||
const name = folder.toLowerCase();
|
||||
if (name === "trash" || name === "profile_pics") continue;
|
||||
|
||||
const fullPath = parentPath ? parentPath + "/" + folder : folder;
|
||||
const hasChildren = Object.keys(tree[folder]).length > 0;
|
||||
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
|
||||
html += `<li class="folder-item">`;
|
||||
const isOpen = displayState !== 'none';
|
||||
|
||||
html += `<li class="folder-item" role="treeitem" aria-expanded="${hasChildren ? String(isOpen) : 'false'}">`;
|
||||
|
||||
html += `<div class="folder-row">`;
|
||||
if (hasChildren) {
|
||||
const toggleSymbol = (displayState === 'none') ? '[+]' : '[' + '<span class="custom-dash">-</span>' + ']';
|
||||
html += `<span class="folder-toggle" data-folder="${fullPath}">${toggleSymbol}</span>`;
|
||||
html += `<button type="button" class="folder-toggle" aria-label="${isOpen ? 'Collapse' : 'Expand'}" data-folder="${fullPath}"></button>`;
|
||||
} else {
|
||||
html += `<span class="folder-indent-placeholder"></span>`;
|
||||
}
|
||||
html += `<span class="folder-option" draggable="true" data-folder="${fullPath}">${escapeHTML(folder)}</span>`;
|
||||
if (hasChildren) {
|
||||
html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
html += `<span class="folder-spacer" aria-hidden="true"></span>`;
|
||||
}
|
||||
html += `
|
||||
<span class="folder-option" draggable="true" data-folder="${fullPath}">
|
||||
<span class="folder-icon" aria-hidden="true" data-icon="${hasChildren ? 'paper' : 'empty'}">
|
||||
${folderSVG(hasChildren ? 'paper' : 'empty')}
|
||||
</span>
|
||||
<span class="folder-label">${escapeHTML(folder)}</span>
|
||||
</span>
|
||||
`;
|
||||
html += `</div>`; // /.folder-row
|
||||
|
||||
if (hasChildren) html += renderFolderTree(tree[folder], fullPath, displayState);
|
||||
html += `</li>`;
|
||||
}
|
||||
|
||||
html += `</ul>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
function expandTreePath(path) {
|
||||
const parts = path.split("/");
|
||||
let cumulative = "";
|
||||
parts.forEach((part, index) => {
|
||||
cumulative = index === 0 ? part : cumulative + "/" + part;
|
||||
const option = document.querySelector(`.folder-option[data-folder="${cumulative}"]`);
|
||||
if (option) {
|
||||
const li = option.parentNode;
|
||||
const nestedUl = li.querySelector("ul");
|
||||
if (nestedUl && (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded"))) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
const toggle = li.querySelector(".folder-toggle");
|
||||
if (toggle) {
|
||||
toggle.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
// replace your current expandTreePath with this version
|
||||
function expandTreePath(path, opts = {}) {
|
||||
const { force = false } = opts;
|
||||
const state = loadFolderTreeState();
|
||||
state[cumulative] = "block";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
const parts = (path || '').split('/').filter(Boolean);
|
||||
let cumulative = '';
|
||||
|
||||
parts.forEach((part, i) => {
|
||||
cumulative = i === 0 ? part : `${cumulative}/${part}`;
|
||||
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
|
||||
if (!option) return;
|
||||
|
||||
const li = option.closest('li[role="treeitem"]');
|
||||
const nestedUl = li ? li.querySelector(':scope > ul') : null;
|
||||
if (!nestedUl) return;
|
||||
|
||||
// Only expand if caller forces it OR saved state says "block"
|
||||
const shouldExpand = force || state[cumulative] === 'block';
|
||||
nestedUl.classList.toggle('expanded', shouldExpand);
|
||||
nestedUl.classList.toggle('collapsed', !shouldExpand);
|
||||
li.setAttribute('aria-expanded', String(!!shouldExpand));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------
|
||||
Drag & Drop Support for Folder Tree Nodes
|
||||
----------------------*/
|
||||
@@ -392,7 +550,7 @@ function folderDropHandler(event) {
|
||||
window.currentFolder = newPath;
|
||||
}
|
||||
return loadFolderTree().then(() => {
|
||||
try { expandTreePath(window.currentFolder || "root"); } catch (_) {}
|
||||
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
|
||||
loadFileList(window.currentFolder || "root");
|
||||
});
|
||||
} else {
|
||||
@@ -442,22 +600,29 @@ function folderDropHandler(event) {
|
||||
// Safe breadcrumb DOM builder
|
||||
function renderBreadcrumbFragment(folderPath) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const parts = folderPath.split("/");
|
||||
let acc = "";
|
||||
|
||||
parts.forEach((part, idx) => {
|
||||
acc = idx === 0 ? part : acc + "/" + part;
|
||||
// Defensive normalize
|
||||
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
|
||||
const crumbs = path.split('/').filter(s => s !== ''); // no empty segments
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("breadcrumb-link");
|
||||
let acc = '';
|
||||
for (let i = 0; i < crumbs.length; i++) {
|
||||
const part = crumbs[i];
|
||||
acc = (i === 0) ? part : (acc + '/' + part);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'breadcrumb-link';
|
||||
span.dataset.folder = acc;
|
||||
span.textContent = part;
|
||||
frag.appendChild(span);
|
||||
|
||||
if (idx < parts.length - 1) {
|
||||
frag.appendChild(document.createTextNode(" / "));
|
||||
if (i < crumbs.length - 1) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'file-breadcrumb-sep';
|
||||
sep.textContent = '›';
|
||||
frag.appendChild(sep);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return frag;
|
||||
}
|
||||
@@ -536,23 +701,61 @@ export async function loadFolderTree(selectedFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<div id="rootRow" class="root-row">
|
||||
<span class="folder-toggle" data-folder="${effectiveRoot}">[<span class="custom-dash">-</span>]</span>
|
||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">${effectiveLabel}</span>
|
||||
</div>`;
|
||||
const state0 = loadFolderTreeState();
|
||||
const rootOpen = state0[effectiveRoot] !== 'none';
|
||||
|
||||
let html = `
|
||||
<div id="rootRow" class="folder-row" role="treeitem" aria-expanded="${String(rootOpen)}">
|
||||
<button type="button" class="folder-toggle" data-folder="${effectiveRoot}" aria-label="${rootOpen ? 'Collapse' : 'Expand'}"></button>
|
||||
<span class="folder-option root-folder-option" data-folder="${effectiveRoot}">
|
||||
<span class="folder-icon" aria-hidden="true"></span>
|
||||
<span class="folder-label">${effectiveLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (folders.length > 0) {
|
||||
const tree = buildFolderTree(folders);
|
||||
html += renderFolderTree(tree, "", "block");
|
||||
// 👇 pass the root's saved state down to first level
|
||||
html += renderFolderTree(tree, "", rootOpen ? "block" : "none");
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
const st = loadFolderTreeState();
|
||||
const rootUl = container.querySelector('#rootRow + ul');
|
||||
if (rootUl) {
|
||||
const expanded = (st[effectiveRoot] ?? 'block') === 'block';
|
||||
rootUl.classList.toggle('expanded', expanded);
|
||||
rootUl.classList.toggle('collapsed', !expanded);
|
||||
const rr = container.querySelector('#rootRow');
|
||||
if (rr) rr.setAttribute('aria-expanded', String(expanded));
|
||||
}
|
||||
|
||||
// Prime icons for everything visible
|
||||
primeFolderIcons(container);
|
||||
|
||||
function primeFolderIcons(scopeEl) {
|
||||
const opts = scopeEl.querySelectorAll('.folder-option[data-folder]');
|
||||
opts.forEach(opt => {
|
||||
const f = opt.getAttribute('data-folder');
|
||||
// Optional: if there are obvious children in DOM, show 'paper' immediately as a hint
|
||||
const li = opt.closest('li[role="treeitem"]');
|
||||
const hasChildren = !!(li && li.querySelector(':scope > ul > li'));
|
||||
setFolderIconForOption(opt, hasChildren ? 'paper' : 'empty');
|
||||
// Then confirm with server (files count)
|
||||
ensureFolderIcon(f);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach drag/drop event listeners.
|
||||
container.querySelectorAll(".folder-option").forEach(el => {
|
||||
const fp = el.getAttribute('data-folder');
|
||||
markNonEmptyIfHasChildren(fp);
|
||||
// Provide folder path payload for folder->folder DnD
|
||||
el.addEventListener("dragstart", (ev) => {
|
||||
const src = el.getAttribute("data-folder");
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
@@ -569,11 +772,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
// Initial breadcrumb + file list
|
||||
updateBreadcrumbTitle(window.currentFolder);
|
||||
applyFolderCapabilities(window.currentFolder);
|
||||
ensureFolderIcon(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
|
||||
const folderState = loadFolderTreeState();
|
||||
if (window.currentFolder !== effectiveRoot && folderState[window.currentFolder] !== "none") {
|
||||
expandTreePath(window.currentFolder);
|
||||
// Show ancestors so the current selection is visible, but don't persist
|
||||
if (window.currentFolder && window.currentFolder !== effectiveRoot) {
|
||||
expandTreePath(window.currentFolder, { persist: false, includeLeaf: false });
|
||||
}
|
||||
|
||||
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
|
||||
@@ -587,8 +791,8 @@ export async function loadFolderTree(selectedFolder) {
|
||||
// Provide folder path payload for folder->folder DnD
|
||||
el.addEventListener("dragstart", (ev) => {
|
||||
const src = el.getAttribute("data-folder");
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) {}
|
||||
try { ev.dataTransfer.setData("application/x-filerise-folder", src); } catch (e) { }
|
||||
try { ev.dataTransfer.setData("text/plain", src); } catch (e) { }
|
||||
ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
|
||||
@@ -602,55 +806,48 @@ export async function loadFolderTree(selectedFolder) {
|
||||
|
||||
updateBreadcrumbTitle(selected);
|
||||
applyFolderCapabilities(selected);
|
||||
ensureFolderIcon(selected);
|
||||
loadFileList(selected);
|
||||
});
|
||||
});
|
||||
|
||||
// Root toggle handler
|
||||
// Root toggle
|
||||
const rootToggle = container.querySelector("#rootRow .folder-toggle");
|
||||
if (rootToggle) {
|
||||
rootToggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const nestedUl = container.querySelector("#rootRow + ul");
|
||||
if (nestedUl) {
|
||||
if (!nestedUl) return;
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
if (nestedUl.classList.contains("collapsed") || !nestedUl.classList.contains("expanded")) {
|
||||
nestedUl.classList.remove("collapsed");
|
||||
nestedUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[effectiveRoot] = "block";
|
||||
} else {
|
||||
nestedUl.classList.remove("expanded");
|
||||
nestedUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[effectiveRoot] = "none";
|
||||
}
|
||||
const expanded = !(nestedUl.classList.contains("expanded"));
|
||||
nestedUl.classList.toggle("expanded", expanded);
|
||||
nestedUl.classList.toggle("collapsed", !expanded);
|
||||
|
||||
document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded));
|
||||
state[effectiveRoot] = expanded ? "block" : "none";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Other folder-toggle handlers
|
||||
container.querySelectorAll(".folder-toggle").forEach(toggle => {
|
||||
// Other toggles
|
||||
|
||||
container.querySelectorAll("button.folder-toggle").forEach(toggle => {
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
const siblingUl = this.parentNode.querySelector("ul");
|
||||
const li = this.closest('li[role="treeitem"]');
|
||||
const siblingUl = li ? li.querySelector(':scope > ul') : null;
|
||||
const folderPath = this.getAttribute("data-folder");
|
||||
if (!siblingUl) return;
|
||||
|
||||
const state = loadFolderTreeState();
|
||||
if (siblingUl) {
|
||||
if (siblingUl.classList.contains("collapsed") || !siblingUl.classList.contains("expanded")) {
|
||||
siblingUl.classList.remove("collapsed");
|
||||
siblingUl.classList.add("expanded");
|
||||
this.innerHTML = "[" + '<span class="custom-dash">-</span>' + "]";
|
||||
state[folderPath] = "block";
|
||||
} else {
|
||||
siblingUl.classList.remove("expanded");
|
||||
siblingUl.classList.add("collapsed");
|
||||
this.textContent = "[+]";
|
||||
state[folderPath] = "none";
|
||||
}
|
||||
const expanded = !(siblingUl.classList.contains("expanded"));
|
||||
siblingUl.classList.toggle("expanded", expanded);
|
||||
siblingUl.classList.toggle("collapsed", !expanded);
|
||||
li.setAttribute("aria-expanded", String(expanded));
|
||||
state[folderPath] = expanded ? "block" : "none";
|
||||
saveFolderTreeState(state);
|
||||
}
|
||||
ensureFolderIcon(folderPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -779,7 +976,7 @@ function openMoveFolderUI(sourceFolder) {
|
||||
targetSel.appendChild(o);
|
||||
});
|
||||
})
|
||||
.catch(()=>{ /* no-op */ });
|
||||
.catch(() => { /* no-op */ });
|
||||
}
|
||||
|
||||
if (modal) modal.style.display = 'block';
|
||||
@@ -1077,7 +1274,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const targetSel = document.getElementById('moveFolderTarget');
|
||||
const cancelBtn = document.getElementById('cancelMoveFolder');
|
||||
const confirmBtn= document.getElementById('confirmMoveFolder');
|
||||
const confirmBtn = document.getElementById('confirmMoveFolder');
|
||||
|
||||
if (moveBtn) {
|
||||
moveBtn.addEventListener('click', () => {
|
||||
@@ -1108,7 +1305,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const data = await safeJson(res);
|
||||
if (res.ok && data && !data.error) {
|
||||
showToast('Folder moved');
|
||||
if (modal) modal.style.display='none';
|
||||
if (modal) modal.style.display = 'none';
|
||||
await loadFolderTree();
|
||||
const base = source.split('/').pop();
|
||||
const newPath = (destination === 'root' ? '' : destination + '/') + base;
|
||||
|
||||
@@ -409,20 +409,38 @@ function bindDarkMode() {
|
||||
|
||||
// --- 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
|
||||
|
||||
// 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 {
|
||||
row.removeAttribute('hidden');
|
||||
row.style.display = '';
|
||||
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' : '';
|
||||
@@ -1037,6 +1055,21 @@ function bindDarkMode() {
|
||||
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';
|
||||
@@ -1102,7 +1135,7 @@ function bindDarkMode() {
|
||||
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(() => {});
|
||||
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.8.8';
|
||||
window.APP_VERSION = 'v1.9.0';
|
||||
|
||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 687 KiB |
BIN
resources/filerise-v1.8.10-latest.gif
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 788 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 666 KiB |
@@ -58,11 +58,25 @@ class AuthController
|
||||
}
|
||||
if ($oidcAction) {
|
||||
$cfg = AdminModel::getConfig();
|
||||
$clientId = $cfg['oidc']['clientId'] ?? null;
|
||||
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
|
||||
// When configured as a public client (no secret), pass null, not an empty string.
|
||||
if ($clientSecret === '') { $clientSecret = null; }
|
||||
|
||||
$oidc = new OpenIDConnectClient(
|
||||
$cfg['oidc']['providerUrl'],
|
||||
$cfg['oidc']['clientId'],
|
||||
$cfg['oidc']['clientSecret']
|
||||
$clientId ?: null,
|
||||
$clientSecret
|
||||
);
|
||||
|
||||
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
|
||||
if (method_exists($oidc, 'setCodeChallengeMethod')) {
|
||||
$oidc->setCodeChallengeMethod('S256');
|
||||
}
|
||||
// client_secret_post with Authelia using config.php
|
||||
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
|
||||
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
|
||||
}
|
||||
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
|
||||
$oidc->addScope(['openid','profile','email']);
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ class FolderController
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/** Stats for a folder (currently: empty/non-empty via folders/files counts). */
|
||||
public static function stats(string $folder, string $user, array $perms): array
|
||||
{
|
||||
// Normalize inside model; this is a thin action
|
||||
return FolderModel::countVisible($folder, $user, $perms);
|
||||
}
|
||||
|
||||
private static function requireCsrf(): void
|
||||
{
|
||||
self::ensureSession();
|
||||
|
||||
@@ -17,6 +17,23 @@ private const OO_SUPPORTED_EXTS = [
|
||||
'pdf'
|
||||
];
|
||||
|
||||
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
|
||||
private function effectiveFileOriginForDocs(): string
|
||||
{
|
||||
$cfg = AdminModel::getConfig();
|
||||
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
|
||||
|
||||
// 1) explicit constant
|
||||
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
|
||||
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
|
||||
}
|
||||
// 2) admin.json setting
|
||||
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
|
||||
|
||||
// 3) fallback: whatever the public sees (may hairpin, but still works)
|
||||
return $this->effectivePublicOrigin();
|
||||
}
|
||||
|
||||
// Never editable via OO (we’ll always set edit=false for these)
|
||||
private const OO_NEVER_EDIT = ['pdf'];
|
||||
|
||||
@@ -127,7 +144,7 @@ private function ooLog(string $level, string $msg): void
|
||||
|
||||
/** GET /api/onlyoffice/status.php */
|
||||
public function status(): void
|
||||
{
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
@@ -139,15 +156,20 @@ private function ooLog(string $level, string $msg): void
|
||||
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
|
||||
|
||||
$exts = self::OO_SUPPORTED_EXTS;
|
||||
// If you want the extras:
|
||||
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
|
||||
|
||||
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
echo json_encode([
|
||||
'enabled' => (bool)$enabled,
|
||||
'exts' => $exts,
|
||||
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
|
||||
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/** GET /api/onlyoffice/config.php?folder=...&file=... */
|
||||
public function config(): void
|
||||
{
|
||||
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
|
||||
public function config(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
@@ -156,51 +178,45 @@ private function ooLog(string $level, string $msg): void
|
||||
$perms = [];
|
||||
$isAdmin = \ACL::isAdmin($perms);
|
||||
|
||||
// Effective toggles
|
||||
$enabled = $this->effectiveEnabled();
|
||||
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
|
||||
$secret = $this->effectiveSecret();
|
||||
|
||||
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
|
||||
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
|
||||
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
|
||||
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
|
||||
|
||||
// Inputs
|
||||
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
|
||||
$file = basename((string)($_GET['file'] ?? ''));
|
||||
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
|
||||
|
||||
// ACL
|
||||
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
|
||||
$canEdit = \ACL::canEdit($user, $perms, $folder);
|
||||
|
||||
// Path
|
||||
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
|
||||
$rel = ($folder === 'root') ? '' : ($folder . '/');
|
||||
$abs = realpath($base . $rel . $file);
|
||||
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
|
||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
|
||||
|
||||
// Public origin
|
||||
$publicOrigin = $this->effectivePublicOrigin();
|
||||
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
|
||||
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
|
||||
|
||||
// Signed download
|
||||
$exp = time() + 10*60;
|
||||
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
|
||||
$sig = hash_hmac('sha256', $data, $secret, true);
|
||||
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
|
||||
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
||||
$fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
|
||||
|
||||
// Callback
|
||||
$cbExp = time() + 10*60;
|
||||
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
|
||||
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php'
|
||||
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
|
||||
. '?folder=' . rawurlencode($folder)
|
||||
. '&file=' . rawurlencode($file)
|
||||
. '&exp=' . $cbExp
|
||||
. '&sig=' . $cbSig;
|
||||
|
||||
// Doc type & key
|
||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
|
||||
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
|
||||
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
|
||||
@@ -234,10 +250,13 @@ private function ooLog(string $level, string $msg): void
|
||||
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
|
||||
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
|
||||
$cfgOut['token'] = "$h.$p.$s";
|
||||
|
||||
// expose to client for preconnect/script load
|
||||
$cfgOut['docs_api_js'] = $docsApiJs;
|
||||
$cfgOut['documentServerOrigin'] = $docsOrigin;
|
||||
|
||||
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
|
||||
public function callback(): void
|
||||
@@ -343,7 +362,7 @@ private function ooLog(string $level, string $msg): void
|
||||
|
||||
/** GET /api/onlyoffice/signed-download.php?tok=... */
|
||||
public function signedDownload(): void
|
||||
{
|
||||
{
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
@@ -374,10 +393,21 @@ private function ooLog(string $level, string $msg): void
|
||||
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
|
||||
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
|
||||
|
||||
// Common headers
|
||||
$mime = mime_content_type($abs) ?: 'application/octet-stream';
|
||||
$len = filesize($abs);
|
||||
header('Content-Type: '.$mime);
|
||||
header('Content-Length: '.filesize($abs));
|
||||
header('Content-Length: '.$len);
|
||||
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
|
||||
readfile($abs);
|
||||
header('Accept-Ranges: none'); // OO doesn’t require ranges; avoids partial edge-cases
|
||||
|
||||
// ---- Key change: for HEAD, do NOT read the file ----
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
|
||||
// send headers only; no body
|
||||
return;
|
||||
}
|
||||
|
||||
// GET → stream the file
|
||||
readfile($abs);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,45 @@ class FolderModel
|
||||
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||
* ============================================================ */
|
||||
|
||||
public static function countVisible(string $folder, string $user, array $perms): array
|
||||
{
|
||||
// Normalize
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// ACL gate: if you can’t read, report empty (no leaks)
|
||||
if (!$user || !ACL::canRead($user, $perms, $folder)) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Resolve paths under UPLOAD_DIR
|
||||
$root = rtrim((string)UPLOAD_DIR, '/\\');
|
||||
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
|
||||
|
||||
$realRoot = @realpath($root);
|
||||
$realPath = @realpath($path);
|
||||
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Count quickly, skipping UI-internal dirs
|
||||
$folders = 0; $files = 0;
|
||||
try {
|
||||
foreach (new DirectoryIterator($realPath) as $f) {
|
||||
if ($f->isDot()) continue;
|
||||
$name = $f->getFilename();
|
||||
if ($name === 'trash' || $name === 'profile_pics') continue;
|
||||
|
||||
if ($f->isDir()) $folders++; else $files++;
|
||||
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Stay quiet + safe
|
||||
$folders = 0; $files = 0;
|
||||
}
|
||||
|
||||
return ['folders' => $folders, 'files' => $files];
|
||||
}
|
||||
|
||||
/** Load the folder → owner map. */
|
||||
public static function getFolderOwners(): array
|
||||
{
|
||||
|
||||