Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 | ||
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 |
148
CHANGELOG.md
@@ -1,5 +1,153 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/27/2025 (v2.1.0)
|
||||||
|
|
||||||
|
🦃🍂 Happy Thanksgiving. 🥧🍁🍽️
|
||||||
|
|
||||||
|
release(v2.1.0): add header zoom controls, preview tags & modal/dock polish
|
||||||
|
|
||||||
|
- **feat(ux): header zoom controls with persisted app zoom**
|
||||||
|
- Add `zoom.js` with percent-based zoom API (`window.fileriseZoom`) and `--app-zoom` CSS variable.
|
||||||
|
- Wrap the main app in `#appZoomShell` and scale via `transform: scale(var(--app-zoom))` so the whole UI zooms uniformly.
|
||||||
|
- Add header zoom UI (+ / − / 100% reset) and wire it via `data-zoom` buttons.
|
||||||
|
- Persist zoom level in `localStorage` and restore on load.
|
||||||
|
|
||||||
|
- **feat(prefs): user toggle to hide header zoom controls**
|
||||||
|
- Add `hide_header_zoom_controls` i18n key.
|
||||||
|
- Extend the Settings → Display fieldset with “Hide header zoom controls”.
|
||||||
|
- Store preference in `localStorage('hideZoomControls')` and respect it from `appCore.js` when initializing header zoom UI.
|
||||||
|
|
||||||
|
- **feat(preview): show file tags next to preview title**
|
||||||
|
- Add `.title-tags` container in the media viewer header.
|
||||||
|
- When opening a file, look up its `tags` from `fileData` and render them as pill badges beside the filename in the modal top bar.
|
||||||
|
|
||||||
|
- **fix(modals): folder modals always centered above header cards**
|
||||||
|
- Introduce `detachFolderModalsToBody()` in `folderManager.js` and call it on init + before opening create/rename/move/delete modals.
|
||||||
|
- Move those modals under `document.body` with a stable high `z-index`, so they’re not clipped/hidden when the cards live in the header dock.
|
||||||
|
|
||||||
|
- **fix(dnd): header dock & hidden cards container**
|
||||||
|
- Change `#hiddenCardsContainer` from `display:none` to an off-screen absolutely positioned container so card internals (modals/layout) still work while represented as header icons.
|
||||||
|
- Ensure sidebar is always visible as a drop target while dragging (even when panels are collapsed), plus improved highlight & placeholder behavior.
|
||||||
|
|
||||||
|
- **feat(ux): header dock hover/lock polish**
|
||||||
|
- Make header icon buttons share the same hover style as other header buttons.
|
||||||
|
- Add `.is-locked` state so a pinned header icon stays visually “pressed” while its card modal is locked open.
|
||||||
|
|
||||||
|
- **feat(ux): header drop zone and zoom bar layout**
|
||||||
|
- Rework `.header-right` to neatly align zoom controls, header dock, and user buttons.
|
||||||
|
- Add a more flexible `.header-drop-zone` with smooth width/padding transitions and a centered `"Drop Zone"` label when active and empty.
|
||||||
|
- Adjust responsive spacing around zoom controls on smaller screens.
|
||||||
|
|
||||||
|
- **tweak(prefs-modal): improve settings modal sizing**
|
||||||
|
- Increase auth/settings modal `max-height` from 500px to 600px to fit the extra display options without excessive scrolling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/26/2025 (v2.0.4)
|
||||||
|
|
||||||
|
release(v2.0.4): harden sessions and align Pro paths with USERS_DIR
|
||||||
|
|
||||||
|
- Enable strict_types in config.php and AdminController
|
||||||
|
- Decouple PHP session lifetime from "remember me" window
|
||||||
|
- Regenerate session ID on persistent token auto-login
|
||||||
|
- Point Pro license / bundle paths at USERS_DIR instead of hardcoded /users
|
||||||
|
- Tweak folder management card drag offset for better alignment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/26/2025 (v2.0.3)
|
||||||
|
|
||||||
|
release(v2.0.3): polish uploads, header dock, and panel fly animations
|
||||||
|
|
||||||
|
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
|
||||||
|
so only one OS file-picker dialog can open at a time.
|
||||||
|
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
|
||||||
|
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
|
||||||
|
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
|
||||||
|
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
|
||||||
|
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
|
||||||
|
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
|
||||||
|
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
|
||||||
|
- Slightly tighten file summary margin in the file list header for better alignment with actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/23/2025 (v2.0.2)
|
||||||
|
|
||||||
|
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
||||||
|
|
||||||
|
- Wire FR_DEMO_MODE through AdminModel/siteConfig and admin getConfig (demoMode flag)
|
||||||
|
- Drive demo detection in JS from __FR_SITE_CFG__.demoMode instead of hostname
|
||||||
|
- Show consistent login tip + toasts for demo using shared __FR_DEMO__ flag
|
||||||
|
- Block password changes for the demo user and profile picture uploads when in demo mode
|
||||||
|
- Keep normal user dropdown/admin UI visible even on the demo, while still protecting the demo account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/23/2025 (v2.0.0)
|
||||||
|
|
||||||
|
### FileRise Core v2.0.0 & FileRise Pro v1.1.0
|
||||||
|
|
||||||
|
```text
|
||||||
|
release(v2.0.0): feat(pro): client portals + portal login flow
|
||||||
|
release(v2.0.1): fix: harden portal + core login redirects for codeql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core v2.0.0
|
||||||
|
|
||||||
|
- **Portal plumbing in core**
|
||||||
|
- New public pages: `portal.html` and `portal-login.html` for client-facing views.
|
||||||
|
- New portal controller + API endpoints that read portal definitions from the Pro bundle, enforce expiry, and expose safe public metadata.
|
||||||
|
- Login flow now respects a `?redirect=` parameter so portals can bounce through login cleanly and land back on the right slug.
|
||||||
|
|
||||||
|
- **Admin UX + styling**
|
||||||
|
- Admin panel CSS pulled into a dedicated `adminPanelStyles.js` helper instead of inline styles.
|
||||||
|
- User Groups and Client Portals modals use the new shared styling and dark-mode tweaks so they match the rest of the UI.
|
||||||
|
|
||||||
|
- **Breadcrumb root fix**
|
||||||
|
- Breadcrumbs now always show **root** explicitly and behave correctly when you’re at top level vs nested folders.
|
||||||
|
|
||||||
|
- **Routing**
|
||||||
|
- Apache rewrite added for pretty portal URLs:
|
||||||
|
`https://host/portal/<slug>` → `portal.html?slug=<slug>` without affecting other routes.
|
||||||
|
|
||||||
|
### Pro v1.1.0 – Client Portals
|
||||||
|
|
||||||
|
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
|
||||||
|
- Create multiple portals, each with:
|
||||||
|
- Slug + display name
|
||||||
|
- Target folder
|
||||||
|
- Optional client email
|
||||||
|
- Upload-only / allow-download flags
|
||||||
|
- Per-portal expiry date
|
||||||
|
- Portal-level copy and branding:
|
||||||
|
- Optional title + instructions
|
||||||
|
- Accent color used throughout the portal UI
|
||||||
|
- Footer text at bottom of the portal page
|
||||||
|
|
||||||
|
- **Optional intake form before uploads**
|
||||||
|
- Enable a form per portal with fields: name, email, reference, notes.
|
||||||
|
- Per-field “default value” and “required” toggles.
|
||||||
|
- Form must be completed before uploads when enabled.
|
||||||
|
|
||||||
|
- **Submissions log**
|
||||||
|
- Each portal keeps a submissions list showing:
|
||||||
|
- Date/time, folder, submitting user, IP address
|
||||||
|
- The intake form values (name, email, reference, notes).
|
||||||
|
|
||||||
|
- **Client-facing experience**
|
||||||
|
- New portal UI with:
|
||||||
|
- Branded header (title + accent color)
|
||||||
|
- Optional intake form
|
||||||
|
- Drag-and-drop upload dropzone
|
||||||
|
- If downloads are enabled, a clean list/grid of files already in that portal’s folder with download buttons.
|
||||||
|
|
||||||
|
- **Portal login page**
|
||||||
|
- Minimal login screen that pulls title/accent/footer from portal metadata.
|
||||||
|
- After successful login, user is redirected back to the original portal URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/21/2025 (v1.9.14)
|
## Changes 11/21/2025 (v1.9.14)
|
||||||
|
|
||||||
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
|
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
|
||||||
|
|||||||
31
README.md
@@ -10,22 +10,25 @@
|
|||||||
[](https://github.com/sponsors/error311)
|
[](https://github.com/sponsors/error311)
|
||||||
[](https://ko-fi.com/error311)
|
[](https://ko-fi.com/error311)
|
||||||
|
|
||||||
**FileRise** is a modern, self‑hosted web file manager / WebDAV server.
|
**FileRise** is a modern, self-hosted web file manager / WebDAV server.
|
||||||
Drag & drop uploads, ACL‑aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||||
|
|
||||||
- 💾 **Self‑hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
- 💾 **Self-hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||||
- 🔐 **Granular per‑folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
- 🔐 **Granular per-folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||||
- 🔄 **Fast drag‑and‑drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
- 🔄 **Fast drag-and-drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||||
- 🌳 **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.
|
||||||
- 🎨 **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.
|
||||||
|
|
||||||

|
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
|

|
||||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open‑source (MIT).
|
|
||||||
|
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||||
|
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +76,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)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -188,3 +194,8 @@ It bundles a small set of well‑known client and server libraries (Bootstrap, C
|
|||||||
All third‑party code remains under its original licenses.
|
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
|
||||||
@@ -16,6 +17,7 @@ define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[.
|
|||||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||||
|
define('FR_DEMO_MODE', false);
|
||||||
|
|
||||||
date_default_timezone_set(TIMEZONE);
|
date_default_timezone_set(TIMEZONE);
|
||||||
|
|
||||||
@@ -100,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:
|
||||||
@@ -154,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"]);
|
||||||
@@ -161,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,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);
|
||||||
}
|
}
|
||||||
@@ -267,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')) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RewriteRule - - [L]
|
|||||||
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||||
|
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||||
|
|
||||||
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||||
# - allow /api/*.php (API endpoints)
|
# - allow /api/*.php (API endpoints)
|
||||||
|
|||||||
27
public/api/pro/portals/get.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/get.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/PortalController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
|
||||||
|
|
||||||
|
// For v1: we do NOT require auth here; this is just metadata,
|
||||||
|
// real ACL/access control must still be enforced at upload/download endpoints.
|
||||||
|
$portal = PortalController::getPortalBySlug($slug);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portal' => $portal,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
32
public/api/pro/portals/list.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/list.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';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$portals = $ctrl->getProPortals();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portals' => $portals,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
108
public/api/pro/portals/publicMeta.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/publicMeta.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
|
||||||
|
// --- Basic Pro checks ---
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'FileRise Pro is not active.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Missing portal slug.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Locate portals.json written by saveProPortals() ---
|
||||||
|
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
|
||||||
|
if ($bundleDir === '' || !is_dir($bundleDir)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Pro bundle directory not found.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
|
||||||
|
if (!is_file($jsonPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'No portals defined.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($jsonPath);
|
||||||
|
if ($raw === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Could not read portals store.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid portals store.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portals = $data['portals'] ?? [];
|
||||||
|
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Portal not found.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portal = $portals[$slug];
|
||||||
|
|
||||||
|
// Optional: handle expiry if you’re using expiresAt as ISO date string
|
||||||
|
if (!empty($portal['expiresAt'])) {
|
||||||
|
$ts = strtotime((string)$portal['expiresAt']);
|
||||||
|
if ($ts !== false && $ts < time()) {
|
||||||
|
http_response_code(410); // Gone
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'This portal has expired.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only expose the bits the login page needs (no folder, email, etc.)
|
||||||
|
$public = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'label' => (string)($portal['label'] ?? ''),
|
||||||
|
'title' => (string)($portal['title'] ?? ''),
|
||||||
|
'introText' => (string)($portal['introText'] ?? ''),
|
||||||
|
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||||
|
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portal' => $public,
|
||||||
|
]);
|
||||||
51
public/api/pro/portals/save.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/save.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';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => 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)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portals = $body['portals'] ?? null;
|
||||||
|
if (!is_array($portals)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$ctrl->saveProPortals($portals);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
64
public/api/pro/portals/submissions.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
|
||||||
|
@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([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot done, release lock for concurrency
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
throw new InvalidArgumentException('Missing slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use your ProPortalSubmissions helper from the bundle
|
||||||
|
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||||
|
if (!is_file($proSubmissionsPath)) {
|
||||||
|
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
require_once $proSubmissionsPath;
|
||||||
|
|
||||||
|
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
|
||||||
|
$submissions = $store->listBySlug($slug, 200);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'slug' => $slug,
|
||||||
|
'submissions' => $submissions,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Server error: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
91
public/api/pro/portals/submitForm.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/submitForm.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/PortalController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, portal forms still require a logged-in user
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($body['slug']) ? trim((string)$body['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing portal slug']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = isset($body['form']) && is_array($body['form']) ? $body['form'] : [];
|
||||||
|
$name = trim((string)($form['name'] ?? ''));
|
||||||
|
$email = trim((string)($form['email'] ?? ''));
|
||||||
|
$reference = trim((string)($form['reference'] ?? ''));
|
||||||
|
$notes = trim((string)($form['notes'] ?? ''));
|
||||||
|
|
||||||
|
// Make sure portal exists and is not expired
|
||||||
|
$portal = PortalController::getPortalBySlug($slug);
|
||||||
|
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||||
|
if (!is_file($subPath)) {
|
||||||
|
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
require_once $subPath;
|
||||||
|
|
||||||
|
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||||
|
$payload = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
|
'folder' => $portal['folder'] ?? '',
|
||||||
|
'form' => [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'reference' => $reference,
|
||||||
|
'notes' => $notes,
|
||||||
|
],
|
||||||
|
'submittedBy' => $submittedBy,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
|
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
'createdAt' => gmdate('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$store = new ProPortalSubmissions(FR_PRO_BUNDLE_DIR);
|
||||||
|
$ok = $store->store($slug, $payload);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RuntimeException('Failed to store portal submission.');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ img.logo{width:50px; height:50px; display:block;}
|
|||||||
#userPermissionsModal .modal-content,
|
#userPermissionsModal .modal-content,
|
||||||
#userFlagsModal .modal-content,
|
#userFlagsModal .modal-content,
|
||||||
#userGroupsModal .modal-content,
|
#userGroupsModal .modal-content,
|
||||||
#groupAclModal .modal-content{border-radius: var(--menu-radius);}
|
#groupAclModal .modal-content,
|
||||||
|
#clientPortalsModal .modal-content{border-radius: var(--menu-radius);}
|
||||||
#fr-login-tip{min-height: 40px;
|
#fr-login-tip{min-height: 40px;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
margin: 8px auto 0;
|
margin: 8px auto 0;
|
||||||
@@ -227,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;}
|
||||||
@@ -253,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;
|
||||||
@@ -1383,6 +1424,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;
|
||||||
@@ -1487,12 +1529,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;
|
||||||
@@ -1501,10 +1538,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;
|
||||||
@@ -2025,7 +2075,7 @@ body.dark-mode #deleteSelectedBtn,body.dark-mode #deleteAllBtn,body.dark-mode #d
|
|||||||
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
|
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
|
||||||
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
|
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
|
||||||
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
|
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
|
||||||
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
|
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header,#clientPortalsModal .modal-content{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
|
||||||
body.dark-mode .user-dropdown .user-menu,body.dark-mode #createMenu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu{background-clip:padding-box}
|
body.dark-mode .user-dropdown .user-menu,body.dark-mode #createMenu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu{background-clip:padding-box}
|
||||||
body:not(.dark-mode){background:var(--fr-bg-light)!important;color:#111!important;background-image:none!important}
|
body:not(.dark-mode){background:var(--fr-bg-light)!important;color:#111!important;background-image:none!important}
|
||||||
body:not(.dark-mode) #fileListContainer,body:not(.dark-mode) #uploadCard,body:not(.dark-mode) #folderManagementCard,body:not(.dark-mode) .card,body:not(.dark-mode) .admin-panel-content{background:var(--fr-surface-light)!important;border-color:var(--fr-border-light)!important;box-shadow:0 3px 8px rgba(0,0,0,.04)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
|
body:not(.dark-mode) #fileListContainer,body:not(.dark-mode) #uploadCard,body:not(.dark-mode) #folderManagementCard,body:not(.dark-mode) .card,body:not(.dark-mode) .admin-panel-content{background:var(--fr-surface-light)!important;border-color:var(--fr-border-light)!important;box-shadow:0 3px 8px rgba(0,0,0,.04)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
|
||||||
@@ -2043,7 +2093,7 @@ body:not(.dark-mode) #deleteSelectedBtn,body:not(.dark-mode) #deleteAllBtn,body:
|
|||||||
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile{background:#f1f1f1!important;border:1px solid var(--fr-border-light)!important}
|
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile{background:#f1f1f1!important;border:1px solid var(--fr-border-light)!important}
|
||||||
body:not(.dark-mode) #customToast{background:#212121!important;color:#fff!important;border:1px solid #000!important;box-shadow:0 8px 18px rgba(0,0,0,.45)!important}
|
body:not(.dark-mode) #customToast{background:#212121!important;color:#fff!important;border:1px solid #000!important;box-shadow:0 8px 18px rgba(0,0,0,.45)!important}
|
||||||
body:not(.dark-mode) #fileSummary{color:var(--fr-muted-light)!important}
|
body:not(.dark-mode) #fileSummary{color:var(--fr-muted-light)!important}
|
||||||
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
|
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header,body:not(.dark-mode) #clientPortalsModal .modal-content{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
|
||||||
#searchIcon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:36px;padding:0;border-radius:999px 0 0 999px;border:1px solid rgba(0,0,0,.18);border-right:none;background:#fff;cursor:pointer;box-shadow:none;transform:none}
|
#searchIcon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:36px;padding:0;border-radius:999px 0 0 999px;border:1px solid rgba(0,0,0,.18);border-right:none;background:#fff;cursor:pointer;box-shadow:none;transform:none}
|
||||||
#searchIcon .material-icons{font-size:20px;line-height:1;color:#555}
|
#searchIcon .material-icons{font-size:20px;line-height:1;color:#555}
|
||||||
#searchIcon:hover{background:#f5f5f5}
|
#searchIcon:hover{background:#f5f5f5}
|
||||||
@@ -2091,4 +2141,118 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
|
|||||||
#fileList tr.folder-row.folder-row-droptarget .folder-row-name{font-weight:600}
|
#fileList 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);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -401,7 +422,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;">
|
||||||
|
|||||||
302
public/js/adminPanelStyles.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
// Admin panel inline CSS moved out of adminPanel.js
|
||||||
|
// This file is imported for its side effects only.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (document.getElementById('adminPanelStyles')) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'adminPanelStyles';
|
||||||
|
style.textContent = `
|
||||||
|
/* Modal sizing */
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
max-width: 1100px;
|
||||||
|
width: 50%;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal header */
|
||||||
|
#adminPanelModal .modal-header {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.15);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#adminPanelModal .modal-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
#adminPanelModal .modal-title .admin-title-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal body layout */
|
||||||
|
#adminPanelModal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelModal .modal-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar nav */
|
||||||
|
#adminPanelSidebar {
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
border-right: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelSidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link .material-icons {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link.active {
|
||||||
|
background: rgba(0, 123, 255, 0.08);
|
||||||
|
border-color: rgba(0, 123, 255, 0.3);
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link:hover {
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
#adminPanelContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.admin-section-title .material-icons {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.admin-section-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field-group {
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
.admin-field-group label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.admin-field-group small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.admin-badge .material-icons {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.admin-table-sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.admin-table-sm th,
|
||||||
|
.admin-table-sm td {
|
||||||
|
padding: 0.35rem 0.4rem !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch alignment */
|
||||||
|
.form-check.form-switch .form-check-input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pro license textarea */
|
||||||
|
#proLicenseInput {
|
||||||
|
font-family: var(--filr-font-mono, monospace);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pro info alert */
|
||||||
|
#proLicenseStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client portals */
|
||||||
|
#clientPortalsBody .portal-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submissions list */
|
||||||
|
#clientPortalsBody .portal-submissions {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-empty {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-item {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-meta {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.dark-mode #adminPanelModal .modal-content {
|
||||||
|
background: #121212 !important;
|
||||||
|
color: #f5f5f5 !important;
|
||||||
|
border-color: rgba(255,255,255,0.15) !important;
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelModal .modal-header {
|
||||||
|
border-bottom-color: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar {
|
||||||
|
border-right-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link.active {
|
||||||
|
background: rgba(13,110,253,0.3);
|
||||||
|
border-color: rgba(13,110,253,0.7);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.dark-mode .admin-section-subtitle {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-field-group small {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-badge {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-table-sm tbody tr:hover td {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-row {
|
||||||
|
border-bottom-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-meta {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-submissions {
|
||||||
|
border-top-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-submissions-empty {
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
@@ -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)
|
||||||
========================= */
|
========================= */
|
||||||
|
|||||||
@@ -34,18 +34,19 @@ window.currentOIDCConfig = currentOIDCConfig;
|
|||||||
|
|
||||||
|
|
||||||
(function installToastFilter() {
|
(function installToastFilter() {
|
||||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
|
||||||
|
|
||||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||||
|
const isDemoMode = !!window.__FR_DEMO__;
|
||||||
|
|
||||||
// Suppress the nag while doing TOTP step-up
|
// Suppress the nag while doing TOTP step-up
|
||||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||||
/please log in/i.test(String(msgKeyOrText)))) {
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return null; // suppress
|
return null; // suppress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo host
|
// Demo mode: swap login prompt for demo creds
|
||||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
if (isDemoMode &&
|
||||||
/please log in/i.test(String(msgKeyOrText)))) {
|
(msgKeyOrText === 'please_log_in_to_continue' ||
|
||||||
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +82,16 @@ window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_requi
|
|||||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||||
|
|
||||||
function showToast(msgKeyOrText, type) {
|
function showToast(msgKeyOrText, type) {
|
||||||
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
const isDemoMode = !!window.__FR_DEMO__;
|
||||||
|
|
||||||
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
// For the pre-login prompt in demo mode, show demo creds instead
|
||||||
if (isDemoHost) {
|
if (isDemoMode &&
|
||||||
|
(msgKeyOrText === "please_log_in_to_continue" ||
|
||||||
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don’t nag during pending TOTP, as you already had
|
// Don’t nag during pending TOTP
|
||||||
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
|
|||||||
let msg = msgKeyOrText;
|
let msg = msgKeyOrText;
|
||||||
try {
|
try {
|
||||||
const translated = t(msgKeyOrText);
|
const translated = t(msgKeyOrText);
|
||||||
// If t() changed it or it's a key-like string, use the translation
|
|
||||||
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
||||||
msg = translated;
|
msg = translated;
|
||||||
}
|
}
|
||||||
} catch { /* if t() isn’t available here, just use the original */ }
|
} catch { }
|
||||||
|
|
||||||
return originalShowToast(msg);
|
return originalShowToast(msg);
|
||||||
}
|
}
|
||||||
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
if (r) r.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// b) admin panel button only on demo.filerise.net
|
|
||||||
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
|
||||||
let a = document.getElementById("adminPanelBtn");
|
|
||||||
if (!a) {
|
|
||||||
a = document.createElement("button");
|
|
||||||
a.id = "adminPanelBtn";
|
|
||||||
a.classList.add("btn", "btn-info");
|
|
||||||
a.setAttribute("data-i18n-title", "admin_panel");
|
|
||||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
|
||||||
a.addEventListener("click", openAdminPanel);
|
|
||||||
}
|
|
||||||
a.style.display = "block";
|
|
||||||
} else {
|
|
||||||
const a = document.getElementById("adminPanelBtn");
|
|
||||||
if (a) a.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// c) user dropdown on non-demo
|
// c) user dropdown on non-demo
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
{
|
||||||
let dd = document.getElementById("userDropdown");
|
let dd = document.getElementById("userDropdown");
|
||||||
|
|
||||||
// choose icon *or* img
|
// choose icon *or* img
|
||||||
@@ -866,6 +850,10 @@ function initAuth() {
|
|||||||
});
|
});
|
||||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
||||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||||
|
if (window.__FR_DEMO__) {
|
||||||
|
showToast("Password changes are disabled on the public demo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
document.getElementById("changePasswordModal").style.display = "block";
|
||||||
document.getElementById("oldPassword").focus();
|
document.getElementById("oldPassword").focus();
|
||||||
});
|
});
|
||||||
@@ -873,6 +861,10 @@ function initAuth() {
|
|||||||
document.getElementById("changePasswordModal").style.display = "none";
|
document.getElementById("changePasswordModal").style.display = "none";
|
||||||
});
|
});
|
||||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||||
|
if (window.__FR_DEMO__) {
|
||||||
|
showToast("Password changes are disabled on the public demo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||||
const newPassword = document.getElementById("newPassword").value.trim();
|
const newPassword = document.getElementById("newPassword").value.trim();
|
||||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -934,7 +934,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";
|
||||||
|
|||||||
@@ -239,7 +239,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 +291,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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/* ----------------------
|
/* ----------------------
|
||||||
@@ -230,23 +253,47 @@ function showNoAccessEmptyState() {
|
|||||||
function renderBreadcrumbFragment(folderPath) {
|
function renderBreadcrumbFragment(folderPath) {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
|
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
|
||||||
|
|
||||||
|
// --- Always start with "Root" crumb ---
|
||||||
|
const rootSpan = document.createElement('span');
|
||||||
|
rootSpan.className = 'breadcrumb-link';
|
||||||
|
rootSpan.dataset.folder = 'root';
|
||||||
|
rootSpan.textContent = 'root';
|
||||||
|
frag.appendChild(rootSpan);
|
||||||
|
|
||||||
|
if (path === 'root') {
|
||||||
|
// You are in root: just "Root"
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator after Root
|
||||||
|
let sep = document.createElement('span');
|
||||||
|
sep.className = 'file-breadcrumb-sep';
|
||||||
|
sep.textContent = '›';
|
||||||
|
frag.appendChild(sep);
|
||||||
|
|
||||||
|
// Now add the rest of the path normally (folder1, folder1/subA, etc.)
|
||||||
const crumbs = path.split('/').filter(Boolean);
|
const crumbs = path.split('/').filter(Boolean);
|
||||||
let acc = '';
|
let acc = '';
|
||||||
|
|
||||||
for (let i = 0; i < crumbs.length; i++) {
|
for (let i = 0; i < crumbs.length; i++) {
|
||||||
const part = crumbs[i];
|
const part = crumbs[i];
|
||||||
acc = (i === 0) ? part : (acc + '/' + part);
|
acc = (i === 0) ? part : (acc + '/' + part);
|
||||||
|
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'breadcrumb-link';
|
span.className = 'breadcrumb-link';
|
||||||
span.dataset.folder = acc;
|
span.dataset.folder = acc;
|
||||||
span.textContent = part;
|
span.textContent = part;
|
||||||
frag.appendChild(span);
|
frag.appendChild(span);
|
||||||
|
|
||||||
if (i < crumbs.length - 1) {
|
if (i < crumbs.length - 1) {
|
||||||
const sep = document.createElement('span');
|
sep = document.createElement('span');
|
||||||
sep.className = 'file-breadcrumb-sep';
|
sep.className = 'file-breadcrumb-sep';
|
||||||
sep.textContent = '›';
|
sep.textContent = '›';
|
||||||
frag.appendChild(sep);
|
frag.appendChild(sep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return frag;
|
return frag;
|
||||||
}
|
}
|
||||||
export function updateBreadcrumbTitle(folder) {
|
export function updateBreadcrumbTitle(folder) {
|
||||||
@@ -1687,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("/");
|
||||||
@@ -1757,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");
|
||||||
@@ -1799,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";
|
||||||
@@ -1861,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;
|
||||||
|
|||||||
@@ -337,7 +337,8 @@ 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"
|
||||||
},
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -62,23 +62,43 @@ async function ensureToastReady() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDemoHost() {
|
function isDemoHost() {
|
||||||
// Handles optional "www." just in case
|
try {
|
||||||
|
const cfg = window.__FR_SITE_CFG__ || {};
|
||||||
|
if (typeof cfg.demoMode !== 'undefined') {
|
||||||
|
return !!cfg.demoMode;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Fallback for older configs / direct demo host:
|
||||||
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginTip(message) {
|
function showLoginTip(message) {
|
||||||
const tip = document.getElementById('fr-login-tip');
|
const tip = document.getElementById('fr-login-tip');
|
||||||
if (!tip) return;
|
if (!tip) return;
|
||||||
tip.innerHTML = ''; // clear
|
tip.innerHTML = ''; // clear
|
||||||
if (message) tip.append(document.createTextNode(message));
|
|
||||||
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
|
if (message) {
|
||||||
const line = document.createElement('div'); line.style.marginTop = '6px';
|
tip.append(document.createTextNode(message));
|
||||||
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
|
}
|
||||||
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
|
|
||||||
document.createTextNode(' · pass: '), mk('demo'));
|
if (isDemoHost()) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.style.marginTop = '6px';
|
||||||
|
const mk = t => {
|
||||||
|
const k = document.createElement('code');
|
||||||
|
k.textContent = t;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
line.append(
|
||||||
|
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||||
|
document.createTextNode(' · pass: '), mk('demo')
|
||||||
|
);
|
||||||
tip.append(line);
|
tip.append(line);
|
||||||
}
|
}
|
||||||
tip.style.display = 'block'; // reveal without shifting layout
|
|
||||||
|
tip.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hideOverlaySmoothly(overlay) {
|
async function hideOverlaySmoothly(overlay) {
|
||||||
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
|
|||||||
return p.then(r => r.clone());
|
return p.then(r => r.clone());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Safe redirect helper (prevents open redirects) ----
|
||||||
|
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (!str) return fallback;
|
||||||
|
|
||||||
|
const candidate = new URL(str, window.location.origin);
|
||||||
|
|
||||||
|
// Enforce same-origin
|
||||||
|
if (candidate.origin !== window.location.origin) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to http/https
|
||||||
|
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return relative URL
|
||||||
|
return candidate.pathname + candidate.search + candidate.hash;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gentle toast normalizer (compatible with showToast(message, duration))
|
// Gentle toast normalizer (compatible with showToast(message, duration))
|
||||||
const origToast = window.showToast;
|
const origToast = window.showToast;
|
||||||
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
||||||
@@ -526,11 +572,13 @@ function bindDarkMode() {
|
|||||||
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
||||||
const j = await r.json().catch(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
window.__FR_SITE_CFG__ = j || {};
|
window.__FR_SITE_CFG__ = j || {};
|
||||||
|
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
|
||||||
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
||||||
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
||||||
return window.__FR_SITE_CFG__;
|
return window.__FR_SITE_CFG__;
|
||||||
} catch {
|
} catch {
|
||||||
window.__FR_SITE_CFG__ = {};
|
window.__FR_SITE_CFG__ = {};
|
||||||
|
window.__FR_DEMO__ = false;
|
||||||
applySiteConfig({}, { phase: 'early' });
|
applySiteConfig({}, { phase: 'early' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -883,6 +931,19 @@ function bindDarkMode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function afterLogin() {
|
function afterLogin() {
|
||||||
|
// If index.html was opened with ?redirect=<url>, honor that first
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const raw = url.searchParams.get('redirect');
|
||||||
|
const safe = sanitizeRedirect(raw, { fallback: null });
|
||||||
|
if (safe) {
|
||||||
|
window.location.href = safe;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore URL/param issues and fall back to normal behavior
|
||||||
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
(function poll() {
|
(function poll() {
|
||||||
checkAuth().then(({ authed }) => {
|
checkAuth().then(({ authed }) => {
|
||||||
|
|||||||
382
public/js/portal-login.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// public/js/portal-login.js
|
||||||
|
|
||||||
|
// -------- URL helpers --------
|
||||||
|
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (!str) return fallback;
|
||||||
|
|
||||||
|
// Resolve against current origin so relative URLs work
|
||||||
|
const candidate = new URL(str, window.location.origin);
|
||||||
|
|
||||||
|
// 1) Must stay on the same origin
|
||||||
|
if (candidate.origin !== window.location.origin) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Only allow http/https
|
||||||
|
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a relative URL (prevents host changes)
|
||||||
|
return candidate.pathname + candidate.search + candidate.hash;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectTarget() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const raw = url.searchParams.get('redirect');
|
||||||
|
|
||||||
|
// Default fallback: root
|
||||||
|
let target = sanitizeRedirect(raw, { fallback: '/' });
|
||||||
|
|
||||||
|
// If there was no *usable* redirect but we have a portal slug,
|
||||||
|
// send them back to that portal by default.
|
||||||
|
if (!target || target === '/') {
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
if (slug) {
|
||||||
|
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target || '/';
|
||||||
|
} catch {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPortalSlugFromUrl() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
|
||||||
|
let slug = url.searchParams.get('slug');
|
||||||
|
if (slug && slug.trim()) {
|
||||||
|
console.log('portal-login: slug from top-level param =', slug.trim());
|
||||||
|
return slug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
console.log('portal-login: raw redirect param =', redirect);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
|
||||||
|
// 2a) ?slug=... in redirect
|
||||||
|
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||||
|
if (innerSlug && innerSlug.trim()) {
|
||||||
|
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
|
||||||
|
return innerSlug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b) Pretty path /portal/<slug> in redirect
|
||||||
|
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||||
|
if (pathMatch && pathMatch[1]) {
|
||||||
|
const fromPath = pathMatch[1].trim();
|
||||||
|
console.log('portal-login: slug from redirect path =', fromPath);
|
||||||
|
return fromPath;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('portal-login: failed to parse redirect URL', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c) Fallback regex on redirect string
|
||||||
|
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
const decoded = decodeURIComponent(m[1]).trim();
|
||||||
|
console.log('portal-login: slug from redirect regex =', decoded);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Legacy fallback on current query string
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m2 && m2[1]) {
|
||||||
|
const decoded2 = decodeURIComponent(m2[1]).trim();
|
||||||
|
console.log('portal-login: slug from own query regex =', decoded2);
|
||||||
|
return decoded2;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('portal-login: no slug found');
|
||||||
|
return '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('portal-login: getPortalSlugFromUrl error', err);
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSRF helpers (same pattern as portal.js) ---
|
||||||
|
function setCsrfToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
window.csrfToken = token;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('csrf', token);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'csrf-token';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
return (
|
||||||
|
window.csrfToken ||
|
||||||
|
(document.querySelector('meta[name="csrf-token"]')?.content) ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCsrfToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/token.php', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
|
if (hdr) setCsrfToken(hdr);
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('portal-login: failed to load CSRF token', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI helpers ---
|
||||||
|
function showError(msg) {
|
||||||
|
const box = document.getElementById('portalLoginError');
|
||||||
|
if (!box) return;
|
||||||
|
box.textContent = msg || 'Login failed.';
|
||||||
|
box.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
const box = document.getElementById('portalLoginError');
|
||||||
|
if (!box) return;
|
||||||
|
box.textContent = '';
|
||||||
|
box.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Portal meta (title + accent) --------
|
||||||
|
async function fetchPortalMeta(slug) {
|
||||||
|
if (!slug) return null;
|
||||||
|
console.log('portal-login: calling publicMeta.php for slug', slug);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
|
||||||
|
{ method: 'GET', credentials: 'include' }
|
||||||
|
);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!res.ok || !data || !data.success || !data.portal) {
|
||||||
|
console.warn('portal-login: publicMeta not ok', res.status, data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.portal;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('portal-login: failed to load portal meta', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPortalBranding(portal) {
|
||||||
|
if (!portal) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
(portal.title && portal.title.trim()) ||
|
||||||
|
portal.label ||
|
||||||
|
portal.slug ||
|
||||||
|
'Client portal';
|
||||||
|
|
||||||
|
const headingEl = document.getElementById('portalLoginTitle');
|
||||||
|
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||||||
|
const footerEl = document.getElementById('portalLoginFooter');
|
||||||
|
|
||||||
|
if (headingEl) {
|
||||||
|
headingEl.textContent = 'Sign in to ' + title;
|
||||||
|
}
|
||||||
|
if (subtitleEl) {
|
||||||
|
subtitleEl.textContent = 'to access this client portal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer text from portal metadata, if provided
|
||||||
|
if (footerEl) {
|
||||||
|
const ft = (portal.footerText && portal.footerText.trim()) || '';
|
||||||
|
if (ft) {
|
||||||
|
footerEl.textContent = ft;
|
||||||
|
footerEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
footerEl.textContent = '';
|
||||||
|
footerEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document title
|
||||||
|
try {
|
||||||
|
document.title = 'Sign in – ' + title;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Accent: portal brandColor -> CSS var
|
||||||
|
const brand = portal.brandColor && portal.brandColor.trim();
|
||||||
|
if (brand) {
|
||||||
|
document.documentElement.style.setProperty('--portal-accent', brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply card/button accent after we know portal color
|
||||||
|
applyAccentFromTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accent (card + button) ---
|
||||||
|
function applyAccentFromTheme() {
|
||||||
|
const card = document.querySelector('.portal-login-card');
|
||||||
|
const btn = document.getElementById('portalLoginSubmit');
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
|
// Prefer per-portal accent if present
|
||||||
|
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
|
||||||
|
if (!accent) {
|
||||||
|
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
card.style.borderTop = `3px solid ${accent}`;
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.style.backgroundColor = accent;
|
||||||
|
btn.style.borderColor = accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (metaTheme) {
|
||||||
|
metaTheme.setAttribute('content', accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Login call (JSON -> auth.php) ---
|
||||||
|
async function doLogin(username, password) {
|
||||||
|
const csrf = getCsrfToken() || '';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
};
|
||||||
|
if (csrf) {
|
||||||
|
payload.csrf_token = csrf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/auth/auth.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrf,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = body.error || body.message || text || 'Login failed.';
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.success === false || body.error || body.logged_in === false) {
|
||||||
|
throw new Error(body.error || 'Invalid username or password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const form = document.getElementById('portalLoginForm');
|
||||||
|
const userEl = document.getElementById('portalLoginUser');
|
||||||
|
const passEl = document.getElementById('portalLoginPass');
|
||||||
|
const btn = document.getElementById('portalLoginSubmit');
|
||||||
|
|
||||||
|
// Accent first (fallback to global accent)
|
||||||
|
applyAccentFromTheme();
|
||||||
|
|
||||||
|
// Try to load portal meta (title + brand color) using slug
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
console.log('portal-login: computed slug =', slug);
|
||||||
|
if (slug) {
|
||||||
|
fetchPortalMeta(slug).then(portal => {
|
||||||
|
if (portal) {
|
||||||
|
console.log('portal-login: got portal meta for', slug, portal);
|
||||||
|
applyPortalBranding(portal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load CSRF (for auth.php)
|
||||||
|
loadCsrfToken().catch(() => {});
|
||||||
|
|
||||||
|
if (!form || !userEl || !passEl || !btn) return;
|
||||||
|
|
||||||
|
// Focus username
|
||||||
|
userEl.focus();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const username = userEl.value.trim();
|
||||||
|
const password = passEl.value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
showError('Username and password are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await doLogin(username, password);
|
||||||
|
const target = getRedirectTarget();
|
||||||
|
window.location.href = target;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('portal-login: auth failed', err);
|
||||||
|
showError(err.message || 'Login failed. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign in';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
716
public/js/portal.js
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
// public/js/portal.js
|
||||||
|
// Standalone client portal logic – no imports from main app JS to avoid DOM coupling.
|
||||||
|
|
||||||
|
let portal = null;
|
||||||
|
let portalFormDone = false;
|
||||||
|
|
||||||
|
// --- Portal helpers: folder + download flag -----------------
|
||||||
|
function portalFolder() {
|
||||||
|
if (!portal) return 'root';
|
||||||
|
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
function portalCanDownload() {
|
||||||
|
if (!portal) return false;
|
||||||
|
|
||||||
|
// Prefer explicit flags if present
|
||||||
|
if (typeof portal.allowDownload !== 'undefined') {
|
||||||
|
return !!portal.allowDownload;
|
||||||
|
}
|
||||||
|
if (typeof portal.allowDownloads !== 'undefined') {
|
||||||
|
return !!portal.allowDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: uploadOnly = true => no downloads
|
||||||
|
if (typeof portal.uploadOnly !== 'undefined') {
|
||||||
|
return !portal.uploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: allow downloads
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- DOM helpers / status -----------------
|
||||||
|
function qs(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
const el = qs('portalStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || '';
|
||||||
|
el.classList.toggle('text-danger', !!isError);
|
||||||
|
if (!isError) {
|
||||||
|
el.classList.add('text-muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Form submit -----------------
|
||||||
|
async function submitPortalForm(slug, formData) {
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
form: formData
|
||||||
|
};
|
||||||
|
const headers = { 'X-CSRF-Token': getCsrfToken() || '' };
|
||||||
|
const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers);
|
||||||
|
if (!res || !res.success) {
|
||||||
|
throw new Error((res && res.error) || 'Error saving form.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Toast -----------------
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.getElementById('customToast');
|
||||||
|
if (!toast) {
|
||||||
|
console.warn('Toast:', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.display = 'block';
|
||||||
|
// Force reflow
|
||||||
|
void toast.offsetWidth;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.display = 'none';
|
||||||
|
}, 200);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Fetch wrapper -----------------
|
||||||
|
async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { ...customHeaders }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data && !(data instanceof FormData)) {
|
||||||
|
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
} else if (data instanceof FormData) {
|
||||||
|
options.body = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const text = await res.text();
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = text;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw payload;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Portal form wiring -----------------
|
||||||
|
function setupPortalForm(slug) {
|
||||||
|
const formSection = qs('portalFormSection');
|
||||||
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
|
if (!portal || !portal.requireForm) {
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = 'portalFormDone:' + slug;
|
||||||
|
if (sessionStorage.getItem(key) === '1') {
|
||||||
|
portalFormDone = true;
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
portalFormDone = false;
|
||||||
|
if (formSection) formSection.style.display = 'block';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '0.5';
|
||||||
|
|
||||||
|
const nameEl = qs('portalFormName');
|
||||||
|
const emailEl = qs('portalFormEmail');
|
||||||
|
const refEl = qs('portalFormReference');
|
||||||
|
const notesEl = qs('portalFormNotes');
|
||||||
|
const submitBtn = qs('portalFormSubmit');
|
||||||
|
|
||||||
|
const fd = portal.formDefaults || {};
|
||||||
|
|
||||||
|
if (nameEl && fd.name && !nameEl.value) {
|
||||||
|
nameEl.value = fd.name;
|
||||||
|
}
|
||||||
|
if (emailEl && fd.email && !emailEl.value) {
|
||||||
|
emailEl.value = fd.email;
|
||||||
|
} else if (emailEl && portal.clientEmail && !emailEl.value) {
|
||||||
|
// fallback to clientEmail
|
||||||
|
emailEl.value = portal.clientEmail;
|
||||||
|
}
|
||||||
|
if (refEl && fd.reference && !refEl.value) {
|
||||||
|
refEl.value = fd.reference;
|
||||||
|
}
|
||||||
|
if (notesEl && fd.notes && !notesEl.value) {
|
||||||
|
notesEl.value = fd.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submitBtn) return;
|
||||||
|
|
||||||
|
submitBtn.onclick = async () => {
|
||||||
|
const name = nameEl ? nameEl.value.trim() : '';
|
||||||
|
const email = emailEl ? emailEl.value.trim() : '';
|
||||||
|
const reference = refEl ? refEl.value.trim() : '';
|
||||||
|
const notes = notesEl ? notesEl.value.trim() : '';
|
||||||
|
|
||||||
|
const req = portal.formRequired || {};
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
if (req.name && !name) missing.push('name');
|
||||||
|
if (req.email && !email) missing.push('email');
|
||||||
|
if (req.reference && !reference) missing.push('reference');
|
||||||
|
if (req.notes && !notes) missing.push('notes');
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
showToast('Please fill in: ' + missing.join(', ') + '.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default behavior when no specific required flags:
|
||||||
|
if (!req.name && !req.email && !req.reference && !req.notes) {
|
||||||
|
if (!name && !email) {
|
||||||
|
showToast('Please provide at least a name or email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitPortalForm(slug, { name, email, reference, notes });
|
||||||
|
portalFormDone = true;
|
||||||
|
sessionStorage.setItem(key, '1');
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
showToast('Thank you. You can now upload files.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('Error saving your info. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- CSRF helpers -----------------
|
||||||
|
function setCsrfToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
window.csrfToken = token;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('csrf', token);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'csrf-token';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
return window.csrfToken || (document.querySelector('meta[name="csrf-token"]')?.content) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCsrfToken() {
|
||||||
|
const res = await fetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
|
||||||
|
|
||||||
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
|
if (hdr) setCsrfToken(hdr);
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Auth -----------------
|
||||||
|
async function ensureAuthenticated() {
|
||||||
|
try {
|
||||||
|
const data = await sendRequest('/api/auth/checkAuth.php', 'GET');
|
||||||
|
if (!data || !data.username) {
|
||||||
|
// redirect to main UI/login; after login, user can re-open portal link
|
||||||
|
const target = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = '/portal-login.html?redirect=' + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lbl = qs('portalUserLabel');
|
||||||
|
if (lbl) {
|
||||||
|
lbl.textContent = data.username || '';
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
const target = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = '/portal-login.html?redirect=' + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Portal fetch + render -----------------
|
||||||
|
async function fetchPortal(slug) {
|
||||||
|
setStatus('Loading portal details…');
|
||||||
|
try {
|
||||||
|
const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET');
|
||||||
|
if (!data || !data.success || !data.portal) {
|
||||||
|
throw new Error((data && data.error) || 'Portal not found.');
|
||||||
|
}
|
||||||
|
portal = data.portal;
|
||||||
|
return portal;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus('This portal could not be found or is no longer available.', true);
|
||||||
|
showToast('Portal not found or expired.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPortalInfo() {
|
||||||
|
if (!portal) return;
|
||||||
|
const titleEl = qs('portalTitle');
|
||||||
|
const descEl = qs('portalDescription');
|
||||||
|
const subtitleEl = qs('portalSubtitle');
|
||||||
|
const brandEl = document.getElementById('portalBrandHeading');
|
||||||
|
const footerEl = document.getElementById('portalFooter');
|
||||||
|
const drop = qs('portalDropzone');
|
||||||
|
const card = document.querySelector('.portal-card');
|
||||||
|
const formBtn = qs('portalFormSubmit');
|
||||||
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
const filesSection = qs('portalFilesSection');
|
||||||
|
|
||||||
|
const heading = portal.title && portal.title.trim()
|
||||||
|
? portal.title.trim()
|
||||||
|
: (portal.label || portal.slug || 'Client portal');
|
||||||
|
|
||||||
|
if (titleEl) titleEl.textContent = heading;
|
||||||
|
if (brandEl) brandEl.textContent = heading;
|
||||||
|
|
||||||
|
if (descEl) {
|
||||||
|
if (portal.introText && portal.introText.trim()) {
|
||||||
|
descEl.textContent = portal.introText.trim();
|
||||||
|
} else {
|
||||||
|
const folder = portalFolder();
|
||||||
|
descEl.textContent = 'Files you upload here go directly into: ' + folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleEl) {
|
||||||
|
const parts = [];
|
||||||
|
if (portal.uploadOnly) parts.push('upload only');
|
||||||
|
if (portalCanDownload()) parts.push('download allowed');
|
||||||
|
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footerEl) {
|
||||||
|
footerEl.textContent = portal.footerText && portal.footerText.trim()
|
||||||
|
? portal.footerText.trim()
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = portal.brandColor && portal.brandColor.trim();
|
||||||
|
if (color) {
|
||||||
|
// expose brand color as a CSS variable for gallery styling
|
||||||
|
document.documentElement.style.setProperty('--portal-accent', color);
|
||||||
|
|
||||||
|
if (drop) {
|
||||||
|
drop.style.borderColor = color;
|
||||||
|
}
|
||||||
|
if (card) {
|
||||||
|
card.style.borderTop = '3px solid ' + color;
|
||||||
|
}
|
||||||
|
if (formBtn) {
|
||||||
|
formBtn.style.backgroundColor = color;
|
||||||
|
formBtn.style.borderColor = color;
|
||||||
|
}
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.style.borderColor = color;
|
||||||
|
refreshBtn.style.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide files section based on download capability
|
||||||
|
if (filesSection) {
|
||||||
|
filesSection.style.display = portalCanDownload() ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- File helpers for gallery -----------------
|
||||||
|
function formatFileSizeLabel(f) {
|
||||||
|
// API currently returns f.size as a human-readable string, so prefer that
|
||||||
|
if (f && f.size) return f.size;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExtLabel(name) {
|
||||||
|
if (!name) return 'FILE';
|
||||||
|
const parts = name.split('.');
|
||||||
|
if (parts.length < 2) return 'FILE';
|
||||||
|
const ext = parts.pop().trim().toUpperCase();
|
||||||
|
if (!ext) return 'FILE';
|
||||||
|
return ext.length <= 4 ? ext : ext.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageName(name) {
|
||||||
|
if (!name) return false;
|
||||||
|
return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Load files for portal gallery -----------------
|
||||||
|
async function loadPortalFiles() {
|
||||||
|
if (!portal || !portalCanDownload()) return;
|
||||||
|
|
||||||
|
const listEl = qs('portalFilesList');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folder = portalFolder();
|
||||||
|
const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET');
|
||||||
|
if (!data || data.error) {
|
||||||
|
const msg = (data && data.error) ? data.error : 'Error loading files.';
|
||||||
|
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize files: handle both array and object-return shapes
|
||||||
|
let files = [];
|
||||||
|
if (Array.isArray(data.files)) {
|
||||||
|
files = data.files;
|
||||||
|
} else if (data.files && typeof data.files === 'object') {
|
||||||
|
files = Object.entries(data.files).map(([name, meta]) => {
|
||||||
|
const f = meta || {};
|
||||||
|
f.name = name;
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = portal.brandColor && portal.brandColor.trim();
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
listEl.classList.add('portal-files-grid'); // gallery layout
|
||||||
|
|
||||||
|
const MAX = 24;
|
||||||
|
const slice = files.slice(0, MAX);
|
||||||
|
|
||||||
|
slice.forEach(f => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'portal-file-card';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'portal-file-card-icon';
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'portal-file-card-main';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('div');
|
||||||
|
nameEl.className = 'portal-file-card-name';
|
||||||
|
nameEl.textContent = f.name || 'Unnamed file';
|
||||||
|
|
||||||
|
const metaEl = document.createElement('div');
|
||||||
|
metaEl.className = 'portal-file-card-meta text-muted';
|
||||||
|
metaEl.textContent = formatFileSizeLabel(f);
|
||||||
|
|
||||||
|
main.appendChild(nameEl);
|
||||||
|
main.appendChild(metaEl);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'portal-file-card-actions';
|
||||||
|
|
||||||
|
// Thumbnail vs extension badge
|
||||||
|
const fname = f.name || '';
|
||||||
|
const folder = portalFolder();
|
||||||
|
|
||||||
|
if (isImageName(fname)) {
|
||||||
|
const thumbUrl =
|
||||||
|
'/api/file/download.php?folder=' +
|
||||||
|
encodeURIComponent(folder) +
|
||||||
|
'&file=' + encodeURIComponent(fname) +
|
||||||
|
'&inline=1&t=' + Date.now();
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = thumbUrl;
|
||||||
|
img.alt = fname;
|
||||||
|
// 🔧 constrain image so it doesn't fill the whole list
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.maxHeight = '120px';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
img.style.display = 'block';
|
||||||
|
img.style.borderRadius = '6px';
|
||||||
|
|
||||||
|
icon.appendChild(img);
|
||||||
|
} else {
|
||||||
|
icon.textContent = fileExtLabel(fname);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accent) {
|
||||||
|
icon.style.borderColor = accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/file/download.php?folder=' +
|
||||||
|
encodeURIComponent(folder) +
|
||||||
|
'&file=' + encodeURIComponent(fname);
|
||||||
|
a.textContent = 'Download';
|
||||||
|
a.className = 'portal-file-card-download';
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
actions.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(icon);
|
||||||
|
card.appendChild(main);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length > MAX) {
|
||||||
|
const more = document.createElement('div');
|
||||||
|
more.className = 'portal-files-more text-muted';
|
||||||
|
more.textContent = 'And ' + (files.length - MAX) + ' more…';
|
||||||
|
listEl.appendChild(more);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Upload -----------------
|
||||||
|
async function uploadFiles(fileList) {
|
||||||
|
if (!portal || !fileList || !fileList.length) return;
|
||||||
|
if (portal.requireForm && !portalFormDone) {
|
||||||
|
showToast('Please fill in your details before uploading.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
const folder = portalFolder();
|
||||||
|
|
||||||
|
setStatus('Uploading ' + files.length + ' file(s)…');
|
||||||
|
let successCount = 0;
|
||||||
|
let failureCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const form = new FormData();
|
||||||
|
|
||||||
|
const csrf = getCsrfToken() || '';
|
||||||
|
|
||||||
|
// Match main upload.js
|
||||||
|
form.append('file[]', file);
|
||||||
|
form.append('folder', folder);
|
||||||
|
if (csrf) {
|
||||||
|
form.append('upload_token', csrf); // legacy alias, but your controller supports it
|
||||||
|
}
|
||||||
|
|
||||||
|
let retried = false;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/upload/upload.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrf || ''
|
||||||
|
},
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await resp.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.csrf_expired && data.csrf_token) {
|
||||||
|
setCsrfToken(data.csrf_token);
|
||||||
|
if (!retried) {
|
||||||
|
retried = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok || (data && data.error)) {
|
||||||
|
failureCount++;
|
||||||
|
console.error('Upload error:', data || text);
|
||||||
|
} else {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Upload error:', e);
|
||||||
|
failureCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount && !failureCount) {
|
||||||
|
setStatus('Uploaded ' + successCount + ' file(s).');
|
||||||
|
showToast('Upload complete.');
|
||||||
|
} else if (successCount && failureCount) {
|
||||||
|
setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true);
|
||||||
|
showToast('Some files failed to upload.');
|
||||||
|
} else {
|
||||||
|
setStatus('Upload failed.', true);
|
||||||
|
showToast('Upload failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
loadPortalFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Upload UI wiring -----------------
|
||||||
|
function wireUploadUI() {
|
||||||
|
const drop = qs('portalDropzone');
|
||||||
|
const input = qs('portalFileInput');
|
||||||
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
|
||||||
|
if (drop && input) {
|
||||||
|
drop.addEventListener('click', () => input.click());
|
||||||
|
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
uploadFiles(files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(ev => {
|
||||||
|
drop.addEventListener(ev, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
drop.classList.add('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(ev => {
|
||||||
|
drop.addEventListener(ev, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
drop.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
drop.addEventListener('drop', e => {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
if (!dt || !dt.files || !dt.files.length) return;
|
||||||
|
uploadFiles(dt.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
loadPortalFiles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Slug + init -----------------
|
||||||
|
function getPortalSlugFromUrl() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// 1) Normal case: slug is directly in query (?slug=portal-xxxxx)
|
||||||
|
let slug = url.searchParams.get('slug');
|
||||||
|
if (slug && slug.trim()) {
|
||||||
|
return slug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Pretty URL: /portal/<slug>
|
||||||
|
// e.g. /portal/portal-h46ozd
|
||||||
|
const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||||
|
if (pathMatch && pathMatch[1]) {
|
||||||
|
return pathMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fallback: slug inside redirect param
|
||||||
|
// e.g. ?redirect=/portal.html?slug=portal-h46ozd
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||||
|
if (innerSlug && innerSlug.trim()) {
|
||||||
|
return innerSlug.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
return decodeURIComponent(m[1]).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Final fallback: old regex on our own query string
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : '';
|
||||||
|
} catch {
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPortal() {
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
if (!slug) {
|
||||||
|
setStatus('Missing portal slug.', true);
|
||||||
|
showToast('Portal slug missing in URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadCsrfToken();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('CSRF load failed (may be fine if unauthenticated yet).', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await ensureAuthenticated();
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
const p = await fetchPortal(slug);
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
renderPortalInfo();
|
||||||
|
setupPortalForm(slug);
|
||||||
|
wireUploadUI();
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
loadPortalFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initPortal().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setStatus('Unexpected error initializing portal.', true);
|
||||||
|
showToast('Unexpected error loading portal.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = 'v1.9.14';
|
window.APP_VERSION = 'v2.1.0';
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
146
public/portal-login.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Sign in – Client Portal</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
|
||||||
|
<!-- Favicons / assets -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- CSS (reuse main app look) -->
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Version stamp -->
|
||||||
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- Portal login JS -->
|
||||||
|
<script type="module" src="/js/portal-login.js?v={{APP_QVER}}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--pre-bg, #f4f4f7);
|
||||||
|
}
|
||||||
|
.portal-login-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-login-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .portal-login-card {
|
||||||
|
background: #1f2933;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.portal-login-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-login-header img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-login-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.portal-login-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .portal-login-subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
#portalLoginError {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#portalLoginError.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.portal-login-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 3px solid var(--filr-accent-500, #0b5ed7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-theme="light">
|
||||||
|
<div class="portal-login-wrapper">
|
||||||
|
<div class="portal-login-card">
|
||||||
|
<div class="portal-login-header">
|
||||||
|
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||||
|
<div>
|
||||||
|
<div id="portalLoginTitle" class="portal-login-title">
|
||||||
|
Sign in to Client Portal
|
||||||
|
</div>
|
||||||
|
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||||
|
to access this client portal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||||
|
|
||||||
|
<form id="portalLoginForm" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="portalLoginUser">Username or email</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="portalLoginUser"
|
||||||
|
autocomplete="username"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="portalLoginPass">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="portalLoginPass"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
id="portalLoginSubmit"
|
||||||
|
class="btn btn-primary btn-sm btn-block">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<small id="portalLoginHint"
|
||||||
|
class="text-muted d-block mt-2"
|
||||||
|
style="font-size:0.75rem;">
|
||||||
|
You’ll be sent back to the portal automatically after signing in.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<small id="portalLoginFooter"
|
||||||
|
class="text-muted d-block mt-1"
|
||||||
|
style="font-size:0.7rem; display:none;">
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
362
public/portal.html
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<style id="pretheme-css">
|
||||||
|
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--portal-accent: #0b5ed7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-card {
|
||||||
|
max-width: 640px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
.portal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.portal-logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-dropzone {
|
||||||
|
border: 2px dashed rgba(0,0,0,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.portal-dropzone.dragover {
|
||||||
|
border-color: var(--portal-accent);
|
||||||
|
background: rgba(11,94,215,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Files list container (scrollable) */
|
||||||
|
.portal-files-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NEW: grid-style gallery inside the list */
|
||||||
|
.portal-files-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
grid-auto-rows: minmax(48px, auto);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
background: rgba(0,0,0,0.01);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.portal-file-card:hover {
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--portal-accent, #0b5ed7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.portal-file-card-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.portal-file-card-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.portal-file-card-download {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.16);
|
||||||
|
background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.portal-file-card-download:hover {
|
||||||
|
background: var(--portal-accent, #0b5ed7);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--portal-accent, #0b5ed7);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#customToast {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 4000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#customToast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (Optional) keep old row style around if anything else uses it */
|
||||||
|
.portal-file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.portal-file-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Client Portal – FileRise</title>
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
|
|
||||||
|
<style id="pretheme-css">
|
||||||
|
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Favicons / assets -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
|
||||||
|
<!-- CSS (reuse main app CSS for look) -->
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Version stamp -->
|
||||||
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- Portal entry -->
|
||||||
|
<script type="module" src="/js/portal.js?v={{APP_QVER}}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.portal-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-card {
|
||||||
|
max-width: min(960px, 100%);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
.portal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.portal-logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-dropzone {
|
||||||
|
border: 2px dashed rgba(0,0,0,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.portal-dropzone.dragover {
|
||||||
|
border-color: #0b5ed7;
|
||||||
|
background: rgba(11,94,215,0.06);
|
||||||
|
}
|
||||||
|
.portal-files-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.portal-file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.portal-file-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.portal-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
#customToast {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 4000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#customToast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="portalRoot" class="portal-wrapper">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="portal-header">
|
||||||
|
<div class="portal-logo">
|
||||||
|
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||||
|
<div>
|
||||||
|
<div id="portalBrandHeading" style="font-weight:600; font-size:1rem;">Client Portal</div>
|
||||||
|
<div id="portalSubtitle" class="text-muted" style="font-size:0.8rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small id="portalUserLabel" class="text-muted"></small>
|
||||||
|
</div>
|
||||||
|
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
|
||||||
|
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
|
||||||
|
|
||||||
|
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||||
|
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||||
|
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||||
|
Please fill in your information before uploading files.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormName">Name</label>
|
||||||
|
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormEmail">Email</label>
|
||||||
|
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormReference">Reference / Case / Order #</label>
|
||||||
|
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:8px;">
|
||||||
|
<label for="portalFormNotes">Notes</label>
|
||||||
|
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalUploadSection">
|
||||||
|
<div id="portalDropzone" class="portal-dropzone">
|
||||||
|
<div><strong>Drop files here</strong> or click to browse.</div>
|
||||||
|
<div style="font-size:0.8rem;" class="text-muted">
|
||||||
|
Files will be uploaded to this portal only.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="portalFileInput" multiple style="display:none;">
|
||||||
|
<div id="portalStatus" class="portal-status text-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalFilesSection" style="margin-top:12px; display:none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong style="font-size:0.95rem;">Files in this portal</strong>
|
||||||
|
<button type="button" id="portalRefreshBtn" class="btn btn-sm btn-outline-secondary">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="portalFilesList" class="portal-files-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="portalFooter" class="text-muted"
|
||||||
|
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="customToast"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 698 KiB |
BIN
resources/dark-user-groups.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 656 KiB |
BIN
resources/filerise-v2.0.0.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
BIN
resources/portal-login.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
resources/portal-optional-form.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
@@ -1,19 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
# === Update FileRise to v2.0.2 (safe rsync) ===
|
||||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
VER="v1.9.1"
|
VER="v2.0.2"
|
||||||
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 +34,15 @@ 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
|
||||||
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='.github/***' \
|
--exclude='.github/***' \
|
||||||
--exclude='docker-compose.yml' \
|
--exclude='docker-compose.yml' \
|
||||||
"$STAGE_DIR"/ "$WEBROOT"/
|
"$STAGE_DIR"/ "$WEBROOT"/
|
||||||
@@ -42,13 +50,23 @@ 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 autoload optimization if composer is available
|
||||||
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
|
fi
|
||||||
|
|
||||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
# 6) Force demo mode ON in config/config.php
|
||||||
|
CFG_FILE="$WEBROOT/config/config.php"
|
||||||
|
if [[ -f "$CFG_FILE" ]]; then
|
||||||
|
# Make a one-time backup of config.php before editing
|
||||||
|
cp "$CFG_FILE" "${CFG_FILE}.bak.$stamp" || true
|
||||||
|
|
||||||
|
# Flip FR_DEMO_MODE to true if it exists as false
|
||||||
|
sed -i "s/define('FR_DEMO_MODE',[[:space:]]*false);/define('FR_DEMO_MODE', true);/" "$CFG_FILE" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 7) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||||
systemctl reload apache2 2>/dev/null || true
|
systemctl reload apache2 2>/dev/null || true
|
||||||
|
|
||||||
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
echo "FileRise updated to ${VER} (code). Demo mode forced ON. Data, Pro bundles, and demo site preserved."
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?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';
|
||||||
@@ -176,6 +177,7 @@ class AdminController
|
|||||||
'version' => $proVersion,
|
'version' => $proVersion,
|
||||||
'license' => $licenseString,
|
'license' => $licenseString,
|
||||||
],
|
],
|
||||||
|
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||||
];
|
];
|
||||||
|
|
||||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||||
@@ -240,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 = [
|
||||||
@@ -272,6 +274,126 @@ public function setLicense(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProPortals(): array
|
||||||
|
{
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||||
|
if (!is_file($proPortalsPath)) {
|
||||||
|
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $proPortalsPath;
|
||||||
|
|
||||||
|
// ProPortals is implemented in the Pro bundle and handles JSON storage.
|
||||||
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
|
$portals = $store->listPortals();
|
||||||
|
|
||||||
|
return $portals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $portalsPayload Raw "portals" array from JSON body
|
||||||
|
*/
|
||||||
|
public function saveProPortals(array $portalsPayload): void
|
||||||
|
{
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||||
|
if (!is_file($proPortalsPath)) {
|
||||||
|
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $proPortalsPath;
|
||||||
|
|
||||||
|
if (!is_array($portalsPayload)) {
|
||||||
|
throw new InvalidArgumentException('Invalid portals format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal normalization; deeper validation can live inside ProPortals
|
||||||
|
$data = ['portals' => []];
|
||||||
|
|
||||||
|
foreach ($portalsPayload as $slug => $info) {
|
||||||
|
$slug = trim((string)$slug);
|
||||||
|
if ($slug === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_array($info)) {
|
||||||
|
$info = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = trim((string)($info['label'] ?? $slug));
|
||||||
|
$folder = trim((string)($info['folder'] ?? ''));
|
||||||
|
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||||
|
$uploadOnly = !empty($info['uploadOnly']);
|
||||||
|
$allowDownload = array_key_exists('allowDownload', $info)
|
||||||
|
? !empty($info['allowDownload'])
|
||||||
|
: true;
|
||||||
|
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||||
|
|
||||||
|
// Optional branding + form behavior
|
||||||
|
$title = trim((string)($info['title'] ?? ''));
|
||||||
|
$introText = trim((string)($info['introText'] ?? ''));
|
||||||
|
$requireForm = !empty($info['requireForm']);
|
||||||
|
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||||
|
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||||
|
|
||||||
|
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||||
|
? $info['formDefaults']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Normalize defaults for known keys
|
||||||
|
$formDefaults = [
|
||||||
|
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||||
|
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||||
|
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||||
|
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||||
|
];
|
||||||
|
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||||
|
? $info['formRequired']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$formRequired = [
|
||||||
|
'name' => !empty($formRequired['name']),
|
||||||
|
'email' => !empty($formRequired['email']),
|
||||||
|
'reference' => !empty($formRequired['reference']),
|
||||||
|
'notes' => !empty($formRequired['notes']),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($folder === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['portals'][$slug] = [
|
||||||
|
'label' => $label,
|
||||||
|
'folder' => $folder,
|
||||||
|
'clientEmail' => $clientEmail,
|
||||||
|
'uploadOnly' => $uploadOnly,
|
||||||
|
'allowDownload' => $allowDownload,
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
// NEW
|
||||||
|
'title' => $title,
|
||||||
|
'introText' => $introText,
|
||||||
|
'requireForm' => $requireForm,
|
||||||
|
'brandColor' => $brandColor,
|
||||||
|
'footerText' => $footerText,
|
||||||
|
'formDefaults' => $formDefaults,
|
||||||
|
'formRequired' => $formRequired,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
|
$ok = $store->savePortals($data);
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RuntimeException('Could not write portals.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function getProGroups(): array
|
public function getProGroups(): array
|
||||||
{
|
{
|
||||||
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
@@ -445,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;
|
||||||
|
|||||||
123
src/controllers/PortalController.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
// src/controllers/PortalController.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
final class PortalController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Look up a portal by slug from the Pro bundle.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* [
|
||||||
|
* 'slug' => string,
|
||||||
|
* 'label' => string,
|
||||||
|
* 'folder' => string,
|
||||||
|
* 'clientEmail' => string,
|
||||||
|
* 'uploadOnly' => bool,
|
||||||
|
* 'allowDownload' => bool,
|
||||||
|
* 'expiresAt' => string,
|
||||||
|
* 'title' => string,
|
||||||
|
* 'introText' => string,
|
||||||
|
* 'requireForm' => bool
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
public static function getPortalBySlug(string $slug): array
|
||||||
|
{
|
||||||
|
$slug = trim($slug);
|
||||||
|
if ($slug === '') {
|
||||||
|
throw new InvalidArgumentException('Missing portal slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('Pro bundle directory not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
|
||||||
|
if (!is_file($proPortalsPath)) {
|
||||||
|
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $proPortalsPath;
|
||||||
|
|
||||||
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
|
$portals = $store->listPortals();
|
||||||
|
|
||||||
|
if (!isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||||
|
throw new RuntimeException('Portal not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$p = $portals[$slug];
|
||||||
|
|
||||||
|
$label = trim((string)($p['label'] ?? $slug));
|
||||||
|
$folder = trim((string)($p['folder'] ?? ''));
|
||||||
|
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||||
|
$uploadOnly = !empty($p['uploadOnly']);
|
||||||
|
$allowDownload = array_key_exists('allowDownload', $p)
|
||||||
|
? !empty($p['allowDownload'])
|
||||||
|
: true;
|
||||||
|
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||||
|
|
||||||
|
// NEW: optional branding + intake behavior
|
||||||
|
$title = trim((string)($p['title'] ?? ''));
|
||||||
|
$introText = trim((string)($p['introText'] ?? ''));
|
||||||
|
$requireForm = !empty($p['requireForm']);
|
||||||
|
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||||
|
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||||
|
|
||||||
|
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||||
|
? $p['formDefaults']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$formDefaults = [
|
||||||
|
'name' => trim((string)($fd['name'] ?? '')),
|
||||||
|
'email' => trim((string)($fd['email'] ?? '')),
|
||||||
|
'reference' => trim((string)($fd['reference'] ?? '')),
|
||||||
|
'notes' => trim((string)($fd['notes'] ?? '')),
|
||||||
|
];
|
||||||
|
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
||||||
|
? $p['formRequired']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$formRequired = [
|
||||||
|
'name' => !empty($fr['name']),
|
||||||
|
'email' => !empty($fr['email']),
|
||||||
|
'reference' => !empty($fr['reference']),
|
||||||
|
'notes' => !empty($fr['notes']),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($folder === '') {
|
||||||
|
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry check
|
||||||
|
if ($expiresAt !== '') {
|
||||||
|
$ts = strtotime($expiresAt . ' 23:59:59');
|
||||||
|
if ($ts !== false && $ts < time()) {
|
||||||
|
throw new RuntimeException('This portal has expired.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'slug' => $slug,
|
||||||
|
'label' => $label,
|
||||||
|
'folder' => $folder,
|
||||||
|
'clientEmail' => $clientEmail,
|
||||||
|
'uploadOnly' => $uploadOnly,
|
||||||
|
'allowDownload' => $allowDownload,
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
|
||||||
|
'title' => $title,
|
||||||
|
'introText' => $introText,
|
||||||
|
'requireForm' => $requireForm,
|
||||||
|
'brandColor' => $brandColor,
|
||||||
|
'footerText' => $footerText,
|
||||||
|
'formDefaults' => $formDefaults,
|
||||||
|
'formRequired' => $formRequired,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -272,6 +272,15 @@ class UserController
|
|||||||
echo json_encode(["error" => "No username in session"]);
|
echo json_encode(["error" => "No username in session"]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
// Block changing the demo account password when in demo mode
|
||||||
|
if (FR_DEMO_MODE && $username === 'demo') {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Password changes are disabled on the public demo.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$data = self::readJson();
|
$data = self::readJson();
|
||||||
$oldPassword = trim($data["oldPassword"] ?? "");
|
$oldPassword = trim($data["oldPassword"] ?? "");
|
||||||
@@ -318,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);
|
||||||
@@ -339,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."]);
|
||||||
@@ -403,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']);
|
||||||
@@ -429,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
|
||||||
@@ -608,6 +651,15 @@ class UserController
|
|||||||
self::requireAuth();
|
self::requireAuth();
|
||||||
self::requireCsrf();
|
self::requireCsrf();
|
||||||
|
|
||||||
|
if (defined('FR_DEMO_MODE') && FR_DEMO_MODE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Profile picture changes are disabled in the demo environment.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
if (empty($_FILES['profile_picture']) || $_FILES['profile_picture']['error'] !== UPLOAD_ERR_OK) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
$config['branding']['headerBgDark'] ?? ''
|
$config['branding']['headerBgDark'] ?? ''
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||||
];
|
];
|
||||||
|
|
||||||
// NEW: include ONLYOFFICE minimal public flag
|
// NEW: include ONLYOFFICE minimal public flag
|
||||||
@@ -136,16 +137,17 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|
||||||
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
|
||||||
|
|
||||||
if ($locked) {
|
if ($locked) {
|
||||||
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
|
||||||
} else {
|
} else {
|
||||||
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
$public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||||
|
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
|
||||||
|
|
||||||
return $public;
|
return $public;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
|
||||||
public static function writeSiteConfig(array $publicSubset): array
|
public static function writeSiteConfig(array $publicSubset): array
|
||||||
|
|||||||