Compare commits

...

12 Commits

31 changed files with 5161 additions and 2270 deletions

View File

@@ -1,5 +1,89 @@
# Changelog
## Changes 12/3/2025 (v2.3.2)
release(v2.3.2): fix media preview URLs and tighten hover card layout
- Reuse the working preview URL as a base when stepping between images/videos
so next/prev navigation keeps using the same inline/download endpoint
- Preserve video progress tracking and watched badges while fixing black-screen
playback issues across browsers
- Slightly shrink the file hover preview card (width/height, grid columns,
gaps, snippet/props heights) for a more compact, less intrusive peek
---
## 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

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,309 @@ 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 = "380px"; // was 420
card.style.maxWidth = "600px"; // was 640
card.style.minHeight = "200px"; // was 220
card.style.padding = "8px 10px"; // slightly tighter padding
card.style.overflow = "hidden";
}
if (grid) {
grid.style.display = "grid";
grid.style.gridTemplateColumns = "200px minmax(240px, 1fr)"; // both columns ~9% smaller
grid.style.gap = "10px";
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 = "120px"; // was 140
thumb.style.marginBottom = "4px"; // slightly tighter
}
if (snippet) {
snippet.style.marginTop = "4px";
snippet.style.maxHeight = "120px";
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.76rem";
propsEl.style.lineHeight = "1.3";
propsEl.style.maxHeight = "140px";
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 +619,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 +785,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 +1586,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 +1917,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 +1936,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 +1966,7 @@ makeActionBtn("share", "share_folder", "btn-secondary",
);
if (stripItem) stripItem.classList.add("selected");
}
loadFileList(dest);
});
@@ -1563,9 +2195,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 +2333,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 +2611,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');
@@ -432,8 +503,28 @@ export function previewFile(fileUrl, fileName) {
const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower);
// Base preview URL from the link we clicked
const baseUrl = fileUrl;
// Use the same preview endpoint, just swap the "file" param.
function siblingPreviewUrl(newName) {
try {
const u = new URL(baseUrl, window.location.origin);
u.searchParams.set('file', newName);
// cache-bust so we dont get stale frames
u.searchParams.set('t', String(Date.now()));
return u.toString();
} catch {
// Fallback: go through generic download/inline endpoint
return buildPreviewUrl(folder, newName);
}
}
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 +543,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 +585,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 = siblingPreviewUrl(newFile); // <-- changed
};
if (images.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
@@ -530,203 +627,226 @@ export function previewFile(fileUrl, fileName) {
return;
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(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 setVideoSrc = (nm) => {
currentName = nm;
video.src = buildPreviewUrl(folder, nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
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");
// Track which file is currently active
let currentName = name;
// Use the URL we were passed in (old behavior) for the *first* video,
// fall back to API URL if for some reason it's empty.
const initialUrl = fileUrl && fileUrl.trim()
? fileUrl
: buildPreviewUrl(folder, 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);
// Helper: set src for a given video name
const setVideoSrc = (nm) => {
currentName = nm;
// For the current file, reuse the original working URL.
// For other files (next/prev), go through the API.
const url = (nm === name) ? initialUrl : buildPreviewUrl(folder, nm);
video.src = url;
video.src = siblingPreviewUrl(nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
const navigate = (dir) => {
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;
setVideoSrc(nm);
renderStatus(null);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
}
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
const navigate = (dir) => {
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;
setVideoSrc(nm);
renderStatus(null);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
// Kick off first video using the original working URL
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");
@@ -735,8 +855,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.4';
window.APP_VERSION = 'v2.3.2';

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

@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
# === Update FileRise to v2.3.2 (safe rsync, no composer on demo) ===
set -Eeuo pipefail
VER="v2.1.0"
VER="v2.3.2"
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
WEBROOT="/var/www"

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');