Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da14d204a6 | ||
|
|
2a87002e1f | ||
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d | ||
|
|
997e5067d3 | ||
|
|
1c0ac50048 | ||
|
|
8fc716387b | ||
|
|
fe3a58924b | ||
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 |
154
CHANGELOG.md
154
CHANGELOG.md
@@ -1,5 +1,159 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/30/2025 (v2.2.4)
|
||||||
|
|
||||||
|
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
||||||
|
|
||||||
|
- Prevented a JS crash when the ONLYOFFICE JWT field isn’t present by always initializing payload.onlyoffice before touching jwtSecret.
|
||||||
|
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isn’t locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/29/2025 (v2.2.3)
|
||||||
|
|
||||||
|
fix(preview): harden SVG handling and normalize mime type
|
||||||
|
release(v2.2.3): round gallery card corners in file grid
|
||||||
|
|
||||||
|
- Stop treating SVGs as inline-previewable images in file list and preview modal
|
||||||
|
- Show a clear “SVG preview disabled for security reasons” message instead
|
||||||
|
- Keep SVGs downloadable via /api/file/download.php with proper image/svg+xml MIME
|
||||||
|
- Add i18n key for svg_preview_disabled
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/29/2025 (v2.2.2)
|
||||||
|
|
||||||
|
release(v2.2.2): feat(folders): show inline folder stats & dates
|
||||||
|
|
||||||
|
- Extend FolderModel::countVisible() to track earliest and latest file mtimes
|
||||||
|
- Format folder created/modified timestamps via DATE_TIME_FORMAT on the backend
|
||||||
|
- Add a small folder stats cache in fileListView.js to reuse isEmpty.php responses
|
||||||
|
- Use shared fetchFolderStats() for both folder strip icons and inline folder rows
|
||||||
|
- Show per-folder item counts, total size, and created/modified dates in inline rows
|
||||||
|
- Make size parsing more robust by accepting multiple backend size keys (bytes/sizeBytes/size/totalBytes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/28/2025 (v2.2.1)
|
||||||
|
|
||||||
|
release(v2.2.1): fix(storage-explorer): DOM-safe rendering + docs for disk usage
|
||||||
|
|
||||||
|
- Refactor adminStorage breadcrumb builder to construct DOM nodes instead of using innerHTML.
|
||||||
|
- Rework Storage explorer folder view to render rows via createElement/textContent, avoiding DOM text reinterpreted as HTML.
|
||||||
|
- Keep deep-delete and pagination behavior unchanged while tightening up XSS/CodeQL concerns.
|
||||||
|
- Update README feature list to mention disk usage summary and Pro storage explorer (ncdu-style) alongside user groups and client portals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/28/2025 (v2.2.0)
|
||||||
|
|
||||||
|
release(v2.2.0): add storage explorer + disk usage scanner
|
||||||
|
|
||||||
|
- New **Storage / Disk Usage** admin section with snapshot-based totals and "Top folders by size".
|
||||||
|
- Disk usage CLI scanner (`src/cli/disk_usage_scan.php`) and background rescan endpoint.
|
||||||
|
|
||||||
|
- New **Storage Explorer** (drilldown, top files view, deep-delete actions) available in FileRise Pro v1.2.0.
|
||||||
|
- Non-Pro installsshow a blurred preview of the explorer with upgrade prompts.
|
||||||
|
|
||||||
|
Features
|
||||||
|
|
||||||
|
- Add new "Storage / Disk Usage" section to the Admin Panel with a summary card and "Top folders by size" table.
|
||||||
|
- Introduce CLI disk usage scanner (src/cli/disk_usage_scan.php) that walks UPLOAD_DIR, applies FS::IGNORE()/SKIP(), and persists a structured snapshot to META_DIR/disk_usage.json.
|
||||||
|
- Add /api/admin/diskUsageSummary.php and /api/admin/diskUsageTriggerScan.php endpoints to expose the snapshot and trigger background rescans from the UI.
|
||||||
|
- Wire the new storage section into adminPanel.js with a Rescan button that launches the CLI worker and polls for a fresh snapshot.
|
||||||
|
|
||||||
|
Improvements
|
||||||
|
|
||||||
|
- Storage summary now shows total files, folders, scan duration, and last scan time, plus grouped volume usage across Uploads / Users / Metadata when available.
|
||||||
|
- "Top folders by size" table supports a Pro-only "show more" interaction, but still provides a clean preview in the core edition.
|
||||||
|
- Slight spacing / layout tweaks so the Storage card doesn’t sit flush against the Admin Panel header.
|
||||||
|
|
||||||
|
Pro integration
|
||||||
|
|
||||||
|
- Keep the full ncdu-style "Storage explorer" (per-folder drilldown + global Top files, deep delete toggle, size filters, etc.) behind FR_PRO_ACTIVE via /api/pro/diskUsageChildren.php and /api/pro/diskUsageTopFiles.php.
|
||||||
|
- Pro-only delete-from-explorer actions are exposed via /api/pro/diskUsageDeleteFilePermanent.php and /api/pro/diskUsageDeleteFolderRecursive.php, reusing FileModel and FolderModel admin helpers.
|
||||||
|
- Non-Pro instances still see the explorer teaser, but the table body is blurred and padded with "Pro" badges, clearly advertising the upgrade path without exposing the Pro internals.
|
||||||
|
|
||||||
|
DX / internals
|
||||||
|
|
||||||
|
- Centralize disk usage logic in DiskUsageModel: snapshot builder, summary (including volumes), per-folder children view, and global Top N file listing.
|
||||||
|
- Ensure adminStorage.js is idempotent and safe to re-init when the Admin Panel is reopened (guards on data-* flags, re-wires only once).
|
||||||
|
- Add robust PHP-CLI discovery and log output for the disk usage worker, mirroring the existing zip worker pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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)
|
## Changes 11/23/2025 (v2.0.2)
|
||||||
|
|
||||||
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -19,9 +19,12 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
|
|||||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||||
|
- 📊 **Storage / disk usage summary** – CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
|
||||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
- 🔑 **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.
|
- 👥 **Pro: user groups, client portals & storage explorer** – Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
|
||||||
|
|
||||||
|
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -74,7 +77,10 @@ http://your-server-ip:8080
|
|||||||
|
|
||||||
On first launch you’ll be guided through creating the **initial admin user**.
|
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)
|
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -189,3 +195,8 @@ It bundles a small set of well‑known client and server libraries (Bootstrap, C
|
|||||||
All third‑party code remains under its original licenses.
|
All third‑party code remains under its original licenses.
|
||||||
|
|
||||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
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
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
// config.php
|
// config.php
|
||||||
|
|
||||||
// Define constants
|
// Define constants
|
||||||
@@ -101,10 +102,15 @@ $secure = ($envSecure !== false)
|
|||||||
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
||||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
: (!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
|
$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
|
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||||
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start session idempotently:
|
* Start session idempotently:
|
||||||
@@ -155,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
|||||||
if (!empty($tokens[$token])) {
|
if (!empty($tokens[$token])) {
|
||||||
$data = $tokens[$token];
|
$data = $tokens[$token];
|
||||||
if ($data['expiry'] >= time()) {
|
if ($data['expiry'] >= time()) {
|
||||||
|
// NEW: mitigate session fixation
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
$_SESSION["authenticated"] = true;
|
$_SESSION["authenticated"] = true;
|
||||||
$_SESSION["username"] = $data["username"];
|
$_SESSION["username"] = $data["username"];
|
||||||
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
||||||
@@ -162,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
|||||||
} else {
|
} else {
|
||||||
// expired — clean up
|
// expired — clean up
|
||||||
unset($tokens[$token]);
|
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);
|
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,14 +268,14 @@ if (!defined('FR_PRO_LICENSE')) {
|
|||||||
|
|
||||||
// JSON license file used by AdminController::setLicense()
|
// JSON license file used by AdminController::setLicense()
|
||||||
if (!defined('PRO_LICENSE_FILE')) {
|
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)
|
// Optional plain-text license file (used as fallback in bootstrap)
|
||||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||||
$lf = getenv('FR_PRO_LICENSE_FILE');
|
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||||
if ($lf === false || $lf === '') {
|
if ($lf === false || $lf === '') {
|
||||||
$lf = PROJECT_ROOT . '/users/proLicense.txt';
|
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
|
||||||
}
|
}
|
||||||
define('FR_PRO_LICENSE_FILE', $lf);
|
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
|
// Where Pro code lives by default → inside users volume
|
||||||
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||||
if ($proDir === false || $proDir === '') {
|
if ($proDir === false || $proDir === '') {
|
||||||
$proDir = PROJECT_ROOT . '/users/pro';
|
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
|
||||||
}
|
}
|
||||||
$proDir = rtrim($proDir, "/\\");
|
$proDir = rtrim($proDir, "/\\");
|
||||||
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||||
|
|||||||
41
public/api/admin/diskUsageSummary.php
Normal file
41
public/api/admin/diskUsageSummary.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/diskUsageSummary.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authenticated = !empty($_SESSION['authenticated']);
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if (!$authenticated || !$isAdmin) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional tuning via query params
|
||||||
|
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
|
||||||
|
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
|
||||||
|
http_response_code($summary['ok'] ? 200 : 404);
|
||||||
|
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
102
public/api/admin/diskUsageTriggerScan.php
Normal file
102
public/api/admin/diskUsageTriggerScan.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/diskUsageTriggerScan.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release session lock early so the scanner/other requests aren't blocked
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
|
||||||
|
// Pro-only gate removed so free FileRise can also use the Rescan button.
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
|
||||||
|
if (!$worker || !is_file($worker)) {
|
||||||
|
throw new RuntimeException('disk_usage_scan.php not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a PHP CLI binary that actually works (same idea as zip_worker)
|
||||||
|
$candidates = array_values(array_filter([
|
||||||
|
PHP_BINARY ?: null,
|
||||||
|
'/usr/local/bin/php',
|
||||||
|
'/usr/bin/php',
|
||||||
|
'/bin/php',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$php = null;
|
||||||
|
foreach ($candidates as $bin) {
|
||||||
|
if (!$bin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rc = 1;
|
||||||
|
@exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc);
|
||||||
|
if ($rc === 0) {
|
||||||
|
$php = $bin;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$php) {
|
||||||
|
throw new RuntimeException('No working php CLI found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = rtrim((string)META_DIR, '/\\');
|
||||||
|
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
|
||||||
|
|
||||||
|
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
|
||||||
|
$cmdStr =
|
||||||
|
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
|
||||||
|
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||||
|
|
||||||
|
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||||
|
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'pid' => $pid > 0 ? $pid : null,
|
||||||
|
'message' => 'Disk usage scan started in the background.',
|
||||||
|
'logFile' => $logFile,
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
53
public/api/pro/diskUsageChildren.php
Normal file
53
public/api/pro/diskUsageChildren.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageChildren.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release session lock to avoid blocking parallel requests
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// Pro-only gate: require Pro active AND ProDiskUsage class available
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var array $result */
|
||||||
|
$result = ProDiskUsage::getChildren($folderKey);
|
||||||
|
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal file
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageDeleteFilePermanent.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FileModel.php';
|
||||||
|
|
||||||
|
// Pro-only gate: make sure Pro is really active
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body) || empty($body['name'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = isset($body['folder']) ? (string)$body['folder'] : 'root';
|
||||||
|
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
|
||||||
|
$name = (string)$body['name'];
|
||||||
|
|
||||||
|
$res = FileModel::deleteFilesPermanent($folder, [$name]);
|
||||||
|
if (!empty($res['error'])) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => $res['error']]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'File deleted.']);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('diskUsageDeleteFilePermanent error: '.$e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Internal error']);
|
||||||
|
}
|
||||||
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal file
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageDeleteFolderRecursive.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||||
|
|
||||||
|
// Pro-only gate
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body) || !isset($body['folder'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = (string)$body['folder'];
|
||||||
|
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
|
||||||
|
|
||||||
|
if (strtolower($folder) === 'root') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Cannot deep delete root folder.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = FolderModel::deleteFolderRecursiveAdmin($folder);
|
||||||
|
if (!empty($res['error'])) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => $res['error']]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'Folder deleted.']);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('diskUsageDeleteFolderRecursive error: '.$e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Internal error']);
|
||||||
|
}
|
||||||
51
public/api/pro/diskUsageTopFiles.php
Normal file
51
public/api/pro/diskUsageTopFiles.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageTopFiles.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// Pro-only gate: require Pro active AND ProDiskUsage class
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = ProDiskUsage::getTopFiles($limit);
|
||||||
|
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -228,10 +228,7 @@ body{letter-spacing: 0.2px;
|
|||||||
padding: 9px;}
|
padding: 9px;}
|
||||||
#userDropdownToggle{border-radius: 4px !important;
|
#userDropdownToggle{border-radius: 4px !important;
|
||||||
padding: 6px 10px !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);
|
.header-buttons button:hover{background-color: rgba(122,179,255,.14);
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
color: #fff;}
|
color: #fff;}
|
||||||
@@ -254,6 +251,49 @@ body{letter-spacing: 0.2px;
|
|||||||
justify-content: center;}
|
justify-content: center;}
|
||||||
}
|
}
|
||||||
.header-buttons button i{font-size: 24px;}
|
.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;
|
.dark-mode-toggle{background-color: #424242;
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -272,11 +312,6 @@ body{letter-spacing: 0.2px;
|
|||||||
.dark-mode .folder-help-tooltip{background-color: #333 !important;
|
.dark-mode .folder-help-tooltip{background-color: #333 !important;
|
||||||
color: #eee !important;
|
color: #eee !important;
|
||||||
border: 1px solid #555 !important;}
|
border: 1px solid #555 !important;}
|
||||||
#folderHelpBtn i.material-icons.folder-help-icon{-webkit-text-fill-color: orange !important;
|
|
||||||
color: inherit !important;
|
|
||||||
padding-right: 10px !important;}
|
|
||||||
.dark-mode #folderHelpBtn i.material-icons.folder-help-icon{-webkit-text-fill-color: #ffa500 !important;
|
|
||||||
padding-right: 10px !important;}
|
|
||||||
@media (max-width: 790px) {
|
@media (max-width: 790px) {
|
||||||
.header-container{flex-wrap: wrap;
|
.header-container{flex-wrap: wrap;
|
||||||
height: auto;}
|
height: auto;}
|
||||||
@@ -1384,6 +1419,7 @@ label{font-size: 0.9rem;}
|
|||||||
}
|
}
|
||||||
#sidebarDropArea.highlight,
|
#sidebarDropArea.highlight,
|
||||||
#uploadFolderRow.highlight{border: 2px dashed #1565C0;
|
#uploadFolderRow.highlight{border: 2px dashed #1565C0;
|
||||||
|
border-radius: var(--menu-radius);
|
||||||
background-color: #eef;}
|
background-color: #eef;}
|
||||||
.drag-header{cursor: grab;
|
.drag-header{cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -1488,12 +1524,7 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
|||||||
.collapse-btn:hover{background: rgba(0, 0, 0, 0.1);}
|
.collapse-btn:hover{background: rgba(0, 0, 0, 0.1);}
|
||||||
.toggle-modal-btn:focus,
|
.toggle-modal-btn:focus,
|
||||||
.collapse-btn:focus{outline: none;}
|
.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;
|
.header-drop-zone.drag-active{border: 2px dashed #1565C0;
|
||||||
background-color: #eef;
|
background-color: #eef;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@@ -1502,10 +1533,23 @@ body:not(.dark-mode){--download-spinner-color: #000;}
|
|||||||
.dark-mode .header-drop-zone.drag-active{background-color: #333;
|
.dark-mode .header-drop-zone.drag-active{background-color: #333;
|
||||||
border: 2px dashed #555;
|
border: 2px dashed #555;
|
||||||
color: #fff;}
|
color: #fff;}
|
||||||
.header-drop-zone.drag-active:empty::before{content: "Drop Zone";
|
.header-drop-zone {
|
||||||
font-size: 10px;
|
position: relative; /* so ::before can absolutely position inside */
|
||||||
padding-right: 6px;
|
}
|
||||||
color: #aaa;}
|
|
||||||
|
.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;
|
#fileList tbody tr.clickable-row{-webkit-user-select: none;
|
||||||
-moz-user-select: none;
|
-moz-user-select: none;
|
||||||
-ms-user-select: none;
|
-ms-user-select: none;
|
||||||
@@ -2092,4 +2136,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 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{padding-top:0!important;padding-bottom:0!important}
|
||||||
#fileList table.filr-table tbody tr.folder-row>td.folder-icon-cell{overflow:visible}
|
#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);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Fonts (ok to keep as real preloads) -->
|
<!-- Fonts -->
|
||||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||||
|
|
||||||
@@ -61,7 +61,27 @@
|
|||||||
<h1>FileRise</h1>
|
<h1>FileRise</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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 id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
@@ -112,6 +132,7 @@
|
|||||||
<!-- Custom Toast Container -->
|
<!-- Custom Toast Container -->
|
||||||
<div id="customToast"></div>
|
<div id="customToast"></div>
|
||||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||||
|
<div id="appZoomShell">
|
||||||
<main id="main" hidden>
|
<main id="main" hidden>
|
||||||
<div class="row mt-4" id="loginForm">
|
<div class="row mt-4" id="loginForm">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -190,10 +211,6 @@
|
|||||||
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
||||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
||||||
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
|
|
||||||
style="padding: 0; border: none; background: none;">
|
|
||||||
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body custom-folder-card-body">
|
<div class="card-body custom-folder-card-body">
|
||||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||||
@@ -276,27 +293,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
|
||||||
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
|
|
||||||
<style>
|
|
||||||
/* Dark mode polish */
|
|
||||||
body.dark-mode #folderHelpTooltip {
|
|
||||||
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
|
|
||||||
}
|
|
||||||
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
|
|
||||||
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
|
|
||||||
</style>
|
|
||||||
<ul class="folder-help-list">
|
|
||||||
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
|
|
||||||
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.</li>
|
|
||||||
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
|
|
||||||
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
|
|
||||||
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
|
|
||||||
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
|
|
||||||
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
|
|
||||||
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -401,7 +397,7 @@
|
|||||||
</div> <!-- end container-fluid -->
|
</div> <!-- end container-fluid -->
|
||||||
</div> <!-- end mainColumn -->
|
</div> <!-- end mainColumn -->
|
||||||
</div> <!-- end main-wrapper -->
|
</div> <!-- end main-wrapper -->
|
||||||
|
</div>
|
||||||
<!-- Download Progress Modal -->
|
<!-- Download Progress Modal -->
|
||||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1684
public/js/adminStorage.js
Normal file
1684
public/js/adminStorage.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,24 @@ export function initializeApp() {
|
|||||||
// default: false (unchecked)
|
// default: false (unchecked)
|
||||||
window.showFoldersInList = stored === 'true';
|
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)
|
// Load public site config early (safe subset)
|
||||||
loadAdminConfigFunc();
|
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)
|
LOGOUT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export async function openUserPanel() {
|
|||||||
color: ${isDark ? '#e0e0e0' : '#000'};
|
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px; width:90%;
|
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'};
|
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -351,66 +351,108 @@ export async function openUserPanel() {
|
|||||||
langFs.appendChild(langSel);
|
langFs.appendChild(langSel);
|
||||||
content.appendChild(langFs);
|
content.appendChild(langFs);
|
||||||
|
|
||||||
// --- Display fieldset: strip + inline folder rows ---
|
// --- Display fieldset: strip + inline folder rows ---
|
||||||
const dispFs = document.createElement('fieldset');
|
const dispFs = document.createElement('fieldset');
|
||||||
dispFs.style.marginBottom = '15px';
|
dispFs.style.marginBottom = '15px';
|
||||||
|
|
||||||
const dispLegend = document.createElement('legend');
|
const dispLegend = document.createElement('legend');
|
||||||
dispLegend.textContent = t('display');
|
dispLegend.textContent = t('display');
|
||||||
dispFs.appendChild(dispLegend);
|
dispFs.appendChild(dispLegend);
|
||||||
|
|
||||||
// 1) Show folder strip above list
|
// 1) Show folder strip above list
|
||||||
const stripLabel = document.createElement('label');
|
const stripLabel = document.createElement('label');
|
||||||
stripLabel.style.cursor = 'pointer';
|
stripLabel.style.cursor = 'pointer';
|
||||||
stripLabel.style.display = 'block';
|
stripLabel.style.display = 'block';
|
||||||
stripLabel.style.marginBottom = '4px';
|
stripLabel.style.marginBottom = '4px';
|
||||||
|
|
||||||
const stripCb = document.createElement('input');
|
const stripCb = document.createElement('input');
|
||||||
stripCb.type = 'checkbox';
|
stripCb.type = 'checkbox';
|
||||||
stripCb.id = 'showFoldersInList';
|
stripCb.id = 'showFoldersInList';
|
||||||
stripCb.style.verticalAlign = 'middle';
|
stripCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
{
|
{
|
||||||
const storedStrip = localStorage.getItem('showFoldersInList');
|
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||||
// default: unchecked
|
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
||||||
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
}
|
||||||
}
|
|
||||||
|
stripLabel.appendChild(stripCb);
|
||||||
stripLabel.appendChild(stripCb);
|
stripLabel.append(` ${t('show_folders_above_files')}`);
|
||||||
stripLabel.append(` ${t('show_folders_above_files')}`);
|
dispFs.appendChild(stripLabel);
|
||||||
dispFs.appendChild(stripLabel);
|
|
||||||
|
// 2) Show inline folder rows above files in table view
|
||||||
// 2) Show inline folder rows above files in table view
|
const inlineLabel = document.createElement('label');
|
||||||
const inlineLabel = document.createElement('label');
|
inlineLabel.style.cursor = 'pointer';
|
||||||
inlineLabel.style.cursor = 'pointer';
|
inlineLabel.style.display = 'block';
|
||||||
inlineLabel.style.display = 'block';
|
|
||||||
|
const inlineCb = document.createElement('input');
|
||||||
const inlineCb = document.createElement('input');
|
inlineCb.type = 'checkbox';
|
||||||
inlineCb.type = 'checkbox';
|
inlineCb.id = 'showInlineFolders';
|
||||||
inlineCb.id = 'showInlineFolders';
|
inlineCb.style.verticalAlign = 'middle';
|
||||||
inlineCb.style.verticalAlign = 'middle';
|
|
||||||
|
{
|
||||||
{
|
const storedInline = localStorage.getItem('showInlineFolders');
|
||||||
const storedInline = localStorage.getItem('showInlineFolders');
|
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
||||||
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
}
|
||||||
}
|
|
||||||
|
inlineLabel.appendChild(inlineCb);
|
||||||
inlineLabel.appendChild(inlineCb);
|
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||||
// you’ll want a string like this in i18n:
|
dispFs.appendChild(inlineLabel);
|
||||||
// "show_inline_folders": "Show folders inline (above files)"
|
|
||||||
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
// 3) Hide header zoom controls
|
||||||
dispFs.appendChild(inlineLabel);
|
const zoomLabel = document.createElement('label');
|
||||||
|
zoomLabel.style.cursor = 'pointer';
|
||||||
content.appendChild(dispFs);
|
zoomLabel.style.display = 'block';
|
||||||
|
zoomLabel.style.marginTop = '4px';
|
||||||
// Handlers: toggle + refresh list
|
|
||||||
stripCb.addEventListener('change', () => {
|
const zoomCb = document.createElement('input');
|
||||||
window.showFoldersInList = stripCb.checked;
|
zoomCb.type = 'checkbox';
|
||||||
localStorage.setItem('showFoldersInList', stripCb.checked);
|
zoomCb.id = 'hideHeaderZoomControls';
|
||||||
if (typeof window.loadFileList === 'function') {
|
zoomCb.style.verticalAlign = 'middle';
|
||||||
window.loadFileList(window.currentFolder || 'root');
|
|
||||||
}
|
{
|
||||||
});
|
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', () => {
|
inlineCb.addEventListener('change', () => {
|
||||||
window.showInlineFolders = inlineCb.checked;
|
window.showInlineFolders = inlineCb.checked;
|
||||||
|
|||||||
@@ -179,9 +179,22 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||||
|
|
||||||
let previewButton = "";
|
let previewButton = "";
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
|
||||||
|
const isSvg = /\.svg$/i.test(file.name);
|
||||||
|
|
||||||
|
// IMPORTANT: do NOT treat SVG as previewable
|
||||||
|
if (
|
||||||
|
!isSvg &&
|
||||||
|
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
|
||||||
|
.test(file.name)
|
||||||
|
) {
|
||||||
let previewIcon = "";
|
let previewIcon = "";
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
|
||||||
|
// images (SVG explicitly excluded)
|
||||||
|
if (
|
||||||
|
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
|
||||||
|
.test(file.name)
|
||||||
|
) {
|
||||||
previewIcon = `<i class="material-icons">image</i>`;
|
previewIcon = `<i class="material-icons">image</i>`;
|
||||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||||
@@ -190,14 +203,16 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||||
}
|
}
|
||||||
previewButton = `<button
|
|
||||||
type="button"
|
previewButton = `
|
||||||
class="btn btn-sm btn-info preview-btn"
|
<button
|
||||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
type="button"
|
||||||
data-preview-name="${safeFileName}"
|
class="btn btn-sm btn-info preview-btn"
|
||||||
title="${t('preview')}">
|
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||||
${previewIcon}
|
data-preview-name="${safeFileName}"
|
||||||
</button>`;
|
title="${t('preview')}">
|
||||||
|
${previewIcon}
|
||||||
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -242,13 +257,13 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
<i class="material-icons">drive_file_rename_outline</i>
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
</button>
|
</button>
|
||||||
<!-- share -->
|
<!-- share -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||||
data-file="${safeFileName}"
|
data-file="${safeFileName}"
|
||||||
title="${t('share')}">
|
title="${t('share')}">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -72,6 +72,41 @@ function animateVerticalSlide(card) {
|
|||||||
}, 260);
|
}, 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) --------------------
|
// -------------------- header (icon+modal) --------------------
|
||||||
function saveHeaderOrder() {
|
function saveHeaderOrder() {
|
||||||
const host = getHeaderDropArea();
|
const host = getHeaderDropArea();
|
||||||
@@ -98,7 +133,19 @@ function insertCardInHeader(card) {
|
|||||||
if (!hidden) {
|
if (!hidden) {
|
||||||
hidden = document.createElement('div');
|
hidden = document.createElement('div');
|
||||||
hidden.id = 'hiddenCardsContainer';
|
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);
|
document.body.appendChild(hidden);
|
||||||
}
|
}
|
||||||
if (card.parentNode?.id !== 'hiddenCardsContainer') hidden.appendChild(card);
|
if (card.parentNode?.id !== 'hiddenCardsContainer') hidden.appendChild(card);
|
||||||
@@ -177,7 +224,12 @@ function insertCardInHeader(card) {
|
|||||||
iconButton.addEventListener('click', (e) => {
|
iconButton.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
isLocked = !isLocked;
|
isLocked = !isLocked;
|
||||||
if (isLocked) showModal(); else hideModal();
|
iconButton.classList.toggle('is-locked', isLocked);
|
||||||
|
if (isLocked) {
|
||||||
|
showModal();
|
||||||
|
} else {
|
||||||
|
hideModal();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
host.appendChild(iconButton);
|
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) --------------------
|
// -------------------- zones toggle (collapse to header) --------------------
|
||||||
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
||||||
|
|
||||||
@@ -340,30 +620,73 @@ function applyCollapsedBodyClass() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setZonesCollapsed(collapsed) {
|
function setZonesCollapsed(collapsed) {
|
||||||
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
|
const currently = isZonesCollapsed();
|
||||||
|
if (collapsed === currently) return;
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
// Move ALL cards to header icons (transient) regardless of where they were.
|
// ---- COLLAPSE: immediately expand file area, then animate cards up into header ----
|
||||||
getCards().forEach(insertCardInHeader);
|
localStorage.setItem('zonesCollapsed', '1');
|
||||||
showHeaderDockPersistent();
|
|
||||||
const sb = getSidebar();
|
// File list area expands right away (no delay)
|
||||||
if (sb) sb.style.display = 'none';
|
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 {
|
} else {
|
||||||
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
|
// ---- EXPAND: immediately shrink file area, then animate cards out of header ----
|
||||||
applyUserLayoutOrDefault();
|
localStorage.setItem('zonesCollapsed', '0');
|
||||||
loadHeaderOrder();
|
|
||||||
hideHeaderDockPersistent();
|
// 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() {
|
function getHeaderHost() {
|
||||||
let host = document.querySelector('.header-container .header-left');
|
let host = document.querySelector('.header-container .header-left');
|
||||||
if (!host) host = document.querySelector('.header-container');
|
if (!host) host = document.querySelector('.header-container');
|
||||||
@@ -371,6 +694,36 @@ function getHeaderHost() {
|
|||||||
return host || document.body;
|
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() {
|
function ensureZonesToggle() {
|
||||||
const host = getHeaderHost();
|
const host = getHeaderHost();
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
@@ -605,7 +958,8 @@ function makeCardDraggable(card) {
|
|||||||
const sb = getSidebar();
|
const sb = getSidebar();
|
||||||
if (sb) {
|
if (sb) {
|
||||||
sb.classList.add('active', 'highlight');
|
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
|
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_MODAL_IDS = [
|
||||||
|
'deleteFilesModal',
|
||||||
|
'downloadZipModal',
|
||||||
|
'downloadProgressModal',
|
||||||
|
'createFileModal',
|
||||||
|
'downloadFileModal',
|
||||||
|
'copyFilesModal',
|
||||||
|
'moveFilesModal',
|
||||||
|
'renameFileModal',
|
||||||
|
'createFolderModal', // if this exists in your HTML
|
||||||
|
];
|
||||||
|
|
||||||
|
function portalFileModalsToBody() {
|
||||||
|
FILE_MODAL_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el.parentNode !== document.body) {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Upload modal "portal" support ---
|
// --- Upload modal "portal" support ---
|
||||||
let _uploadCardSentinel = null;
|
let _uploadCardSentinel = null;
|
||||||
@@ -818,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
// Expose initFileActions so it can be called from fileManager.js
|
// Expose initFileActions so it can be called from fileManager.js
|
||||||
export function initFileActions() {
|
export function initFileActions() {
|
||||||
|
portalFileModalsToBody();
|
||||||
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
||||||
if (deleteSelectedBtn) {
|
if (deleteSelectedBtn) {
|
||||||
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
||||||
|
|||||||
@@ -295,6 +295,27 @@ try {
|
|||||||
// Global flag for advanced search mode.
|
// Global flag for advanced search mode.
|
||||||
window.advancedSearchEnabled = false;
|
window.advancedSearchEnabled = false;
|
||||||
|
|
||||||
|
// --- Folder stats cache (for isEmpty.php) ---
|
||||||
|
const _folderStatsCache = new Map();
|
||||||
|
|
||||||
|
function fetchFolderStats(folder) {
|
||||||
|
if (!folder) return Promise.resolve(null);
|
||||||
|
|
||||||
|
if (_folderStatsCache.has(folder)) {
|
||||||
|
return _folderStatsCache.get(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`;
|
||||||
|
const p = _fetchJSONWithTimeout(url, 2500)
|
||||||
|
.catch(() => ({ folders: 0, files: 0 }))
|
||||||
|
.finally(() => {
|
||||||
|
// keep the resolved value; the Promise itself stays in the map
|
||||||
|
});
|
||||||
|
|
||||||
|
_folderStatsCache.set(folder, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================================
|
/* ===========================================================
|
||||||
SECURITY: build file URLs only via the API (no /uploads)
|
SECURITY: build file URLs only via the API (no /uploads)
|
||||||
=========================================================== */
|
=========================================================== */
|
||||||
@@ -428,19 +449,19 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
|
|||||||
// make sure this brand-new SVG is sized correctly
|
// make sure this brand-new SVG is sized correctly
|
||||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||||
|
|
||||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
|
fetchFolderStats(fullPath)
|
||||||
_fetchJSONWithTimeout(url, 2500)
|
.then(stats => {
|
||||||
.then(({ folders = 0, files = 0 }) => {
|
if (!stats) return;
|
||||||
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
const folders = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||||||
// 2) swap to "paper" icon
|
const files = Number.isFinite(stats.files) ? stats.files : 0;
|
||||||
iconSpan.dataset.kind = 'paper';
|
|
||||||
iconSpan.innerHTML = folderSVG('paper');
|
|
||||||
|
|
||||||
// re-apply sizing to this new SVG too
|
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
|
||||||
try { syncFolderIconSizeToRowHeight(); } catch {}
|
iconSpan.dataset.kind = 'paper';
|
||||||
}
|
iconSpan.innerHTML = folderSVG('paper');
|
||||||
})
|
try { syncFolderIconSizeToRowHeight(); } catch {}
|
||||||
.catch(() => { /* ignore */ });
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------
|
/* -----------------------------
|
||||||
@@ -934,7 +955,7 @@ export async function loadFileList(folderParam) {
|
|||||||
if (!summaryElem) {
|
if (!summaryElem) {
|
||||||
summaryElem = document.createElement("div");
|
summaryElem = document.createElement("div");
|
||||||
summaryElem.id = "fileSummary";
|
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);
|
actionsContainer.appendChild(summaryElem);
|
||||||
}
|
}
|
||||||
summaryElem.style.display = "block";
|
summaryElem.style.display = "block";
|
||||||
@@ -1156,6 +1177,19 @@ function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
|
|||||||
);
|
);
|
||||||
if (actionsIdx < 0) actionsIdx = -1;
|
if (actionsIdx < 0) actionsIdx = -1;
|
||||||
|
|
||||||
|
// NEW: created / modified column indices (uploaded = created in your header)
|
||||||
|
let createdIdx = headerCells.findIndex(th =>
|
||||||
|
(th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) ||
|
||||||
|
/\b(uploaded|created)\b/i.test((th.textContent || "").trim())
|
||||||
|
);
|
||||||
|
if (createdIdx < 0) createdIdx = -1;
|
||||||
|
|
||||||
|
let modifiedIdx = headerCells.findIndex(th =>
|
||||||
|
(th.dataset && th.dataset.column === "modified") ||
|
||||||
|
/\bmodified\b/i.test((th.textContent || "").trim())
|
||||||
|
);
|
||||||
|
if (modifiedIdx < 0) modifiedIdx = -1;
|
||||||
|
|
||||||
// Remove any previous folder rows
|
// Remove any previous folder rows
|
||||||
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
|
||||||
|
|
||||||
@@ -1356,19 +1390,32 @@ if (iconSpan) {
|
|||||||
iconSpan.style.marginTop = "0px"; // small down nudge
|
iconSpan.style.marginTop = "0px"; // small down nudge
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
|
// ----- FOLDER STATS + OWNER + CAPS -----
|
||||||
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
|
||||||
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
|
||||||
|
const createdCellIndex = (createdIdx >= 0 && createdIdx < tr.cells.length) ? createdIdx : -1;
|
||||||
|
const modifiedCellIndex = (modifiedIdx >= 0 && modifiedIdx < tr.cells.length) ? modifiedIdx : -1;
|
||||||
|
|
||||||
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`;
|
fetchFolderStats(sf.full).then(stats => {
|
||||||
_fetchJSONWithTimeout(url, 2500).then(stats => {
|
|
||||||
if (!stats) return;
|
if (!stats) return;
|
||||||
|
|
||||||
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
|
||||||
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
|
||||||
const bytes = Number.isFinite(stats.bytes)
|
// Try multiple possible size keys so backend + JS can drift a bit
|
||||||
? stats.bytes
|
let bytes = null;
|
||||||
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
|
const sizeCandidates = [
|
||||||
|
stats.bytes,
|
||||||
|
stats.sizeBytes,
|
||||||
|
stats.size,
|
||||||
|
stats.totalBytes
|
||||||
|
];
|
||||||
|
for (const v of sizeCandidates) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (Number.isFinite(n) && n >= 0) {
|
||||||
|
bytes = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let pieces = [];
|
let pieces = [];
|
||||||
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
|
||||||
@@ -1395,6 +1442,26 @@ if (iconSpan) {
|
|||||||
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (createdCellIndex >= 0) {
|
||||||
|
const createdCell = tr.cells[createdCellIndex];
|
||||||
|
if (createdCell) {
|
||||||
|
const txt = (stats && typeof stats.earliest_uploaded === 'string')
|
||||||
|
? stats.earliest_uploaded
|
||||||
|
: '';
|
||||||
|
createdCell.textContent = txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiedCellIndex >= 0) {
|
||||||
|
const modCell = tr.cells[modifiedCellIndex];
|
||||||
|
if (modCell) {
|
||||||
|
const txt = (stats && typeof stats.latest_mtime === 'string')
|
||||||
|
? stats.latest_mtime
|
||||||
|
: '';
|
||||||
|
modCell.textContent = txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (sizeCellIndex >= 0) {
|
if (sizeCellIndex >= 0) {
|
||||||
const sizeCell = tr.cells[sizeCellIndex];
|
const sizeCell = tr.cells[sizeCellIndex];
|
||||||
@@ -1887,7 +1954,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
let thumbnail;
|
let thumbnail;
|
||||||
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
|
if (/\.(jpe?g|png|gif|bmp|webp|ico)$/i.test(file.name)) {
|
||||||
const cacheKey = previewURL; // include folder & file
|
const cacheKey = previewURL; // include folder & file
|
||||||
if (window.imageCache && window.imageCache[cacheKey]) {
|
if (window.imageCache && window.imageCache[cacheKey]) {
|
||||||
thumbnail = `<img
|
thumbnail = `<img
|
||||||
@@ -1931,7 +1998,7 @@ export function renderGalleryView(folder, container) {
|
|||||||
galleryHTML += `
|
galleryHTML += `
|
||||||
<div class="gallery-card"
|
<div class="gallery-card"
|
||||||
data-file-name="${escapeHTML(file.name)}"
|
data-file-name="${escapeHTML(file.name)}"
|
||||||
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
style="position:relative; border-radius: 12px; border:1px solid #ccc; padding:5px; text-align:center;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="file-checkbox"
|
class="file-checkbox"
|
||||||
id="cb-${idSafe}"
|
id="cb-${idSafe}"
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ export function openShareModal(file, folder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------- Media modal viewer -------------------------------- */
|
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||||
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
// Images that are safe to inline in <img> tags:
|
||||||
|
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|ico)$/i;
|
||||||
|
|
||||||
|
// SVG handled separately so we *don’t* inline it
|
||||||
|
const SVG_RE = /\.svg$/i;
|
||||||
|
|
||||||
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||||
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||||
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
|
||||||
@@ -239,7 +244,26 @@ function ensureMediaModal() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
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:
|
// theme the close “×” for visibility + hover rules that match your site:
|
||||||
const closeBtn = overlay.querySelector("#closeFileModal");
|
const closeBtn = overlay.querySelector("#closeFileModal");
|
||||||
function paintCloseBase() {
|
function paintCloseBase() {
|
||||||
@@ -272,17 +296,46 @@ function ensureMediaModal() {
|
|||||||
function setTitle(overlay, name) {
|
function setTitle(overlay, name) {
|
||||||
const textEl = overlay.querySelector('.title-text');
|
const textEl = overlay.querySelector('.title-text');
|
||||||
const iconEl = overlay.querySelector('.title-icon');
|
const iconEl = overlay.querySelector('.title-icon');
|
||||||
|
const tagsEl = overlay.querySelector('.title-tags');
|
||||||
|
|
||||||
|
// File name + tooltip
|
||||||
if (textEl) {
|
if (textEl) {
|
||||||
textEl.textContent = name || '';
|
textEl.textContent = name || '';
|
||||||
textEl.setAttribute('title', name || '');
|
textEl.setAttribute('title', name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File type icon
|
||||||
if (iconEl) {
|
if (iconEl) {
|
||||||
iconEl.textContent = getIconForFile(name);
|
iconEl.textContent = getIconForFile(name);
|
||||||
// keep the icon legible in both themes
|
|
||||||
const dark = document.documentElement.classList.contains('dark-mode');
|
const dark = document.documentElement.classList.contains('dark-mode');
|
||||||
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
|
||||||
iconEl.style.opacity = dark ? '0.96' : '0.9';
|
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
|
// Topbar icon (theme-aware) used for image tools + video actions
|
||||||
@@ -374,11 +427,19 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
const folder = window.currentFolder || 'root';
|
const folder = window.currentFolder || 'root';
|
||||||
const name = fileName;
|
const name = fileName;
|
||||||
const lower = (name || '').toLowerCase();
|
const lower = (name || '').toLowerCase();
|
||||||
|
const isSvg = SVG_RE.test(lower);
|
||||||
const isImage = IMG_RE.test(lower);
|
const isImage = IMG_RE.test(lower);
|
||||||
const isVideo = VID_RE.test(lower);
|
const isVideo = VID_RE.test(lower);
|
||||||
const isAudio = AUD_RE.test(lower);
|
const isAudio = AUD_RE.test(lower);
|
||||||
|
|
||||||
setTitle(overlay, name);
|
setTitle(overlay, name);
|
||||||
|
if (isSvg) {
|
||||||
|
container.textContent =
|
||||||
|
t("svg_preview_disabled") ||
|
||||||
|
"SVG preview is disabled for security. Use Download to view this file.";
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------- IMAGES -------------------- */
|
/* -------------------- IMAGES -------------------- */
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
|
|||||||
@@ -10,6 +10,29 @@ import { fetchWithCsrf } from './auth.js?v={{APP_QVER}}';
|
|||||||
import { loadCsrfToken } from './appCore.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;
|
const PAGE_LIMIT = 100;
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
@@ -1711,6 +1734,7 @@ function bindFolderManagerContextMenu() {
|
|||||||
Rename / Delete / Create hooks
|
Rename / Delete / Create hooks
|
||||||
----------------------*/
|
----------------------*/
|
||||||
export function openRenameFolderModal() {
|
export function openRenameFolderModal() {
|
||||||
|
detachFolderModalsToBody();
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to rename."); return; }
|
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to rename."); return; }
|
||||||
const parts = selectedFolder.split("/");
|
const parts = selectedFolder.split("/");
|
||||||
@@ -1781,6 +1805,7 @@ if (submitRename) submitRename.addEventListener("click", function (event) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function openDeleteFolderModal() {
|
export function openDeleteFolderModal() {
|
||||||
|
detachFolderModalsToBody();
|
||||||
const selectedFolder = window.currentFolder || "root";
|
const selectedFolder = window.currentFolder || "root";
|
||||||
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to delete."); return; }
|
if (!selectedFolder || selectedFolder === "root") { showToast("Please select a valid folder to delete."); return; }
|
||||||
const msgEl = document.getElementById("deleteFolderMessage");
|
const msgEl = document.getElementById("deleteFolderMessage");
|
||||||
@@ -1823,6 +1848,7 @@ if (confirmDelete) confirmDelete.addEventListener("click", async function () {
|
|||||||
|
|
||||||
const createBtn = document.getElementById("createFolderBtn");
|
const createBtn = document.getElementById("createFolderBtn");
|
||||||
if (createBtn) createBtn.addEventListener("click", function () {
|
if (createBtn) createBtn.addEventListener("click", function () {
|
||||||
|
detachFolderModalsToBody();
|
||||||
const modal = document.getElementById("createFolderModal");
|
const modal = document.getElementById("createFolderModal");
|
||||||
const input = document.getElementById("newFolderName");
|
const input = document.getElementById("newFolderName");
|
||||||
if (modal) modal.style.display = "block";
|
if (modal) modal.style.display = "block";
|
||||||
@@ -1885,6 +1911,7 @@ if (submitCreate) submitCreate.addEventListener("click", async () => {
|
|||||||
Move (modal) + Color carry + State migration as well
|
Move (modal) + Color carry + State migration as well
|
||||||
----------------------*/
|
----------------------*/
|
||||||
export function openMoveFolderUI(sourceFolder) {
|
export function openMoveFolderUI(sourceFolder) {
|
||||||
|
detachFolderModalsToBody();
|
||||||
const modal = document.getElementById('moveFolderModal');
|
const modal = document.getElementById('moveFolderModal');
|
||||||
const targetSel = document.getElementById('moveFolderTarget');
|
const targetSel = document.getElementById('moveFolderTarget');
|
||||||
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;
|
if (sourceFolder && sourceFolder !== 'root') window.currentFolder = sourceFolder;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const translations = {
|
|||||||
"tag_name": "Tag Name:",
|
"tag_name": "Tag Name:",
|
||||||
"tag_color": "Tag Color:",
|
"tag_color": "Tag Color:",
|
||||||
"save_tag": "Save Tag",
|
"save_tag": "Save Tag",
|
||||||
|
"no_tags_available": "No tags available",
|
||||||
|
"current_tags": "Current Tags",
|
||||||
"light_mode": "Light Mode",
|
"light_mode": "Light Mode",
|
||||||
"dark_mode": "Dark Mode",
|
"dark_mode": "Dark Mode",
|
||||||
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
||||||
@@ -337,7 +339,11 @@ const translations = {
|
|||||||
"size": "Size",
|
"size": "Size",
|
||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"owner": "Owner"
|
"owner": "Owner",
|
||||||
|
"hide_header_zoom_controls": "Hide header zoom controls",
|
||||||
|
"preview_not_available": "Preview is not available for this file type.",
|
||||||
|
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
|
||||||
|
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"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() {
|
function getUserDraftContext() {
|
||||||
const all = loadResumableDraftsAll();
|
const all = loadResumableDraftsAll();
|
||||||
const userKey = getCurrentUserKey();
|
const userKey = getCurrentUserKey();
|
||||||
@@ -253,23 +317,35 @@ function getFilesFromDataTransferItems(items) {
|
|||||||
|
|
||||||
function setDropAreaDefault() {
|
function setDropAreaDefault() {
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) {
|
if (!dropArea) return;
|
||||||
dropArea.innerHTML = `
|
|
||||||
<div id="uploadInstruction" class="upload-instruction">
|
dropArea.innerHTML = `
|
||||||
${t("upload_instruction")}
|
<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>
|
||||||
<div id="uploadFileRow" class="upload-file-row">
|
</div>
|
||||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
<!-- File input for file picker (files only) -->
|
||||||
</div>
|
<input
|
||||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
type="file"
|
||||||
<div id="fileInfoContainer" class="file-info-container">
|
id="file"
|
||||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
name="file[]"
|
||||||
</div>
|
class="form-control-file"
|
||||||
</div>
|
multiple
|
||||||
<!-- File input for file picker (files only) -->
|
style="opacity:0; position:absolute; width:1px; height:1px;"
|
||||||
<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() {
|
function adjustFolderHelpExpansion() {
|
||||||
@@ -608,6 +684,7 @@ const useResumable = true;
|
|||||||
let resumableInstance = null;
|
let resumableInstance = null;
|
||||||
let _pendingPickedFiles = []; // files picked before library/instance ready
|
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||||
let _resumableReady = false;
|
let _resumableReady = false;
|
||||||
|
let _currentResumableIds = new Set();
|
||||||
|
|
||||||
// Make init async-safe; it resolves when Resumable is constructed
|
// Make init async-safe; it resolves when Resumable is constructed
|
||||||
async function initResumableUpload() {
|
async function initResumableUpload() {
|
||||||
@@ -644,18 +721,20 @@ async function initResumableUpload() {
|
|||||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
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) {
|
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
|
// Initialize custom paused flag
|
||||||
file.paused = false;
|
file.paused = false;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -663,13 +742,13 @@ async function initResumableUpload() {
|
|||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
}
|
}
|
||||||
window.selectedFiles.push(file);
|
window.selectedFiles.push(file);
|
||||||
|
|
||||||
// Track as in-progress draft at 0%
|
// Track as in-progress draft at 0%
|
||||||
upsertResumableDraft(file, 0);
|
upsertResumableDraft(file, 0);
|
||||||
showResumableDraftBanner();
|
showResumableDraftBanner();
|
||||||
|
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
|
|
||||||
// Check if a wrapper already exists; if not, create one with a UL inside.
|
// Check if a wrapper already exists; if not, create one with a UL inside.
|
||||||
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
||||||
let list;
|
let list;
|
||||||
@@ -685,7 +764,7 @@ async function initResumableUpload() {
|
|||||||
} else {
|
} else {
|
||||||
list = listWrapper.querySelector("ul.upload-progress-list");
|
list = listWrapper.querySelector("ul.upload-progress-list");
|
||||||
}
|
}
|
||||||
|
|
||||||
const li = createFileEntry(file);
|
const li = createFileEntry(file);
|
||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
@@ -1119,9 +1198,17 @@ function submitFiles(allFiles) {
|
|||||||
Main initUpload: Sets up file input, drop area, and form submission.
|
Main initUpload: Sets up file input, drop area, and form submission.
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
function initUpload() {
|
function initUpload() {
|
||||||
const fileInput = document.getElementById("file");
|
window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
|
||||||
|
|
||||||
const uploadForm = document.getElementById("uploadFileForm");
|
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.
|
// For file picker, remove directory attributes so only files can be chosen.
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -1131,67 +1218,50 @@ function initUpload() {
|
|||||||
fileInput.setAttribute("multiple", "");
|
fileInput.setAttribute("multiple", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropAreaDefault();
|
|
||||||
|
|
||||||
// Drag–and–drop events (for folder uploads) use original processing.
|
// 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.classList.add("upload-drop-area");
|
||||||
|
|
||||||
dropArea.addEventListener("dragover", function (e) {
|
dropArea.addEventListener("dragover", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
|
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
|
||||||
});
|
});
|
||||||
|
|
||||||
dropArea.addEventListener("dragleave", function (e) {
|
dropArea.addEventListener("dragleave", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
dropArea.addEventListener("drop", function (e) {
|
dropArea.addEventListener("drop", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
const dt = e.dataTransfer || window.__pendingDropData || null;
|
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||||
window.__pendingDropData = null;
|
window.__pendingDropData = null;
|
||||||
if (dt.items && dt.items.length > 0) {
|
if (dt && dt.items && dt.items.length > 0) {
|
||||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
processFiles(files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (dt.files && dt.files.length > 0) {
|
} else if (dt && dt.files && dt.files.length > 0) {
|
||||||
processFiles(dt.files);
|
processFiles(dt.files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Clicking drop area triggers file input.
|
|
||||||
dropArea.addEventListener("click", function () {
|
|
||||||
if (fileInput) fileInput.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput) {
|
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
|
||||||
fileInput.addEventListener("change", async function () {
|
dropArea.addEventListener("click", function (e) {
|
||||||
const files = Array.from(fileInput.files || []);
|
// If the click originated from the "Choose files" button or the file input itself,
|
||||||
if (!files.length) return;
|
// let their handlers deal with it.
|
||||||
|
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
|
||||||
if (useResumable) {
|
return;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
triggerFilePickerOnce();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadForm) {
|
if (uploadForm && !uploadForm.__uploadSubmitBound) {
|
||||||
|
uploadForm.__uploadSubmitBound = true;
|
||||||
uploadForm.addEventListener("submit", async function (e) {
|
uploadForm.addEventListener("submit", async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -1205,7 +1275,6 @@ function initUpload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have any files queued in Resumable, treat this as a resumable upload.
|
|
||||||
const hasResumableFiles =
|
const hasResumableFiles =
|
||||||
useResumable &&
|
useResumable &&
|
||||||
resumableInstance &&
|
resumableInstance &&
|
||||||
@@ -1215,7 +1284,6 @@ function initUpload() {
|
|||||||
if (hasResumableFiles) {
|
if (hasResumableFiles) {
|
||||||
if (!_resumableReady) await initResumableUpload();
|
if (!_resumableReady) await initResumableUpload();
|
||||||
if (resumableInstance) {
|
if (resumableInstance) {
|
||||||
// Keep folder/token fresh
|
|
||||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
@@ -1223,11 +1291,9 @@ function initUpload() {
|
|||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
showToast("Resumable upload started...");
|
showToast("Resumable upload started...");
|
||||||
} else {
|
} else {
|
||||||
// Hard fallback – should basically never happen
|
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No resumable queue → drag-and-drop / paste / simple input → XHR path
|
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v2.0.2';
|
window.APP_VERSION = 'v2.2.4';
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
BIN
resources/StorageDiskUsage.png
Normal file
BIN
resources/StorageDiskUsage.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 KiB |
@@ -1,19 +1,25 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
|
||||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
VER="v1.9.1"
|
VER="v2.1.0"
|
||||||
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||||
|
|
||||||
WEBROOT="/var/www"
|
WEBROOT="/var/www"
|
||||||
TMP="/tmp/filerise-update"
|
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)"
|
stamp="$(date +%F-%H%M)"
|
||||||
mkdir -p /root/backups
|
mkdir -p /root/backups
|
||||||
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
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"
|
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||||
|
|
||||||
# 1) Fetch the release zip
|
# 1) Fetch the release zip
|
||||||
@@ -29,12 +35,17 @@ STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" |
|
|||||||
# 3) Sync code into /var/www
|
# 3) Sync code into /var/www
|
||||||
# - keep public/.htaccess
|
# - keep public/.htaccess
|
||||||
# - keep data dirs and current config.php
|
# - keep data dirs and current config.php
|
||||||
|
# - DO NOT touch filerise-site / bundles / demo config
|
||||||
|
# - DO NOT touch vendor/ so Stripe + other libs stay intact on demo
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude='public/.htaccess' \
|
--exclude='public/.htaccess' \
|
||||||
--exclude='uploads/***' \
|
--exclude='uploads/***' \
|
||||||
--exclude='users/***' \
|
--exclude='users/***' \
|
||||||
--exclude='metadata/***' \
|
--exclude='metadata/***' \
|
||||||
--exclude='config/config.php' \
|
--exclude='filerise-bundles/***' \
|
||||||
|
--exclude='filerise-config/***' \
|
||||||
|
--exclude='filerise-site/***' \
|
||||||
|
--exclude='vendor/***' \
|
||||||
--exclude='.github/***' \
|
--exclude='.github/***' \
|
||||||
--exclude='docker-compose.yml' \
|
--exclude='docker-compose.yml' \
|
||||||
"$STAGE_DIR"/ "$WEBROOT"/
|
"$STAGE_DIR"/ "$WEBROOT"/
|
||||||
@@ -42,13 +53,20 @@ rsync -a --delete \
|
|||||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||||
chown -R www-data:www-data "$WEBROOT"
|
chown -R www-data:www-data "$WEBROOT"
|
||||||
|
|
||||||
# 5) (optional) Composer autoload optimization if composer is available
|
# 5) Composer — still disabled on demo
|
||||||
if command -v composer >/dev/null 2>&1; then
|
# if command -v composer >/dev/null 2>&1; then
|
||||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
# cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||||
composer install --no-dev --optimize-autoloader
|
# composer install --no-dev --optimize-autoloader
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# 6) Force demo mode ON in config/config.php
|
||||||
|
CFG_FILE="$WEBROOT/config/config.php"
|
||||||
|
if [[ -f "$CFG_FILE" ]]; then
|
||||||
|
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
|
||||||
|
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
# 7) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||||
systemctl reload apache2 2>/dev/null || true
|
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, site, and vendor/ (Stripe) preserved."
|
||||||
42
src/cli/disk_usage_scan.php
Normal file
42
src/cli/disk_usage_scan.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// src/cli/disk_usage_scan.php
|
||||||
|
//
|
||||||
|
// Build or refresh the disk usage snapshot used by the Admin "Storage / Disk Usage" view.
|
||||||
|
|
||||||
|
require __DIR__ . '/../../config/config.php';
|
||||||
|
require __DIR__ . '/../../src/models/DiskUsageModel.php';
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$snapshot = DiskUsageModel::buildSnapshot();
|
||||||
|
$elapsed = microtime(true) - $start;
|
||||||
|
|
||||||
|
$bytes = (int)($snapshot['root_bytes'] ?? 0);
|
||||||
|
$files = (int)($snapshot['root_files'] ?? 0);
|
||||||
|
|
||||||
|
$human = function (int $b): string {
|
||||||
|
if ($b <= 0) return '0 B';
|
||||||
|
$units = ['B','KB','MB','GB','TB','PB'];
|
||||||
|
$i = (int)floor(log($b, 1024));
|
||||||
|
$i = max(0, min($i, count($units) - 1));
|
||||||
|
$val = $b / pow(1024, $i);
|
||||||
|
return sprintf('%.2f %s', $val, $units[$i]);
|
||||||
|
};
|
||||||
|
|
||||||
|
$msg = sprintf(
|
||||||
|
"Disk usage snapshot written to %s\nScanned %d files, total %s in %.2f seconds.\n",
|
||||||
|
DiskUsageModel::snapshotPath(),
|
||||||
|
$files,
|
||||||
|
$human($bytes),
|
||||||
|
$elapsed
|
||||||
|
);
|
||||||
|
fwrite(STDOUT, $msg);
|
||||||
|
exit(0);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fwrite(STDERR, "Error building disk usage snapshot: " . $e->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
// src/controllers/AdminController.php
|
// src/controllers/AdminController.php
|
||||||
|
|
||||||
require_once __DIR__ . '/../../config/config.php';
|
require_once __DIR__ . '/../../config/config.php';
|
||||||
@@ -241,7 +242,7 @@ public function setLicense(): void
|
|||||||
// Store license + updatedAt in JSON file
|
// Store license + updatedAt in JSON file
|
||||||
if (!defined('PRO_LICENSE_FILE')) {
|
if (!defined('PRO_LICENSE_FILE')) {
|
||||||
// Fallback if constant not defined for some reason
|
// 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 = [
|
$payload = [
|
||||||
@@ -566,10 +567,11 @@ public function installProBundle(): void
|
|||||||
|
|
||||||
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
$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')
|
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
|
||||||
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
|
? 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
|
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
|
||||||
$proDocsDir = $bundleRoot;
|
$proDocsDir = $bundleRoot;
|
||||||
|
|||||||
@@ -327,6 +327,14 @@ class UserController
|
|||||||
exit;
|
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;
|
$totp_enabled = isset($data['totp_enabled']) ? filter_var($data['totp_enabled'], FILTER_VALIDATE_BOOLEAN) : false;
|
||||||
$result = UserModel::updateUserPanel($username, $totp_enabled);
|
$result = UserModel::updateUserPanel($username, $totp_enabled);
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
@@ -348,6 +356,14 @@ class UserController
|
|||||||
exit;
|
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);
|
$result = UserModel::disableTOTPSecret($username);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
echo json_encode(["success" => true, "message" => "TOTP disabled successfully."]);
|
||||||
@@ -412,6 +428,16 @@ class UserController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$userId = $_SESSION['username'];
|
$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)) {
|
if (!preg_match(REGEX_USER, $userId)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
echo json_encode(['status' => 'error', 'message' => 'Invalid user identifier']);
|
||||||
@@ -438,6 +464,14 @@ class UserController
|
|||||||
exit;
|
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();
|
self::requireCsrf();
|
||||||
|
|
||||||
// Fix: if username not present (pending flow), fall back to pending_login_user
|
// Fix: if username not present (pending flow), fall back to pending_login_user
|
||||||
|
|||||||
723
src/models/DiskUsageModel.php
Normal file
723
src/models/DiskUsageModel.php
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
<?php
|
||||||
|
// src/models/DiskUsageModel.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/FS.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DiskUsageModel
|
||||||
|
*
|
||||||
|
* Builds and reads a cached snapshot of disk usage under UPLOAD_DIR.
|
||||||
|
* Snapshot is stored as JSON under META_DIR . '/disk_usage.json'.
|
||||||
|
*
|
||||||
|
* Folder keys mirror the rest of FileRise:
|
||||||
|
* - "root" is the upload root
|
||||||
|
* - "foo/bar" are subfolders under UPLOAD_DIR
|
||||||
|
*
|
||||||
|
* We intentionally skip:
|
||||||
|
* - trash subtree
|
||||||
|
* - profile_pics subtree
|
||||||
|
* - dot-prefixed names
|
||||||
|
* - FS::IGNORE() entries like @eaDir, .DS_Store, etc.
|
||||||
|
*/
|
||||||
|
class DiskUsageModel
|
||||||
|
{
|
||||||
|
/** Where we persist the snapshot JSON. */
|
||||||
|
public const SNAPSHOT_BASENAME = 'disk_usage.json';
|
||||||
|
|
||||||
|
/** Maximum number of per-file records to keep (for Top N view). */
|
||||||
|
private const TOP_FILE_LIMIT = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute path to the snapshot JSON file.
|
||||||
|
*/
|
||||||
|
public static function snapshotPath(): string
|
||||||
|
{
|
||||||
|
$meta = rtrim((string)META_DIR, '/\\');
|
||||||
|
return $meta . DIRECTORY_SEPARATOR . self::SNAPSHOT_BASENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fresh snapshot of disk usage under UPLOAD_DIR and write it to disk.
|
||||||
|
*
|
||||||
|
* Returns the structured snapshot array (same shape as stored JSON).
|
||||||
|
*
|
||||||
|
* @throws RuntimeException on configuration or IO errors.
|
||||||
|
*/
|
||||||
|
public static function buildSnapshot(): array
|
||||||
|
{
|
||||||
|
$start = microtime(true);
|
||||||
|
|
||||||
|
$root = realpath(UPLOAD_DIR);
|
||||||
|
if ($root === false || !is_dir($root)) {
|
||||||
|
throw new RuntimeException('Uploads directory is not configured correctly.');
|
||||||
|
}
|
||||||
|
$root = rtrim($root, DIRECTORY_SEPARATOR);
|
||||||
|
|
||||||
|
$IGNORE = FS::IGNORE();
|
||||||
|
$SKIP = FS::SKIP();
|
||||||
|
|
||||||
|
// Folder map: key => [
|
||||||
|
// 'key' => string,
|
||||||
|
// 'parent' => string|null,
|
||||||
|
// 'name' => string,
|
||||||
|
// 'bytes' => int,
|
||||||
|
// 'files' => int,
|
||||||
|
// 'dirs' => int,
|
||||||
|
// 'latest_mtime' => int
|
||||||
|
// ]
|
||||||
|
$folders = [];
|
||||||
|
|
||||||
|
// Root entry
|
||||||
|
$folders['root'] = [
|
||||||
|
'key' => 'root',
|
||||||
|
'parent' => null,
|
||||||
|
'name' => 'root',
|
||||||
|
'bytes' => 0,
|
||||||
|
'files' => 0,
|
||||||
|
'dirs' => 0,
|
||||||
|
'latest_mtime' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
// File records (we may trim to TOP_FILE_LIMIT later)
|
||||||
|
// Each item: [
|
||||||
|
// 'folder' => folderKey,
|
||||||
|
// 'name' => file name,
|
||||||
|
// 'path' => "folder/name" or just name if root,
|
||||||
|
// 'bytes' => int,
|
||||||
|
// 'mtime' => int
|
||||||
|
// ]
|
||||||
|
$files = [];
|
||||||
|
|
||||||
|
$rootLen = strlen($root);
|
||||||
|
|
||||||
|
$it = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator(
|
||||||
|
$root,
|
||||||
|
FilesystemIterator::SKIP_DOTS
|
||||||
|
| FilesystemIterator::FOLLOW_SYMLINKS
|
||||||
|
),
|
||||||
|
RecursiveIteratorIterator::SELF_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($it as $path => $info) {
|
||||||
|
/** @var SplFileInfo $info */
|
||||||
|
$name = $info->getFilename();
|
||||||
|
|
||||||
|
// Skip dotfiles / dotdirs
|
||||||
|
if ($name === '.' || $name === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($name[0] === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip system/ignored entries
|
||||||
|
if (in_array($name, $IGNORE, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative path under UPLOAD_DIR, normalized with '/'
|
||||||
|
$rel = substr($path, $rootLen);
|
||||||
|
$rel = str_replace('\\', '/', $rel);
|
||||||
|
$rel = ltrim($rel, '/');
|
||||||
|
|
||||||
|
// Should only happen for the root itself, which we seeded
|
||||||
|
if ($rel === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDir = $info->isDir();
|
||||||
|
|
||||||
|
if ($isDir) {
|
||||||
|
$folderKey = $rel;
|
||||||
|
$lowerRel = strtolower($folderKey);
|
||||||
|
|
||||||
|
// Skip trash/profile_pics subtrees entirely
|
||||||
|
if ($lowerRel === 'trash' || strpos($lowerRel, 'trash/') === 0) {
|
||||||
|
$it->next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($lowerRel === 'profile_pics' || strpos($lowerRel, 'profile_pics/') === 0) {
|
||||||
|
$it->next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip SKIP entries at any level
|
||||||
|
$baseLower = strtolower(basename($folderKey));
|
||||||
|
if (in_array($baseLower, $SKIP, true)) {
|
||||||
|
$it->next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register folder
|
||||||
|
if (!isset($folders[$folderKey])) {
|
||||||
|
$parent = self::parentKeyOf($folderKey);
|
||||||
|
if (!isset($folders[$parent])) {
|
||||||
|
// Ensure parent exists (important for aggregation step later)
|
||||||
|
$folders[$parent] = [
|
||||||
|
'key' => $parent,
|
||||||
|
'parent' => self::parentKeyOf($parent),
|
||||||
|
'name' => self::basenameKey($parent),
|
||||||
|
'bytes' => 0,
|
||||||
|
'files' => 0,
|
||||||
|
'dirs' => 0,
|
||||||
|
'latest_mtime' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$folders[$folderKey] = [
|
||||||
|
'key' => $folderKey,
|
||||||
|
'parent' => $parent,
|
||||||
|
'name' => self::basenameKey($folderKey),
|
||||||
|
'bytes' => 0,
|
||||||
|
'files' => 0,
|
||||||
|
'dirs' => 0,
|
||||||
|
'latest_mtime' => 0,
|
||||||
|
];
|
||||||
|
// Increment dir count on parent
|
||||||
|
if ($parent !== null && isset($folders[$parent])) {
|
||||||
|
$folders[$parent]['dirs']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File entry
|
||||||
|
// Determine folder key where this file resides
|
||||||
|
$relDir = str_replace('\\', '/', dirname($rel));
|
||||||
|
if ($relDir === '.' || $relDir === '') {
|
||||||
|
$folderKey = 'root';
|
||||||
|
} else {
|
||||||
|
$folderKey = $relDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lowerFolder = strtolower($folderKey);
|
||||||
|
if ($lowerFolder === 'trash' || strpos($lowerFolder, 'trash/') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($lowerFolder === 'profile_pics' || strpos($lowerFolder, 'profile_pics/') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip SKIP entries for files inside unwanted app-specific dirs
|
||||||
|
$baseLower = strtolower(basename($folderKey));
|
||||||
|
if (in_array($baseLower, $SKIP, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure folder exists in map
|
||||||
|
if (!isset($folders[$folderKey])) {
|
||||||
|
$parent = self::parentKeyOf($folderKey);
|
||||||
|
if (!isset($folders[$parent])) {
|
||||||
|
$folders[$parent] = [
|
||||||
|
'key' => $parent,
|
||||||
|
'parent' => self::parentKeyOf($parent),
|
||||||
|
'name' => self::basenameKey($parent),
|
||||||
|
'bytes' => 0,
|
||||||
|
'files' => 0,
|
||||||
|
'dirs' => 0,
|
||||||
|
'latest_mtime' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$folders[$folderKey] = [
|
||||||
|
'key' => $folderKey,
|
||||||
|
'parent' => $parent,
|
||||||
|
'name' => self::basenameKey($folderKey),
|
||||||
|
'bytes' => 0,
|
||||||
|
'files' => 0,
|
||||||
|
'dirs' => 0,
|
||||||
|
'latest_mtime' => 0,
|
||||||
|
];
|
||||||
|
if ($parent !== null && isset($folders[$parent])) {
|
||||||
|
$folders[$parent]['dirs']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = (int)$info->getSize();
|
||||||
|
$mtime = (int)$info->getMTime();
|
||||||
|
|
||||||
|
// Update folder leaf stats
|
||||||
|
$folders[$folderKey]['bytes'] += $bytes;
|
||||||
|
$folders[$folderKey]['files']++;
|
||||||
|
if ($mtime > $folders[$folderKey]['latest_mtime']) {
|
||||||
|
$folders[$folderKey]['latest_mtime'] = $mtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember file record (we may trim later)
|
||||||
|
$filePath = ($folderKey === 'root')
|
||||||
|
? $name
|
||||||
|
: ($folderKey . '/' . $name);
|
||||||
|
|
||||||
|
$files[] = [
|
||||||
|
'folder' => $folderKey,
|
||||||
|
'name' => $name,
|
||||||
|
'path' => $filePath,
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'mtime' => $mtime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate folder bytes up the tree so each folder includes its descendants.
|
||||||
|
// Process folders from deepest to shallowest.
|
||||||
|
$keys = array_keys($folders);
|
||||||
|
usort($keys, function (string $a, string $b): int {
|
||||||
|
return self::depthOf($b) <=> self::depthOf($a);
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$parent = $folders[$key]['parent'];
|
||||||
|
if ($parent !== null && isset($folders[$parent])) {
|
||||||
|
$folders[$parent]['bytes'] += $folders[$key]['bytes'];
|
||||||
|
$folders[$parent]['files'] += $folders[$key]['files'];
|
||||||
|
$folders[$parent]['dirs'] += $folders[$key]['dirs'];
|
||||||
|
$parentLatest = $folders[$parent]['latest_mtime'];
|
||||||
|
if ($folders[$key]['latest_mtime'] > $parentLatest) {
|
||||||
|
$folders[$parent]['latest_mtime'] = $folders[$key]['latest_mtime'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root aggregate
|
||||||
|
$rootBytes = isset($folders['root']) ? (int)$folders['root']['bytes'] : 0;
|
||||||
|
$rootFiles = isset($folders['root']) ? (int)$folders['root']['files'] : 0;
|
||||||
|
|
||||||
|
// Count of folders under the upload root (excluding "root" itself)
|
||||||
|
$rootFolders = 0;
|
||||||
|
if (!empty($folders)) {
|
||||||
|
$rootFolders = max(0, count($folders) - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim top files list
|
||||||
|
usort($files, function (array $a, array $b): int {
|
||||||
|
// descending by bytes, then by path
|
||||||
|
if ($a['bytes'] === $b['bytes']) {
|
||||||
|
return strcmp($a['path'], $b['path']);
|
||||||
|
}
|
||||||
|
return ($a['bytes'] < $b['bytes']) ? 1 : -1;
|
||||||
|
});
|
||||||
|
if (count($files) > self::TOP_FILE_LIMIT) {
|
||||||
|
$files = array_slice($files, 0, self::TOP_FILE_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshot = [
|
||||||
|
'version' => 1,
|
||||||
|
'generated_at' => time(),
|
||||||
|
'scan_seconds' => microtime(true) - $start,
|
||||||
|
'root_bytes' => $rootBytes,
|
||||||
|
'root_files' => $rootFiles,
|
||||||
|
'root_folders' => $rootFolders,
|
||||||
|
// Store folders as numerically-indexed array
|
||||||
|
'folders' => array_values($folders),
|
||||||
|
'files' => $files,
|
||||||
|
];
|
||||||
|
|
||||||
|
$path = self::snapshotPath();
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Failed to encode disk usage snapshot.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (@file_put_contents($path, $json) === false) {
|
||||||
|
throw new RuntimeException('Failed to write disk usage snapshot to ' . $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the snapshot from disk, or return null if missing or invalid.
|
||||||
|
*/
|
||||||
|
public static function loadSnapshot(): ?array
|
||||||
|
{
|
||||||
|
$path = self::snapshotPath();
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
if ($raw === false || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isset($data['version']) || (int)$data['version'] !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a lightweight summary for the Admin panel.
|
||||||
|
*
|
||||||
|
* @param int $maxTopFolders How many top folders to include.
|
||||||
|
* @param int $maxTopFilesPreview Optional number of top files to include as preview.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getSummary(int $maxTopFolders = 5, int $maxTopFilesPreview = 0): array
|
||||||
|
{
|
||||||
|
$snapshot = self::loadSnapshot();
|
||||||
|
if ($snapshot === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'no_snapshot',
|
||||||
|
'message' => 'No disk usage snapshot found. Run the disk usage scan to generate one.',
|
||||||
|
'generatedAt' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
||||||
|
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
|
||||||
|
|
||||||
|
// --- Build "volumes" across core FileRise dirs (UPLOAD/USERS/META) ---
|
||||||
|
$volumeRoots = [
|
||||||
|
'uploads' => defined('UPLOAD_DIR') ? (string)UPLOAD_DIR : null,
|
||||||
|
'users' => defined('USERS_DIR') ? (string)USERS_DIR : null,
|
||||||
|
'meta' => defined('META_DIR') ? (string)META_DIR : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$volumesMap = [];
|
||||||
|
$uploadReal = null;
|
||||||
|
|
||||||
|
if (defined('UPLOAD_DIR')) {
|
||||||
|
$tmp = realpath(UPLOAD_DIR);
|
||||||
|
if ($tmp !== false && is_dir($tmp)) {
|
||||||
|
$uploadReal = $tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($volumeRoots as $kind => $dir) {
|
||||||
|
if ($dir === null || $dir === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$real = realpath($dir);
|
||||||
|
if ($real === false || !is_dir($real)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = @disk_total_space($real);
|
||||||
|
$free = @disk_free_space($real);
|
||||||
|
if ($total === false || $free === false || $total <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int)$total;
|
||||||
|
$free = (int)$free;
|
||||||
|
$used = $total - $free;
|
||||||
|
if ($used < 0) {
|
||||||
|
$used = 0;
|
||||||
|
}
|
||||||
|
$usedPct = ($used * 100.0) / $total;
|
||||||
|
|
||||||
|
// Group by same total+free => assume same underlying volume
|
||||||
|
$bucketKey = $total . ':' . $free;
|
||||||
|
if (!isset($volumesMap[$bucketKey])) {
|
||||||
|
$volumesMap[$bucketKey] = [
|
||||||
|
'totalBytes' => $total,
|
||||||
|
'freeBytes' => $free,
|
||||||
|
'usedBytes' => $used,
|
||||||
|
'usedPercent' => $usedPct,
|
||||||
|
'roots' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$volumesMap[$bucketKey]['roots'][] = [
|
||||||
|
'kind' => $kind, // "uploads" | "users" | "meta"
|
||||||
|
'path' => $real,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$volumes = array_values($volumesMap);
|
||||||
|
// Sort by usedPercent desc (heaviest first)
|
||||||
|
usort($volumes, function (array $a, array $b): int {
|
||||||
|
$pa = (float)($a['usedPercent'] ?? 0.0);
|
||||||
|
$pb = (float)($b['usedPercent'] ?? 0.0);
|
||||||
|
if ($pa === $pb) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ($pa < $pb) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backwards-compat: root filesystem metrics based on the volume
|
||||||
|
// that contains UPLOAD_DIR (if we can detect it).
|
||||||
|
$fsTotalBytes = null;
|
||||||
|
$fsFreeBytes = null;
|
||||||
|
$fsUsedBytes = null;
|
||||||
|
$fsUsedPct = null;
|
||||||
|
|
||||||
|
if ($uploadReal && !empty($volumes)) {
|
||||||
|
foreach ($volumes as $vol) {
|
||||||
|
foreach ($vol['roots'] as $root) {
|
||||||
|
if (!isset($root['path'])) continue;
|
||||||
|
if ((string)$root['path'] === (string)$uploadReal) {
|
||||||
|
$fsTotalBytes = (int)$vol['totalBytes'];
|
||||||
|
$fsFreeBytes = (int)$vol['freeBytes'];
|
||||||
|
$fsUsedBytes = (int)$vol['usedBytes'];
|
||||||
|
$fsUsedPct = (float)$vol['usedPercent'];
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top N non-root folders by bytes (from snapshot)
|
||||||
|
$candidates = array_filter($folders, function (array $f): bool {
|
||||||
|
return isset($f['key']) && $f['key'] !== 'root';
|
||||||
|
});
|
||||||
|
|
||||||
|
usort($candidates, function (array $a, array $b): int {
|
||||||
|
$ba = (int)($a['bytes'] ?? 0);
|
||||||
|
$bb = (int)($b['bytes'] ?? 0);
|
||||||
|
if ($ba === $bb) {
|
||||||
|
return strcmp((string)$a['key'], (string)$b['key']);
|
||||||
|
}
|
||||||
|
return ($ba < $bb) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($maxTopFolders > 0 && count($candidates) > $maxTopFolders) {
|
||||||
|
$candidates = array_slice($candidates, 0, $maxTopFolders);
|
||||||
|
}
|
||||||
|
|
||||||
|
$topFolders = [];
|
||||||
|
foreach ($candidates as $f) {
|
||||||
|
$bytes = (int)($f['bytes'] ?? 0);
|
||||||
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
||||||
|
$topFolders[] = [
|
||||||
|
'folder' => (string)$f['key'],
|
||||||
|
'name' => (string)$f['name'],
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'files' => (int)($f['files'] ?? 0),
|
||||||
|
'dirs' => (int)($f['dirs'] ?? 0),
|
||||||
|
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
|
||||||
|
'percentOfTotal' => $pct,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// totalFolders: prefer snapshot["root_folders"], but fall back to counting
|
||||||
|
$totalFolders = isset($snapshot['root_folders'])
|
||||||
|
? (int)$snapshot['root_folders']
|
||||||
|
: max(0, count($folders) - 1);
|
||||||
|
|
||||||
|
$out = [
|
||||||
|
'ok' => true,
|
||||||
|
'generatedAt' => (int)($snapshot['generated_at'] ?? 0),
|
||||||
|
'scanSeconds' => (float)($snapshot['scan_seconds'] ?? 0.0),
|
||||||
|
'totalBytes' => $rootBytes,
|
||||||
|
'totalFiles' => (int)($snapshot['root_files'] ?? 0),
|
||||||
|
'totalFolders' => $totalFolders,
|
||||||
|
'topFolders' => $topFolders,
|
||||||
|
// original fields (for single-root view)
|
||||||
|
'uploadRoot' => $uploadReal,
|
||||||
|
'fsTotalBytes' => $fsTotalBytes,
|
||||||
|
'fsFreeBytes' => $fsFreeBytes,
|
||||||
|
'fsUsedBytes' => $fsUsedBytes,
|
||||||
|
'fsUsedPercent' => $fsUsedPct,
|
||||||
|
// new grouped volumes: each with total/free/used and roots[]
|
||||||
|
'volumes' => $volumes,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($maxTopFilesPreview > 0) {
|
||||||
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
||||||
|
if (count($files) > $maxTopFilesPreview) {
|
||||||
|
$files = array_slice($files, 0, $maxTopFilesPreview);
|
||||||
|
}
|
||||||
|
$out['topFiles'] = $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return direct children (folders + files) of a given folder key.
|
||||||
|
*
|
||||||
|
* @param string $folderKey
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getChildren(string $folderKey): array
|
||||||
|
{
|
||||||
|
$folderKey = ($folderKey === '' || $folderKey === '/') ? 'root' : $folderKey;
|
||||||
|
|
||||||
|
$snapshot = self::loadSnapshot();
|
||||||
|
if ($snapshot === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'no_snapshot',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
||||||
|
$folders = is_array($snapshot['folders'] ?? null) ? $snapshot['folders'] : [];
|
||||||
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
||||||
|
|
||||||
|
// Index folders by key
|
||||||
|
$folderByKey = [];
|
||||||
|
foreach ($folders as $f) {
|
||||||
|
if (!isset($f['key'])) continue;
|
||||||
|
$folderByKey[(string)$f['key']] = $f;
|
||||||
|
}
|
||||||
|
if (!isset($folderByKey[$folderKey])) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'folder_not_found',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$childrenFolders = [];
|
||||||
|
foreach ($folders as $f) {
|
||||||
|
if (!isset($f['parent']) || !isset($f['key'])) continue;
|
||||||
|
if ((string)$f['parent'] === $folderKey) {
|
||||||
|
$bytes = (int)($f['bytes'] ?? 0);
|
||||||
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
||||||
|
$childrenFolders[] = [
|
||||||
|
'type' => 'folder',
|
||||||
|
'folder' => (string)$f['key'],
|
||||||
|
'name' => (string)$f['name'],
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'files' => (int)($f['files'] ?? 0),
|
||||||
|
'dirs' => (int)($f['dirs'] ?? 0),
|
||||||
|
'latest_mtime' => (int)($f['latest_mtime'] ?? 0),
|
||||||
|
'percentOfTotal' => $pct,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$childrenFiles = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (!isset($file['folder']) || !isset($file['name'])) continue;
|
||||||
|
if ((string)$file['folder'] !== $folderKey) continue;
|
||||||
|
|
||||||
|
$bytes = (int)($file['bytes'] ?? 0);
|
||||||
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
||||||
|
$childrenFiles[] = [
|
||||||
|
'type' => 'file',
|
||||||
|
'folder' => (string)$file['folder'],
|
||||||
|
'name' => (string)$file['name'],
|
||||||
|
'path' => (string)($file['path'] ?? $file['name']),
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'mtime' => (int)($file['mtime'] ?? 0),
|
||||||
|
'percentOfTotal' => $pct,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort children: folders first (by bytes desc), then files (by bytes desc)
|
||||||
|
usort($childrenFolders, function (array $a, array $b): int {
|
||||||
|
$ba = (int)($a['bytes'] ?? 0);
|
||||||
|
$bb = (int)($b['bytes'] ?? 0);
|
||||||
|
if ($ba === $bb) {
|
||||||
|
return strcmp((string)$a['name'], (string)$b['name']);
|
||||||
|
}
|
||||||
|
return ($ba < $bb) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
usort($childrenFiles, function (array $a, array $b): int {
|
||||||
|
$ba = (int)($a['bytes'] ?? 0);
|
||||||
|
$bb = (int)($b['bytes'] ?? 0);
|
||||||
|
if ($ba === $bb) {
|
||||||
|
return strcmp((string)$a['name'], (string)$b['name']);
|
||||||
|
}
|
||||||
|
return ($ba < $bb) ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'folder' => $folderKey,
|
||||||
|
'folders' => $childrenFolders,
|
||||||
|
'files' => $childrenFiles,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the global Top N files by size from the snapshot.
|
||||||
|
*
|
||||||
|
* @param int $limit
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getTopFiles(int $limit = 100): array
|
||||||
|
{
|
||||||
|
$snapshot = self::loadSnapshot();
|
||||||
|
if ($snapshot === null) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'no_snapshot',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootBytes = (int)($snapshot['root_bytes'] ?? 0);
|
||||||
|
$files = is_array($snapshot['files'] ?? null) ? $snapshot['files'] : [];
|
||||||
|
|
||||||
|
if ($limit > 0 && count($files) > $limit) {
|
||||||
|
$files = array_slice($files, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$bytes = (int)($file['bytes'] ?? 0);
|
||||||
|
$pct = ($rootBytes > 0) ? ($bytes * 100.0 / $rootBytes) : 0.0;
|
||||||
|
$out[] = [
|
||||||
|
'folder' => (string)($file['folder'] ?? 'root'),
|
||||||
|
'name' => (string)($file['name'] ?? ''),
|
||||||
|
'path' => (string)($file['path'] ?? ($file['name'] ?? '')),
|
||||||
|
'bytes' => $bytes,
|
||||||
|
'mtime' => (int)($file['mtime'] ?? 0),
|
||||||
|
'percentOfTotal' => $pct,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => true,
|
||||||
|
'files' => $out,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: derive the parent folder key ("root" -> null, "foo/bar" -> "foo").
|
||||||
|
*/
|
||||||
|
private static function parentKeyOf(string $key): ?string
|
||||||
|
{
|
||||||
|
if ($key === 'root' || $key === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$key = trim($key, '/');
|
||||||
|
if ($key === '') return null;
|
||||||
|
$pos = strrpos($key, '/');
|
||||||
|
if ($pos === false) {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
$parent = substr($key, 0, $pos);
|
||||||
|
return ($parent === '' ? 'root' : $parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: basename of a folder key. "root" -> "root", "foo/bar" -> "bar".
|
||||||
|
*/
|
||||||
|
private static function basenameKey(?string $key): string
|
||||||
|
{
|
||||||
|
if ($key === null || $key === '' || $key === 'root') {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
$key = trim($key, '/');
|
||||||
|
$pos = strrpos($key, '/');
|
||||||
|
if ($pos === false) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
return substr($key, $pos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: approximate depth of a folder key (root->0, "foo"->1, "foo/bar"->2, etc.)
|
||||||
|
*/
|
||||||
|
private static function depthOf(string $key): int
|
||||||
|
{
|
||||||
|
if ($key === '' || $key === 'root') return 0;
|
||||||
|
return substr_count(trim($key, '/'), '/') + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -503,13 +503,13 @@ class FileModel {
|
|||||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||||
return ["error" => "Invalid file name."];
|
return ["error" => "Invalid file name."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the real upload directory.
|
// Determine the real upload directory.
|
||||||
$uploadDirReal = realpath(UPLOAD_DIR);
|
$uploadDirReal = realpath(UPLOAD_DIR);
|
||||||
if ($uploadDirReal === false) {
|
if ($uploadDirReal === false) {
|
||||||
return ["error" => "Server misconfiguration."];
|
return ["error" => "Server misconfiguration."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine directory based on folder.
|
// Determine directory based on folder.
|
||||||
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
if (strtolower($folder) === 'root' || trim($folder) === '') {
|
||||||
$directory = $uploadDirReal;
|
$directory = $uploadDirReal;
|
||||||
@@ -524,11 +524,11 @@ class FileModel {
|
|||||||
return ["error" => "Invalid folder path."];
|
return ["error" => "Invalid folder path."];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the file path.
|
// Build the file path.
|
||||||
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
|
||||||
$realFilePath = realpath($filePath);
|
$realFilePath = realpath($filePath);
|
||||||
|
|
||||||
// Ensure the file exists and is within the allowed directory.
|
// Ensure the file exists and is within the allowed directory.
|
||||||
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
if ($realFilePath === false || strpos($realFilePath, $uploadDirReal) !== 0) {
|
||||||
return ["error" => "Access forbidden."];
|
return ["error" => "Access forbidden."];
|
||||||
@@ -536,19 +536,86 @@ class FileModel {
|
|||||||
if (!file_exists($realFilePath)) {
|
if (!file_exists($realFilePath)) {
|
||||||
return ["error" => "File not found."];
|
return ["error" => "File not found."];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the MIME type with safe fallback.
|
// Get the MIME type with safe fallback.
|
||||||
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
$mimeType = function_exists('mime_content_type') ? mime_content_type($realFilePath) : null;
|
||||||
if (!$mimeType) {
|
if (!$mimeType) {
|
||||||
$mimeType = 'application/octet-stream';
|
$mimeType = 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OPTIONAL: normalize SVG MIME
|
||||||
|
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||||
|
if ($ext === 'svg') {
|
||||||
|
$mimeType = 'image/svg+xml';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"filePath" => $realFilePath,
|
"filePath" => $realFilePath,
|
||||||
"mimeType" => $mimeType
|
"mimeType" => $mimeType
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function deleteFilesPermanent(string $folder, array $files): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
$deleted = [];
|
||||||
|
|
||||||
|
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
|
||||||
|
if ($err) return ['error' => $err];
|
||||||
|
|
||||||
|
$uploadDir = rtrim($uploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
|
||||||
|
foreach ($files as $fileName) {
|
||||||
|
$originalName = basename(trim((string)$fileName));
|
||||||
|
$basename = $originalName;
|
||||||
|
|
||||||
|
if ($basename === '') {
|
||||||
|
$errors[] = 'Empty file name.';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match($safeFileNamePattern, $basename)) {
|
||||||
|
$errors[] = "$basename has an invalid name.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = $uploadDir . $basename;
|
||||||
|
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
if (!@unlink($filePath)) {
|
||||||
|
$errors[] = "Failed to delete {$basename}.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted[] = $basename;
|
||||||
|
|
||||||
|
// Remove from folder metadata if present
|
||||||
|
$metadataFile = self::getMetadataFilePath($folder);
|
||||||
|
if (file_exists($metadataFile)) {
|
||||||
|
$meta = json_decode(file_get_contents($metadataFile), true);
|
||||||
|
if (is_array($meta) && isset($meta[$basename])) {
|
||||||
|
unset($meta[$basename]);
|
||||||
|
@file_put_contents($metadataFile, json_encode($meta, JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors && !$deleted) {
|
||||||
|
return ['error' => implode('; ', $errors)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
return [
|
||||||
|
'error' => implode('; ', $errors),
|
||||||
|
'success' => 'Deleted: ' . implode(', ', $deleted),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => 'Deleted: ' . implode(', ', $deleted)];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ZIP archive of the specified files from a given folder.
|
* Creates a ZIP archive of the specified files from a given folder.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,110 +12,135 @@ class FolderModel
|
|||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
public static function countVisible(string $folder, string $user, array $perms): array
|
public static function countVisible(string $folder, string $user, array $perms): array
|
||||||
{
|
{
|
||||||
$folder = ACL::normalizeFolder($folder);
|
$folder = ACL::normalizeFolder($folder);
|
||||||
|
|
||||||
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
|
||||||
$canViewFolder = ACL::isAdmin($perms)
|
$canViewFolder = ACL::isAdmin($perms)
|
||||||
|| ACL::canRead($user, $perms, $folder)
|
|| ACL::canRead($user, $perms, $folder)
|
||||||
|| ACL::canReadOwn($user, $perms, $folder);
|
|| ACL::canReadOwn($user, $perms, $folder);
|
||||||
if (!$canViewFolder) {
|
if (!$canViewFolder) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: distinguish full read vs own-only for this folder
|
// NEW: distinguish full read vs own-only for this folder
|
||||||
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
|
||||||
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
// if !$hasFullRead but $canViewFolder is true, they’re effectively "view own" only
|
||||||
|
|
||||||
$base = realpath((string)UPLOAD_DIR);
|
$base = realpath((string)UPLOAD_DIR);
|
||||||
if ($base === false) {
|
if ($base === false) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target dir + ACL-relative prefix
|
// Resolve target dir + ACL-relative prefix
|
||||||
if ($folder === 'root') {
|
if ($folder === 'root') {
|
||||||
$dir = $base;
|
$dir = $base;
|
||||||
$relPrefix = '';
|
$relPrefix = '';
|
||||||
} else {
|
} else {
|
||||||
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
|
||||||
foreach ($parts as $seg) {
|
foreach ($parts as $seg) {
|
||||||
if (!self::isSafeSegment($seg)) {
|
if (!self::isSafeSegment($seg)) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
|
||||||
$dir = self::safeReal($base, $guess);
|
$dir = self::safeReal($base, $guess);
|
||||||
if ($dir === null || !is_dir($dir)) {
|
if ($dir === null || !is_dir($dir)) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
$relPrefix = implode('/', $parts);
|
$relPrefix = implode('/', $parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
|
||||||
$SKIP = ['trash', 'profile_pics'];
|
$SKIP = ['trash', 'profile_pics'];
|
||||||
|
|
||||||
$entries = @scandir($dir);
|
$entries = @scandir($dir);
|
||||||
if ($entries === false) {
|
if ($entries === false) {
|
||||||
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folderCount = 0;
|
$folderCount = 0;
|
||||||
$fileCount = 0;
|
$fileCount = 0;
|
||||||
$totalBytes = 0;
|
$totalBytes = 0;
|
||||||
|
|
||||||
$MAX_SCAN = 4000;
|
// NEW: stats for created / modified
|
||||||
$scanned = 0;
|
$earliestUploaded = null; // min mtime
|
||||||
|
$latestMtime = null; // max mtime
|
||||||
foreach ($entries as $name) {
|
|
||||||
if (++$scanned > $MAX_SCAN) {
|
$MAX_SCAN = 4000;
|
||||||
break;
|
$scanned = 0;
|
||||||
}
|
|
||||||
|
foreach ($entries as $name) {
|
||||||
if ($name === '.' || $name === '..') continue;
|
if (++$scanned > $MAX_SCAN) {
|
||||||
if ($name[0] === '.') continue;
|
break;
|
||||||
if (in_array($name, $IGNORE, true)) continue;
|
}
|
||||||
if (in_array(strtolower($name), $SKIP, true)) continue;
|
|
||||||
if (!self::isSafeSegment($name)) continue;
|
if ($name === '.' || $name === '..') continue;
|
||||||
|
if ($name[0] === '.') continue;
|
||||||
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
if (in_array($name, $IGNORE, true)) continue;
|
||||||
|
if (in_array(strtolower($name), $SKIP, true)) continue;
|
||||||
if (@is_dir($abs)) {
|
if (!self::isSafeSegment($name)) continue;
|
||||||
if (@is_link($abs)) {
|
|
||||||
$safe = self::safeReal($base, $abs);
|
$abs = $dir . DIRECTORY_SEPARATOR . $name;
|
||||||
if ($safe === null || !is_dir($safe)) {
|
|
||||||
continue;
|
if (@is_dir($abs)) {
|
||||||
}
|
if (@is_link($abs)) {
|
||||||
}
|
$safe = self::safeReal($base, $abs);
|
||||||
|
if ($safe === null || !is_dir($safe)) {
|
||||||
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
continue;
|
||||||
if (
|
}
|
||||||
ACL::isAdmin($perms)
|
}
|
||||||
|| ACL::canRead($user, $perms, $childRel)
|
|
||||||
|| ACL::canReadOwn($user, $perms, $childRel)
|
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
|
||||||
) {
|
if (
|
||||||
$folderCount++;
|
ACL::isAdmin($perms)
|
||||||
}
|
|| ACL::canRead($user, $perms, $childRel)
|
||||||
} elseif (@is_file($abs)) {
|
|| ACL::canReadOwn($user, $perms, $childRel)
|
||||||
// Only count files if the user has full read on *this* folder.
|
) {
|
||||||
// If they’re view_own-only here, don’t leak or mis-report counts.
|
$folderCount++;
|
||||||
if (!$hasFullRead) {
|
}
|
||||||
continue;
|
} elseif (@is_file($abs)) {
|
||||||
}
|
// Only count files if the user has full read on *this* folder.
|
||||||
|
// If they’re view_own-only here, don’t leak or mis-report counts.
|
||||||
$fileCount++;
|
if (!$hasFullRead) {
|
||||||
$sz = @filesize($abs);
|
continue;
|
||||||
if (is_int($sz) && $sz > 0) {
|
}
|
||||||
$totalBytes += $sz;
|
|
||||||
}
|
$fileCount++;
|
||||||
}
|
$sz = @filesize($abs);
|
||||||
}
|
if (is_int($sz) && $sz > 0) {
|
||||||
|
$totalBytes += $sz;
|
||||||
return [
|
}
|
||||||
'folders' => $folderCount,
|
|
||||||
'files' => $fileCount,
|
// NEW: track earliest / latest mtime from visible files
|
||||||
'bytes' => $totalBytes,
|
$mt = @filemtime($abs);
|
||||||
];
|
if (is_int($mt) && $mt > 0) {
|
||||||
}
|
if ($earliestUploaded === null || $mt < $earliestUploaded) {
|
||||||
|
$earliestUploaded = $mt;
|
||||||
|
}
|
||||||
|
if ($latestMtime === null || $mt > $latestMtime) {
|
||||||
|
$latestMtime = $mt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'folders' => $folderCount,
|
||||||
|
'files' => $fileCount,
|
||||||
|
'bytes' => $totalBytes,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only include when we actually saw at least one readable file
|
||||||
|
if ($earliestUploaded !== null) {
|
||||||
|
$result['earliest_uploaded'] = date(DATE_TIME_FORMAT, $earliestUploaded);
|
||||||
|
}
|
||||||
|
if ($latestMtime !== null) {
|
||||||
|
$result['latest_mtime'] = date(DATE_TIME_FORMAT, $latestMtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/* Helpers (private) */
|
/* Helpers (private) */
|
||||||
private static function isSafeSegment(string $name): bool
|
private static function isSafeSegment(string $name): bool
|
||||||
@@ -483,6 +508,64 @@ class FolderModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static function deleteFolderRecursiveAdmin(string $folder): array
|
||||||
|
{
|
||||||
|
if (strtolower($folder) === 'root') {
|
||||||
|
return ['error' => 'Cannot delete root folder.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$real, $relative, $err] = self::resolveFolderPath($folder, false);
|
||||||
|
if ($err) return ['error' => $err];
|
||||||
|
|
||||||
|
if (!is_dir($real)) {
|
||||||
|
return ['error' => 'Folder not found.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$it = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($real, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($it as $path => $info) {
|
||||||
|
if ($info->isDir()) {
|
||||||
|
if (!@rmdir($path)) {
|
||||||
|
$errors[] = "Failed to delete directory: {$path}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!@unlink($path)) {
|
||||||
|
$errors[] = "Failed to delete file: {$path}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@rmdir($real)) {
|
||||||
|
$errors[] = "Failed to delete directory: {$real}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove metadata JSONs for this subtree
|
||||||
|
$relative = trim($relative, "/\\ ");
|
||||||
|
if ($relative !== '' && $relative !== 'root') {
|
||||||
|
$prefix = str_replace(['/', '\\', ' '], '-', $relative);
|
||||||
|
$globPat = META_DIR . $prefix . '*_metadata.json';
|
||||||
|
$metaFiles = glob($globPat) ?: [];
|
||||||
|
foreach ($metaFiles as $mf) {
|
||||||
|
@unlink($mf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ownership mappings for the subtree.
|
||||||
|
self::removeOwnerForTree($relative);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
return ['error' => implode('; ', $errors)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => 'Folder and all contents deleted.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a folder if it is empty and removes its corresponding metadata.
|
* Deletes a folder if it is empty and removes its corresponding metadata.
|
||||||
* Also removes ownership mappings for this folder and all its descendants.
|
* Also removes ownership mappings for this folder and all its descendants.
|
||||||
|
|||||||
Reference in New Issue
Block a user