Compare commits

..

12 Commits

30 changed files with 4949 additions and 2092 deletions

View File

@@ -1,5 +1,85 @@
# Changelog
## Changes 12/3/2025 (v2.3.1)
release(v2.3.1): polish file list actions & hover preview peak
- Replace per-row action button stack with compact 3-dot “More actions” menu in file list and folder tree
- Add desktop hover preview peak card for files & folders (image thumb, text snippet, quick metadata)
- Add per-user toggle to disable file hover preview (stored in localStorage)
- Improve preview overlay: add Download button, Zoom/Rotate labels, keep download target in sync when navigating images/videos
- Fix mobile table layout so Size column is visible for files & folders
- Tweak dark/light glassmorphism styles for hover card and action buttons
- Clean up size parsing and editable flag logic for big/unknown files
---
## Changes 12/2/2025 (v2.3.0)
release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export
**v2.3.0 Portal branding, intake presets & upload limits**
**Client portals (Pro)**
- Added **per-portal branding**:
- Custom accent color and footer text, applied to both the portal page and the login card.
- Optional **portal logo** stored under `uploads/profile_pics`, with a simple upload flow from the Client Portals modal.
- Upgraded the **intake form**:
- Per-field labels, defaults, visibility, and "required" switches for Name, Email, Reference, and Notes.
- New presets for common workflows: **Legal intake**, **Tax client**, and **Order / RMA** that pre-fill labels and hints.
- New **thank-you screen**:
- Optional “Thank you” message shown after successful uploads, configurable per portal.
- New **upload rules per portal**:
- Max file size (MB) override.
- Allowed extensions whitelist (comma-separated).
- Simple per-browser daily upload limit, enforced in the portal UI with clear messaging.
- Improved **portal description**:
- Portal page now shows active rules (max size, allowed types, daily limit) so clients know whats allowed.
- **Submissions block** in the Client Portals modal:
- Inline list of portal submissions with timestamps, folder, submitter and IP.
- “Load submissions” button with paging-style UI and improved styling in both light and dark mode.
- (New) **Export to CSV** action from the submissions block for easier reporting and audits.
**Portal login**
- Portal login screen now respects **per-portal branding**:
- Uses the portals logo (or falls back to the default FileRise logo).
- Reuses accent color and footer text from portal metadata so login matches the portal look.
**Admin panel**
- Added dedicated **Client Portals** editor section with:
- Portal slug / label, folder picker, expiry, upload/download options.
- Branding, logo upload, intake presets, upload limits, thank-you message, and live submissions preview.
- Wired up new **ONLYOFFICE** admin section:
- Toggle, document server origin, JWT secret management, plus built-in connection tests and CSP helper.
- Wired up **Sponsor** section helper with copy-to-clipboard convenience for support links.
- Moved a bunch of admin-panel specific styles into `styles.css` for better maintainability (modal sizing, section headers, dark-mode tweaks).
**File Preview**
- Remember the users volume (and mute state) in localStorage and re-apply it for every video preview in browser.
**Security / hardening**
- New `public/api/pro/portals/uploadLogo.php` endpoint for portal logos:
- Pro-only, admin-only, CSRF-protected.
- Accepts JPEG/PNG/GIF up to 2MB and stores them under `UPLOAD_DIR/profile_pics` with randomised names.
_No breaking changes expected; existing portals continue to work with default settings._
---
## Changes 11/30/2025 (v2.2.4)
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
- Prevented a JS crash when the ONLYOFFICE JWT field isnt present by always initializing payload.onlyoffice before touching jwtSecret.
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isnt locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
---
## Changes 11/29/2025 (v2.2.3)
fix(preview): harden SVG handling and normalize mime type

135
README.md
View File

@@ -7,6 +7,7 @@
[![Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://demo.filerise.net)
[![Release](https://img.shields.io/github/v/release/error311/FileRise?include_prereleases&sort=semver)](https://github.com/error311/FileRise/releases)
[![License](https://img.shields.io/github/license/error311/FileRise)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join_chat-5865F2?logo=discord&logoColor=white)](https://discord.gg/7WN6f56X2e)
[![Sponsor on GitHub](https://img.shields.io/badge/Sponsor-❤-red)](https://github.com/sponsors/error311)
[![Support on Ko-fi](https://img.shields.io/badge/Ko--fi-Buy%20me%20a%20coffee-orange)](https://ko-fi.com/error311)
@@ -26,7 +27,7 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.3.1.png)
> 💡 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).
@@ -41,21 +42,22 @@ Full list of features available at [Full Feature Wiki](https://github.com/error3
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
---
## 1. What FileRise does
FileRise turns a folder on your server into a **webbased file explorer** with:
FileRise turns a folder on your server into a **web-based file explorer** with:
- Folder tree + breadcrumbs for fast navigation
- Multifile/folder draganddrop uploads
- Multi-file/folder drag-and-drop uploads
- Move / copy / rename / delete / extract ZIP
- Public share links (optionally passwordprotected & expiring)
- Public share links (optionally password-protected & expiring)
- Tagging and search by name, tag, uploader, and content
- Trash with restore/purge
- Inline previews (images, audio, video, PDF) and a builtin code editor
- Inline previews (images, audio, video, PDF) and a built-in code editor
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
@@ -65,8 +67,22 @@ Everything flows through a single ACL engine, so permissions are enforced consis
The easiest way to run FileRise is the official Docker image.
### Option A Quick start (docker run)
```bash
docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
docker run -d \
--name filerise \
-p 8080:80 \
-e TIMEZONE="America/New_York" \
-e TOTAL_UPLOAD_SIZE="10G" \
-e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
-e SCAN_ON_START="true" \
-e CHOWN_ON_START="true" \
-v ~/filerise/uploads:/var/www/uploads \
-v ~/filerise/users:/var/www/users \
-v ~/filerise/metadata:/var/www/metadata \
error311/filerise-docker:latest
```
Then visit:
@@ -77,22 +93,97 @@ http://your-server-ip:8080
On first launch youll be guided through creating the **initial admin user**.
**More Docker options (Unraid, dockercompose, env vars, reverse proxy, etc.)**
[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)
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you dont want a recursive `chown` on every start.
> ⚠️ **Uploads folder recommendation**
>
> Its strongly recommended to bind `/var/www/uploads` to a **dedicated folder**
> (for example `~/filerise/uploads` or `/mnt/user/appdata/FileRise/uploads`),
> not the root of a huge media share.
>
> If you really want FileRise to sit “on top of” an existing share, use a
> subfolder (e.g. `/mnt/user/media/filerise_root`) instead of the share root,
> so scans and permission changes stay scoped to that folder.
---
### Option B docker-compose.yml
```yaml
services:
filerise:
image: error311/filerise-docker:latest
container_name: filerise
ports:
- "8080:80"
environment:
TIMEZONE: "America/New_York"
TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false"
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
SCAN_ON_START: "true" # auto-index existing files on startup
CHOWN_ON_START: "true" # fix permissions on uploads/users/metadata on startup
volumes:
- ./uploads:/var/www/uploads
- ./users:/var/www/users
- ./metadata:/var/www/metadata
```
Bring it up with:
```bash
docker compose up -d
```
---
### Common environment variables
| Variable | Required | Example | What it does |
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
> (currently `m/d/y h:iA`).
> 🗂 **Using an existing folder tree**
>
> - Point `/var/www/uploads` at the folder you want FileRise to manage.
> - Set `SCAN_ON_START="true"` on the first run to index existing files, then
> usually set it to `"false"` so the container doesnt rescan on every restart.
> - `CHOWN_ON_START="true"` is handy on first run to fix permissions. If you map
> a large share or already manage ownership yourself, set it to `"false"` to
> avoid recursive `chown` on every start.
>
> Volumes:
> - `/var/www/uploads` your actual files
> - `/var/www/users` user & pro jsons
> - `/var/www/metadata` tags, search index, share links, etc.
**More Docker / orchestration options (Unraid, Portainer, k8s, 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)
- [Kubernetes / k8s deployment](https://github.com/error311/FileRise/wiki/Kubernetes---k8s-deployment)
- Portainer templates: add this URL in Portainer → Settings → App Templates:
`https://raw.githubusercontent.com/error311/filerise-portainer-templates/refs/heads/main/templates.json`
- See also the Docker repo: [error311/filerise-docker](https://github.com/error311/filerise-docker)
---
## 3. Manual install (PHP web server)
Prefer baremetal or your own stack? FileRise is just PHP + a few extensions.
Prefer bare-metal or your own stack? FileRise is just PHP + a few extensions.
**Requirements**
- PHP **8.3+**
- Web server (Apache / Nginx / Caddy + PHPFPM)
- Web server (Apache / Nginx / Caddy + PHP-FPM)
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
- No database required
@@ -125,7 +216,7 @@ Prefer baremetal or your own stack? FileRise is just PHP + a few extensions.
5. Browse to your FileRise URL and follow the **admin setup** screen.
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
---
@@ -146,14 +237,14 @@ See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
### ONLYOFFICE integration
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs viewonly).
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view-only).
Configure it in **Admin → ONLYOFFICE**:
- Enable ONLYOFFICE
- Set your Document Server origin (e.g. `https://docs.example.com`)
- Configure a shared JWT secret
- Copy the suggested ContentSecurityPolicy header into your reverse proxy
- Copy the suggested Content-Security-Policy header into your reverse proxy
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
@@ -174,8 +265,8 @@ Please report vulnerabilities responsibly via the channels listed in **SECURITY.
## 6. Community, support & contributing
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
- 💬 **Unraid forum thread:** for Unraidspecific setup and tuning.
- 🌍 **Reddit / selfhosting communities:** occasional release posts & feedback threads.
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
Contributions are welcome — from bug fixes and docs to translations and UI polish.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
@@ -183,16 +274,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
- ☕ [Kofi](https://ko-fi.com/error311)
- ☕ [Ko-fi](https://ko-fi.com/error311)
---
## 7. License & thirdparty code
## 7. License & third-party code
FileRise Core is released under the **MIT License** see [LICENSE](LICENSE).
It bundles a small set of wellknown client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
All thirdparty code remains under its original licenses.
It bundles a small set of well-known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
All third-party code remains under its original licenses.
See `THIRD_PARTY.md` and the `licenses/` folder for full details.

View File

@@ -100,6 +100,7 @@ $public = [
'introText' => (string)($portal['introText'] ?? ''),
'brandColor' => (string)($portal['brandColor'] ?? ''),
'footerText' => (string)($portal['footerText'] ?? ''),
'logoFile' => (string)($portal['logoFile'] ?? ''),
];
echo json_encode([

View File

@@ -0,0 +1,30 @@
<?php
// public/api/pro/portals/uploadLogo.php
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
header('Content-Type: application/json; charset=utf-8');
// Pro-only gate
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'FileRise Pro is not active on this instance.'
]);
exit;
}
try {
$ctrl = new UserController();
$ctrl->uploadPortalLogo();
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}

View File

@@ -1475,7 +1475,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
.dark-mode .card{background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;}
.card-header{font-size: 1.2rem;
.card-header{font-size: 1.1rem;
font-weight: bold;}
.custom-folder-card-body{padding-top: 5px !important;
padding-right: 0 !important;
@@ -2250,4 +2250,678 @@ body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
}
/* Modal sizing */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 60% !important;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
@media (max-width: 900px) {
#adminPanelModal .modal-content {
width: 90% !important;
max-width: none !important;
}
}
.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
.dark-mode .form-control::placeholder { color:#888; }
.section-header {
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
}
.section-header:first-of-type { margin-top:0; }
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
.section-header .material-icons { transition:transform .3s; color:#444; }
.dark-mode .section-header { background:#3a3a3a; color:#eee; }
.dark-mode .section-header .material-icons { color:#ccc; }
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
#adminPanelModal .editor-close-btn {
position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
border:2px solid transparent; transition:all .3s;
}
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
/* ---------- Folder access editor ---------- */
.folder-access-toolbar {
display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
}
.folder-access-list {
--col-perm: 84px;
--col-folder-min: 340px;
max-height: 320px;
overflow: auto;
border: 1px solid #ccc;
border-radius: 6px;
padding: 0;
}
.dark-mode .folder-access-list { border-color:#555; }
.folder-access-header,
.folder-access-row {
display: grid;
grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, var(--col-perm));
gap: 8px;
align-items: center;
padding: 8px 10px;
}
.folder-access-header {
position: sticky;
top: 0;
z-index: 2;
background: #fff;
font-weight: 700;
border-bottom: 1px solid rgba(0,0,0,0.12);
}
.dark-mode .folder-access-header { background:#2c2c2c; }
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
.folder-access-row:last-child { border-bottom: none; }
.perm-col { text-align:center; white-space:nowrap; }
.folder-access-header > div { white-space: nowrap; }
.folder-badge {
display:inline-flex; align-items:center; gap:6px;
font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
min-width: 0;
}
.muted { opacity:.65; font-size:.9em; }
/* Inheritance visuals */
.inherited-row {
opacity: 0.8;
background: rgba(32, 132, 255, 0.06);
}
.inherited-tag {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
background: rgba(32,132,255,0.12);
color: #2064ff;
margin-left: 6px;
}
.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
@media (max-width: 900px) {
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
}
/* Folder cell: horizontal-only scroll */
.folder-cell{
overflow-x:auto;
overflow-y:hidden;
white-space:nowrap;
-webkit-overflow-scrolling:touch;
}
/* nicer thin scrollbar (supported browsers) */
.folder-cell::-webkit-scrollbar{ height:8px; }
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
/* Badge now doesn't clip; let the wrapper handle scroll */
.folder-badge{
display:inline-flex; align-items:center; gap:6px;
font-weight:600;
min-width:0; /* allow child to be as wide as needed inside scroller */
}
.group-members-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.group-member-pill {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 999px;
font-size: 11px;
background-color: #1e88e5;
color: #fff;
}
.dark-mode .group-member-pill {
background-color: #1565c0;
color: #fff;
}
/* Client portal cards */
#clientPortalsBody .portal-card {
position: relative;
border-radius: 12px;
border: 1px solid #ddd;
padding: 10px 12px 8px;
margin-bottom: 10px;
}
.dark-mode #clientPortalsBody .portal-card {
border-color: #555;
background: #1f1f1f;
}
.portal-card-header {
display:flex;
align-items:center;
gap:8px;
cursor:pointer;
padding:4px 4px 4px 0;
}
.portal-card-header .portal-card-caret {
display:inline-block;
font-size:14px;
transform:rotate(-90deg);
transition:transform .15s ease;
}
.portal-card-header[aria-expanded="true"] .portal-card-caret {
transform:rotate(0deg);
}
.portal-card-header-main {
display:flex;
flex-wrap:wrap;
gap:6px;
align-items:baseline;
}
.portal-card-header-main strong {
font-size:.9rem;
}
.portal-card-header-main .portal-card-slug {
font-family:monospace;
font-size:.8rem;
opacity:.75;
}
.portal-card-delete,
.group-card-delete {
position:absolute;
top:10px;
right:6px;
width:30px;
height:30px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
padding:0;
}
.group-card-delete {
top:4px;
}
.portal-card-body {
margin-top:6px;
}
#clientPortalsBody .portal-meta-row {
display:flex;
flex-wrap:wrap;
gap:8px;
align-items:center;
margin-top:6px;
}
#clientPortalsBody .portal-meta-row label {
margin:0;
font-size:.8rem;
}
/* Make date input look consistent */
#clientPortalsBody input[type="date"].form-control-sm {
border-radius:.25rem;
}
/* -------- Client portals: Expires alignment + date styling -------- */
#clientPortalsBody .portal-expires-group {
display: inline-flex;
align-items: center;
gap: 6px;
}
#clientPortalsBody .portal-expires-group label {
margin: 0;
font-size: 0.85rem;
}
#clientPortalsBody .portal-expiry-input {
max-width: 170px;
border-radius: 6px;
}
.dark-mode #clientPortalsBody .portal-expiry-input {
background-color: #333;
border-color: #555;
color: #eee;
}
#clientPortalsBody .portal-submissions-block {
margin-top: 8px;
padding-top: 6px;
border-top: 1px dashed rgba(0,0,0,0.1);
}
#clientPortalsBody .portal-submissions-list {
max-height: 180px;
overflow: auto;
margin-top: 4px;
padding: 4px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.08);
background: rgba(0,0,0,0.02);
font-size: 0.8rem;
}
.dark-mode #clientPortalsBody .portal-submissions-list {
border-color: #555;
background: rgba(255,255,255,0.02);
}
#clientPortalsBody .portal-submissions-item {
padding: 4px 2px;
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;
}
/* Client portal submissions load button */
.portal-submissions-block .portal-submissions-load-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid rgba(108, 117, 125, 0.9); /* ~Bootstrap secondary */
background: rgba(108, 117, 125, 0.06);
font-size: 0.78rem;
line-height: 1.4;
cursor: pointer;
white-space: nowrap;
}
.portal-submissions-block .portal-submissions-load-btn:hover,
.portal-submissions-block .portal-submissions-load-btn:focus-visible {
background: rgba(108, 117, 125, 0.18);
}
body.dark-mode .portal-submissions-block .portal-submissions-load-btn {
border-color: rgba(200, 200, 200, 0.7);
background: rgba(255, 255, 255, 0.04);
}
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:hover,
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:focus-visible {
background: rgba(255, 255, 255, 0.10);
}
/* ============================================
TABLE ACTIONS: 3-dot header + row buttons
============================================ */
/* Compact "Actions" column */
th[data-column="actions"],
td.actions-cell,
td.folder-actions-cell {
width: 40px;
max-width: 40px;
text-align: center;
white-space: nowrap;
}
/* Hide "Actions" text but keep it for screen readers */
th[data-column="actions"] {
position: relative;
text-indent: -9999px;
}
/* Show a 3-dot Material icon in the header instead */
th[data-column="actions"]::after {
content: "more_horiz";
font-family: "Material Icons";
text-indent: 0;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #6b7280;
}
.dark-mode th[data-column="actions"]::after,
[data-theme="dark"] th[data-column="actions"]::after {
color: #9ca3af;
}
/* Row-level 3-dot button */
.btn-actions-ellipsis {
border: none;
background: transparent;
padding: 0;
line-height: 1;
box-shadow: none;
border-radius: 999px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color 0.16s ease-out,
box-shadow 0.16s ease-out,
transform 0.12s ease-out;
}
.btn-actions-ellipsis .material-icons {
font-size: 20px;
color: var(--filr-icon-muted, #6b7280);
}
/* Dark theme icon color */
.dark-mode .btn-actions-ellipsis .material-icons,
[data-theme="dark"] .btn-actions-ellipsis .material-icons {
color: #e5e7eb;
}
/* Glassy hover for 3-dot trigger (light) */
.btn-actions-ellipsis:hover,
.btn-actions-ellipsis:focus-visible {
outline: none;
background-color: rgba(148, 163, 184, 0.18);
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.4),
0 6px 14px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transform: translateY(-1px);
}
/* Glassy hover for 3-dot trigger (dark) */
.dark-mode .btn-actions-ellipsis:hover,
.dark-mode .btn-actions-ellipsis:focus-visible,
[data-theme="dark"] .btn-actions-ellipsis:hover,
[data-theme="dark"] .btn-actions-ellipsis:focus-visible {
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
box-shadow:
0 0 0 1px var(--fr-border-dark),
0 10px 24px rgba(0, 0, 0, 0.7);
}
.btn-actions-ellipsis.btn-link,
.btn-actions-ellipsis.btn-link:hover,
.btn-actions-ellipsis.btn-link:focus,
.btn-actions-ellipsis.btn-link:focus-visible {
text-decoration: none !important;
}
/* ============================================
HOVER PREVIEW CARD glassmorphism
============================================ */
/* Clickable glass hover card */
#hoverPreview {
pointer-events: auto;
}
/* === DARK THEME GLASS CARD (no banding) ======================= */
.hover-preview-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 420px;
max-width: 640px;
min-height: 220px;
padding: 10px 12px;
border-radius: 14px;
overflow: hidden;
/* Base: semi-opaque dark, no banding */
background-color: color-mix(
in srgb,
var(--fr-surface-dark, #0f172a) 78%,
transparent
) !important;
/* Very subtle linear sheen (small contrast = no visible bands) */
background-image: linear-gradient(
135deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.0)
);
border: 1px solid color-mix(
in srgb,
var(--fr-border-dark, #1f2937) 70%,
transparent
);
box-shadow:
0 18px 40px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(0, 0, 0, 0.35);
color: #e5e7eb;
font-size: 12px;
/* Glass feel: blur + mild saturation */
backdrop-filter: blur(18px) saturate(135%);
-webkit-backdrop-filter: blur(18px) saturate(135%);
}
/* === LIGHT THEME GLASS CARD =================================== */
[data-theme="light"] .hover-preview-card {
background-color: rgba(255, 255, 255, 0.86) !important;
background-image: linear-gradient(
135deg,
rgba(255, 255, 255, 0.98),
rgba(249, 250, 251, 0.80)
);
border-color: rgba(148, 163, 184, 0.45);
box-shadow:
0 16px 32px rgba(15, 23, 42, 0.16),
0 0 0 1px rgba(255, 255, 255, 0.9);
color: #111827;
backdrop-filter: blur(16px) saturate(130%);
-webkit-backdrop-filter: blur(16px) saturate(130%);
}
/* Two-column inner layout */
.hover-preview-grid {
display: grid;
grid-template-columns: 220px minmax(260px, 1fr);
gap: 12px;
align-items: center; /* center LEFT + RIGHT in the same row */
width: 100%;
}
/* Left column: image + snippet */
.hover-preview-left {
display: flex;
flex-direction: column;
justify-content: center; /* center inside its own grid cell */
min-width: 0;
}
/* Right column: title + meta + props */
.hover-preview-right {
display: flex;
flex-direction: column;
justify-content: center; /* center inside its own grid cell */
min-width: 0;
overflow: hidden;
}
/* Thumb area */
.hover-preview-thumb {
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
margin-bottom: 6px;
}
/* Text / folder peek snippet block */
.hover-preview-snippet {
margin-top: 4px;
max-height: 140px;
overflow: auto;
font-size: 0.78rem;
white-space: pre-wrap;
padding: 6px 8px;
border-radius: 6px;
/* Dark chip so it always has contrast vs the card */
background-color: rgba(39, 39, 39, 0.92) !important;
color: #e5e7eb !important;
}
/* You can keep this same in light mode (still looks good), or tweak slightly */
[data-theme="light"] .hover-preview-snippet {
background-color: rgba(39, 39, 39, 0.92) !important;
color: #f9fafb !important;
}
/* Title + meta + props */
.hover-preview-title {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.hover-preview-meta {
font-size: 0.8rem;
opacity: 0.8;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
[data-theme="light"] .hover-preview-meta {
color: #6b7280;
}
.hover-preview-props {
font-size: 0.78rem;
line-height: 1.3;
max-height: 160px;
overflow: auto;
padding-right: 4px;
word-break: break-word;
}
.hover-prop-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Icon color */
.hover-preview-icon.material-icons {
font-size: 26px;
color: #93c5fd;
}
[data-theme="light"] .hover-preview-icon.material-icons {
color: #2563eb;
}
/* Row-level 3-dot button: shared between file list + folder tree */
.btn-actions-ellipsis,
.folder-kebab {
border: none;
background: transparent;
padding: 0;
line-height: 1;
box-shadow: none;
border-radius: 999px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color 0.16s ease-out,
box-shadow 0.16s ease-out,
transform 0.12s ease-out;
}
/* Icon sizing + base color */
.btn-actions-ellipsis .material-icons,
.folder-kebab.material-icons {
font-size: 20px;
color: var(--filr-icon-muted, #6b7280);
}
/* Dark theme icon color */
.dark-mode .btn-actions-ellipsis .material-icons,
[data-theme="dark"] .btn-actions-ellipsis .material-icons,
.dark-mode .folder-kebab.material-icons,
[data-theme="dark"] .folder-kebab.material-icons {
color: #e5e7eb;
}
/* Glassy hover for 3-dot trigger (light) */
.btn-actions-ellipsis:hover,
.btn-actions-ellipsis:focus-visible,
.folder-kebab:hover,
.folder-kebab:focus-visible {
outline: none;
background-color: rgba(148, 163, 184, 0.18);
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.4),
0 6px 14px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transform: translateY(-1px);
}
/* Glassy hover for 3-dot trigger (dark) */
.dark-mode .btn-actions-ellipsis:hover,
.dark-mode .btn-actions-ellipsis:focus-visible,
[data-theme="dark"] .btn-actions-ellipsis:hover,
[data-theme="dark"] .btn-actions-ellipsis:focus-visible,
.dark-mode .folder-kebab:hover,
.dark-mode .folder-kebab:focus-visible,
[data-theme="dark"] .folder-kebab:hover,
[data-theme="dark"] .folder-kebab:focus-visible {
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
box-shadow:
0 0 0 1px var(--fr-border-dark),
0 10px 24px rgba(0, 0, 0, 0.7);
}
/* Keep folder modals in DOM for JS, but hide the old toolbar icons */
.folder-actions {
/* still exists so modals can be found + detached */
display: block;
position: relative;
}
/* Hide the icon buttons, keep their IDs for JS wiring */
.folder-actions > button {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -0,0 +1,511 @@
// public/js/adminOnlyOffice.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { showToast } from './domUtils.js?v={{APP_QVER}}';
/**
* Translate with fallback
*/
const tf = (key, fallback) => {
const v = t(key);
return (v && v !== key) ? v : fallback;
};
/**
* Local masked-input renderer (copied from adminPanel.js style)
*/
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
const type = isSecret ? 'password' : 'text';
const disabled = hasValue
? 'disabled data-replace="0" placeholder="•••••• (saved)"'
: 'data-replace="1"';
const replaceBtn = hasValue
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
const note = hasValue
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
: '';
return `
<div class="form-group">
<label for="${id}">${label}:</label>
<div style="display:flex; gap:8px; align-items:center;">
<input type="${type}" id="${id}" class="form-control" ${disabled} />
${replaceBtn}
</div>
${note}
</div>
`;
}
/**
* Local "Replace" wiring (copied from adminPanel.js style, but scoped)
*/
function wireReplaceButtons(scope = document) {
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
if (btn.__wired) return;
btn.__wired = true;
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-replace-for');
const inp = scope.querySelector('#' + id);
if (!inp) return;
inp.disabled = false;
inp.dataset.replace = '1';
inp.placeholder = '';
inp.value = '';
btn.textContent = 'Keep saved value';
btn.removeAttribute('data-replace-for');
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
}, { once: true });
});
}
/**
* Trusted origin helper (mirror of your inline logic)
*/
function getTrustedDocsOrigin(raw) {
try {
const u = new URL(String(raw || '').trim());
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
if (u.username || u.password) return null; // no creds in URL
return u.origin;
} catch {
return null;
}
}
function buildOnlyOfficeApiUrl(origin) {
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
u.searchParams.set('probe', String(Date.now()));
return u.toString();
}
/**
* Lightweight JSON helper for this module
*/
async function safeJsonLocal(res) {
const txt = await res.text();
let body = null;
try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ }
if (!res.ok) {
const msg =
(body && (body.error || body.message)) ||
(txt && txt.trim()) ||
`HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
return body ?? {};
}
/**
* Script probe for api.js (mirrors old ooProbeScript)
*/
async function ooProbeScript(docsOrigin) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const src = buildOnlyOfficeApiUrl(base);
const s = document.createElement('script');
s.id = 'ooProbeScript';
s.async = true;
s.src = src;
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
if (nonce) s.setAttribute('nonce', nonce);
const cleanup = () => { try { s.remove(); } catch { /* ignore */ } };
s.onload = () => { cleanup(); resolve({ ok: true }); };
s.onerror = () => { cleanup(); resolve({ ok: false }); };
// origin is validated, path is fixed => safe
document.head.appendChild(s);
});
}
/**
* Iframe probe for DS (mirrors old ooProbeFrame)
*/
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
return new Promise(resolve => {
const base = getTrustedDocsOrigin(docsOrigin);
if (!base) { resolve({ ok: false }); return; }
const f = document.createElement('iframe');
f.id = 'ooProbeFrame';
f.src = base;
f.style.display = 'none';
const cleanup = () => { try { f.remove(); } catch { /* ignore */ } };
const t = setTimeout(() => {
cleanup();
resolve({ ok: false, timeout: true });
}, timeoutMs);
f.onload = () => {
clearTimeout(t);
cleanup();
resolve({ ok: true });
};
f.onerror = () => {
clearTimeout(t);
cleanup();
resolve({ ok: false });
};
// src constrained to validated http/https origin
document.body.appendChild(f);
});
}
/**
* Copy helpers (same behavior you had before)
*/
async function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through
}
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
function selectElementContents(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
/**
* Builds the ONLYOFFICE test card and wires Run tests button
*/
function attachOnlyOfficeTests(container) {
const testBox = document.createElement('div');
testBox.className = 'card';
testBox.style.marginTop = '12px';
testBox.innerHTML = `
<div class="card-body">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
<strong>Test ONLYOFFICE connection</strong>
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
<span id="ooTestSpinner" style="display:none;">⏳</span>
</div>
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
<small class="text-muted">
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
</small>
</div>
`;
container.appendChild(testBox);
const spinner = testBox.querySelector('#ooTestSpinner');
const out = testBox.querySelector('#ooTestResults');
function ooRow(label, status, detail = '') {
const li = document.createElement('li');
li.style.margin = '6px 0';
const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌';
li.innerHTML =
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
` <strong>${label}</strong>` +
(detail ? ` — <span>${detail}</span>` : '');
return li;
}
function ooClear() {
while (out.firstChild) out.removeChild(out.firstChild);
}
async function runOnlyOfficeTests() {
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
spinner.style.display = 'inline';
ooClear();
// 1) FileRise status
let statusOk = false;
try {
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
const statusJson = await r.json().catch(() => ({}));
if (r.ok) {
if (statusJson.enabled) {
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
statusOk = true;
} else {
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
}
} else {
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
}
} catch (e) {
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
}
// 2) Secret presence (fresh read)
try {
const cfg = await fetch('/api/admin/getConfig.php', {
credentials: 'include',
cache: 'no-store'
}).then(r => r.json());
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
out.appendChild(
ooRow(
'JWT secret saved',
hasSecret ? 'ok' : 'fail',
hasSecret ? 'Present' : 'Missing'
)
);
} catch {
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
}
// 3) Callback reachable
try {
const r = await fetch('/api/onlyoffice/callback.php?ping=1', {
credentials: 'include',
cache: 'no-store'
});
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
} catch {
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
}
// Basic sanity on origin
if (!/^https?:\/\//i.test(docsOrigin)) {
out.appendChild(
ooRow(
'Document Server Origin',
'fail',
'Enter a valid http(s) origin (e.g., https://docs.example.com)'
)
);
spinner.style.display = 'none';
return;
}
// 4a) api.js
const sRes = await ooProbeScript(docsOrigin);
out.appendChild(
ooRow(
'Load api.js',
sRes.ok ? 'ok' : 'fail',
sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'
)
);
// 4b) iframe
const fRes = await ooProbeFrame(docsOrigin);
out.appendChild(
ooRow(
'Embed DS iframe',
fRes.ok ? 'ok' : 'fail',
fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'
)
);
if (!statusOk || !sRes.ok || !fRes.ok) {
const tip = document.createElement('li');
tip.style.marginTop = '8px';
tip.innerHTML =
'💡 <em>Tip:</em> Use the CSP helper below to include your Document Server in ' +
'<code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.';
out.appendChild(tip);
}
spinner.style.display = 'none';
}
testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
}
/**
* CSP helper card (Apache + Nginx snippets)
*/
function attachOnlyOfficeCspHelper(container) {
const cspHelp = document.createElement('div');
cspHelp.className = 'alert alert-info';
cspHelp.style.marginTop = '12px';
cspHelp.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<strong>Content-Security-Policy help</strong>
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
</div>
<div class="form-text" style="margin-bottom:8px;">
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
embedding the editor iframe, and letting the script make XHR to your Document Server.
</div>
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
<div class="form-text" style="margin-top:8px;">
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
otherwise the browser will block it as mixed content.
</div>
<details style="margin-top:8px;">
<summary>Nginx equivalent</summary>
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
</details>
`;
container.appendChild(cspHelp);
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
function buildCspApache(originRaw) {
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
}
function buildCspNginx(originRaw) {
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
const api = `${o}/web-apps/apps/api/documents/api.js`;
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
}
const ooDocsInput = document.getElementById('ooDocsOrigin');
const cspPre = document.getElementById('ooCspSnippet');
const cspPreNgx = document.getElementById('ooCspSnippetNginx');
function refreshCsp() {
const raw = (ooDocsInput?.value || '').trim();
const base = getTrustedDocsOrigin(raw) || raw;
cspPre.textContent = buildCspApache(base);
cspPreNgx.textContent = buildCspNginx(base);
}
ooDocsInput?.addEventListener('input', refreshCsp);
refreshCsp();
document.getElementById('copyOoCsp')?.addEventListener('click', async () => {
const txt = (cspPre.textContent || '').trim();
const ok = await copyToClipboard(txt);
if (ok) {
showToast('CSP line copied.');
} else {
try { selectElementContents(cspPre); } catch { /* ignore */ }
const reason = window.isSecureContext ? '' : ' (page is not HTTPS or localhost)';
showToast('Copy failed' + reason + '. Press Ctrl/Cmd+C to copy.');
}
});
document.getElementById('selectOoCsp')?.addEventListener('click', () => {
try {
selectElementContents(cspPre);
showToast('Selected — press Ctrl/Cmd+C');
} catch {
/* ignore */
}
});
}
/**
* Public: build + wire ONLYOFFICE admin section
*/
export function initOnlyOfficeUI({ config }) {
const sec = document.getElementById('onlyofficeContent');
if (!sec) return;
const onlyCfg = config.onlyoffice || {};
const hasOOSecret = !!onlyCfg.hasJwtSecret;
window.__HAS_OO_SECRET = hasOOSecret;
// Base content
sec.innerHTML = `
<div class="form-group">
<input type="checkbox" id="ooEnabled" />
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
</div>
<div class="form-group">
<label for="ooDocsOrigin">Document Server Origin:</label>
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
<small class="text-muted">
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
</small>
</div>
${renderMaskedInput({
id: 'ooJwtSecret',
label: 'JWT Secret',
hasValue: hasOOSecret,
isSecret: true
})}
`;
wireReplaceButtons(sec);
// Tests + CSP helper
attachOnlyOfficeTests(sec);
attachOnlyOfficeCspHelper(sec);
// Initial values
const enabled = !!onlyCfg.enabled;
const docsOrigin = onlyCfg.docsOrigin || '';
const enabledEl = document.getElementById('ooEnabled');
const originEl = document.getElementById('ooDocsOrigin');
if (enabledEl) enabledEl.checked = enabled;
if (originEl) originEl.value = docsOrigin;
// Locking (managed in config.php)
const locked = !!onlyCfg.lockedByPhp;
window.__OO_LOCKED = locked;
if (locked) {
sec.querySelectorAll('input,button').forEach(el => {
el.disabled = true;
});
const note = document.createElement('div');
note.className = 'form-text';
note.style.marginTop = '6px';
note.textContent = 'Managed by config.php — edit ONLYOFFICE_* constants there.';
sec.appendChild(note);
}
}
/**
* Public: inject ONLYOFFICE settings into payload (used in handleSave)
*/
export function collectOnlyOfficeSettingsForSave(payload) {
const ooEnabledEl = document.getElementById('ooEnabled');
const ooDocsOriginEl = document.getElementById('ooDocsOrigin');
const ooSecretEl = document.getElementById('ooJwtSecret');
const onlyoffice = {
enabled: !!(ooEnabledEl && ooEnabledEl.checked),
docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || ''
};
if (!window.__OO_LOCKED && ooSecretEl) {
const val = ooSecretEl.value.trim();
const hasSaved = !!window.__HAS_OO_SECRET;
const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
if (shouldReplace && val !== '') {
onlyoffice.jwtSecret = val;
}
}
payload.onlyoffice = onlyoffice;
return payload;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,302 +0,0 @@
// 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);
})();

1574
public/js/adminPortals.js Normal file

File diff suppressed because it is too large Load Diff

118
public/js/adminSponsor.js Normal file
View File

@@ -0,0 +1,118 @@
// public/js/adminSponsor.js
import { t } from './i18n.js?v={{APP_QVER}}';
import { showToast } from './domUtils.js?v={{APP_QVER}}';
// Tiny "translate with fallback" helper, same as in adminPanel.js
const tf = (key, fallback) => {
const v = t(key);
return (v && v !== key) ? v : fallback;
};
const SPONSOR_GH = 'https://github.com/sponsors/error311';
const SPONSOR_KOFI = 'https://ko-fi.com/error311';
/**
* Initialize the Sponsor / Donations section inside the Admin Panel.
* Safe to call multiple times; it no-ops after the first run.
*/
export function initAdminSponsorSection() {
const container = document.getElementById('sponsorContent');
if (!container) return;
// Avoid double-wiring if initAdminSponsorSection gets called again
if (container.__sponsorInited) return;
container.__sponsorInited = true;
container.innerHTML = `
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorGitHub">${tf("github_sponsors_url", "GitHub Sponsors URL")}:</label>
<div class="input-group">
<input
type="url"
id="sponsorGitHub"
class="form-control"
value="${SPONSOR_GH}"
readonly
data-ignore-dirty="1"
/>
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">
${tf("copy", "Copy")}
</button>
<a
class="btn btn-outline-secondary"
id="openSponsorGitHub"
target="_blank"
rel="noopener"
>
${tf("open", "Open")}
</a>
</div>
</div>
<div class="form-group" style="margin-bottom:12px;">
<label for="sponsorKoFi">${tf("ko_fi_url", "Ko-fi URL")}:</label>
<div class="input-group">
<input
type="url"
id="sponsorKoFi"
class="form-control"
value="${SPONSOR_KOFI}"
readonly
data-ignore-dirty="1"
/>
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">
${tf("copy", "Copy")}
</button>
<a
class="btn btn-outline-secondary"
id="openSponsorKoFi"
target="_blank"
rel="noopener"
>
${tf("open", "Open")}
</a>
</div>
</div>
<small class="text-muted">
${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
</small>
`;
const ghInput = document.getElementById('sponsorGitHub');
const kfInput = document.getElementById('sponsorKoFi');
const copyGhBtn = document.getElementById('copySponsorGitHub');
const copyKfBtn = document.getElementById('copySponsorKoFi');
const openGh = document.getElementById('openSponsorGitHub');
const openKf = document.getElementById('openSponsorKoFi');
if (openGh) openGh.href = SPONSOR_GH;
if (openKf) openKf.href = SPONSOR_KOFI;
async function copyToClipboardSafe(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast(tf("copied", "Copied!"));
} catch {
showToast(tf("copy_failed", "Could not copy. Please copy manually."));
}
}
if (copyGhBtn && ghInput) {
copyGhBtn.addEventListener('click', () => copyToClipboardSafe(ghInput.value));
}
if (copyKfBtn && kfInput) {
copyKfBtn.addEventListener('click', () => copyToClipboardSafe(kfInput.value));
}
}

View File

@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
//window.__lastLoginData = data;
}
function isHoverPreviewDisabled() {
if (window.disableHoverPreview === true) return true;
try {
return localStorage.getItem('disableHoverPreview') === 'true';
} catch {
return false;
}
}
export function openTOTPLoginModal() {
let totpLoginModal = document.getElementById("totpLoginModal");
const isDarkMode = document.body.classList.contains("dark-mode");
@@ -454,6 +463,43 @@ export async function openUserPanel() {
}
});
// 4) Disable hover preview
const hoverLabel = document.createElement('label');
hoverLabel.style.cursor = 'pointer';
hoverLabel.style.display = 'block';
hoverLabel.style.marginTop = '4px';
const hoverCb = document.createElement('input');
hoverCb.type = 'checkbox';
hoverCb.id = 'disableHoverPreview';
hoverCb.style.verticalAlign = 'middle';
{
const storedHover = localStorage.getItem('disableHoverPreview');
hoverCb.checked = storedHover === 'true';
// also mirror into a global flag for runtime checks
window.disableHoverPreview = hoverCb.checked;
}
hoverLabel.appendChild(hoverCb);
hoverLabel.append(
` ${t('disable_hover_preview') || 'Disable file hover preview'}`
);
dispFs.appendChild(hoverLabel);
// Handler: toggle hover preview
hoverCb.addEventListener('change', () => {
const disabled = hoverCb.checked;
localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
window.disableHoverPreview = disabled;
// Hide any currently-visible preview right away
const preview = document.getElementById('hoverPreview');
if (preview) {
preview.style.display = 'none';
}
});
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
@@ -524,6 +570,13 @@ export async function openUserPanel() {
}
}
const hoverCb = modal.querySelector('#disableHoverPreview');
if (hoverCb) {
const storedHover = localStorage.getItem('disableHoverPreview');
hoverCb.checked = storedHover === 'true';
window.disableHoverPreview = hoverCb.checked;
}
// show
modal.style.display = 'flex';
}

View File

@@ -163,9 +163,9 @@ export function buildFileTableHeader(sortOrder) {
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="sortable-col"> ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} </th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th>
<th data-column="actions" class="actions-col">${t("actions")}</th>
</tr>
</thead>
`;
@@ -175,99 +175,32 @@ export function buildFileTableRow(file, folderPath) {
const safeFileName = escapeHTML(file.name);
const safeModified = escapeHTML(file.modified);
const safeUploaded = escapeHTML(file.uploaded);
const safeSize = escapeHTML(file.size);
const safeSize = escapeHTML(file.size);
const safeUploader = escapeHTML(file.uploader || "Unknown");
let previewButton = "";
const isSvg = /\.svg$/i.test(file.name);
// IMPORTANT: do NOT treat SVG as previewable
if (
!isSvg &&
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
.test(file.name)
) {
let previewIcon = "";
// images (SVG explicitly excluded)
if (
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
.test(file.name)
) {
previewIcon = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`;
} else if (/\.pdf$/i.test(file.name)) {
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`;
}
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
}
return `
<tr class="clickable-row">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
<button
type="button"
class="btn btn-sm btn-success download-btn"
data-download-name="${file.name}"
data-download-folder="${file.folder || 'root'}"
title="${t('download')}">
<i class="material-icons">file_download</i>
</button>
${file.editable ? `
<button
type="button"
class="btn btn-sm btn-secondary edit-btn"
data-edit-name="${file.name}"
data-edit-folder="${file.folder || 'root'}"
title="${t('edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
${previewButton}
<button
type="button"
class="btn btn-sm btn-warning rename-btn"
data-rename-name="${file.name}"
data-rename-folder="${file.folder || 'root'}"
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<!-- share -->
<button
<tr class="clickable-row" data-file-name="${safeFileName}">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell name-cell">
${safeFileName}
</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap size-cell">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td class="actions-cell">
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
class="btn btn-link btn-actions-ellipsis"
title="${t("more_actions")}"
>
<span class="material-icons">more_vert</span>
</button>
</div>
</td>
</tr>
`;
</td>
</tr>
`;
}
export function buildBottomControls(itemsPerPageSetting) {

View File

@@ -1,6 +1,6 @@
// fileDragDrop.js
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
/* ---------------- helpers ---------------- */
function getRowEl(el) {
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
/* ---------------- drag start (rows/cards) ---------------- */
export function fileDragStartHandler(event) {
try { cancelHoverPreview(); } catch {}
const row = getRowEl(event.currentTarget);
if (!row) return;

View File

@@ -214,6 +214,308 @@ function repaintStripIcon(folder) {
const kind = iconSpan.dataset.kind || 'empty';
iconSpan.innerHTML = folderSVG(kind);
}
const TEXT_PREVIEW_MAX_BYTES = 120 * 1024; // ~120 KB
const _fileSnippetCache = new Map();
function getFileExt(name) {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
async function fillFileSnippet(file, snippetEl) {
if (!snippetEl) return;
snippetEl.textContent = "";
snippetEl.style.display = "none";
const folder = file.folder || window.currentFolder || "root";
const key = `${folder}::${file.name}`;
if (!canEditFile(file.name)) {
// No text preview possible for this type cache the fact and bail
_fileSnippetCache.set(key, "");
return;
}
const bytes = Number.isFinite(file.sizeBytes) ? file.sizeBytes : null;
if (bytes != null && bytes > TEXT_PREVIEW_MAX_BYTES) {
// File is too large to safely preview inline
const msg = t("no_preview_available") || "No preview available";
snippetEl.style.display = "block";
snippetEl.textContent = msg;
_fileSnippetCache.set(key, msg);
return;
}
// Use cache if we have it
if (_fileSnippetCache.has(key)) {
const cached = _fileSnippetCache.get(key);
if (cached) {
snippetEl.textContent = cached;
snippetEl.style.display = "block";
}
return;
}
snippetEl.style.display = "block";
snippetEl.textContent = t("loading") || "Loading...";
try {
const url = apiFileUrl(folder, file.name, true);
const res = await fetch(url, { credentials: "include" });
if (!res.ok) throw 0;
const text = await res.text();
const MAX_LINES = 6;
const MAX_CHARS = 600;
const allLines = text.split(/\r?\n/);
let visibleLines = allLines.slice(0, MAX_LINES);
let snippet = visibleLines.join("\n");
let truncated = allLines.length > MAX_LINES;
if (snippet.length > MAX_CHARS) {
snippet = snippet.slice(0, MAX_CHARS);
truncated = true;
}
snippet = snippet.trim();
let finalSnippet = snippet || "(empty file)";
if (truncated) {
finalSnippet += "\n…";
}
_fileSnippetCache.set(key, finalSnippet);
snippetEl.textContent = finalSnippet;
} catch {
snippetEl.textContent = "";
snippetEl.style.display = "none";
_fileSnippetCache.set(key, "");
}
}
function wireEllipsisContextMenu(fileListContent) {
if (!fileListContent) return;
fileListContent
.querySelectorAll(".btn-actions-ellipsis")
.forEach(btn => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const row = btn.closest("tr");
if (!row) return;
const rect = btn.getBoundingClientRect();
const evt = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.bottom
});
row.dispatchEvent(evt);
});
});
}
let hoverPreviewEl = null;
let hoverPreviewTimer = null;
let hoverPreviewActiveRow = null;
let hoverPreviewContext = null;
let hoverPreviewHoveringCard = false;
// Let other modules (drag/drop) kill the hover card instantly.
export function cancelHoverPreview() {
try {
if (hoverPreviewTimer) {
clearTimeout(hoverPreviewTimer);
hoverPreviewTimer = null;
}
} catch {}
hoverPreviewActiveRow = null;
hoverPreviewContext = null;
hoverPreviewHoveringCard = false;
if (hoverPreviewEl) {
hoverPreviewEl.style.display = 'none';
}
}
function isHoverPreviewDisabled() {
// Live flag from user panel
if (window.disableHoverPreview === true) return true;
// Fallback to localStorage (e.g. on first page load)
try {
return localStorage.getItem('disableHoverPreview') === 'true';
} catch {
return false;
}
}
function ensureHoverPreviewEl() {
if (hoverPreviewEl) return hoverPreviewEl;
const el = document.createElement("div");
el.id = "hoverPreview";
el.style.position = "fixed";
el.style.zIndex = "9999";
el.style.display = "none";
el.innerHTML = `
<div class="hover-preview-card">
<div class="hover-preview-grid">
<div class="hover-preview-left">
<div class="hover-preview-thumb"></div>
<pre class="hover-preview-snippet"></pre>
</div>
<div class="hover-preview-right">
<div class="hover-preview-title"></div>
<div class="hover-preview-meta"></div>
<div class="hover-preview-props"></div>
</div>
</div>
</div>
`;
document.body.appendChild(el);
hoverPreviewEl = el;
// ---- Layout + sizing tweaks ---------------------------------
const card = el.querySelector(".hover-preview-card");
const grid = el.querySelector(".hover-preview-grid");
const leftCol = el.querySelector(".hover-preview-left");
const rightCol = el.querySelector(".hover-preview-right");
const thumb = el.querySelector(".hover-preview-thumb");
const snippet = el.querySelector(".hover-preview-snippet");
const titleEl = el.querySelector(".hover-preview-title");
const metaEl = el.querySelector(".hover-preview-meta");
const propsEl = el.querySelector(".hover-preview-props");
if (card) {
card.style.minWidth = "420px";
card.style.maxWidth = "640px";
card.style.minHeight = "220px";
card.style.padding = "10px 12px";
card.style.overflow = "hidden";
}
if (grid) {
grid.style.display = "grid";
grid.style.gridTemplateColumns = "220px minmax(260px, 1fr)";
grid.style.gap = "12px";
grid.style.alignItems = "center";
}
if (leftCol) {
leftCol.style.display = "flex";
leftCol.style.flexDirection = "column";
leftCol.style.justifyContent = "center";
leftCol.style.minWidth = "0";
}
if (rightCol) {
rightCol.style.display = "flex";
rightCol.style.flexDirection = "column";
rightCol.style.justifyContent = "center";
rightCol.style.minWidth = "0";
rightCol.style.overflow = "hidden";
}
if (thumb) {
thumb.style.display = "flex";
thumb.style.alignItems = "center";
thumb.style.justifyContent = "center";
thumb.style.minHeight = "140px";
thumb.style.marginBottom = "6px";
}
if (snippet) {
snippet.style.marginTop = "4px";
snippet.style.maxHeight = "140px";
snippet.style.overflow = "auto";
snippet.style.fontSize = "0.78rem";
snippet.style.whiteSpace = "pre-wrap";
snippet.style.padding = "6px 8px";
snippet.style.borderRadius = "6px";
// Dark-mode friendly styling that still looks OK in light mode
//snippet.style.backgroundColor = "rgba(39, 39, 39, 0.92)";
snippet.style.color = "#e5e7eb";
}
if (titleEl) {
titleEl.style.fontWeight = "600";
titleEl.style.fontSize = "0.95rem";
titleEl.style.marginBottom = "2px";
titleEl.style.whiteSpace = "nowrap";
titleEl.style.overflow = "hidden";
titleEl.style.textOverflow = "ellipsis";
titleEl.style.maxWidth = "100%";
}
if (metaEl) {
metaEl.style.fontSize = "0.8rem";
metaEl.style.opacity = "0.8";
metaEl.style.marginBottom = "6px";
metaEl.style.whiteSpace = "nowrap";
metaEl.style.overflow = "hidden";
metaEl.style.textOverflow = "ellipsis";
metaEl.style.maxWidth = "100%";
}
if (propsEl) {
propsEl.style.fontSize = "0.78rem";
propsEl.style.lineHeight = "1.3";
propsEl.style.maxHeight = "160px";
propsEl.style.overflow = "auto";
propsEl.style.paddingRight = "4px";
propsEl.style.wordBreak = "break-word";
}
// Allow the user to move onto the card without it vanishing
el.addEventListener("mouseenter", () => {
hoverPreviewHoveringCard = true;
});
el.addEventListener("mouseleave", () => {
hoverPreviewHoveringCard = false;
// If we've left both the row and the card, hide after a tiny delay
setTimeout(() => {
if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
hideHoverPreview();
}
}, 120);
});
// Click anywhere on the card = open preview/editor/folder
el.addEventListener("click", (e) => {
e.stopPropagation();
if (!hoverPreviewContext) return;
const ctx = hoverPreviewContext;
// Hide the hover card immediately so it doesn't hang around
hideHoverPreview();
if (ctx.type === "file") {
openDefaultFileFromHover(ctx.file);
} else if (ctx.type === "folder") {
const dest = ctx.folder;
if (dest) {
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch {}
updateBreadcrumbTitle(dest);
loadFileList(dest);
}
}
});
return el;
}
function hideHoverPreview() {
cancelHoverPreview();
}
function applyFolderStripLayout(strip) {
if (!strip) return;
@@ -316,6 +618,105 @@ function fetchFolderStats(folder) {
return p;
}
// --- Folder "peek" cache (first few child folders/files) ---
const FOLDER_PEEK_MAX_ITEMS = 6;
const _folderPeekCache = new Map();
/**
* Best-effort peek: first few direct child folders + files for a folder.
* Uses existing getFolderList.php + getFileList.php.
*
* Returns: { items: Array<{type,name}>, truncated: boolean }
*/
async function fetchFolderPeek(folder) {
if (!folder) return null;
if (_folderPeekCache.has(folder)) {
return _folderPeekCache.get(folder);
}
const p = (async () => {
try {
// 1) Files in this folder
let files = [];
try {
const res = await fetch(
`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=0&t=${Date.now()}`,
{ credentials: "include" }
);
const raw = await safeJson(res);
if (Array.isArray(raw.files)) {
files = raw.files;
} else if (raw.files && typeof raw.files === "object") {
files = Object.entries(raw.files).map(([name, meta]) => ({
...(meta || {}),
name
}));
}
} catch {
// ignore file errors; we can still show folders
}
// 2) Direct subfolders
let subfolderNames = [];
try {
const res2 = await fetch(
`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`,
{ credentials: "include" }
);
const raw2 = await safeJson(res2);
if (Array.isArray(raw2)) {
const allPaths = raw2.map(item => item.folder ?? item);
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
subfolderNames = allPaths
.filter(p => {
if (folder === "root") return p.indexOf("/") === -1;
if (!p.startsWith(folder + "/")) return false;
return p.split("/").length === depth;
})
.map(p => p.split("/").pop() || p);
}
} catch {
// ignore folder errors
}
const items = [];
// Folders first
for (const name of subfolderNames) {
if (!name) continue;
items.push({ type: "folder", name });
if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
}
// Then a few files
if (items.length < FOLDER_PEEK_MAX_ITEMS && Array.isArray(files)) {
for (const f of files) {
if (!f || !f.name) continue;
items.push({ type: "file", name: f.name });
if (items.length >= FOLDER_PEEK_MAX_ITEMS) break;
}
}
// Were there more candidates than we showed?
const totalCandidates =
(Array.isArray(subfolderNames) ? subfolderNames.length : 0) +
(Array.isArray(files) ? files.length : 0);
const truncated = totalCandidates > items.length;
return { items, truncated };
} catch {
return null;
}
})();
_folderPeekCache.set(folder, p);
return p;
}
/* ===========================================================
SECURITY: build file URLs only via the API (no /uploads)
=========================================================== */
@@ -383,6 +784,258 @@ function wireSelectAll(fileListContent) {
syncHeader();
}
function fillHoverPreviewForRow(row) {
if (isHoverPreviewDisabled()) {
hideHoverPreview();
return;
}
const el = ensureHoverPreviewEl();
const titleEl = el.querySelector(".hover-preview-title");
const metaEl = el.querySelector(".hover-preview-meta");
const thumbEl = el.querySelector(".hover-preview-thumb");
const propsEl = el.querySelector(".hover-preview-props");
const snippetEl = el.querySelector(".hover-preview-snippet");
if (!titleEl || !metaEl || !thumbEl || !propsEl || !snippetEl) return;
// Reset content
thumbEl.innerHTML = "";
propsEl.innerHTML = "";
snippetEl.textContent = "";
snippetEl.style.display = "none";
metaEl.textContent = "";
titleEl.textContent = "";
// Reset per-row sizing (we only make this tall for images)
thumbEl.style.minHeight = "0";
const isFolder = row.classList.contains("folder-row");
if (isFolder) {
// =========================
// FOLDER HOVER PREVIEW
// =========================
const folderPath = row.dataset.folder || "";
const folderName = folderPath.split("/").pop() || folderPath || "(root)";
titleEl.textContent = folderName;
hoverPreviewContext = {
type: "folder",
folder: folderPath
};
// Right column: icon + path
const iconHtml = `
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
<strong>${t("folder") || "Folder"}</strong>
</div>
`;
let propsHtml = iconHtml;
propsHtml += `
<div class="hover-prop-line">
<strong>${t("path") || "Path"}:</strong> ${escapeHTML(folderPath || "root")}
</div>
`;
propsEl.innerHTML = propsHtml;
// Meta: counts + size
fetchFolderStats(folderPath).then(stats => {
if (!stats || !document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
let bytes = null;
const sizeCandidates = [stats.bytes, stats.sizeBytes, stats.size, stats.totalBytes];
for (const v of sizeCandidates) {
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
bytes = n;
break;
}
}
const pieces = [];
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
if (!pieces.length) pieces.push("0 items");
const sizeLabel = bytes != null && bytes >= 0 ? formatSize(bytes) : "";
metaEl.textContent = sizeLabel
? `${pieces.join(", ")}${sizeLabel}`
: pieces.join(", ");
}).catch(() => {});
// Left side: peek inside folder (first few children)
// Left side: peek inside folder (first few children)
fetchFolderPeek(folderPath).then(result => {
if (!document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
if (!result) {
snippetEl.style.display = "none";
return;
}
const { items, truncated } = result;
// If nothing inside, show a friendly message like files do
if (!items || !items.length) {
const msg =
t("no_files_or_folders") ||
t("no_files_found") ||
"No files or folders";
snippetEl.textContent = msg;
snippetEl.style.display = "block";
return;
}
const lines = items.map(it => {
const prefix = it.type === "folder" ? "📁 " : "📄 ";
return prefix + it.name;
});
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, turn the LAST line into "…"
if (truncated && lines.length) {
lines[lines.length - 1] = "…";
}
snippetEl.textContent = lines.join("\n");
snippetEl.style.display = "block";
}).catch(() => {});
} else {
// ======================
// FILE HOVER PREVIEW
// ======================
const name = row.getAttribute("data-file-name") || "";
const file = fileData.find(f => f.name === name) || null;
hoverPreviewContext = {
type: "file",
file
};
if (!file) {
titleEl.textContent = name || "(unknown)";
metaEl.textContent = "";
return;
}
titleEl.textContent = file.name;
// IMPORTANT: no duplicate "size • modified • owner" under the title
metaEl.textContent = "";
const ext = getFileExt(file.name);
const lower = file.name.toLowerCase();
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|heic)$/i.test(lower);
const isVideo = /\.(mp4|mkv|webm|mov|ogv)$/i.test(lower);
const isAudio = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(lower);
const isPdf = /\.pdf$/i.test(lower);
const folder = file.folder || window.currentFolder || "root";
const url = apiFileUrl(folder, file.name, true);
const canTextPreview = canEditFile(file.name);
// Left: image preview OR text snippet OR "No preview"
if (isImage) {
thumbEl.style.minHeight = "140px";
const img = document.createElement("img");
img.src = url;
img.alt = file.name;
img.style.maxWidth = "180px";
img.style.maxHeight = "120px";
img.style.display = "block";
thumbEl.appendChild(img);
}
// Icon type for right column
let iconName = "insert_drive_file";
if (isImage) iconName = "image";
else if (isVideo) iconName = "movie";
else if (isAudio) iconName = "audiotrack";
else if (isPdf) iconName = "picture_as_pdf";
const props = [];
// Icon row at the top of the right column
props.push(`
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
<span class="hover-preview-icon material-icons" style="margin-right:6px;">${iconName}</span>
<strong>${escapeHTML(ext || "").toUpperCase() || t("file") || "File"}</strong>
</div>
`);
if (ext) {
props.push(`<div class="hover-prop-line"><strong>${t("extension") || "Ext"}:</strong> .${escapeHTML(ext)}</div>`);
}
if (file.size) {
props.push(`<div class="hover-prop-line"><strong>${t("size") || "Size"}:</strong> ${escapeHTML(file.size)}</div>`);
}
if (file.modified) {
props.push(`<div class="hover-prop-line"><strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(file.modified)}</div>`);
}
if (file.uploaded) {
props.push(`<div class="hover-prop-line"><strong>${t("created") || "Created"}:</strong> ${escapeHTML(file.uploaded)}</div>`);
}
if (file.uploader) {
props.push(`<div class="hover-prop-line"><strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(file.uploader)}</div>`);
}
propsEl.innerHTML = props.join("");
// Text snippet (left) for smaller text/code files
if (canTextPreview) {
fillFileSnippet(file, snippetEl);
} else if (!isImage) {
// Non-image, non-text → explicit "No preview"
const msg = t("no_preview_available") || "No preview available";
thumbEl.innerHTML = `
<div style="
padding:6px 8px;
border-radius:6px;
font-size:0.8rem;
text-align:center;
background-color:rgba(15,23,42,0.92);
color:#e5e7eb;
max-width:100%;
">
${escapeHTML(msg)}
</div>
`;
}
}
}
function positionHoverPreview(x, y) {
const el = ensureHoverPreviewEl();
const CARD_OFFSET_X = 16;
const CARD_OFFSET_Y = 12;
let left = x + CARD_OFFSET_X;
let top = y + CARD_OFFSET_Y;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (left + rect.width > vw - 10) {
left = x - rect.width - CARD_OFFSET_X;
}
if (top + rect.height > vh - 10) {
top = y - rect.height - CARD_OFFSET_Y;
}
el.style.left = `${Math.max(4, left)}px`;
el.style.top = `${Math.max(4, top)}px`;
}
// ---- Folder-strip icon helpers (same geometry as tree, but colored inline) ----
function _hexToHsl(hex) {
hex = String(hex || '').replace('#', '');
@@ -932,15 +1585,22 @@ export async function loadFileList(folderParam) {
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
// Prefer numeric size if API provides it; otherwise parse the "1.2 MB" string
let bytes = Number.isFinite(f.sizeBytes)
? f.sizeBytes
: parseSizeToBytes(String(f.size || ""));
if (!Number.isFinite(bytes)) bytes = Infinity;
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
// If we can't parse a sane size, treat as "unknown" instead of Infinity
if (!Number.isFinite(bytes) || bytes < 0) {
bytes = null;
}
f.sizeBytes = bytes;
// For editing: if size is unknown, assume it's OK and let the editor enforce limits.
const safeForEdit = (bytes == null) || (bytes <= MAX_EDIT_BYTES);
f.editable = canEditFile(f.name) && safeForEdit;
f.folder = folder;
return f;
});
@@ -1256,51 +1916,17 @@ if (headerClass) {
} else if (i === actionsIdx) {
td.classList.add("folder-actions-cell");
const group = document.createElement("div");
group.className = "btn-group btn-group-sm folder-actions-group";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "File actions");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-link btn-actions-ellipsis";
btn.title = t("more_actions");
const makeActionBtn = (iconName, titleKey, btnClass, actionKey, handler) => {
const btn = document.createElement("button");
btn.type = "button";
// base classes same size as file actions
btn.className = `btn ${btnClass} py-1`;
// kill any Bootstrap margin helpers that got passed in
btn.classList.remove("ml-2", "mx-2");
btn.setAttribute("data-folder-action", actionKey);
btn.setAttribute("data-i18n-title", titleKey);
btn.title = t(titleKey);
const icon = document.createElement("i");
icon.className = "material-icons";
icon.textContent = iconName;
btn.appendChild(icon);
btn.addEventListener("click", e => {
e.stopPropagation();
window.currentFolder = sf.full;
try { localStorage.setItem("lastOpenedFolder", sf.full); } catch {}
handler();
});
// start disabled; caps logic will enable
btn.disabled = true;
btn.style.pointerEvents = "none";
btn.style.opacity = "0.5";
group.appendChild(btn);
};
const icon = document.createElement("span");
icon.className = "material-icons";
icon.textContent = "more_vert";
makeActionBtn("drive_file_move", "move_folder", "btn-warning folder-move-btn", "move", () => openMoveFolderUI());
makeActionBtn("palette", "color_folder", "btn-color-folder","color", () => openColorFolderModal(sf.full));
makeActionBtn("drive_file_rename_outline", "rename_folder", "btn-warning folder-rename-btn", "rename", () => openRenameFolderModal());
makeActionBtn("share", "share_folder", "btn-secondary", "share", () => openFolderShareModal(sf.full));
td.appendChild(group);
btn.appendChild(icon);
td.appendChild(btn);
}
// IMPORTANT: always append the cell, no matter which column we're in
@@ -1309,22 +1935,27 @@ makeActionBtn("share", "share_folder", "btn-secondary",
// click → navigate, same as before
tr.addEventListener("click", e => {
// If the click came from the 3-dot button, let the context menu logic handle it
if (e.target.closest(".btn-actions-ellipsis")) {
return;
}
if (e.button !== 0) return;
const dest = sf.full;
if (!dest) return;
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
const treeNode = document.querySelector(
`.folder-option[data-folder="${CSS.escape(dest)}"]`
);
if (treeNode) treeNode.classList.add("selected");
const strip = document.getElementById("folderStripContainer");
if (strip) {
strip.querySelectorAll(".folder-item.selected")
@@ -1334,7 +1965,7 @@ makeActionBtn("share", "share_folder", "btn-secondary",
);
if (stripItem) stripItem.classList.add("selected");
}
loadFileList(dest);
});
@@ -1563,9 +2194,30 @@ function syncFolderIconSizeToRowHeight() {
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
});
}
async function openDefaultFileFromHover(file) {
if (!file) return;
const folder = file.folder || window.currentFolder || "root";
try {
if (canEditFile(file.name) && file.editable) {
const m = await import('./fileEditor.js?v={{APP_QVER}}');
m.editFile(file.name, folder);
} else {
const url = apiFileUrl(folder, file.name, true);
const m = await import('./filePreview.js?v={{APP_QVER}}');
m.previewFile(url, file.name);
}
} catch (e) {
console.error("Failed to open hover preview action", e);
}
}
/**
* Render table view
*/
export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
@@ -1680,11 +2332,100 @@ export function renderFileTable(folder, container, subfolders) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// Inject inline folder rows for THIS page (Explorer-style)
if (window.showInlineFolders !== false && pageFolders.length) {
injectInlineFolderRows(fileListContent, folder, pageFolders);
}
wireSelectAll(fileListContent);
// ---- MOBILE FIX: show "Size" column for files (Name | Size | Actions) ----
(function fixMobileFileSizeColumn() {
const isMobile = window.innerWidth <= 640;
if (!isMobile) return;
const table = fileListContent.querySelector("table.filr-table");
if (!table || !table.tHead || !table.tBodies.length) return;
const thead = table.tHead;
const tbody = table.tBodies[0];
const headerCells = Array.from(thead.querySelectorAll("th"));
// Find the Size column index by label or data-column
const sizeIdx = headerCells.findIndex(th =>
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
/\bsize\b/i.test((th.textContent || "").trim())
);
if (sizeIdx < 0) return;
// Unhide Size header on mobile
const sizeTh = headerCells[sizeIdx];
sizeTh.classList.remove(
"hide-small",
"hide-medium",
"d-none",
"d-sm-table-cell",
"d-md-table-cell",
"d-lg-table-cell",
"d-xl-table-cell"
);
// Unhide the Size cell in every body row (files + folders)
Array.from(tbody.rows).forEach(row => {
if (sizeIdx >= row.cells.length) return;
const td = row.cells[sizeIdx];
if (!td) return;
td.classList.remove(
"hide-small",
"hide-medium",
"d-none",
"d-sm-table-cell",
"d-md-table-cell",
"d-lg-table-cell",
"d-xl-table-cell"
);
});
})();
// Inject inline folder rows for THIS page (Explorer-style) first
if (window.showInlineFolders !== false && pageFolders.length) {
injectInlineFolderRows(fileListContent, folder, pageFolders);
}
// Now wire 3-dot ellipsis so it also picks up folder rows
wireEllipsisContextMenu(fileListContent);
// Hover preview (desktop only, and only if user didnt disable it)
if (window.innerWidth >= 768 && !isHoverPreviewDisabled()) {
fileListContent.querySelectorAll("tbody tr").forEach(row => {
if (row.classList.contains("folder-strip-row")) return;
row.addEventListener("mouseenter", (e) => {
hoverPreviewActiveRow = row;
clearTimeout(hoverPreviewTimer);
hoverPreviewTimer = setTimeout(() => {
if (hoverPreviewActiveRow === row && !isHoverPreviewDisabled()) {
fillHoverPreviewForRow(row);
const el = ensureHoverPreviewEl();
el.style.display = "block";
positionHoverPreview(e.clientX, e.clientY);
}
}, 180);
});
row.addEventListener("mouseleave", () => {
hoverPreviewActiveRow = null;
clearTimeout(hoverPreviewTimer);
setTimeout(() => {
if (!hoverPreviewActiveRow && !hoverPreviewHoveringCard) {
hideHoverPreview();
}
}, 120);
});
row.addEventListener("contextmenu", () => {
hoverPreviewActiveRow = null;
clearTimeout(hoverPreviewTimer);
hideHoverPreview();
});
});
}
wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs
// PATCH each row's preview/thumb to use the secure API URLs
@@ -1869,7 +2610,10 @@ export function renderFileTable(folder, container, subfolders) {
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});
// Right-click context menu stays for power users
bindFileListContextMenu();
refreshViewedBadges(folder).catch(() => { });
}

View File

@@ -9,6 +9,56 @@ export function buildPreviewUrl(folder, name) {
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
// New: build a download URL (attachment)
export function buildDownloadUrl(folder, name) {
const f = (!folder || folder === '') ? 'root' : String(folder);
const params = new URLSearchParams({
folder: f,
file: name,
inline: '0',
t: String(Date.now())
});
return `/api/file/download.php?${params.toString()}`;
}
const MEDIA_VOLUME_KEY = 'frMediaVolume';
const MEDIA_MUTED_KEY = 'frMediaMuted';
function loadSavedMediaVolume(el) {
if (!el) return;
try {
const v = localStorage.getItem(MEDIA_VOLUME_KEY);
if (v !== null) {
const vol = parseFloat(v);
if (!Number.isNaN(vol)) {
el.volume = Math.max(0, Math.min(1, vol));
}
}
const m = localStorage.getItem(MEDIA_MUTED_KEY);
if (m !== null) {
el.muted = (m === '1');
}
} catch {
// ignore storage errors
}
}
function attachVolumePersistence(el) {
if (!el) return;
try {
el.addEventListener('volumechange', () => {
try {
localStorage.setItem(MEDIA_VOLUME_KEY, String(el.volume));
localStorage.setItem(MEDIA_MUTED_KEY, el.muted ? '1' : '0');
} catch {
// ignore storage errors
}
});
} catch {
// ignore
}
}
/* -------------------------------- Share modal (existing) -------------------------------- */
export function openShareModal(file, folder) {
const existing = document.getElementById("shareModal");
@@ -338,6 +388,27 @@ function setTitle(overlay, name) {
}
}
// New: Download icon that uses current file name
function makeDownloadButton(folder, getName) {
const btn = makeTopIcon('download', t('download') || 'Download');
btn.addEventListener('click', (e) => {
e.stopPropagation();
const nm = getName && getName();
if (!nm) return;
const url = buildDownloadUrl(folder, nm);
// Use a temporary <a> with download attribute for nicer behavior
const a = document.createElement('a');
a.href = url;
a.download = nm;
document.body.appendChild(a);
a.click();
a.remove();
});
return btn;
}
// Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button');
@@ -434,6 +505,9 @@ export function previewFile(fileUrl, fileName) {
setTitle(overlay, name);
if (isSvg) {
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
container.textContent =
t("svg_preview_disabled") ||
"SVG preview is disabled for security. Use Download to view this file.";
@@ -452,12 +526,17 @@ export function previewFile(fileUrl, fileName) {
img.dataset.scale = 1;
img.dataset.rotate = 0;
container.appendChild(img);
let currentName = name;
// topbar-aligned, theme-aware icons
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
const downloadBtn = makeDownloadButton(folder, () => currentName);
actionWrap.appendChild(downloadBtn);
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
@@ -489,21 +568,22 @@ export function previewFile(fileUrl, fileName) {
});
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
overlay.mediaType = 'image';
overlay.mediaList = images;
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
setNavVisibility(overlay, images.length > 1, images.length > 1);
overlay.mediaType = 'image';
overlay.mediaList = images;
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
setNavVisibility(overlay, images.length > 1, images.length > 1);
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const newFile = overlay.mediaList[overlay.mediaIndex].name;
setTitle(overlay, newFile);
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
img.src = buildPreviewUrl(folder, newFile);
};
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const newFile = overlay.mediaList[overlay.mediaIndex].name;
currentName = newFile; // keep download button pointing to the right file
setTitle(overlay, newFile);
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
img.src = buildPreviewUrl(folder, newFile);
};
if (images.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
@@ -539,21 +619,29 @@ export function previewFile(fileUrl, fileName) {
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Apply last-used volume/mute, and persist future changes
loadSavedMediaVolume(video);
attachVolumePersistence(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
// Track which file is currently active
let currentName = name;
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
// Track which file is currently active
let currentName = name;
const downloadBtn = makeDownloadButton(folder, () => currentName);
// Order: Download | Mark | Reset
actionWrap.appendChild(downloadBtn);
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
const setVideoSrc = (nm) => {
currentName = nm;
@@ -702,6 +790,7 @@ export function previewFile(fileUrl, fileName) {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
currentName = nm; // keep download button in sync
setVideoSrc(nm);
renderStatus(null);
};
@@ -735,8 +824,19 @@ export function previewFile(fileUrl, fileName) {
audio.className = "audio-modal";
audio.style.maxWidth = "88vw";
container.appendChild(audio);
// Share the same volume/mute behavior with videos
loadSavedMediaVolume(audio);
attachVolumePersistence(audio);
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
overlay.style.display = "flex";
} else {
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
overlay.style.display = "flex";
}

View File

@@ -1066,6 +1066,41 @@ export function openColorFolderModal(folder) {
}
});
}
function addFolderActionButton(rowEl, folderPath) {
if (!rowEl || !folderPath) return;
if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
const btn = document.createElement('button');
btn.type = 'button';
// share styling with file list kebab
btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
btn.textContent = 'more_vert';
const label = t('folder_actions') || 'Folder actions';
btn.title = label;
btn.setAttribute('aria-label', label);
// only control visibility/layout here; let CSS handle colors/hover
Object.assign(btn.style, {
display: 'none',
marginLeft: '4px',
flexShrink: '0'
});
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const rect = btn.getBoundingClientRect();
const x = rect.right;
const y = rect.bottom;
const opt = rowEl.querySelector('.folder-option');
await openFolderActionsMenu(folderPath, opt, x, y);
});
rowEl.appendChild(btn);
}
/* ----------------------
DOM builders & DnD
----------------------*/
@@ -1125,6 +1160,10 @@ function makeChildLi(parentPath, item) {
opt.append(icon, label);
row.append(spacer, opt);
// Add 3-dot actions button for unlocked folders
if (!locked) addFolderActionButton(row, fullPath);
li.append(row);
// <ul class="folder-tree collapsed" role="group"></ul>
@@ -1300,6 +1339,28 @@ function getULForFolder(folder) {
const li = opt ? opt.closest('li[role="treeitem"]') : null;
return li ? li.querySelector(':scope > ul.folder-tree') : null;
}
function updateFolderActionButtons() {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
// Hide all kebabs by default
container.querySelectorAll('.folder-kebab').forEach(btn => {
btn.style.display = 'none';
});
// Show only for the currently selected, unlocked folder
const selectedOpt = container.querySelector('.folder-option.selected');
if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
const row = selectedOpt.closest('.folder-row');
if (!row) return;
const kebab = row.querySelector('.folder-kebab');
if (kebab) {
kebab.style.display = 'inline-flex';
}
}
async function selectFolder(selected) {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
@@ -1368,6 +1429,9 @@ async function selectFolder(selected) {
saveFolderTreeState(st);
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
}
// Keep the 3-dot action aligned to the active folder
updateFolderActionButtons();
}
/* ----------------------
@@ -1432,6 +1496,12 @@ export async function loadFolderTree(selectedFolder) {
`;
container.innerHTML = html;
// Add 3-dot actions button for root
const rootRow = document.getElementById('rootRow');
if (rootRow) {
addFolderActionButton(rootRow, effectiveRoot);
}
// Determine root's lock state
const rootOpt = container.querySelector('.root-folder-option');
let rootLocked = false;
@@ -1654,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
if (menu) menu.hidden = true;
}
async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
// Clear previous selection in tree + breadcrumb
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
// Mark the clicked thing selected (folder-option or breadcrumb)
if (targetEl) targetEl.classList.add('selected');
// Also sync selection in the tree if we invoked from a breadcrumb or kebab
const tree = document.getElementById('folderTreeContainer');
if (tree) {
const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
if (inTree) inTree.classList.add('selected');
}
// Show the kebab only for this selected folder
updateFolderActionButtons();
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
const menuItems = [
{
label: t('create_folder'),
action: () => {
const modal = document.getElementById('createFolderModal');
const input = document.getElementById('newFolderName');
if (modal) modal.style.display = 'block';
if (input) input.focus();
}
},
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
];
showFolderManagerContextMenu(clientX, clientY, menuItems);
}
async function folderManagerContextMenuHandler(e) {
const target = e.target.closest('.folder-option, .breadcrumb-link');
if (!target) return;
e.preventDefault();
e.stopPropagation();
// Toggle-only for locked nodes
// Toggle-only for locked nodes (no menu)
if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder);
@@ -1679,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
const folder = target.getAttribute('data-folder');
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
target.classList.add('selected');
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
const menuItems = [
{ label: t('create_folder'), action: () => {
const modal = document.getElementById('createFolderModal');
const input = document.getElementById('newFolderName');
if (modal) modal.style.display = 'block';
if (input) input.focus();
}},
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
];
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
const x = e.clientX;
const y = e.clientY;
await openFolderActionsMenu(folder, target, x, y);
}
function bindFolderManagerContextMenu() {

View File

@@ -343,7 +343,16 @@ const translations = {
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
"svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
"no_files_or_folders": "No files or folders to display.",
"no_preview_available": "No preview available.",
"more_actions": "More Actions",
"folder_actions": "Folder Actions",
"disable_hover_preview": "Disable hover preview in file list",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"rotate_left": "Rotate Left",
"rotate_right": "Rotate Right"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -218,6 +218,7 @@ function getRedirectTarget() {
const headingEl = document.getElementById('portalLoginTitle');
const subtitleEl = document.getElementById('portalLoginSubtitle');
const footerEl = document.getElementById('portalLoginFooter');
const logoEl = document.getElementById('portalLoginLogo');
if (headingEl) {
headingEl.textContent = 'Sign in to ' + title;
@@ -237,6 +238,24 @@ function getRedirectTarget() {
footerEl.style.display = 'none';
}
}
// 🔹 Portal logo: use logoFile from metadata if present
if (logoEl) {
let logoSrc = null;
// If you ever decide to store a direct URL:
if (portal.logoUrl && portal.logoUrl.trim()) {
logoSrc = portal.logoUrl.trim();
} else if (portal.logoFile && portal.logoFile.trim()) {
// Same convention as portal.html: files live in uploads/profile_pics
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
}
if (logoSrc) {
logoEl.src = logoSrc;
logoEl.alt = title;
}
}
// Document title
try {

View File

@@ -30,6 +30,127 @@ function portalCanDownload() {
return true;
}
function getPortalSlug() {
return portal && (portal.slug || portal.label || '') || '';
}
function normalizeExtList(raw) {
if (!raw) return [];
return String(raw)
.split(/[,\s]+/)
.map(x => x.trim().replace(/^\./, '').toLowerCase())
.filter(Boolean);
}
function getAllowedExts() {
if (!portal || !portal.uploadExtWhitelist) return [];
return normalizeExtList(portal.uploadExtWhitelist);
}
function getMaxSizeBytes() {
if (!portal || !portal.uploadMaxSizeMb) return 0;
const n = parseInt(portal.uploadMaxSizeMb, 10);
if (!n || n <= 0) return 0;
return n * 1024 * 1024;
}
// Simple per-browser-per-day counter; not true IP-based.
function applyUploadRateLimit(desiredCount) {
if (!portal || !portal.uploadMaxPerDay) return desiredCount;
const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
if (!maxPerDay || maxPerDay <= 0) return desiredCount;
const slug = getPortalSlug() || 'default';
const today = new Date().toISOString().slice(0, 10);
const key = 'portalUploadRate:' + slug;
let state = { date: today, count: 0 };
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.date === today && typeof parsed.count === 'number') {
state = parsed;
}
}
} catch {
// ignore
}
if (state.count >= maxPerDay) {
showToast('Daily upload limit reached for this portal.');
return 0;
}
const remaining = maxPerDay - state.count;
if (desiredCount > remaining) {
showToast('You can only upload ' + remaining + ' more file(s) today for this portal.');
return remaining;
}
return desiredCount;
}
function bumpUploadRateCounter(delta) {
if (!portal || !portal.uploadMaxPerDay || !delta) return;
const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
if (!maxPerDay || maxPerDay <= 0) return;
const slug = getPortalSlug() || 'default';
const today = new Date().toISOString().slice(0, 10);
const key = 'portalUploadRate:' + slug;
let state = { date: today, count: 0 };
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.date === today && typeof parsed.count === 'number') {
state = parsed.date === today ? parsed : state;
}
}
} catch {
// ignore
}
if (state.date !== today) {
state = { date: today, count: 0 };
}
state.count += delta;
if (state.count < 0) state.count = 0;
try {
localStorage.setItem(key, JSON.stringify(state));
} catch {
// ignore
}
}
function showThankYouScreen() {
if (!portal || !portal.showThankYou) return;
const section = qs('portalThankYouSection');
const msgEl = document.getElementById('portalThankYouMessage');
const upload = qs('portalUploadSection');
if (msgEl) {
const text =
(portal.thankYouText && portal.thankYouText.trim()) ||
'Thank you. Your files have been uploaded successfully.';
msgEl.textContent = text;
}
if (section) {
section.style.display = 'block';
}
if (upload) {
upload.style.opacity = '0.3';
}
}
// ----------------- DOM helpers / status -----------------
function qs(id) {
return document.getElementById(id);
@@ -45,6 +166,33 @@ function setStatus(msg, isError = false) {
}
}
// ----------------- Form labels (custom captions) -----------------
function applyPortalFormLabels() {
if (!portal) return;
const labels = portal.formLabels || {};
const required = portal.formRequired || {};
const defs = [
{ key: 'name', forId: 'portalFormName', defaultLabel: 'Name' },
{ key: 'email', forId: 'portalFormEmail', defaultLabel: 'Email' },
{ key: 'reference', forId: 'portalFormReference', defaultLabel: 'Reference / Case / Order #' },
{ key: 'notes', forId: 'portalFormNotes', defaultLabel: 'Notes' },
];
defs.forEach(def => {
const labelEl = document.querySelector(`label[for="${def.forId}"]`);
if (!labelEl) return;
const base = (labels[def.key] || def.defaultLabel || '').trim() || def.defaultLabel;
const isRequired = !!required[def.key];
// Add a subtle "*" for required fields; skip if already added
const text = isRequired && !base.endsWith('*') ? `${base} *` : base;
labelEl.textContent = text;
});
}
// ----------------- Form submit -----------------
async function submitPortalForm(slug, formData) {
const payload = {
@@ -109,7 +257,7 @@ async function sendRequest(url, method = 'GET', data = null, customHeaders = {})
// ----------------- Portal form wiring -----------------
function setupPortalForm(slug) {
const formSection = qs('portalFormSection');
const formSection = qs('portalFormSection');
const uploadSection = qs('portalUploadSection');
if (!portal || !portal.requireForm) {
@@ -136,39 +284,103 @@ function setupPortalForm(slug) {
const notesEl = qs('portalFormNotes');
const submitBtn = qs('portalFormSubmit');
const fd = portal.formDefaults || {};
const groupName = qs('portalFormGroupName');
const groupEmail = qs('portalFormGroupEmail');
const groupReference = qs('portalFormGroupReference');
const groupNotes = qs('portalFormGroupNotes');
if (nameEl && fd.name && !nameEl.value) {
const labelName = qs('portalFormLabelName');
const labelEmail = qs('portalFormLabelEmail');
const labelReference = qs('portalFormLabelReference');
const labelNotes = qs('portalFormLabelNotes');
const fd = portal.formDefaults || {};
const labels = portal.formLabels || {};
const visRaw = portal.formVisible || portal.formVisibility || {};
const req = portal.formRequired || {};
// default: visible when not specified
const visible = {
name: visRaw.name !== false,
email: visRaw.email !== false,
reference: visRaw.reference !== false,
notes: visRaw.notes !== false,
};
// Apply labels (fallback to defaults)
if (labelName) labelName.textContent = labels.name || 'Name';
if (labelEmail) labelEmail.textContent = labels.email || 'Email';
if (labelReference) labelReference.textContent = labels.reference || 'Reference / Case / Order #';
if (labelNotes) labelNotes.textContent = labels.notes || 'Notes';
// Helper to (re)add the required star spans
const setStar = (labelEl, isVisible, isRequired) => {
if (!labelEl) return;
// remove any previous star
const old = labelEl.querySelector('.portal-required-star');
if (old) old.remove();
if (isVisible && isRequired) {
const s = document.createElement('span');
s.className = 'portal-required-star';
s.textContent = ' *';
labelEl.appendChild(s);
}
};
// Show/hide groups
if (groupName) groupName.style.display = visible.name ? '' : 'none';
if (groupEmail) groupEmail.style.display = visible.email ? '' : 'none';
if (groupReference) groupReference.style.display = visible.reference ? '' : 'none';
if (groupNotes) groupNotes.style.display = visible.notes ? '' : 'none';
// Apply stars AFTER labels and visibility
setStar(labelName, visible.name, !!req.name);
setStar(labelEmail, visible.email, !!req.email);
setStar(labelReference, visible.reference, !!req.reference);
setStar(labelNotes, visible.notes, !!req.notes);
// If literally no fields are visible, just treat as no form
if (!visible.name && !visible.email && !visible.reference && !visible.notes) {
portalFormDone = true;
sessionStorage.setItem(key, '1');
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
}
// Prefill defaults only for visible fields
if (nameEl && visible.name && 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 (emailEl && visible.email) {
if (fd.email && !emailEl.value) {
emailEl.value = fd.email;
} else if (portal.clientEmail && !emailEl.value) {
emailEl.value = portal.clientEmail;
}
}
if (refEl && fd.reference && !refEl.value) {
if (refEl && visible.reference && fd.reference && !refEl.value) {
refEl.value = fd.reference;
}
if (notesEl && fd.notes && !notesEl.value) {
if (notesEl && visible.notes && fd.notes && !notesEl.value) {
notesEl.value = fd.notes;
}
if (!submitBtn) return;
submitBtn.onclick = async () => {
const name = nameEl ? nameEl.value.trim() : '';
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const reference = refEl ? refEl.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');
// Only validate visible fields
if (visible.name && req.name && !name) missing.push(labels.name || 'Name');
if (visible.email && req.email && !email) missing.push(labels.email || 'Email');
if (visible.reference && req.reference && !reference) missing.push(labels.reference || 'Reference');
if (visible.notes && req.notes && !notes) missing.push(labels.notes || 'Notes');
if (missing.length) {
showToast('Please fill in: ' + missing.join(', ') + '.');
@@ -176,8 +388,11 @@ function setupPortalForm(slug) {
}
// default behavior when no specific required flags:
// at least name or email, but only if those fields are visible
if (!req.name && !req.email && !req.reference && !req.notes) {
if (!name && !email) {
const hasNameField = visible.name;
const hasEmailField = visible.email;
if ((hasNameField || hasEmailField) && !name && !email) {
showToast('Please provide at least a name or email.');
return;
}
@@ -285,6 +500,7 @@ function renderPortalInfo() {
const footerEl = document.getElementById('portalFooter');
const drop = qs('portalDropzone');
const card = document.querySelector('.portal-card');
const logoImg = document.querySelector('.portal-logo img');
const formBtn = qs('portalFormSubmit');
const refreshBtn = qs('portalRefreshBtn');
const filesSection = qs('portalFilesSection');
@@ -303,6 +519,34 @@ function renderPortalInfo() {
const folder = portalFolder();
descEl.textContent = 'Files you upload here go directly into: ' + folder;
}
const bits = [];
if (portal.uploadMaxSizeMb) {
bits.push('Max file size: ' + portal.uploadMaxSizeMb + ' MB');
}
const exts = getAllowedExts();
if (exts.length) {
bits.push('Allowed types: ' + exts.join(', '));
}
if (portal.uploadMaxPerDay) {
bits.push('Daily upload limit: ' + portal.uploadMaxPerDay + ' file(s)');
}
if (bits.length) {
descEl.textContent += ' (' + bits.join(' • ') + ')';
}
}
if (logoImg) {
if (portal.logoUrl && portal.logoUrl.trim()) {
logoImg.src = portal.logoUrl.trim();
} else if (portal.logoFile && portal.logoFile.trim()) {
// Fallback if backend only supplies logoFile
logoImg.src = '/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim());
}
}
if (subtitleEl) {
@@ -317,7 +561,7 @@ function renderPortalInfo() {
? portal.footerText.trim()
: '';
}
applyPortalFormLabels();
const color = portal.brandColor && portal.brandColor.trim();
if (color) {
// expose brand color as a CSS variable for gallery styling
@@ -502,7 +746,71 @@ async function uploadFiles(fileList) {
return;
}
const files = Array.from(fileList);
let files = Array.from(fileList);
if (!files.length) return;
// 1) Filter by max size
const maxBytes = getMaxSizeBytes();
if (maxBytes > 0) {
const tooBigNames = [];
files = files.filter(f => {
if (f.size && f.size > maxBytes) {
tooBigNames.push(f.name || 'unnamed');
return false;
}
return true;
});
if (tooBigNames.length) {
showToast(
'Skipped ' +
tooBigNames.length +
' file(s) over ' +
portal.uploadMaxSizeMb +
' MB.'
);
}
}
// 2) Filter by allowed extensions
const allowedExts = getAllowedExts();
if (allowedExts.length) {
const skipped = [];
files = files.filter(f => {
const name = f.name || '';
const parts = name.split('.');
const ext = parts.length > 1 ? parts.pop().trim().toLowerCase() : '';
if (!ext || !allowedExts.includes(ext)) {
skipped.push(name || 'unnamed');
return false;
}
return true;
});
if (skipped.length) {
showToast(
'Skipped ' +
skipped.length +
' file(s) not matching allowed types: ' +
allowedExts.join(', ')
);
}
}
if (!files.length) {
setStatus('No files to upload after applying portal rules.', true);
return;
}
// 3) Rate-limit per day (simple per-browser guard)
const requestedCount = files.length;
const allowedCount = applyUploadRateLimit(requestedCount);
if (!allowedCount) {
setStatus('Upload blocked by daily limit.', true);
return;
}
if (allowedCount < requestedCount) {
files = files.slice(0, allowedCount);
}
const folder = portalFolder();
setStatus('Uploading ' + files.length + ' file(s)…');
@@ -575,9 +883,19 @@ async function uploadFiles(fileList) {
showToast('Upload failed.');
}
// Bump local daily counter by successful uploads
if (successCount > 0) {
bumpUploadRateCounter(successCount);
}
if (portalCanDownload()) {
loadPortalFiles();
}
// Optional thank-you screen
if (successCount > 0 && portal.showThankYou) {
showThankYouScreen();
}
}
// ----------------- Upload UI wiring -----------------

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v2.2.3';
window.APP_VERSION = 'v2.3.1';

View File

@@ -92,17 +92,19 @@
<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 class="portal-login-header">
<img id="portalLoginLogo"
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>

View File

@@ -169,6 +169,9 @@
.portal-file-row:last-child {
border-bottom: none;
}
.portal-required-star {
color: #dc3545;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -300,37 +303,38 @@
</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 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 id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelName" for="portalFormName">Name</label>
<input type="text" id="portalFormName" class="form-control form-control-sm">
</div>
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
</div>
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
<input type="text" id="portalFormReference" class="form-control form-control-sm">
</div>
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
<label id="portalFormLabelNotes" 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 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">
@@ -352,6 +356,16 @@
</div>
<div id="portalFilesList" class="portal-files-list"></div>
</div>
<div id="portalThankYouSection"
style="margin-top:12px; display:none;">
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
<strong>Thank you!</strong>
<span id="portalThankYouMessage">
Your files have been uploaded.
</span>
</div>
</div>
<div id="portalFooter" class="text-muted"
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -314,7 +314,6 @@ public function saveProPortals(array $portalsPayload): void
throw new InvalidArgumentException('Invalid portals format.');
}
// Minimal normalization; deeper validation can live inside ProPortals
$data = ['portals' => []];
foreach ($portalsPayload as $slug => $info) {
@@ -334,55 +333,100 @@ public function saveProPortals(array $portalsPayload): void
? !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']
: [];
// 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'] ?? ''));
// 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']
: [];
// Optional logo info
$logoFile = trim((string)($info['logoFile'] ?? ''));
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
// Upload rules / thank-you behavior
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
$showThankYou = !empty($info['showThankYou']);
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
// Form defaults
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
? $info['formDefaults']
: [];
$formDefaults = [
'name' => trim((string)($formDefaults['name'] ?? '')),
'email' => trim((string)($formDefaults['email'] ?? '')),
'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
// Required flags
$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']),
];
// Labels
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
? $info['formLabels']
: [];
$formLabels = [
'name' => trim((string)($formLabels['name'] ?? 'Name')),
'email' => trim((string)($formLabels['email'] ?? 'Email')),
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
];
// Visibility
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
? $info['formVisible']
: [];
$formVisible = [
'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
];
$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,
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'logoFile' => $logoFile,
'logoUrl' => $logoUrl,
'uploadMaxSizeMb' => $uploadMaxSizeMb,
'uploadExtWhitelist' => $uploadExtWhitelist,
'uploadMaxPerDay' => $uploadMaxPerDay,
'showThankYou' => $showThankYou,
'thankYouText' => $thankYouText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
'formLabels' => $formLabels,
'formVisible' => $formVisible,
];
}

View File

@@ -11,16 +11,29 @@ final class PortalController
*
* Returns:
* [
* 'slug' => string,
* 'label' => string,
* 'folder' => string,
* 'clientEmail' => string,
* 'uploadOnly' => bool,
* 'allowDownload' => bool,
* 'expiresAt' => string,
* 'title' => string,
* 'introText' => string,
* 'requireForm' => bool
* 'slug' => string,
* 'label' => string,
* 'folder' => string,
* 'clientEmail' => string,
* 'uploadOnly' => bool,
* 'allowDownload' => bool,
* 'expiresAt' => string,
* 'title' => string,
* 'introText' => string,
* 'requireForm' => bool,
* 'brandColor' => string,
* 'footerText' => string,
* 'formDefaults' => array,
* 'formRequired' => array,
* 'formLabels' => array,
* 'formVisible' => array,
* 'logoFile' => string,
* 'logoUrl' => string,
* 'uploadMaxSizeMb' => int,
* 'uploadExtWhitelist' => string,
* 'uploadMaxPerDay' => int,
* 'showThankYou' => bool,
* 'thankYouText' => string,
* ]
*/
public static function getPortalBySlug(string $slug): array
@@ -62,13 +75,14 @@ final class PortalController
: 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'] ?? ''));
// 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'] ?? ''));
// Defaults / required
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
? $p['formDefaults']
: [];
@@ -79,16 +93,52 @@ final class PortalController
'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']),
];
$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']),
];
// Optional formLabels
$fl = isset($p['formLabels']) && is_array($p['formLabels'])
? $p['formLabels']
: [];
$formLabels = [
'name' => trim((string)($fl['name'] ?? 'Name')),
'email' => trim((string)($fl['email'] ?? 'Email')),
'reference' => trim((string)($fl['reference'] ?? 'Reference / Case / Order #')),
'notes' => trim((string)($fl['notes'] ?? 'Notes')),
];
// Optional visibility
$fv = isset($p['formVisible']) && is_array($p['formVisible'])
? $p['formVisible']
: [];
$formVisible = [
'name' => !array_key_exists('name', $fv) || !empty($fv['name']),
'email' => !array_key_exists('email', $fv) || !empty($fv['email']),
'reference' => !array_key_exists('reference', $fv) || !empty($fv['reference']),
'notes' => !array_key_exists('notes', $fv) || !empty($fv['notes']),
];
// Optional per-portal logo
$logoFile = trim((string)($p['logoFile'] ?? ''));
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
// Upload rules / thank-you behavior
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
$showThankYou = !empty($p['showThankYou']);
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.');
@@ -103,21 +153,29 @@ final class PortalController
}
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,
'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,
'formLabels' => $formLabels,
'formVisible' => $formVisible,
'logoFile' => $logoFile,
'logoUrl' => $logoUrl,
'uploadMaxSizeMb' => $uploadMaxSizeMb,
'uploadExtWhitelist' => $uploadExtWhitelist,
'uploadMaxPerDay' => $uploadMaxPerDay,
'showThankYou' => $showThankYou,
'thankYouText' => $thankYouText,
];
}
}

View File

@@ -797,6 +797,90 @@ class UserController
exit;
}
/**
* Upload a logo for a specific client portal (Pro-only; admin, CSRF).
* Stores the file in UPLOAD_DIR/profile_pics and returns filename + URL.
*/
public function uploadPortalLogo(): void
{
self::jsonHeaders();
// Auth, admin & CSRF
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
if (empty($_FILES['portal_logo']) || $_FILES['portal_logo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['portal_logo'];
// Optional: which portal (used only for filename prefix)
$slugRaw = isset($_POST['slug']) ? (string)$_POST['slug'] : '';
$slug = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slugRaw) ?: 'portal';
// Validate MIME & size (same rules as uploadPicture / uploadBrandLogo)
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
exit;
}
if ($file['size'] > 2 * 1024 * 1024) { // 2MB
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'File too large']);
exit;
}
// Destination: reuse profile_pics directory
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
$ext = $allowed[$mime];
$filename = 'portal_' . $slug . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
// Build a web path similar to uploadBrandLogo
$fsPath = $uploadDir . '/' . $filename;
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
if ($url === '' || $url[0] !== '/') {
$url = '/' . ltrim($url, '/\\');
}
echo json_encode([
'success' => true,
'fileName' => $filename,
'url' => $url,
]);
exit;
}
public function siteConfig(): void
{
header('Content-Type: application/json');