Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 |
72
CHANGELOG.md
72
CHANGELOG.md
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/27/2025 (v2.1.0)
|
||||
|
||||
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
|
||||
|
||||
release(v2.1.0): add header zoom controls, preview tags & modal/dock polish
|
||||
|
||||
- **feat(ux): header zoom controls with persisted app zoom**
|
||||
- Add `zoom.js` with percent-based zoom API (`window.fileriseZoom`) and `--app-zoom` CSS variable.
|
||||
- Wrap the main app in `#appZoomShell` and scale via `transform: scale(var(--app-zoom))` so the whole UI zooms uniformly.
|
||||
- Add header zoom UI (+ / − / 100% reset) and wire it via `data-zoom` buttons.
|
||||
- Persist zoom level in `localStorage` and restore on load.
|
||||
|
||||
- **feat(prefs): user toggle to hide header zoom controls**
|
||||
- Add `hide_header_zoom_controls` i18n key.
|
||||
- Extend the Settings → Display fieldset with “Hide header zoom controls”.
|
||||
- Store preference in `localStorage('hideZoomControls')` and respect it from `appCore.js` when initializing header zoom UI.
|
||||
|
||||
- **feat(preview): show file tags next to preview title**
|
||||
- Add `.title-tags` container in the media viewer header.
|
||||
- When opening a file, look up its `tags` from `fileData` and render them as pill badges beside the filename in the modal top bar.
|
||||
|
||||
- **fix(modals): folder modals always centered above header cards**
|
||||
- Introduce `detachFolderModalsToBody()` in `folderManager.js` and call it on init + before opening create/rename/move/delete modals.
|
||||
- Move those modals under `document.body` with a stable high `z-index`, so they’re not clipped/hidden when the cards live in the header dock.
|
||||
|
||||
- **fix(dnd): header dock & hidden cards container**
|
||||
- Change `#hiddenCardsContainer` from `display:none` to an off-screen absolutely positioned container so card internals (modals/layout) still work while represented as header icons.
|
||||
- Ensure sidebar is always visible as a drop target while dragging (even when panels are collapsed), plus improved highlight & placeholder behavior.
|
||||
|
||||
- **feat(ux): header dock hover/lock polish**
|
||||
- Make header icon buttons share the same hover style as other header buttons.
|
||||
- Add `.is-locked` state so a pinned header icon stays visually “pressed” while its card modal is locked open.
|
||||
|
||||
- **feat(ux): header drop zone and zoom bar layout**
|
||||
- Rework `.header-right` to neatly align zoom controls, header dock, and user buttons.
|
||||
- Add a more flexible `.header-drop-zone` with smooth width/padding transitions and a centered `"Drop Zone"` label when active and empty.
|
||||
- Adjust responsive spacing around zoom controls on smaller screens.
|
||||
|
||||
- **tweak(prefs-modal): improve settings modal sizing**
|
||||
- Increase auth/settings modal `max-height` from 500px to 600px to fit the extra display options without excessive scrolling.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/26/2025 (v2.0.4)
|
||||
|
||||
release(v2.0.4): harden sessions and align Pro paths with USERS_DIR
|
||||
|
||||
- Enable strict_types in config.php and AdminController
|
||||
- Decouple PHP session lifetime from "remember me" window
|
||||
- Regenerate session ID on persistent token auto-login
|
||||
- Point Pro license / bundle paths at USERS_DIR instead of hardcoded /users
|
||||
- Tweak folder management card drag offset for better alignment
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/26/2025 (v2.0.3)
|
||||
|
||||
release(v2.0.3): polish uploads, header dock, and panel fly animations
|
||||
|
||||
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
|
||||
so only one OS file-picker dialog can open at a time.
|
||||
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
|
||||
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
|
||||
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
|
||||
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
|
||||
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
|
||||
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
|
||||
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
|
||||
- Slightly tighten file summary margin in the file list header for better alignment with actions.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/23/2025 (v2.0.2)
|
||||
|
||||
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
||||
|
||||
12
README.md
12
README.md
@@ -23,6 +23,8 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
|
||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||
- 👥 **User groups & client portals (Pro)** – Group-based ACLs and brandable client upload portals.
|
||||
|
||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
@@ -74,7 +76,10 @@ http://your-server-ip:8080
|
||||
|
||||
On first launch you’ll be guided through creating the **initial admin user**.
|
||||
|
||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
||||
[nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
||||
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
||||
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||
|
||||
---
|
||||
@@ -189,3 +194,8 @@ It bundles a small set of well‑known client and server libraries (Bootstrap, C
|
||||
All third‑party code remains under its original licenses.
|
||||
|
||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||
|
||||
## 8. Press
|
||||
|
||||
- [Heise / iX Magazin – “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
|
||||
- [Heise / iX Magazin – “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// config.php
|
||||
|
||||
// Define constants
|
||||
@@ -101,10 +102,15 @@ $secure = ($envSecure !== false)
|
||||
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
// Choose session lifetime based on "remember me" cookie
|
||||
|
||||
// PHP session lifetime (independent of "remember me")
|
||||
// Keep this reasonably short; "remember me" uses its own token.
|
||||
$defaultSession = 7200; // 2 hours
|
||||
$sessionLifetime = $defaultSession;
|
||||
|
||||
// "Remember me" window (how long the persistent token itself is valid)
|
||||
// This is used in persistent_tokens.json, *not* for PHP session lifetime.
|
||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
||||
|
||||
/**
|
||||
* Start session idempotently:
|
||||
@@ -155,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
||||
if (!empty($tokens[$token])) {
|
||||
$data = $tokens[$token];
|
||||
if ($data['expiry'] >= time()) {
|
||||
// NEW: mitigate session fixation
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
$_SESSION["authenticated"] = true;
|
||||
$_SESSION["username"] = $data["username"];
|
||||
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
||||
@@ -162,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
||||
} else {
|
||||
// expired — clean up
|
||||
unset($tokens[$token]);
|
||||
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX);
|
||||
file_put_contents(
|
||||
$tokFile,
|
||||
encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey),
|
||||
LOCK_EX
|
||||
);
|
||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||
}
|
||||
}
|
||||
@@ -253,14 +268,14 @@ if (!defined('FR_PRO_LICENSE')) {
|
||||
|
||||
// JSON license file used by AdminController::setLicense()
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
|
||||
}
|
||||
|
||||
// Optional plain-text license file (used as fallback in bootstrap)
|
||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||
if ($lf === false || $lf === '') {
|
||||
$lf = PROJECT_ROOT . '/users/proLicense.txt';
|
||||
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
|
||||
}
|
||||
define('FR_PRO_LICENSE_FILE', $lf);
|
||||
}
|
||||
@@ -268,7 +283,7 @@ if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||
// Where Pro code lives by default → inside users volume
|
||||
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||
if ($proDir === false || $proDir === '') {
|
||||
$proDir = PROJECT_ROOT . '/users/pro';
|
||||
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
|
||||
}
|
||||
$proDir = rtrim($proDir, "/\\");
|
||||
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||
|
||||
@@ -228,10 +228,7 @@ body{letter-spacing: 0.2px;
|
||||
padding: 9px;}
|
||||
#userDropdownToggle{border-radius: 4px !important;
|
||||
padding: 6px 10px !important;}
|
||||
#headerDropArea.header-drop-zone{display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: 40px;}
|
||||
|
||||
.header-buttons button:hover{background-color: rgba(122,179,255,.14);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;}
|
||||
@@ -254,6 +251,49 @@ body{letter-spacing: 0.2px;
|
||||
justify-content: center;}
|
||||
}
|
||||
.header-buttons button i{font-size: 24px;}
|
||||
|
||||
.header-zoom-controls .zoom-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
padding: 4px;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.header-zoom-controls .zoom-btn:hover {
|
||||
background-color: rgba(122,179,255,.14);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-zoom-controls .zoom-btn .material-icons {
|
||||
font-size: 16px;
|
||||
}
|
||||
.header-buttons button,
|
||||
#headerDropArea .header-card-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.header-buttons button:not(#userDropdownToggle),
|
||||
#headerDropArea .header-card-icon {
|
||||
border-radius: 50%;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.header-buttons button:hover,
|
||||
#headerDropArea .header-card-icon:hover,
|
||||
#headerDropArea .header-card-icon.is-locked {
|
||||
background-color: rgba(122,179,255,.14) !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark-mode-toggle{background-color: #424242;
|
||||
border: 1px solid #fff;
|
||||
color: #fff;
|
||||
@@ -1384,6 +1424,7 @@ label{font-size: 0.9rem;}
|
||||
}
|
||||
#sidebarDropArea.highlight,
|
||||
#uploadFolderRow.highlight{border: 2px dashed #1565C0;
|
||||
border-radius: var(--menu-radius);
|
||||
background-color: #eef;}
|
||||
.drag-header{cursor: grab;
|
||||
user-select: none;
|
||||
@@ -1488,12 +1529,7 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
||||
.collapse-btn:hover{background: rgba(0, 0, 0, 0.1);}
|
||||
.toggle-modal-btn:focus,
|
||||
.collapse-btn:focus{outline: none;}
|
||||
.header-drop-zone{width: 66px;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
display: inline-flex;}
|
||||
|
||||
.header-drop-zone.drag-active{border: 2px dashed #1565C0;
|
||||
background-color: #eef;
|
||||
background-color: transparent;
|
||||
@@ -1502,10 +1538,23 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
||||
.dark-mode .header-drop-zone.drag-active{background-color: #333;
|
||||
border: 2px dashed #555;
|
||||
color: #fff;}
|
||||
.header-drop-zone.drag-active:empty::before{content: "Drop Zone";
|
||||
font-size: 10px;
|
||||
padding-right: 6px;
|
||||
color: #aaa;}
|
||||
.header-drop-zone {
|
||||
position: relative; /* so ::before can absolutely position inside */
|
||||
}
|
||||
|
||||
.header-drop-zone.drag-active:empty::before {
|
||||
content: "Drop Zone";
|
||||
position: absolute;
|
||||
inset: 0; /* top/right/bottom/left: 0 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 10px;
|
||||
padding-right: 2px;
|
||||
color: #aaa;
|
||||
pointer-events: none; /* optional, so it doesn't block drops */
|
||||
}
|
||||
#fileList tbody tr.clickable-row{-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
@@ -2092,4 +2141,118 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
|
||||
#fileList tr.folder-row.folder-row-droptarget .folder-row-name{font-weight:600}
|
||||
#fileList table.filr-table tbody tr.folder-row>td{padding-top:0!important;padding-bottom:0!important}
|
||||
#fileList table.filr-table tbody tr.folder-row>td.folder-icon-cell{overflow:visible}
|
||||
#fileList tr.folder-row .folder-row-inner,#fileList tr.folder-row .folder-row-name{cursor:inherit}
|
||||
#fileList tr.folder-row .folder-row-inner,#fileList tr.folder-row .folder-row-name{cursor:inherit}
|
||||
|
||||
:root {
|
||||
--app-zoom: 1; /* 1.0 = 100% */
|
||||
}
|
||||
|
||||
#appZoomShell {
|
||||
transform-origin: top left;
|
||||
transform: scale(var(--app-zoom));
|
||||
/* compensate so scaled content still fills the viewport */
|
||||
width: calc(100% / var(--app-zoom));
|
||||
height: calc(100% / var(--app-zoom));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.header-zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: 10px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body:not(.dark-mode) .header-zoom-controls .zoom-vertical,
|
||||
body:not(.dark-mode) .header-zoom-controls .zoom-meta,
|
||||
body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn,
|
||||
body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.header-zoom-controls .zoom-vertical,
|
||||
.header-zoom-controls .zoom-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.header-zoom-controls .btn-icon.zoom-btn {
|
||||
width: 24px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Smaller material icons */
|
||||
.header-zoom-controls .btn-icon.zoom-btn .material-icons {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoom-display {
|
||||
min-width: 3ch;
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
.header-zoom-controls {
|
||||
border-right: none;
|
||||
padding-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-drop-zone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
margin-right: 0px;
|
||||
min-width: 0;
|
||||
min-height: 50px;
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
min-width 0.15s ease,
|
||||
padding 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.header-card-icon {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-card-icon .material-icons {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.header-drop-zone.drag-active {
|
||||
padding: 0 12px;
|
||||
min-width: 100px;
|
||||
border-radius: 16px;
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
@@ -61,7 +61,27 @@
|
||||
<h1>FileRise</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||
<!-- Zoom controls FIRST on the right -->
|
||||
<div class="header-zoom-controls">
|
||||
<!-- Left stack: + / - -->
|
||||
<div class="zoom-vertical">
|
||||
<button class="btn-icon zoom-btn" data-zoom="in" title="Zoom in">
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
<button class="btn-icon zoom-btn" data-zoom="out" title="Zoom out">
|
||||
<span class="material-icons">remove</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Right stack: 100% / reset -->
|
||||
<div class="zoom-meta">
|
||||
<span id="zoomDisplay" class="zoom-display">100%</span>
|
||||
<button class="btn-icon zoom-btn" data-zoom="reset" title="Reset zoom">
|
||||
<span class="material-icons">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center;">
|
||||
|
||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||
<div class="header-buttons">
|
||||
@@ -112,6 +132,7 @@
|
||||
<!-- Custom Toast Container -->
|
||||
<div id="customToast"></div>
|
||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||
<div id="appZoomShell">
|
||||
<main id="main" hidden>
|
||||
<div class="row mt-4" id="loginForm">
|
||||
<div class="col-12">
|
||||
@@ -401,7 +422,7 @@
|
||||
</div> <!-- end container-fluid -->
|
||||
</div> <!-- end mainColumn -->
|
||||
</div> <!-- end main-wrapper -->
|
||||
|
||||
</div>
|
||||
<!-- Download Progress Modal -->
|
||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||
|
||||
@@ -93,6 +93,24 @@ export function initializeApp() {
|
||||
// default: false (unchecked)
|
||||
window.showFoldersInList = stored === 'true';
|
||||
|
||||
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||
if (zoomWrap) {
|
||||
const hideZoom = localStorage.getItem('hideZoomControls') === 'true';
|
||||
if (hideZoom) {
|
||||
zoomWrap.style.display = 'none';
|
||||
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
zoomWrap.style.display = 'flex';
|
||||
zoomWrap.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
// Always load zoom.js once app is running
|
||||
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`).catch(err => {
|
||||
console.warn('[zoom] failed to load zoom.js', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Load public site config early (safe subset)
|
||||
loadAdminConfigFunc();
|
||||
|
||||
@@ -176,6 +194,25 @@ export function initializeApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Zoom controls: load only for logged-in app ----
|
||||
(function loadZoomControls() {
|
||||
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||
if (!zoomWrap) return;
|
||||
|
||||
// show container (keep CSS default = hidden)
|
||||
zoomWrap.style.display = 'flex';
|
||||
zoomWrap.style.alignItems = 'center';
|
||||
|
||||
try {
|
||||
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`)
|
||||
.catch(err => console.warn('[zoom] failed to load:', err));
|
||||
} catch (e) {
|
||||
console.warn('[zoom] load error:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
/* =========================
|
||||
LOGOUT (shared)
|
||||
========================= */
|
||||
|
||||
@@ -195,7 +195,7 @@ export async function openUserPanel() {
|
||||
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||
padding: 20px;
|
||||
max-width: 600px; width:90%;
|
||||
overflow-y: auto; max-height: 500px;
|
||||
overflow-y: auto; max-height: 600px;
|
||||
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||
box-sizing: border-box;
|
||||
scrollbar-width: none;
|
||||
@@ -351,66 +351,108 @@ export async function openUserPanel() {
|
||||
langFs.appendChild(langSel);
|
||||
content.appendChild(langFs);
|
||||
|
||||
// --- Display fieldset: strip + inline folder rows ---
|
||||
const dispFs = document.createElement('fieldset');
|
||||
dispFs.style.marginBottom = '15px';
|
||||
|
||||
const dispLegend = document.createElement('legend');
|
||||
dispLegend.textContent = t('display');
|
||||
dispFs.appendChild(dispLegend);
|
||||
|
||||
// 1) Show folder strip above list
|
||||
const stripLabel = document.createElement('label');
|
||||
stripLabel.style.cursor = 'pointer';
|
||||
stripLabel.style.display = 'block';
|
||||
stripLabel.style.marginBottom = '4px';
|
||||
|
||||
const stripCb = document.createElement('input');
|
||||
stripCb.type = 'checkbox';
|
||||
stripCb.id = 'showFoldersInList';
|
||||
stripCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||
// default: unchecked
|
||||
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
||||
}
|
||||
|
||||
stripLabel.appendChild(stripCb);
|
||||
stripLabel.append(` ${t('show_folders_above_files')}`);
|
||||
dispFs.appendChild(stripLabel);
|
||||
|
||||
// 2) Show inline folder rows above files in table view
|
||||
const inlineLabel = document.createElement('label');
|
||||
inlineLabel.style.cursor = 'pointer';
|
||||
inlineLabel.style.display = 'block';
|
||||
|
||||
const inlineCb = document.createElement('input');
|
||||
inlineCb.type = 'checkbox';
|
||||
inlineCb.id = 'showInlineFolders';
|
||||
inlineCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedInline = localStorage.getItem('showInlineFolders');
|
||||
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
||||
}
|
||||
|
||||
inlineLabel.appendChild(inlineCb);
|
||||
// you’ll want a string like this in i18n:
|
||||
// "show_inline_folders": "Show folders inline (above files)"
|
||||
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||
dispFs.appendChild(inlineLabel);
|
||||
|
||||
content.appendChild(dispFs);
|
||||
|
||||
// Handlers: toggle + refresh list
|
||||
stripCb.addEventListener('change', () => {
|
||||
window.showFoldersInList = stripCb.checked;
|
||||
localStorage.setItem('showFoldersInList', stripCb.checked);
|
||||
if (typeof window.loadFileList === 'function') {
|
||||
window.loadFileList(window.currentFolder || 'root');
|
||||
}
|
||||
});
|
||||
// --- Display fieldset: strip + inline folder rows ---
|
||||
const dispFs = document.createElement('fieldset');
|
||||
dispFs.style.marginBottom = '15px';
|
||||
|
||||
const dispLegend = document.createElement('legend');
|
||||
dispLegend.textContent = t('display');
|
||||
dispFs.appendChild(dispLegend);
|
||||
|
||||
// 1) Show folder strip above list
|
||||
const stripLabel = document.createElement('label');
|
||||
stripLabel.style.cursor = 'pointer';
|
||||
stripLabel.style.display = 'block';
|
||||
stripLabel.style.marginBottom = '4px';
|
||||
|
||||
const stripCb = document.createElement('input');
|
||||
stripCb.type = 'checkbox';
|
||||
stripCb.id = 'showFoldersInList';
|
||||
stripCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
||||
}
|
||||
|
||||
stripLabel.appendChild(stripCb);
|
||||
stripLabel.append(` ${t('show_folders_above_files')}`);
|
||||
dispFs.appendChild(stripLabel);
|
||||
|
||||
// 2) Show inline folder rows above files in table view
|
||||
const inlineLabel = document.createElement('label');
|
||||
inlineLabel.style.cursor = 'pointer';
|
||||
inlineLabel.style.display = 'block';
|
||||
|
||||
const inlineCb = document.createElement('input');
|
||||
inlineCb.type = 'checkbox';
|
||||
inlineCb.id = 'showInlineFolders';
|
||||
inlineCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedInline = localStorage.getItem('showInlineFolders');
|
||||
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
||||
}
|
||||
|
||||
inlineLabel.appendChild(inlineCb);
|
||||
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||
dispFs.appendChild(inlineLabel);
|
||||
|
||||
// 3) Hide header zoom controls
|
||||
const zoomLabel = document.createElement('label');
|
||||
zoomLabel.style.cursor = 'pointer';
|
||||
zoomLabel.style.display = 'block';
|
||||
zoomLabel.style.marginTop = '4px';
|
||||
|
||||
const zoomCb = document.createElement('input');
|
||||
zoomCb.type = 'checkbox';
|
||||
zoomCb.id = 'hideHeaderZoomControls';
|
||||
zoomCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedZoom = localStorage.getItem('hideZoomControls');
|
||||
zoomCb.checked = storedZoom === 'true';
|
||||
}
|
||||
|
||||
zoomLabel.appendChild(zoomCb);
|
||||
zoomLabel.append(` ${t('hide_header_zoom_controls') || 'Hide zoom controls in header'}`);
|
||||
dispFs.appendChild(zoomLabel);
|
||||
|
||||
content.appendChild(dispFs);
|
||||
|
||||
// Handlers: toggle + refresh list
|
||||
stripCb.addEventListener('change', () => {
|
||||
window.showFoldersInList = stripCb.checked;
|
||||
localStorage.setItem('showFoldersInList', stripCb.checked);
|
||||
if (typeof window.loadFileList === 'function') {
|
||||
window.loadFileList(window.currentFolder || 'root');
|
||||
}
|
||||
});
|
||||
|
||||
inlineCb.addEventListener('change', () => {
|
||||
window.showInlineFolders = inlineCb.checked;
|
||||
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||
if (typeof window.loadFileList === 'function') {
|
||||
window.loadFileList(window.currentFolder || 'root');
|
||||
}
|
||||
});
|
||||
|
||||
// NEW: zoom hide/show handler
|
||||
zoomCb.addEventListener('change', () => {
|
||||
const hideZoom = zoomCb.checked;
|
||||
localStorage.setItem('hideZoomControls', hideZoom ? 'true' : 'false');
|
||||
|
||||
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||
if (!zoomWrap) return;
|
||||
|
||||
if (hideZoom) {
|
||||
zoomWrap.style.display = 'none';
|
||||
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
zoomWrap.style.display = 'flex';
|
||||
zoomWrap.removeAttribute('aria-hidden');
|
||||
}
|
||||
});
|
||||
|
||||
inlineCb.addEventListener('change', () => {
|
||||
window.showInlineFolders = inlineCb.checked;
|
||||
|
||||
@@ -72,6 +72,41 @@ function animateVerticalSlide(card) {
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function createCardGhost(card, rect, opts) {
|
||||
const options = opts || {};
|
||||
const scale = typeof options.scale === 'number' ? options.scale : 1;
|
||||
const opacity = typeof options.opacity === 'number' ? options.opacity : 1;
|
||||
|
||||
const ghost = card.cloneNode(true);
|
||||
const cs = window.getComputedStyle(card);
|
||||
|
||||
// Give the ghost the same “card” chrome even though it’s attached to <body>
|
||||
Object.assign(ghost.style, {
|
||||
position: 'fixed',
|
||||
left: rect.left + 'px',
|
||||
top: rect.top + 'px',
|
||||
width: rect.width + 'px',
|
||||
height: rect.height + 'px',
|
||||
margin: '0',
|
||||
zIndex: '12000',
|
||||
pointerEvents: 'none',
|
||||
transformOrigin: 'center center',
|
||||
transform: 'scale(' + scale + ')',
|
||||
opacity: String(opacity),
|
||||
|
||||
// pull key visuals from the real card
|
||||
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
||||
borderRadius: cs.borderRadius || '',
|
||||
boxShadow: cs.boxShadow || '',
|
||||
borderColor: cs.borderColor || '',
|
||||
borderWidth: cs.borderWidth || '',
|
||||
borderStyle: cs.borderStyle || '',
|
||||
backdropFilter: cs.backdropFilter || '',
|
||||
});
|
||||
|
||||
return ghost;
|
||||
}
|
||||
|
||||
// -------------------- header (icon+modal) --------------------
|
||||
function saveHeaderOrder() {
|
||||
const host = getHeaderDropArea();
|
||||
@@ -98,7 +133,19 @@ function insertCardInHeader(card) {
|
||||
if (!hidden) {
|
||||
hidden = document.createElement('div');
|
||||
hidden.id = 'hiddenCardsContainer';
|
||||
hidden.style.display = 'none';
|
||||
|
||||
// Park cards off–screen but keep them rendered so modals/layout still work
|
||||
Object.assign(hidden.style, {
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: '0',
|
||||
width: '0',
|
||||
height: '0',
|
||||
overflow: 'visible',
|
||||
pointerEvents: 'none'
|
||||
// **NO** display:none here
|
||||
});
|
||||
|
||||
document.body.appendChild(hidden);
|
||||
}
|
||||
if (card.parentNode?.id !== 'hiddenCardsContainer') hidden.appendChild(card);
|
||||
@@ -177,7 +224,12 @@ function insertCardInHeader(card) {
|
||||
iconButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
isLocked = !isLocked;
|
||||
if (isLocked) showModal(); else hideModal();
|
||||
iconButton.classList.toggle('is-locked', isLocked);
|
||||
if (isLocked) {
|
||||
showModal();
|
||||
} else {
|
||||
hideModal();
|
||||
}
|
||||
});
|
||||
|
||||
host.appendChild(iconButton);
|
||||
@@ -325,6 +377,234 @@ function hideHeaderDockPersistent() {
|
||||
}
|
||||
}
|
||||
|
||||
function animateCardsIntoHeaderAndThen(done) {
|
||||
const sb = getSidebar();
|
||||
const top = getTopZone();
|
||||
const liveCards = [];
|
||||
|
||||
if (sb) liveCards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
if (top) liveCards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
|
||||
if (!liveCards.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot their current positions before we move the real DOM
|
||||
const snapshots = liveCards.map(card => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
return { card, rect };
|
||||
});
|
||||
|
||||
// Show dock so icons exist / have positions
|
||||
showHeaderDockPersistent();
|
||||
|
||||
// Move real cards into header (hidden container + icons)
|
||||
snapshots.forEach(({ card }) => {
|
||||
try { insertCardInHeader(card); } catch {}
|
||||
});
|
||||
|
||||
const ghosts = [];
|
||||
|
||||
snapshots.forEach(({ card, rect }) => {
|
||||
// remember the size for the expand animation later
|
||||
card.dataset.lastWidth = String(rect.width);
|
||||
card.dataset.lastHeight = String(rect.height);
|
||||
|
||||
const iconBtn = card.headerIconButton;
|
||||
if (!iconBtn) return;
|
||||
|
||||
const iconRect = iconBtn.getBoundingClientRect();
|
||||
|
||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
|
||||
ghost.id = card.id + '-ghost-collapse';
|
||||
ghost.classList.add('card-collapse-ghost');
|
||||
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||
});
|
||||
|
||||
if (!ghosts.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
ghosts.forEach(({ ghost, from, to }) => {
|
||||
const fromCx = from.left + from.width / 2;
|
||||
const fromCy = from.top + from.height / 2;
|
||||
const toCx = to.left + to.width / 2;
|
||||
const toCy = to.top + to.height / 2;
|
||||
|
||||
const dx = toCx - fromCx;
|
||||
const dy = toCy - fromCy;
|
||||
|
||||
const rawScale = to.width / from.width;
|
||||
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
|
||||
|
||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||
ghost.style.opacity = '0';
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||
done();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function resolveTargetZoneForExpand(cardId) {
|
||||
const layout = readLayout();
|
||||
const saved = layout[cardId];
|
||||
const isUpload = (cardId === 'uploadCard');
|
||||
|
||||
// 🔒 If the user explicitly pinned this card to the HEADER,
|
||||
// it should remain a header-only icon and NEVER fly out.
|
||||
if (saved === ZONES.HEADER) {
|
||||
return null; // caller will skip animation + placement
|
||||
}
|
||||
|
||||
let zone = saved || null;
|
||||
|
||||
// No saved zone yet: mirror applyUserLayoutOrDefault defaults
|
||||
if (!zone) {
|
||||
if (isSmallScreen()) {
|
||||
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
} else {
|
||||
zone = ZONES.SIDEBAR;
|
||||
}
|
||||
}
|
||||
|
||||
// On small screens, anything targeting SIDEBAR gets lifted into the top cols
|
||||
if (isSmallScreen() && zone === ZONES.SIDEBAR) {
|
||||
zone = isUpload ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
}
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
function getZoneHost(zoneId) {
|
||||
switch (zoneId) {
|
||||
case ZONES.SIDEBAR: return getSidebar();
|
||||
case ZONES.TOP_LEFT: return getLeftCol();
|
||||
case ZONES.TOP_RIGHT: return getRightCol();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Animate cards "flying out" of header icons back into their zones.
|
||||
function animateCardsOutOfHeaderThen(done) {
|
||||
const header = getHeaderDropArea();
|
||||
if (!header) { done(); return; }
|
||||
|
||||
const cards = getCards().filter(c => c && c.headerIconButton);
|
||||
if (!cards.length) { done(); return; }
|
||||
|
||||
// Make sure target containers are visible so their rects are non-zero.
|
||||
const sb = getSidebar();
|
||||
const top = getTopZone();
|
||||
if (sb) sb.style.display = '';
|
||||
if (top) top.style.display = '';
|
||||
|
||||
const SAFE_TOP = 16; // minimum distance from top of viewport
|
||||
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
|
||||
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
|
||||
|
||||
const ghosts = [];
|
||||
|
||||
cards.forEach(card => {
|
||||
const iconBtn = card.headerIconButton;
|
||||
if (!iconBtn) return;
|
||||
|
||||
const zoneId = resolveTargetZoneForExpand(card.id);
|
||||
if (!zoneId) return; // header-only card, stays as icon
|
||||
|
||||
const host = getZoneHost(zoneId);
|
||||
if (!host) return;
|
||||
|
||||
const iconRect = iconBtn.getBoundingClientRect();
|
||||
const zoneRect = host.getBoundingClientRect();
|
||||
if (!zoneRect.width) return;
|
||||
|
||||
// Where the ghost "comes from" (near the icon)
|
||||
const fromCx = iconRect.left + iconRect.width / 2;
|
||||
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
|
||||
|
||||
// Where we want it to "land" (roughly center of the zone, a bit down)
|
||||
let toCx = zoneRect.left + zoneRect.width / 2;
|
||||
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||
|
||||
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
|
||||
if (zoneId === ZONES.SIDEBAR) {
|
||||
if (card.id === 'uploadCard') {
|
||||
toCy -= 48; // a bit higher
|
||||
} else if (card.id === 'folderManagementCard') {
|
||||
toCy += 48; // a bit lower
|
||||
}
|
||||
}
|
||||
|
||||
// Try to match the real card size we captured during collapse
|
||||
const savedW = parseFloat(card.dataset.lastWidth || '');
|
||||
const savedH = parseFloat(card.dataset.lastHeight || '');
|
||||
const targetWidth = !Number.isNaN(savedW)
|
||||
? savedW
|
||||
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
||||
|
||||
// Make sure the top of the ghost never goes above SAFE_TOP
|
||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||
|
||||
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
|
||||
const ghostRect = {
|
||||
left: fromCx - targetWidth / 2,
|
||||
top: startTop,
|
||||
width: targetWidth,
|
||||
height: targetHeight
|
||||
};
|
||||
|
||||
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
|
||||
ghost.id = card.id + '-ghost-expand';
|
||||
ghost.classList.add('card-expand-ghost');
|
||||
|
||||
// Override transform/transition for our flight animation
|
||||
ghost.style.transform = 'translate(0,0) scale(0.7)';
|
||||
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({
|
||||
ghost,
|
||||
from: { cx: fromCx, cy: fromCy },
|
||||
to: { cx: toCx, cy: toCy },
|
||||
zoneId
|
||||
});
|
||||
});
|
||||
|
||||
if (!ghosts.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off the flight on the next frame
|
||||
requestAnimationFrame(() => {
|
||||
ghosts.forEach(({ ghost, from, to }) => {
|
||||
const dx = to.cx - from.cx;
|
||||
const dy = to.cy - from.cy;
|
||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(1)`;
|
||||
ghost.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up ghosts and then do real layout restore
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => {
|
||||
try { ghost.remove(); } catch {}
|
||||
});
|
||||
done();
|
||||
}, 280); // just over the 0.25s transition
|
||||
}
|
||||
|
||||
// -------------------- zones toggle (collapse to header) --------------------
|
||||
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
||||
|
||||
@@ -340,30 +620,73 @@ function applyCollapsedBodyClass() {
|
||||
}
|
||||
|
||||
function setZonesCollapsed(collapsed) {
|
||||
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
|
||||
const currently = isZonesCollapsed();
|
||||
if (collapsed === currently) return;
|
||||
|
||||
if (collapsed) {
|
||||
// Move ALL cards to header icons (transient) regardless of where they were.
|
||||
getCards().forEach(insertCardInHeader);
|
||||
showHeaderDockPersistent();
|
||||
const sb = getSidebar();
|
||||
if (sb) sb.style.display = 'none';
|
||||
// ---- COLLAPSE: immediately expand file area, then animate cards up into header ----
|
||||
localStorage.setItem('zonesCollapsed', '1');
|
||||
|
||||
// File list area expands right away (no delay)
|
||||
applyCollapsedBodyClass();
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: true } })
|
||||
);
|
||||
|
||||
try {
|
||||
animateCardsIntoHeaderAndThen(() => {
|
||||
const sb = getSidebar();
|
||||
if (sb) sb.style.display = 'none';
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
showHeaderDockPersistent();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[zones] collapse animation failed, collapsing instantly', e);
|
||||
// Fallback: old instant behavior
|
||||
getCards().forEach(insertCardInHeader);
|
||||
showHeaderDockPersistent();
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
}
|
||||
} else {
|
||||
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
|
||||
applyUserLayoutOrDefault();
|
||||
loadHeaderOrder();
|
||||
hideHeaderDockPersistent();
|
||||
// ---- EXPAND: immediately shrink file area, then animate cards out of header ----
|
||||
localStorage.setItem('zonesCollapsed', '0');
|
||||
|
||||
// File list shrinks back right away
|
||||
applyCollapsedBodyClass();
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('zones:collapsed-changed', { detail: { collapsed: false } })
|
||||
);
|
||||
|
||||
try {
|
||||
animateCardsOutOfHeaderThen(() => {
|
||||
// After ghosts land, put the REAL cards back into their proper zones
|
||||
applyUserLayoutOrDefault();
|
||||
loadHeaderOrder();
|
||||
hideHeaderDockPersistent();
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[zones] expand animation failed, expanding instantly', e);
|
||||
// Fallback: just restore layout
|
||||
applyUserLayoutOrDefault();
|
||||
loadHeaderOrder();
|
||||
hideHeaderDockPersistent();
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
applyCollapsedBodyClass();
|
||||
|
||||
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
|
||||
}
|
||||
|
||||
|
||||
function getHeaderHost() {
|
||||
let host = document.querySelector('.header-container .header-left');
|
||||
if (!host) host = document.querySelector('.header-container');
|
||||
@@ -371,6 +694,36 @@ function getHeaderHost() {
|
||||
return host || document.body;
|
||||
}
|
||||
|
||||
function animateZonesCollapseAndThen(done) {
|
||||
const sb = getSidebar();
|
||||
const top = getTopZone();
|
||||
const cards = [];
|
||||
|
||||
if (sb) cards.push(...sb.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
if (top) cards.push(...top.querySelectorAll('#uploadCard, #folderManagementCard'));
|
||||
|
||||
if (!cards.length) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// quick "rise away" animation
|
||||
cards.forEach(card => {
|
||||
card.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
|
||||
card.style.transform = 'translateY(-10px)';
|
||||
card.style.opacity = '0';
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
cards.forEach(card => {
|
||||
card.style.transition = '';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
});
|
||||
done();
|
||||
}, 190);
|
||||
}
|
||||
|
||||
function ensureZonesToggle() {
|
||||
const host = getHeaderHost();
|
||||
if (!host) return;
|
||||
@@ -605,7 +958,8 @@ function makeCardDraggable(card) {
|
||||
const sb = getSidebar();
|
||||
if (sb) {
|
||||
sb.classList.add('active', 'highlight');
|
||||
if (!isZonesCollapsed()) sb.style.display = 'block';
|
||||
// Always show sidebar as a drop target while dragging
|
||||
sb.style.display = 'block';
|
||||
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
|
||||
}
|
||||
|
||||
|
||||
@@ -934,7 +934,7 @@ export async function loadFileList(folderParam) {
|
||||
if (!summaryElem) {
|
||||
summaryElem = document.createElement("div");
|
||||
summaryElem.id = "fileSummary";
|
||||
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
|
||||
summaryElem.style.cssText = "float:right; margin:0 30px 0 auto; font-size:0.9em;";
|
||||
actionsContainer.appendChild(summaryElem);
|
||||
}
|
||||
summaryElem.style.display = "block";
|
||||
|
||||
@@ -239,7 +239,26 @@ function ensureMediaModal() {
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
// Ensure a container for tags next to the title (created once)
|
||||
(function ensureTitleTagsContainer() {
|
||||
const titleRow = overlay.querySelector('.media-title');
|
||||
if (!titleRow) return;
|
||||
|
||||
let tagsEl = overlay.querySelector('.title-tags');
|
||||
if (!tagsEl) {
|
||||
tagsEl = document.createElement('div');
|
||||
tagsEl.className = 'title-tags';
|
||||
Object.assign(tagsEl.style, {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
marginLeft: '6px',
|
||||
maxHeight: '32px',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
titleRow.appendChild(tagsEl);
|
||||
}
|
||||
})();
|
||||
// theme the close “×” for visibility + hover rules that match your site:
|
||||
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||
function paintCloseBase() {
|
||||
@@ -272,17 +291,46 @@ function ensureMediaModal() {
|
||||
function setTitle(overlay, name) {
|
||||
const textEl = overlay.querySelector('.title-text');
|
||||
const iconEl = overlay.querySelector('.title-icon');
|
||||
const tagsEl = overlay.querySelector('.title-tags');
|
||||
|
||||
// File name + tooltip
|
||||
if (textEl) {
|
||||
textEl.textContent = name || '';
|
||||
textEl.setAttribute('title', name || '');
|
||||
}
|
||||
|
||||
// File type icon
|
||||
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';
|
||||
}
|
||||
|
||||
// Tag badges next to the title
|
||||
if (tagsEl) {
|
||||
tagsEl.innerHTML = '';
|
||||
|
||||
let fileObj = null;
|
||||
if (Array.isArray(fileData)) {
|
||||
fileObj = fileData.find(f => f.name === name);
|
||||
}
|
||||
|
||||
if (fileObj && Array.isArray(fileObj.tags) && fileObj.tags.length) {
|
||||
fileObj.tags.forEach(tag => {
|
||||
const badge = document.createElement('span');
|
||||
badge.textContent = tag.name;
|
||||
badge.style.backgroundColor = tag.color || '#444';
|
||||
badge.style.color = '#fff';
|
||||
badge.style.padding = '2px 6px';
|
||||
badge.style.borderRadius = '999px';
|
||||
badge.style.fontSize = '0.75rem';
|
||||
badge.style.lineHeight = '1.2';
|
||||
badge.style.whiteSpace = 'nowrap';
|
||||
tagsEl.appendChild(badge);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
|
||||
@@ -10,6 +10,29 @@ import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
|
||||
import { loadCsrfToken } from './appCore.js?v={{APP_QVER}}';
|
||||
|
||||
|
||||
function detachFolderModalsToBody() {
|
||||
const ids = [
|
||||
'createFolderModal',
|
||||
'deleteFolderModal',
|
||||
'moveFolderModal',
|
||||
'renameFolderModal',
|
||||
];
|
||||
|
||||
ids.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
if (el.parentNode !== document.body) {
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
if (!el.style.zIndex) {
|
||||
el.style.zIndex = '13000';
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', detachFolderModalsToBody);
|
||||
|
||||
const PAGE_LIMIT = 100;
|
||||
|
||||
/* ----------------------
|
||||
@@ -1711,6 +1734,7 @@ function bindFolderManagerContextMenu() {
|
||||
Rename / Delete / Create hooks
|
||||
----------------------*/
|
||||
export function openRenameFolderModal() {
|
||||
detachFolderModalsToBody();
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to rename."); return; }
|
||||
const parts = selectedFolder.split("/");
|
||||
@@ -1781,6 +1805,7 @@ if (submitRename) submitRename.addEventListener("click", function (event) {
|
||||
});
|
||||
|
||||
export function openDeleteFolderModal() {
|
||||
detachFolderModalsToBody();
|
||||
const selectedFolder = window.currentFolder || "root";
|
||||
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to delete."); return; }
|
||||
const msgEl = document.getElementById("deleteFolderMessage");
|
||||
@@ -1823,6 +1848,7 @@ if (confirmDelete) confirmDelete.addEventListener("click", async function () {
|
||||
|
||||
const createBtn = document.getElementById("createFolderBtn");
|
||||
if (createBtn) createBtn.addEventListener("click", function () {
|
||||
detachFolderModalsToBody();
|
||||
const modal = document.getElementById("createFolderModal");
|
||||
const input = document.getElementById("newFolderName");
|
||||
if (modal) modal.style.display = "block";
|
||||
@@ -1885,6 +1911,7 @@ if (submitCreate) submitCreate.addEventListener("click", async () => {
|
||||
Move (modal) + Color carry + State migration as well
|
||||
----------------------*/
|
||||
export function openMoveFolderUI(sourceFolder) {
|
||||
detachFolderModalsToBody();
|
||||
const modal = document.getElementById('moveFolderModal');
|
||||
const targetSel = document.getElementById('moveFolderTarget');
|
||||
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;
|
||||
|
||||
@@ -337,7 +337,8 @@ const translations = {
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"created": "Created",
|
||||
"owner": "Owner"
|
||||
"owner": "Owner",
|
||||
"hide_header_zoom_controls": "Hide header zoom controls"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -39,6 +39,70 @@ function saveResumableDraftsAll(all) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single file-picker trigger guard (prevents multiple OS dialogs) ---
|
||||
let _lastFilePickerOpen = 0;
|
||||
|
||||
function triggerFilePickerOnce() {
|
||||
const now = Date.now();
|
||||
// ignore any extra calls within 400ms of the last open
|
||||
if (now - _lastFilePickerOpen < 400) return;
|
||||
_lastFilePickerOpen = now;
|
||||
|
||||
const fi = document.getElementById('file');
|
||||
if (fi) {
|
||||
fi.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the "Choose files" button so it always uses the guarded trigger
|
||||
function wireChooseButton() {
|
||||
const btn = document.getElementById('customChooseBtn');
|
||||
if (!btn || btn.__uploadBound) return;
|
||||
btn.__uploadBound = true;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // don't let it bubble to the drop-area click handler
|
||||
triggerFilePickerOnce();
|
||||
});
|
||||
}
|
||||
|
||||
function wireFileInputChange(fileInput) {
|
||||
if (!fileInput || fileInput.__uploadChangeBound) return;
|
||||
fileInput.__uploadChangeBound = true;
|
||||
|
||||
// For file picker, remove directory attributes so only files can be chosen.
|
||||
fileInput.removeAttribute("webkitdirectory");
|
||||
fileInput.removeAttribute("mozdirectory");
|
||||
fileInput.removeAttribute("directory");
|
||||
fileInput.setAttribute("multiple", "");
|
||||
|
||||
fileInput.addEventListener("change", async function () {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
if (!files.length) return;
|
||||
|
||||
if (useResumable) {
|
||||
// New resumable batch: reset selectedFiles so the count is correct
|
||||
window.selectedFiles = [];
|
||||
_currentResumableIds.clear(); // <--- add this
|
||||
|
||||
// Ensure the lib/instance exists
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (resumableInstance) {
|
||||
for (const f of files) {
|
||||
resumableInstance.addFile(f);
|
||||
}
|
||||
} else {
|
||||
// If Resumable failed to load, fall back to XHR
|
||||
processFiles(files);
|
||||
}
|
||||
} else {
|
||||
// Non-resumable: normal XHR path, drag-and-drop etc.
|
||||
processFiles(files);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getUserDraftContext() {
|
||||
const all = loadResumableDraftsAll();
|
||||
const userKey = getCurrentUserKey();
|
||||
@@ -253,23 +317,35 @@ function getFilesFromDataTransferItems(items) {
|
||||
|
||||
function setDropAreaDefault() {
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
if (dropArea) {
|
||||
dropArea.innerHTML = `
|
||||
<div id="uploadInstruction" class="upload-instruction">
|
||||
${t("upload_instruction")}
|
||||
if (!dropArea) return;
|
||||
|
||||
dropArea.innerHTML = `
|
||||
<div id="uploadInstruction" class="upload-instruction">
|
||||
${t("upload_instruction")}
|
||||
</div>
|
||||
<div id="uploadFileRow" class="upload-file-row">
|
||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
||||
</div>
|
||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
||||
<div id="fileInfoContainer" class="file-info-container">
|
||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
||||
</div>
|
||||
<div id="uploadFileRow" class="upload-file-row">
|
||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
||||
</div>
|
||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
||||
<div id="fileInfoContainer" class="file-info-container">
|
||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- File input for file picker (files only) -->
|
||||
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" />
|
||||
`;
|
||||
}
|
||||
</div>
|
||||
<!-- File input for file picker (files only) -->
|
||||
<input
|
||||
type="file"
|
||||
id="file"
|
||||
name="file[]"
|
||||
class="form-control-file"
|
||||
multiple
|
||||
style="opacity:0; position:absolute; width:1px; height:1px;"
|
||||
/>
|
||||
`;
|
||||
|
||||
// After rebuilding markup, re-wire controls:
|
||||
const fileInput = dropArea.querySelector('#file');
|
||||
wireFileInputChange(fileInput);
|
||||
wireChooseButton();
|
||||
}
|
||||
|
||||
function adjustFolderHelpExpansion() {
|
||||
@@ -608,6 +684,7 @@ const useResumable = true;
|
||||
let resumableInstance = null;
|
||||
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||
let _resumableReady = false;
|
||||
let _currentResumableIds = new Set();
|
||||
|
||||
// Make init async-safe; it resolves when Resumable is constructed
|
||||
async function initResumableUpload() {
|
||||
@@ -644,18 +721,20 @@ async function initResumableUpload() {
|
||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||
}
|
||||
|
||||
const fileInput = document.getElementById("file");
|
||||
if (fileInput) {
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
resumableInstance.addFile(fileInput.files[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
resumableInstance.on("fileAdded", function (file) {
|
||||
|
||||
// Build a stable per-file key
|
||||
const id =
|
||||
file.uniqueIdentifier ||
|
||||
((file.fileName || file.name || '') + ':' + (file.size || 0));
|
||||
|
||||
// If we've already seen this id in the current batch, skip wiring it again
|
||||
if (_currentResumableIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
_currentResumableIds.add(id);
|
||||
|
||||
// Initialize custom paused flag
|
||||
file.paused = false;
|
||||
file.uploadIndex = file.uniqueIdentifier;
|
||||
@@ -663,13 +742,13 @@ async function initResumableUpload() {
|
||||
window.selectedFiles = [];
|
||||
}
|
||||
window.selectedFiles.push(file);
|
||||
|
||||
|
||||
// Track as in-progress draft at 0%
|
||||
upsertResumableDraft(file, 0);
|
||||
showResumableDraftBanner();
|
||||
|
||||
|
||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||
|
||||
|
||||
// Check if a wrapper already exists; if not, create one with a UL inside.
|
||||
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
||||
let list;
|
||||
@@ -685,7 +764,7 @@ async function initResumableUpload() {
|
||||
} else {
|
||||
list = listWrapper.querySelector("ul.upload-progress-list");
|
||||
}
|
||||
|
||||
|
||||
const li = createFileEntry(file);
|
||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||
list.appendChild(li);
|
||||
@@ -1119,9 +1198,17 @@ function submitFiles(allFiles) {
|
||||
Main initUpload: Sets up file input, drop area, and form submission.
|
||||
----------------------------------------------------- */
|
||||
function initUpload() {
|
||||
const fileInput = document.getElementById("file");
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
|
||||
window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
|
||||
|
||||
const uploadForm = document.getElementById("uploadFileForm");
|
||||
const dropArea = document.getElementById("uploadDropArea");
|
||||
|
||||
// Always (re)build the inner markup and wire the Choose button
|
||||
setDropAreaDefault();
|
||||
wireChooseButton();
|
||||
|
||||
const fileInput = document.getElementById("file");
|
||||
|
||||
// For file picker, remove directory attributes so only files can be chosen.
|
||||
if (fileInput) {
|
||||
@@ -1131,67 +1218,50 @@ function initUpload() {
|
||||
fileInput.setAttribute("multiple", "");
|
||||
}
|
||||
|
||||
setDropAreaDefault();
|
||||
|
||||
// Drag–and–drop events (for folder uploads) use original processing.
|
||||
if (dropArea) {
|
||||
if (dropArea && !dropArea.__uploadBound) {
|
||||
dropArea.__uploadBound = true;
|
||||
dropArea.classList.add("upload-drop-area");
|
||||
|
||||
dropArea.addEventListener("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
|
||||
});
|
||||
|
||||
dropArea.addEventListener("dragleave", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = "";
|
||||
});
|
||||
|
||||
dropArea.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = "";
|
||||
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||
window.__pendingDropData = null;
|
||||
if (dt.items && dt.items.length > 0) {
|
||||
window.__pendingDropData = null;
|
||||
if (dt && dt.items && dt.items.length > 0) {
|
||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
}
|
||||
});
|
||||
} else if (dt.files && dt.files.length > 0) {
|
||||
} else if (dt && dt.files && dt.files.length > 0) {
|
||||
processFiles(dt.files);
|
||||
}
|
||||
});
|
||||
// Clicking drop area triggers file input.
|
||||
dropArea.addEventListener("click", function () {
|
||||
if (fileInput) fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", async function () {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
if (!files.length) return;
|
||||
|
||||
if (useResumable) {
|
||||
// New resumable batch: reset selectedFiles so the count is correct
|
||||
window.selectedFiles = [];
|
||||
|
||||
// Ensure the lib/instance exists
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (resumableInstance) {
|
||||
for (const f of files) {
|
||||
resumableInstance.addFile(f);
|
||||
}
|
||||
} else {
|
||||
// If Resumable failed to load, fall back to XHR
|
||||
processFiles(files);
|
||||
}
|
||||
} else {
|
||||
// Non-resumable: normal XHR path, drag-and-drop etc.
|
||||
processFiles(files);
|
||||
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
|
||||
dropArea.addEventListener("click", function (e) {
|
||||
// If the click originated from the "Choose files" button or the file input itself,
|
||||
// let their handlers deal with it.
|
||||
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
|
||||
return;
|
||||
}
|
||||
triggerFilePickerOnce();
|
||||
});
|
||||
}
|
||||
|
||||
if (uploadForm) {
|
||||
if (uploadForm && !uploadForm.__uploadSubmitBound) {
|
||||
uploadForm.__uploadSubmitBound = true;
|
||||
uploadForm.addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1205,7 +1275,6 @@ function initUpload() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have any files queued in Resumable, treat this as a resumable upload.
|
||||
const hasResumableFiles =
|
||||
useResumable &&
|
||||
resumableInstance &&
|
||||
@@ -1215,7 +1284,6 @@ function initUpload() {
|
||||
if (hasResumableFiles) {
|
||||
if (!_resumableReady) await initResumableUpload();
|
||||
if (resumableInstance) {
|
||||
// Keep folder/token fresh
|
||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||
@@ -1223,11 +1291,9 @@ function initUpload() {
|
||||
resumableInstance.upload();
|
||||
showToast("Resumable upload started...");
|
||||
} else {
|
||||
// Hard fallback – should basically never happen
|
||||
submitFiles(files);
|
||||
}
|
||||
} else {
|
||||
// No resumable queue → drag-and-drop / paste / simple input → XHR path
|
||||
submitFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v2.0.2';
|
||||
window.APP_VERSION = 'v2.1.0';
|
||||
|
||||
92
public/js/zoom.js
Normal file
92
public/js/zoom.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// /js/zoom.js
|
||||
(function () {
|
||||
const MIN_PERCENT = 60; // 60%
|
||||
const MAX_PERCENT = 140; // 140%
|
||||
const STEP_PERCENT = 5; // 5%
|
||||
const STORAGE_KEY = 'filerise.appZoomPercent';
|
||||
|
||||
function clampPercent(p) {
|
||||
return Math.max(MIN_PERCENT, Math.min(MAX_PERCENT, p));
|
||||
}
|
||||
|
||||
function updateDisplay(p) {
|
||||
const el = document.getElementById('zoomDisplay');
|
||||
if (el) el.textContent = `${p}%`;
|
||||
}
|
||||
|
||||
function applyZoomPercent(p) {
|
||||
const clamped = clampPercent(p);
|
||||
const scale = clamped / 100;
|
||||
|
||||
document.documentElement.style.setProperty('--app-zoom', String(scale));
|
||||
try { localStorage.setItem(STORAGE_KEY, String(clamped)); } catch {}
|
||||
|
||||
updateDisplay(clamped);
|
||||
return clamped;
|
||||
}
|
||||
|
||||
function getCurrentPercent() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const n = parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n > 0) return clampPercent(n);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const v = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--app-zoom')
|
||||
.trim();
|
||||
const n = parseFloat(v);
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
return clampPercent(Math.round(n * 100));
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Public-ish API (percent-based)
|
||||
window.fileriseZoom = {
|
||||
in() {
|
||||
const next = getCurrentPercent() + STEP_PERCENT;
|
||||
return applyZoomPercent(next);
|
||||
},
|
||||
out() {
|
||||
const next = getCurrentPercent() - STEP_PERCENT;
|
||||
return applyZoomPercent(next);
|
||||
},
|
||||
reset() {
|
||||
return applyZoomPercent(100);
|
||||
},
|
||||
setPercent(p) {
|
||||
return applyZoomPercent(p);
|
||||
},
|
||||
currentPercent: getCurrentPercent
|
||||
};
|
||||
|
||||
function initZoomUI() {
|
||||
// bind buttons
|
||||
const btns = document.querySelectorAll('.zoom-btn[data-zoom]');
|
||||
btns.forEach(btn => {
|
||||
if (btn.__zoomBound) return;
|
||||
btn.__zoomBound = true;
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const mode = btn.dataset.zoom;
|
||||
if (mode === 'in') window.fileriseZoom.in();
|
||||
else if (mode === 'out') window.fileriseZoom.out();
|
||||
else if (mode === 'reset') window.fileriseZoom.reset();
|
||||
});
|
||||
});
|
||||
|
||||
// apply initial zoom + update display
|
||||
const initial = getCurrentPercent();
|
||||
applyZoomPercent(initial);
|
||||
}
|
||||
|
||||
// Run immediately if DOM is ready, otherwise wait
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initZoomUI, { once: true });
|
||||
} else {
|
||||
initZoomUI();
|
||||
}
|
||||
})();
|
||||
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
||||
|
||||
# === Update FileRise to v2.0.2 (safe rsync) ===
|
||||
set -Eeuo pipefail
|
||||
|
||||
VER="v1.9.1"
|
||||
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
||||
VER="v2.0.2"
|
||||
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||
WEBROOT="/var/www"
|
||||
TMP="/tmp/filerise-update"
|
||||
|
||||
# 0) (optional) quick backup of critical bits
|
||||
# 0) quick backup of critical bits (include Pro/demo stuff too)
|
||||
stamp="$(date +%F-%H%M)"
|
||||
mkdir -p /root/backups
|
||||
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
||||
public/.htaccess config users uploads metadata || true
|
||||
public/.htaccess \
|
||||
config \
|
||||
users \
|
||||
uploads \
|
||||
metadata \
|
||||
filerise-bundles \
|
||||
filerise-config \
|
||||
filerise-site || true
|
||||
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||
|
||||
# 1) Fetch the release zip
|
||||
@@ -29,12 +34,15 @@ STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" |
|
||||
# 3) Sync code into /var/www
|
||||
# - keep public/.htaccess
|
||||
# - keep data dirs and current config.php
|
||||
# - DO NOT touch filerise-site / bundles / demo config
|
||||
rsync -a --delete \
|
||||
--exclude='public/.htaccess' \
|
||||
--exclude='uploads/***' \
|
||||
--exclude='users/***' \
|
||||
--exclude='metadata/***' \
|
||||
--exclude='config/config.php' \
|
||||
--exclude='filerise-bundles/***' \
|
||||
--exclude='filerise-config/***' \
|
||||
--exclude='filerise-site/***' \
|
||||
--exclude='.github/***' \
|
||||
--exclude='docker-compose.yml' \
|
||||
"$STAGE_DIR"/ "$WEBROOT"/
|
||||
@@ -42,13 +50,23 @@ rsync -a --delete \
|
||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# 5) (optional) Composer autoload optimization if composer is available
|
||||
# 5) Composer autoload optimization if composer is available
|
||||
if command -v composer >/dev/null 2>&1; then
|
||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||
composer install --no-dev --optimize-autoloader
|
||||
fi
|
||||
|
||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||
# 6) Force demo mode ON in config/config.php
|
||||
CFG_FILE="$WEBROOT/config/config.php"
|
||||
if [[ -f "$CFG_FILE" ]]; then
|
||||
# Make a one-time backup of config.php before editing
|
||||
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
|
||||
|
||||
# Flip FR_DEMO_MODE to true if it exists as false
|
||||
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
|
||||
fi
|
||||
|
||||
# 7) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||
systemctl reload apache2 2>/dev/null || true
|
||||
|
||||
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
||||
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, and demo site preserved."
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// src/controllers/AdminController.php
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
@@ -241,7 +242,7 @@ public function setLicense(): void
|
||||
// Store license + updatedAt in JSON file
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
// Fallback if constant not defined for some reason
|
||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
@@ -566,10 +567,11 @@ public function installProBundle(): void
|
||||
|
||||
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
||||
|
||||
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
|
||||
// Where Pro bundle code lives (defaults to USERS_DIR . '/pro')
|
||||
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
||||
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
|
||||
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
|
||||
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
|
||||
: (rtrim(USERS_DIR, "/\\") . DIRECTORY_SEPARATOR . 'pro');
|
||||
|
||||
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
|
||||
$proDocsDir = $bundleRoot;
|
||||
|
||||
@@ -327,6 +327,14 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'error' => 'TOTP settings are disabled for the demo account.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
||||
$result = UserModel::updateUserPanel($username, $totp_enabled);
|
||||
echo json_encode($result);
|
||||
@@ -348,6 +356,14 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'error' => 'TOTP settings are disabled for the demo account.'
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$result = UserModel::disableTOTPSecret($username);
|
||||
if ($result) {
|
||||
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
||||
@@ -412,6 +428,16 @@ class UserController
|
||||
}
|
||||
|
||||
$userId = $_SESSION['username'];
|
||||
|
||||
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $userId === 'demo') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => 'TOTP settings are disabled for the demo account.',
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!preg_match(REGEX_USER, $userId)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||
@@ -438,6 +464,14 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
$username = $_SESSION['username'] ?? ($_SESSION['pending_login_user'] ?? '');
|
||||
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE && $username === 'demo') {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'TOTP setup is disabled for the demo account.']);
|
||||
}
|
||||
|
||||
|
||||
self::requireCsrf();
|
||||
|
||||
// Fix: if username not present (pending flow), fall back to pending_login_user
|
||||
|
||||
Reference in New Issue
Block a user