Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot]
47b4cc4489 chore(release): set APP_VERSION to v2.1.0 [skip ci] 2025-11-27 07:04:40 +00:00
Ryan
3f0d1780a1 release(v2.1.0): add header zoom controls, preview tags & modal/dock polish 2025-11-27 02:04:29 -05:00
github-actions[bot]
3b62e27c7c chore(release): set APP_VERSION to v2.0.4 [skip ci] 2025-11-27 02:42:10 +00:00
Ryan
f967134631 release(v2.0.4): harden sessions and align Pro paths with USERS_DIR 2025-11-26 21:41:59 -05:00
Ryan
6b93d65d6a docs(readme): add Heise / iX press section 2025-11-26 18:36:05 -05:00
github-actions[bot]
1856325b1f chore(release): set APP_VERSION to v2.0.3 [skip ci] 2025-11-26 08:58:36 +00:00
Ryan
9e6da52691 release(v2.0.3): polish uploads, header dock, and panel fly animations 2025-11-26 03:58:25 -05:00
Ryan
959206c91c docs(readme): link install, nginx and FAQ wiki pages 2025-11-23 22:11:28 -05:00
Ryan
837deddec5 docs: add full feature wiki to README 2025-11-23 22:07:06 -05:00
Ryan
2810b97568 chore(demo): update manual sync script and lock TOTP for demo account
- Update scripts/manual-sync.sh to pull v2.0.2, backup extra demo/Pro dirs,
  and safely rsync core code without touching data, bundles, or site overrides
- After sync, automatically flip FR_DEMO_MODE to true in config/config.php
  so the droplet always runs in demo mode
- Block TOTP enable/disable/setup and recovery code generation for the
  demo account when FR_DEMO_MODE is enabled, returning 403 with clear
  JSON errors
2025-11-23 06:43:51 -05:00
18 changed files with 1196 additions and 194 deletions

View File

@@ -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 theyre 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

View File

@@ -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)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
> 💡 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 youll be guided through creating the **initial admin user**.
**More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
**More Docker options (Unraid, dockercompose, 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 wellknown client and server libraries (Bootstrap, C
All thirdparty 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)

View File

@@ -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')) {

View File

@@ -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);
}

View File

@@ -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;">

View File

@@ -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)
========================= */

View File

@@ -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);
// youll 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;

View File

@@ -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 its 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 offscreen 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
}

View File

@@ -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";

View File

@@ -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

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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();
// Draganddrop 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);
}
});

View File

@@ -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
View 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();
}
})();

View File

@@ -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 (dont fail the whole script if reload isnt 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 (dont fail the whole script if reload isnt 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."

View File

@@ -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;

View File

@@ -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