Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405ed7f925 | ||
|
|
6491a7b1b3 | ||
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad | ||
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 | ||
|
|
e2d1b705bd | ||
|
|
4798afa89e | ||
|
|
da968e51e1 | ||
|
|
c06452600d | ||
|
|
758ad7719b | ||
|
|
3587f5041c |
200
CHANGELOG.md
@@ -1,5 +1,205 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 12/6/2025 (v2.3.6)
|
||||||
|
|
||||||
|
release(v2.3.6): add non-zip multi-download, richer hover preview/peak, modified sort default
|
||||||
|
|
||||||
|
- download: add "Download (no ZIP)" bulk action
|
||||||
|
- New context-menu action to download multiple selected files individually without creating a ZIP.
|
||||||
|
- Shows a centered stepper panel with "Download next" / "Cancel" while walking the queue.
|
||||||
|
- Limits plain multi-downloads (default 20) and nudges user to ZIP for larger batches.
|
||||||
|
- Uses existing /api/file/download.php URLs and respects current folder + selection.
|
||||||
|
|
||||||
|
- hover preview/peak: richer folder/file details and safer snippets
|
||||||
|
- Folder hover now shows:
|
||||||
|
- Icon + path
|
||||||
|
- Owner (from folder caps, when available)
|
||||||
|
- "Your access" summary (Upload / Move / Rename / Share / Delete) based on capabilities.
|
||||||
|
- Created / Modified timestamps derived from folder stats.
|
||||||
|
- Peek into child items (📁 / 📄) with trimmed labels and a clean "…" when truncated.
|
||||||
|
- File hover now adds:
|
||||||
|
- Tags/metadata line (tag names + MIME, duration, resolution when present).
|
||||||
|
- Text snippets are now capped per-line and by total characters to avoid huge blocks and keep previews/peak tidy.
|
||||||
|
|
||||||
|
- sorting: modified-desc default and folder stats for created/modified
|
||||||
|
- Default sort for the file list is now `Modified ↓` (newest first), matching typical Explorer-style views.
|
||||||
|
- Folders respect Created/Uploaded and Modified sort using folder stats:
|
||||||
|
- Created/Uploaded uses `earliest_uploaded`.
|
||||||
|
- Modified uses `latest_mtime`.
|
||||||
|
- Added a shared compareFilesForSort() so table view and gallery view use the same sort pipeline.
|
||||||
|
- Inline folders still render A>Z by name, so tree/folder strip remain predictable.
|
||||||
|
|
||||||
|
- UX / plumbing
|
||||||
|
- Added i18n strings for the new download queue labels and permission names ("Your access", Upload/Move/Rename/Share/Delete).
|
||||||
|
- Reset hover snippet styling per-row so folder previews and file previews each get the right wrapping behavior.
|
||||||
|
- Exported downloadSelectedFilesIndividually on window for file context menu integration and optional debugging helpers.
|
||||||
|
- Changed default file list row height from 48px to 44px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changese 12/6/2025 (v2.3.5)
|
||||||
|
|
||||||
|
release(v2.3.5): make client portals ACL-aware and improve admin UX
|
||||||
|
|
||||||
|
- Wire PortalController into ACL.php and expose canUpload/canDownload flags
|
||||||
|
- Gate portal uploads/downloads on both portal flags and folder ACL for logged-in users
|
||||||
|
- Normalize legacy portal JSON (uploadOnly) with new allowDownload checkbox semantics
|
||||||
|
- Disable portal upload UI when uploads are turned off; hide refresh when downloads are disabled
|
||||||
|
- Improve portal subtitles (“Upload & download”, “Upload only”, etc.) and status messaging
|
||||||
|
- Add quick-access buttons in Client Portals modal for Add user, Folder access, and User groups
|
||||||
|
- Enforce slug + folder as required on both frontend and backend, with inline hints and scroll-to-first-error
|
||||||
|
- Auto-focus newly created portals’ folder input for faster setup
|
||||||
|
- Raise user permissions modal z-index so it appears above the portals modal
|
||||||
|
- Enhance portal form submission logging with better client IP detection (X-Forwarded-For / X-Real-IP aware)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 12/5/2025 (v2.3.4)
|
||||||
|
|
||||||
|
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 12/5/2025 (v2.3.3)
|
||||||
|
|
||||||
|
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
||||||
|
|
||||||
|
**Branding & footer**
|
||||||
|
|
||||||
|
- Added **Pro-only footer branding** (`branding.footerHtml`) stored in `adminConfig.json` and exposed via the Admin API.
|
||||||
|
- Footer is now rendered from config; if no Pro footer is set, FileRise shows:
|
||||||
|
`© YEAR FileRise` with a link to **filerise.net**.
|
||||||
|
- New **“Header & Footer settings”** section in the Admin Panel, with a textarea for footer HTML (simple HTML + links allowed for Pro users).
|
||||||
|
|
||||||
|
**FileRise Pro & license UX**
|
||||||
|
|
||||||
|
- Bumped UI hint to `PRO_LATEST_BUNDLE_VERSION = v1.2.1`.
|
||||||
|
- Pro bundle install now:
|
||||||
|
- Parses the version from the uploaded ZIP basename (works with `C:\fakepath\FileRisePro-v1.2.1.zip`).
|
||||||
|
- Invalidates OPcache for updated Pro files so new code is active immediately.
|
||||||
|
- Re-fetches admin config after a successful install and displays the actual active Pro bundle version in the status line.
|
||||||
|
- Admin config now exposes richer Pro metadata (plan, expiresAt, maxMajor), and the Admin Panel shows:
|
||||||
|
- License type + email,
|
||||||
|
- Friendly **plan** description (early supporter vs personal/business),
|
||||||
|
- **Lifetime** vs **Valid until …** wording instead of a scary raw timestamp.
|
||||||
|
|
||||||
|
**Upload UX**
|
||||||
|
|
||||||
|
- Upload button is now only visible/enabled when there are files queued (regular or resumable):
|
||||||
|
- Hidden when the list is empty or after clearing uploads.
|
||||||
|
- Shown again when user picks or drags in files.
|
||||||
|
- Adjusted Upload / Choose Files button sizing and spacing for a cleaner upload card, especially on smaller screens.
|
||||||
|
|
||||||
|
**File list & hover preview polish**
|
||||||
|
|
||||||
|
- Inline folders now respect the current sort mode:
|
||||||
|
- **Name** sort: A–Z / Z–A.
|
||||||
|
- **Size** sort: uses folder stats (bytes) and sorts accordingly.
|
||||||
|
- Size and meta columns:
|
||||||
|
- Right-aligned **size**, **uploaded/created**, **modified**, and **owner/uploader** columns.
|
||||||
|
- Use tabular numerals for nicer numeric alignment.
|
||||||
|
- Hover preview:
|
||||||
|
- Skips “fake” rows (e.g. “No files found”) and rows that don’t resolve to a real file.
|
||||||
|
- Uses `sizeBytes` + `formatSize()` for a consistent, human-readable size.
|
||||||
|
- `formatSize()` now uses 1 decimal place (KB/MB/GB) and short `B` label for bytes.
|
||||||
|
- File metadata normalization:
|
||||||
|
- Every file gets a `sizeBytes`, normalized display `size`, and a `cacheKey` derived from modified/uploaded/size, used for stable cache-busting.
|
||||||
|
- Gallery / preview URLs now use `apiFileUrl()` with a stable `t` parameter instead of `Date.now()`, improving browser caching behavior.
|
||||||
|
|
||||||
|
**Layout & animation tweaks**
|
||||||
|
|
||||||
|
- Slightly reduced default upload card padding and button sizes to make the homepage cards feel less “tall”.
|
||||||
|
- New **site footer** styling (subtle border, centered text) added below the main layout.
|
||||||
|
- Drag-and-drop card (upload/folder cards to header dock) animations:
|
||||||
|
- Crisper ghost cards with better text opacity and anti-jank tweaks.
|
||||||
|
- Longer, smoother easing and more readable motion (both collapse-to-header and expand-from-header).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 what’s 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 portal’s 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 user’s 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)
|
## Changes 11/30/2025 (v2.2.4)
|
||||||
|
|
||||||
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
||||||
|
|||||||
135
README.md
@@ -7,6 +7,7 @@
|
|||||||
[](https://demo.filerise.net)
|
[](https://demo.filerise.net)
|
||||||
[](https://github.com/error311/FileRise/releases)
|
[](https://github.com/error311/FileRise/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://discord.gg/7WN6f56X2e)
|
||||||
[](https://github.com/sponsors/error311)
|
[](https://github.com/sponsors/error311)
|
||||||
[](https://ko-fi.com/error311)
|
[](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)
|
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
> 💡 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).
|
> 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)
|
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||||
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||||
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
- 🐳 **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)
|
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. What FileRise does
|
## 1. What FileRise does
|
||||||
|
|
||||||
FileRise turns a folder on your server into a **web‑based file explorer** with:
|
FileRise turns a folder on your server into a **web-based file explorer** with:
|
||||||
|
|
||||||
- Folder tree + breadcrumbs for fast navigation
|
- Folder tree + breadcrumbs for fast navigation
|
||||||
- Multi‑file/folder drag‑and‑drop uploads
|
- Multi-file/folder drag-and-drop uploads
|
||||||
- Move / copy / rename / delete / extract ZIP
|
- Move / copy / rename / delete / extract ZIP
|
||||||
- Public share links (optionally password‑protected & expiring)
|
- Public share links (optionally password-protected & expiring)
|
||||||
- Tagging and search by name, tag, uploader, and content
|
- Tagging and search by name, tag, uploader, and content
|
||||||
- Trash with restore/purge
|
- Trash with restore/purge
|
||||||
- Inline previews (images, audio, video, PDF) and a built‑in 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.
|
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.
|
The easiest way to run FileRise is the official Docker image.
|
||||||
|
|
||||||
|
### Option A – Quick start (docker run)
|
||||||
|
|
||||||
```bash
|
```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:
|
Then visit:
|
||||||
@@ -77,22 +93,97 @@ http://your-server-ip:8080
|
|||||||
|
|
||||||
On first launch you’ll be guided through creating the **initial admin user**.
|
On first launch you’ll be guided through creating the **initial admin user**.
|
||||||
|
|
||||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you don’t want a recursive `chown` on every start.
|
||||||
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
|
||||||
[nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
> ⚠️ **Uploads folder recommendation**
|
||||||
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
>
|
||||||
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
> It’s 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 doesn’t 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)
|
## 3. Manual install (PHP web server)
|
||||||
|
|
||||||
Prefer bare‑metal 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**
|
**Requirements**
|
||||||
|
|
||||||
- PHP **8.3+**
|
- PHP **8.3+**
|
||||||
- Web server (Apache / Nginx / Caddy + PHP‑FPM)
|
- Web server (Apache / Nginx / Caddy + PHP-FPM)
|
||||||
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
||||||
- No database required
|
- No database required
|
||||||
|
|
||||||
@@ -125,7 +216,7 @@ Prefer bare‑metal or your own stack? FileRise is just PHP + a few extensions.
|
|||||||
|
|
||||||
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
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
|
### 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 view‑only).
|
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**:
|
Configure it in **Admin → ONLYOFFICE**:
|
||||||
|
|
||||||
- Enable ONLYOFFICE
|
- Enable ONLYOFFICE
|
||||||
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||||
- Configure a shared JWT secret
|
- Configure a shared JWT secret
|
||||||
- Copy the suggested Content‑Security‑Policy 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)
|
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
|
## 6. Community, support & contributing
|
||||||
|
|
||||||
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||||
- 💬 **Unraid forum thread:** for Unraid‑specific setup and tuning.
|
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
|
||||||
- 🌍 **Reddit / self‑hosting communities:** occasional release posts & feedback threads.
|
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
|
||||||
|
|
||||||
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
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:
|
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)
|
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||||
- ☕ [Ko‑fi](https://ko-fi.com/error311)
|
- ☕ [Ko-fi](https://ko-fi.com/error311)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. License & third‑party code
|
## 7. License & third-party code
|
||||||
|
|
||||||
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||||
|
|
||||||
It bundles a small set of well‑known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
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.
|
All third-party code remains under its original licenses.
|
||||||
|
|
||||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ $public = [
|
|||||||
'introText' => (string)($portal['introText'] ?? ''),
|
'introText' => (string)($portal['introText'] ?? ''),
|
||||||
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||||
'footerText' => (string)($portal['footerText'] ?? ''),
|
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||||
|
'logoFile' => (string)($portal['logoFile'] ?? ''),
|
||||||
];
|
];
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ try {
|
|||||||
require_once $subPath;
|
require_once $subPath;
|
||||||
|
|
||||||
$submittedBy = (string)($_SESSION['username'] ?? '');
|
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||||
|
|
||||||
|
// ─────────────────────────────
|
||||||
|
// Better client IP detection
|
||||||
|
// ─────────────────────────────
|
||||||
|
$ip = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Can be a comma-separated list; use the first non-empty
|
||||||
|
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$candidate = trim($part);
|
||||||
|
if ($candidate !== '') {
|
||||||
|
$ip = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||||
|
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'portalLabel' => $portal['label'] ?? '',
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
@@ -69,7 +90,7 @@ try {
|
|||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
],
|
],
|
||||||
'submittedBy' => $submittedBy,
|
'submittedBy' => $submittedBy,
|
||||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
'ip' => $ip,
|
||||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
'createdAt' => gmdate('c'),
|
'createdAt' => gmdate('c'),
|
||||||
];
|
];
|
||||||
|
|||||||
30
public/api/pro/portals/uploadLogo.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -543,21 +543,22 @@ body{letter-spacing: 0.2px;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 5px;}
|
gap: 5px;}
|
||||||
#uploadBtn{font-size: 20px;
|
#uploadBtn{font-size: 18px;
|
||||||
padding: 10px 22px;
|
padding: 10px 18px;
|
||||||
align-items: center;}
|
align-items: center;
|
||||||
|
margin-top:20px;}
|
||||||
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
|
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
|
||||||
#customChooseBtn{background-color: #9E9E9E;
|
#customChooseBtn{background-color: #9E9E9E;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 18px;
|
padding: 8px 14px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;}
|
white-space: nowrap;}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#customChooseBtn{font-size: 14px;
|
#customChooseBtn{font-size: 12px;
|
||||||
padding: 6px 14px;}
|
padding: 6px 10px;}
|
||||||
}
|
}
|
||||||
.pause-resume-btn{background: none;
|
.pause-resume-btn{background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -772,7 +773,7 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
|
|||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
line-height: 1.2 !important;
|
line-height: 1.2 !important;
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
padding: 8px 10px !important;
|
padding: 2px 4px !important;
|
||||||
max-width: 250px !important;
|
max-width: 250px !important;
|
||||||
min-width: 120px !important;}
|
min-width: 120px !important;}
|
||||||
@media (min-width: 500px) {
|
@media (min-width: 500px) {
|
||||||
@@ -1442,8 +1443,6 @@ label{font-size: 0.9rem;}
|
|||||||
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
|
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
min-height: 320px;
|
|
||||||
|
|
||||||
border-radius: var(--menu-radius);
|
border-radius: var(--menu-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--card-border, #e5e7eb);
|
border: 1px solid var(--card-border, #e5e7eb);
|
||||||
@@ -1475,7 +1474,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
|
|||||||
.dark-mode .card{background-color: #2c2c2c;
|
.dark-mode .card{background-color: #2c2c2c;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
border: 1px solid #444;}
|
border: 1px solid #444;}
|
||||||
.card-header{font-size: 1.2rem;
|
.card-header{font-size: 1.1rem;
|
||||||
font-weight: bold;}
|
font-weight: bold;}
|
||||||
.custom-folder-card-body{padding-top: 5px !important;
|
.custom-folder-card-body{padding-top: 5px !important;
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
@@ -2251,3 +2250,694 @@ body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
|
|||||||
background-color: rgba(255, 255, 255, 0.06);
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--filr-muted-text, #777);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer span {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
||||||
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
|
<div class="form-group flex-grow-1" style="margin-bottom: 0rem;">
|
||||||
<div id="uploadDropArea"
|
<div id="uploadDropArea"
|
||||||
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
||||||
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto"
|
<button type="submit" id="uploadBtn" class="btn btn-primary mx-auto"
|
||||||
data-i18n-key="upload">Upload</button>
|
data-i18n-key="upload">Upload</button>
|
||||||
<div id="uploadProgressContainer"></div>
|
<div id="uploadProgressContainer"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||||
<div id="folderTreeContainer"></div>
|
<div id="folderTreeContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="folder-actions mt-3">
|
<div class="folder-actions">
|
||||||
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
||||||
<i class="material-icons">create_new_folder</i>
|
<i class="material-icons">create_new_folder</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -474,25 +474,95 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
||||||
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
|
<button type="button" class="mi"
|
||||||
|
data-action="create_file"
|
||||||
|
data-when="always">
|
||||||
|
<i class="material-icons">note_add</i>
|
||||||
|
<span>Create file</span>
|
||||||
|
</button>
|
||||||
<div class="sep" data-when="always"></div>
|
<div class="sep" data-when="always"></div>
|
||||||
|
|
||||||
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
|
<button type="button" class="mi"
|
||||||
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
|
data-action="delete_selected"
|
||||||
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
|
data-when="any">
|
||||||
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
|
<i class="material-icons">delete</i>
|
||||||
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
|
<span>Delete selected</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="copy_selected"
|
||||||
|
data-when="any">
|
||||||
|
<i class="material-icons">content_copy</i>
|
||||||
|
<span>Copy selected</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="move_selected"
|
||||||
|
data-when="any">
|
||||||
|
<i class="material-icons">drive_file_move</i>
|
||||||
|
<span>Move selected</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="download_zip"
|
||||||
|
data-when="any">
|
||||||
|
<i class="material-icons">archive</i>
|
||||||
|
<span>Download as ZIP</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- NEW: multi-download without ZIP -->
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="download_plain"
|
||||||
|
data-when="any">
|
||||||
|
<i class="material-icons">file_download</i>
|
||||||
|
<span>Download (no ZIP)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="extract_zip"
|
||||||
|
data-when="zip">
|
||||||
|
<i class="material-icons">unarchive</i>
|
||||||
|
<span>Extract ZIP</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="sep" data-when="any"></div>
|
<div class="sep" data-when="any"></div>
|
||||||
|
|
||||||
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
|
<button type="button" class="mi"
|
||||||
|
data-action="tag_selected"
|
||||||
|
data-when="many">
|
||||||
|
<i class="material-icons">sell</i>
|
||||||
|
<span>Tag selected</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
|
<button type="button" class="mi"
|
||||||
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
|
data-action="preview"
|
||||||
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
|
data-when="one">
|
||||||
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
|
<i class="material-icons">visibility</i>
|
||||||
</div>
|
<span>Preview</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="edit"
|
||||||
|
data-when="can-edit">
|
||||||
|
<i class="material-icons">edit</i>
|
||||||
|
<span>Edit</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="rename"
|
||||||
|
data-when="one">
|
||||||
|
<i class="material-icons">drive_file_rename_outline</i>
|
||||||
|
<span>Rename</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" class="mi"
|
||||||
|
data-action="tag_file"
|
||||||
|
data-when="one">
|
||||||
|
<i class="material-icons">sell</i>
|
||||||
|
<span>Tag file</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div id="removeUserModal" class="modal" style="display:none;">
|
<div id="removeUserModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
||||||
@@ -538,5 +608,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer id="siteFooter" class="site-footer">
|
||||||
|
<span>
|
||||||
|
© 2025
|
||||||
|
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
|
||||||
|
FileRise
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
511
public/js/adminOnlyOffice.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
})();
|
|
||||||
1765
public/js/adminPortals.js
Normal file
118
public/js/adminSponsor.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ export async function loadCsrfToken() {
|
|||||||
APP INIT (shared)
|
APP INIT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
const saved = parseInt(localStorage.getItem('rowHeight') || '44', 10);
|
||||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
|
|
||||||
const last = localStorage.getItem('lastOpenedFolder');
|
const last = localStorage.getItem('lastOpenedFolder');
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
|
|||||||
//window.__lastLoginData = data;
|
//window.__lastLoginData = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHoverPreviewDisabled() {
|
||||||
|
if (window.disableHoverPreview === true) return true;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('disableHoverPreview') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openTOTPLoginModal() {
|
export function openTOTPLoginModal() {
|
||||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
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', () => {
|
inlineCb.addEventListener('change', () => {
|
||||||
window.showInlineFolders = inlineCb.checked;
|
window.showInlineFolders = inlineCb.checked;
|
||||||
localStorage.setItem('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
|
// show
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="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="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="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 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
`;
|
`;
|
||||||
@@ -178,96 +178,29 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
const safeSize = escapeHTML(file.size);
|
const safeSize = escapeHTML(file.size);
|
||||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
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 `
|
return `
|
||||||
<tr class="clickable-row">
|
<tr class="clickable-row" data-file-name="${safeFileName}">
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||||
</td>
|
</td>
|
||||||
<td class="file-name-cell">${safeFileName}</td>
|
<td class="file-name-cell name-cell">
|
||||||
|
${safeFileName}
|
||||||
|
</td>
|
||||||
<td class="hide-small nowrap">${safeModified}</td>
|
<td class="hide-small nowrap">${safeModified}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap size-cell">${safeSize}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<td>
|
<td class="actions-cell">
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-success download-btn"
|
class="btn btn-link btn-actions-ellipsis"
|
||||||
data-download-name="${file.name}"
|
title="${t("more_actions")}"
|
||||||
data-download-folder="${file.folder || 'root'}"
|
>
|
||||||
title="${t('download')}">
|
<span class="material-icons">more_vert</span>
|
||||||
<i class="material-icons">file_download</i>
|
|
||||||
</button>
|
</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
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
|
||||||
data-file="${safeFileName}"
|
|
||||||
title="${t('share')}">
|
|
||||||
<i class="material-icons">share</i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBottomControls(itemsPerPageSetting) {
|
export function buildBottomControls(itemsPerPageSetting) {
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ function createCardGhost(card, rect, opts) {
|
|||||||
const ghost = card.cloneNode(true);
|
const ghost = card.cloneNode(true);
|
||||||
const cs = window.getComputedStyle(card);
|
const cs = window.getComputedStyle(card);
|
||||||
|
|
||||||
// Give the ghost the same “card” chrome even though it’s attached to <body>
|
|
||||||
Object.assign(ghost.style, {
|
Object.assign(ghost.style, {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: rect.left + 'px',
|
left: rect.left + 'px',
|
||||||
@@ -94,7 +93,6 @@ function createCardGhost(card, rect, opts) {
|
|||||||
transform: 'scale(' + scale + ')',
|
transform: 'scale(' + scale + ')',
|
||||||
opacity: String(opacity),
|
opacity: String(opacity),
|
||||||
|
|
||||||
// pull key visuals from the real card
|
|
||||||
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
||||||
borderRadius: cs.borderRadius || '',
|
borderRadius: cs.borderRadius || '',
|
||||||
boxShadow: cs.boxShadow || '',
|
boxShadow: cs.boxShadow || '',
|
||||||
@@ -102,8 +100,17 @@ function createCardGhost(card, rect, opts) {
|
|||||||
borderWidth: cs.borderWidth || '',
|
borderWidth: cs.borderWidth || '',
|
||||||
borderStyle: cs.borderStyle || '',
|
borderStyle: cs.borderStyle || '',
|
||||||
backdropFilter: cs.backdropFilter || '',
|
backdropFilter: cs.backdropFilter || '',
|
||||||
|
|
||||||
|
// ✨ make the ghost crisper
|
||||||
|
overflow: 'hidden',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
backfaceVisibility: 'hidden'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subtle: de-emphasize inner text so it doesn’t look “smeared”
|
||||||
|
const ghBody = ghost.querySelector('.card-body');
|
||||||
|
if (ghBody) ghBody.style.opacity = '0.6';
|
||||||
|
|
||||||
return ghost;
|
return ghost;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +403,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
return { card, rect };
|
return { card, rect };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show dock so icons exist / have positions
|
// Make sure header dock is visible so icons are laid out
|
||||||
showHeaderDockPersistent();
|
showHeaderDockPersistent();
|
||||||
|
|
||||||
// Move real cards into header (hidden container + icons)
|
// Move real cards into header (hidden container + icons)
|
||||||
@@ -416,10 +423,10 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
|
|
||||||
const iconRect = iconBtn.getBoundingClientRect();
|
const iconRect = iconBtn.getBoundingClientRect();
|
||||||
|
|
||||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
|
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 });
|
||||||
ghost.id = card.id + '-ghost-collapse';
|
ghost.id = card.id + '-ghost-collapse';
|
||||||
ghost.classList.add('card-collapse-ghost');
|
ghost.classList.add('card-collapse-ghost');
|
||||||
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
|
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||||
|
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||||
@@ -430,6 +437,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off motion on next frame
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ghosts.forEach(({ ghost, from, to }) => {
|
ghosts.forEach(({ ghost, from, to }) => {
|
||||||
const fromCx = from.left + from.width / 2;
|
const fromCx = from.left + from.width / 2;
|
||||||
@@ -441,17 +449,18 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
const dy = toCy - fromCy;
|
const dy = toCy - fromCy;
|
||||||
|
|
||||||
const rawScale = to.width / from.width;
|
const rawScale = to.width / from.width;
|
||||||
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
|
const scale = Math.max(0.35, Math.min(0.6, rawScale * 0.9));
|
||||||
|
|
||||||
|
// ✨ more readable: clear slide + shrink, but don’t fully vanish mid-flight
|
||||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||||
ghost.style.opacity = '0';
|
ghost.style.opacity = '0.35';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||||
done();
|
done();
|
||||||
}, 260);
|
}, 430); // a bit over the 0.4s transition
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTargetZoneForExpand(cardId) {
|
function resolveTargetZoneForExpand(cardId) {
|
||||||
@@ -508,9 +517,9 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
if (sb) sb.style.display = '';
|
if (sb) sb.style.display = '';
|
||||||
if (top) top.style.display = '';
|
if (top) top.style.display = '';
|
||||||
|
|
||||||
const SAFE_TOP = 16; // minimum distance from top of viewport
|
const SAFE_TOP = 16;
|
||||||
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
|
const START_OFFSET_Y = 32; // a touch closer to header
|
||||||
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
|
const DEST_EXTRA_Y = 120;
|
||||||
|
|
||||||
const ghosts = [];
|
const ghosts = [];
|
||||||
|
|
||||||
@@ -528,24 +537,20 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
const zoneRect = host.getBoundingClientRect();
|
const zoneRect = host.getBoundingClientRect();
|
||||||
if (!zoneRect.width) return;
|
if (!zoneRect.width) return;
|
||||||
|
|
||||||
// Where the ghost "comes from" (near the icon)
|
|
||||||
const fromCx = iconRect.left + iconRect.width / 2;
|
const fromCx = iconRect.left + iconRect.width / 2;
|
||||||
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
|
const fromCy = iconRect.bottom + START_OFFSET_Y;
|
||||||
|
|
||||||
// Where we want it to "land" (roughly center of the zone, a bit down)
|
|
||||||
let toCx = zoneRect.left + zoneRect.width / 2;
|
let toCx = zoneRect.left + zoneRect.width / 2;
|
||||||
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||||
|
|
||||||
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
|
|
||||||
if (zoneId === ZONES.SIDEBAR) {
|
if (zoneId === ZONES.SIDEBAR) {
|
||||||
if (card.id === 'uploadCard') {
|
if (card.id === 'uploadCard') {
|
||||||
toCy -= 48; // a bit higher
|
toCy -= 48;
|
||||||
} else if (card.id === 'folderManagementCard') {
|
} else if (card.id === 'folderManagementCard') {
|
||||||
toCy += 48; // a bit lower
|
toCy += 48;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match the real card size we captured during collapse
|
|
||||||
const savedW = parseFloat(card.dataset.lastWidth || '');
|
const savedW = parseFloat(card.dataset.lastWidth || '');
|
||||||
const savedH = parseFloat(card.dataset.lastHeight || '');
|
const savedH = parseFloat(card.dataset.lastHeight || '');
|
||||||
const targetWidth = !Number.isNaN(savedW)
|
const targetWidth = !Number.isNaN(savedW)
|
||||||
@@ -553,10 +558,8 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||||
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
||||||
|
|
||||||
// Make sure the top of the ghost never goes above SAFE_TOP
|
|
||||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||||
|
|
||||||
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
|
|
||||||
const ghostRect = {
|
const ghostRect = {
|
||||||
left: fromCx - targetWidth / 2,
|
left: fromCx - targetWidth / 2,
|
||||||
top: startTop,
|
top: startTop,
|
||||||
@@ -564,13 +567,12 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
height: targetHeight
|
height: targetHeight
|
||||||
};
|
};
|
||||||
|
|
||||||
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
|
const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 });
|
||||||
ghost.id = card.id + '-ghost-expand';
|
ghost.id = card.id + '-ghost-expand';
|
||||||
ghost.classList.add('card-expand-ghost');
|
ghost.classList.add('card-expand-ghost');
|
||||||
|
|
||||||
// Override transform/transition for our flight animation
|
ghost.style.transform = 'translate(0,0) scale(0.75)';
|
||||||
ghost.style.transform = 'translate(0,0) scale(0.7)';
|
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||||
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
|
||||||
|
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
ghosts.push({
|
ghosts.push({
|
||||||
@@ -586,7 +588,6 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick off the flight on the next frame
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ghosts.forEach(({ ghost, from, to }) => {
|
ghosts.forEach(({ ghost, from, to }) => {
|
||||||
const dx = to.cx - from.cx;
|
const dx = to.cx - from.cx;
|
||||||
@@ -596,13 +597,12 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up ghosts and then do real layout restore
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ghosts.forEach(({ ghost }) => {
|
ghosts.forEach(({ ghost }) => {
|
||||||
try { ghost.remove(); } catch {}
|
try { ghost.remove(); } catch {}
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
}, 280); // just over the 0.25s transition
|
}, 430);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- zones toggle (collapse to header) --------------------
|
// -------------------- zones toggle (collapse to header) --------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// fileDragDrop.js
|
// fileDragDrop.js
|
||||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
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 ---------------- */
|
/* ---------------- helpers ---------------- */
|
||||||
function getRowEl(el) {
|
function getRowEl(el) {
|
||||||
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
|||||||
|
|
||||||
/* ---------------- drag start (rows/cards) ---------------- */
|
/* ---------------- drag start (rows/cards) ---------------- */
|
||||||
export function fileDragStartHandler(event) {
|
export function fileDragStartHandler(event) {
|
||||||
|
try { cancelHoverPreview(); } catch {}
|
||||||
const row = getRowEl(event.currentTarget);
|
const row = getRowEl(event.currentTarget);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
} from './fileActions.js?v={{APP_QVER}}';
|
} from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
import { canEditFile, fileData, downloadSelectedFilesIndividually } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
|
||||||
const MENU_ID = 'fileContextMenu';
|
const MENU_ID = 'fileContextMenu';
|
||||||
|
|
||||||
function qMenu() { return document.getElementById(MENU_ID); }
|
function qMenu() { return document.getElementById(MENU_ID); }
|
||||||
@@ -31,7 +32,9 @@ function localizeMenu() {
|
|||||||
'preview': 'preview',
|
'preview': 'preview',
|
||||||
'edit': 'edit',
|
'edit': 'edit',
|
||||||
'rename': 'rename',
|
'rename': 'rename',
|
||||||
'tag_file': 'tag_file'
|
'tag_file': 'tag_file',
|
||||||
|
// NEW:
|
||||||
|
'download_plain': 'download_plain'
|
||||||
};
|
};
|
||||||
Object.entries(map).forEach(([action, key]) => {
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
@@ -187,6 +190,10 @@ function menuClickDelegate(ev) {
|
|||||||
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
||||||
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
||||||
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
||||||
|
case 'download_plain':
|
||||||
|
// Uses current checkbox selection; limit enforced in fileListView
|
||||||
|
downloadSelectedFilesIndividually(s.files);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'tag_selected':
|
case 'tag_selected':
|
||||||
openMultiTagModal(s.files); // s.files are the real file objects
|
openMultiTagModal(s.files); // s.files are the real file objects
|
||||||
|
|||||||
@@ -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()}`;
|
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) -------------------------------- */
|
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||||
export function openShareModal(file, folder) {
|
export function openShareModal(file, folder) {
|
||||||
const existing = document.getElementById("shareModal");
|
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
|
// Topbar icon (theme-aware) used for image tools + video actions
|
||||||
function makeTopIcon(name, title) {
|
function makeTopIcon(name, title) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
@@ -432,8 +503,28 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
const isVideo = VID_RE.test(lower);
|
const isVideo = VID_RE.test(lower);
|
||||||
const isAudio = AUD_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 don’t 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);
|
setTitle(overlay, name);
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
|
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||||
|
actionWrap.appendChild(downloadBtn);
|
||||||
|
|
||||||
container.textContent =
|
container.textContent =
|
||||||
t("svg_preview_disabled") ||
|
t("svg_preview_disabled") ||
|
||||||
"SVG preview is disabled for security. Use Download to view this file.";
|
"SVG preview is disabled for security. Use Download to view this file.";
|
||||||
@@ -453,11 +544,16 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
img.dataset.rotate = 0;
|
img.dataset.rotate = 0;
|
||||||
container.appendChild(img);
|
container.appendChild(img);
|
||||||
|
|
||||||
|
let currentName = name;
|
||||||
|
|
||||||
// topbar-aligned, theme-aware icons
|
// topbar-aligned, theme-aware icons
|
||||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||||
|
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||||
|
|
||||||
|
actionWrap.appendChild(downloadBtn);
|
||||||
actionWrap.appendChild(zoomInBtn);
|
actionWrap.appendChild(zoomInBtn);
|
||||||
actionWrap.appendChild(zoomOutBtn);
|
actionWrap.appendChild(zoomOutBtn);
|
||||||
actionWrap.appendChild(rotateLeft);
|
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));
|
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||||
overlay.mediaType = 'image';
|
overlay.mediaType = 'image';
|
||||||
overlay.mediaList = images;
|
overlay.mediaList = images;
|
||||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||||
|
|
||||||
const navigate = (dir) => {
|
const navigate = (dir) => {
|
||||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||||
|
currentName = newFile; // keep download button pointing to the right file
|
||||||
setTitle(overlay, newFile);
|
setTitle(overlay, newFile);
|
||||||
img.dataset.scale = 1;
|
img.dataset.scale = 1;
|
||||||
img.dataset.rotate = 0;
|
img.dataset.rotate = 0;
|
||||||
img.style.transform = 'scale(1) rotate(0deg)';
|
img.style.transform = 'scale(1) rotate(0deg)';
|
||||||
img.src = buildPreviewUrl(folder, newFile);
|
img.src = siblingPreviewUrl(newFile); // <-- changed
|
||||||
};
|
};
|
||||||
|
|
||||||
if (images.length > 1) {
|
if (images.length > 1) {
|
||||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
@@ -531,7 +628,7 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- VIDEOS -------------------- */
|
/* -------------------- VIDEOS -------------------- */
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
let video = document.createElement("video");
|
let video = document.createElement("video");
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||||
@@ -540,9 +637,27 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
video.style.objectFit = "contain";
|
video.style.objectFit = "contain";
|
||||||
container.appendChild(video);
|
container.appendChild(video);
|
||||||
|
|
||||||
|
// Apply last-used volume/mute, and persist future changes
|
||||||
|
loadSavedMediaVolume(video);
|
||||||
|
attachVolumePersistence(video);
|
||||||
|
|
||||||
// Top-right action icons (Material icons, theme-aware)
|
// Top-right action icons (Material icons, theme-aware)
|
||||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
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(markBtnIcon);
|
||||||
actionWrap.appendChild(clearBtnIcon);
|
actionWrap.appendChild(clearBtnIcon);
|
||||||
|
|
||||||
@@ -552,12 +667,16 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||||
|
|
||||||
// Track which file is currently active
|
// Helper: set src for a given video name
|
||||||
let currentName = name;
|
|
||||||
|
|
||||||
const setVideoSrc = (nm) => {
|
const setVideoSrc = (nm) => {
|
||||||
currentName = nm;
|
currentName = nm;
|
||||||
video.src = buildPreviewUrl(folder, 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);
|
setTitle(overlay, nm);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -721,11 +840,12 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
overlay._onKey = onKey;
|
overlay._onKey = onKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off first video using the original working URL
|
||||||
setVideoSrc(name);
|
setVideoSrc(name);
|
||||||
renderStatus(null);
|
renderStatus(null);
|
||||||
overlay.style.display = "flex";
|
overlay.style.display = "flex";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- AUDIO / OTHER -------------------- */
|
/* -------------------- AUDIO / OTHER -------------------- */
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
@@ -735,8 +855,19 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
audio.className = "audio-modal";
|
audio.className = "audio-modal";
|
||||||
audio.style.maxWidth = "88vw";
|
audio.style.maxWidth = "88vw";
|
||||||
container.appendChild(audio);
|
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";
|
overlay.style.display = "flex";
|
||||||
} else {
|
} else {
|
||||||
|
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||||
|
actionWrap.appendChild(downloadBtn);
|
||||||
|
|
||||||
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||||
overlay.style.display = "flex";
|
overlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
DOM builders & DnD
|
||||||
----------------------*/
|
----------------------*/
|
||||||
@@ -1125,6 +1160,10 @@ function makeChildLi(parentPath, item) {
|
|||||||
|
|
||||||
opt.append(icon, label);
|
opt.append(icon, label);
|
||||||
row.append(spacer, opt);
|
row.append(spacer, opt);
|
||||||
|
|
||||||
|
// Add 3-dot actions button for unlocked folders
|
||||||
|
if (!locked) addFolderActionButton(row, fullPath);
|
||||||
|
|
||||||
li.append(row);
|
li.append(row);
|
||||||
|
|
||||||
// <ul class="folder-tree collapsed" role="group"></ul>
|
// <ul class="folder-tree collapsed" role="group"></ul>
|
||||||
@@ -1300,6 +1339,28 @@ function getULForFolder(folder) {
|
|||||||
const li = opt ? opt.closest('li[role="treeitem"]') : null;
|
const li = opt ? opt.closest('li[role="treeitem"]') : null;
|
||||||
return li ? li.querySelector(':scope > ul.folder-tree') : 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) {
|
async function selectFolder(selected) {
|
||||||
const container = document.getElementById('folderTreeContainer');
|
const container = document.getElementById('folderTreeContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -1368,6 +1429,9 @@ async function selectFolder(selected) {
|
|||||||
saveFolderTreeState(st);
|
saveFolderTreeState(st);
|
||||||
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
|
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;
|
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
|
// Determine root's lock state
|
||||||
const rootOpt = container.querySelector('.root-folder-option');
|
const rootOpt = container.querySelector('.root-folder-option');
|
||||||
let rootLocked = false;
|
let rootLocked = false;
|
||||||
@@ -1654,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
|
|||||||
if (menu) menu.hidden = true;
|
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) {
|
async function folderManagerContextMenuHandler(e) {
|
||||||
const target = e.target.closest('.folder-option, .breadcrumb-link');
|
const target = e.target.closest('.folder-option, .breadcrumb-link');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Toggle-only for locked nodes
|
// Toggle-only for locked nodes (no menu)
|
||||||
if (target.classList && target.classList.contains('locked')) {
|
if (target.classList && target.classList.contains('locked')) {
|
||||||
const folder = target.getAttribute('data-folder') || '';
|
const folder = target.getAttribute('data-folder') || '';
|
||||||
const ul = getULForFolder(folder);
|
const ul = getULForFolder(folder);
|
||||||
@@ -1679,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
|
|||||||
const folder = target.getAttribute('data-folder');
|
const folder = target.getAttribute('data-folder');
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
|
|
||||||
window.currentFolder = folder;
|
const x = e.clientX;
|
||||||
await applyFolderCapabilities(folder);
|
const y = e.clientY;
|
||||||
|
await openFolderActionsMenu(folder, target, x, y);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindFolderManagerContextMenu() {
|
function bindFolderManagerContextMenu() {
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const translations = {
|
|||||||
|
|
||||||
// Admin Panel
|
// Admin Panel
|
||||||
"header_settings": "Header Settings",
|
"header_settings": "Header Settings",
|
||||||
|
"header_footer_settings": "Header & Footer Settings",
|
||||||
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
||||||
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
||||||
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
||||||
@@ -343,7 +344,29 @@ const translations = {
|
|||||||
"hide_header_zoom_controls": "Hide header zoom controls",
|
"hide_header_zoom_controls": "Hide header zoom controls",
|
||||||
"preview_not_available": "Preview is not available for this file type.",
|
"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.",
|
"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",
|
||||||
|
"download_plain": "Download (no ZIP)",
|
||||||
|
"download_next": "Download next",
|
||||||
|
"nonzip_queue_title": "Files queued for download",
|
||||||
|
"nonzip_queue_subtitle": "{count} files queued. Click \"Download next\" for each file.",
|
||||||
|
"nonzip_queue_cleared": "Download queue cleared.",
|
||||||
|
"your_access": "Your access",
|
||||||
|
|
||||||
|
"perm_upload": "Upload",
|
||||||
|
"perm_move": "Move",
|
||||||
|
"perm_rename": "Rename",
|
||||||
|
"perm_share": "Share",
|
||||||
|
"perm_delete": "Delete"
|
||||||
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ function bindDarkMode() {
|
|||||||
|
|
||||||
// Always keep <title> correct early (no visual flicker)
|
// Always keep <title> correct early (no visual flicker)
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
|
||||||
// --- Header logo (branding) in BOTH phases ---
|
// --- Header logo (branding) in BOTH phases ---
|
||||||
try {
|
try {
|
||||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
@@ -470,6 +471,7 @@ function bindDarkMode() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// non-fatal; ignore branding issues
|
// non-fatal; ignore branding issues
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Header colors (branding) in BOTH phases ---
|
// --- Header colors (branding) in BOTH phases ---
|
||||||
try {
|
try {
|
||||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
@@ -487,10 +489,28 @@ function bindDarkMode() {
|
|||||||
// non-fatal
|
// non-fatal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Footer HTML (branding) in BOTH phases ---
|
||||||
|
try {
|
||||||
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
|
const footerEl = document.getElementById('siteFooter');
|
||||||
|
if (footerEl) {
|
||||||
|
const html = (branding.footerHtml || '').trim();
|
||||||
|
if (html) {
|
||||||
|
// allow simple HTML from config
|
||||||
|
footerEl.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
footerEl.innerHTML =
|
||||||
|
`© ${year} <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||||
|
|
||||||
|
|
||||||
// be tolerant to key variants just in case
|
// be tolerant to key variants just in case
|
||||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ function getRedirectTarget() {
|
|||||||
const headingEl = document.getElementById('portalLoginTitle');
|
const headingEl = document.getElementById('portalLoginTitle');
|
||||||
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||||||
const footerEl = document.getElementById('portalLoginFooter');
|
const footerEl = document.getElementById('portalLoginFooter');
|
||||||
|
const logoEl = document.getElementById('portalLoginLogo');
|
||||||
|
|
||||||
if (headingEl) {
|
if (headingEl) {
|
||||||
headingEl.textContent = 'Sign in to ' + title;
|
headingEl.textContent = 'Sign in to ' + title;
|
||||||
@@ -238,6 +239,24 @@ function getRedirectTarget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔹 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
|
// Document title
|
||||||
try {
|
try {
|
||||||
document.title = 'Sign in – ' + title;
|
document.title = 'Sign in – ' + title;
|
||||||
|
|||||||
@@ -10,10 +10,33 @@ function portalFolder() {
|
|||||||
return portal.folder || portal.targetFolder || portal.path || 'root';
|
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function portalCanUpload() {
|
||||||
|
if (!portal) return false;
|
||||||
|
|
||||||
|
// Prefer explicit flags from backend (PortalController)
|
||||||
|
if (typeof portal.canUpload !== 'undefined') {
|
||||||
|
return !!portal.canUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks for older bundles (if you ever add these)
|
||||||
|
if (typeof portal.allowUpload !== 'undefined') {
|
||||||
|
return !!portal.allowUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy behavior: portals were always upload-capable;
|
||||||
|
// uploadOnly only controlled download visibility.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function portalCanDownload() {
|
function portalCanDownload() {
|
||||||
if (!portal) return false;
|
if (!portal) return false;
|
||||||
|
|
||||||
// Prefer explicit flags if present
|
// Prefer explicit flag if present (PortalController)
|
||||||
|
if (typeof portal.canDownload !== 'undefined') {
|
||||||
|
return !!portal.canDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to allowDownload / allowDownloads (older payloads)
|
||||||
if (typeof portal.allowDownload !== 'undefined') {
|
if (typeof portal.allowDownload !== 'undefined') {
|
||||||
return !!portal.allowDownload;
|
return !!portal.allowDownload;
|
||||||
}
|
}
|
||||||
@@ -21,7 +44,7 @@ function portalCanDownload() {
|
|||||||
return !!portal.allowDownloads;
|
return !!portal.allowDownloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: uploadOnly = true => no downloads
|
// Legacy: uploadOnly = true => no downloads
|
||||||
if (typeof portal.uploadOnly !== 'undefined') {
|
if (typeof portal.uploadOnly !== 'undefined') {
|
||||||
return !portal.uploadOnly;
|
return !portal.uploadOnly;
|
||||||
}
|
}
|
||||||
@@ -30,6 +53,127 @@ function portalCanDownload() {
|
|||||||
return true;
|
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 -----------------
|
// ----------------- DOM helpers / status -----------------
|
||||||
function qs(id) {
|
function qs(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
@@ -45,6 +189,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 -----------------
|
// ----------------- Form submit -----------------
|
||||||
async function submitPortalForm(slug, formData) {
|
async function submitPortalForm(slug, formData) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -112,7 +283,7 @@ function setupPortalForm(slug) {
|
|||||||
const formSection = qs('portalFormSection');
|
const formSection = qs('portalFormSection');
|
||||||
const uploadSection = qs('portalUploadSection');
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
if (!portal || !portal.requireForm) {
|
if (!portal || !portal.requireForm || !portalCanUpload()) {
|
||||||
if (formSection) formSection.style.display = 'none';
|
if (formSection) formSection.style.display = 'none';
|
||||||
if (uploadSection) uploadSection.style.opacity = '1';
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
return;
|
return;
|
||||||
@@ -136,21 +307,85 @@ function setupPortalForm(slug) {
|
|||||||
const notesEl = qs('portalFormNotes');
|
const notesEl = qs('portalFormNotes');
|
||||||
const submitBtn = qs('portalFormSubmit');
|
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;
|
nameEl.value = fd.name;
|
||||||
}
|
}
|
||||||
if (emailEl && fd.email && !emailEl.value) {
|
if (emailEl && visible.email) {
|
||||||
|
if (fd.email && !emailEl.value) {
|
||||||
emailEl.value = fd.email;
|
emailEl.value = fd.email;
|
||||||
} else if (emailEl && portal.clientEmail && !emailEl.value) {
|
} else if (portal.clientEmail && !emailEl.value) {
|
||||||
// fallback to clientEmail
|
|
||||||
emailEl.value = portal.clientEmail;
|
emailEl.value = portal.clientEmail;
|
||||||
}
|
}
|
||||||
if (refEl && fd.reference && !refEl.value) {
|
}
|
||||||
|
if (refEl && visible.reference && fd.reference && !refEl.value) {
|
||||||
refEl.value = fd.reference;
|
refEl.value = fd.reference;
|
||||||
}
|
}
|
||||||
if (notesEl && fd.notes && !notesEl.value) {
|
if (notesEl && visible.notes && fd.notes && !notesEl.value) {
|
||||||
notesEl.value = fd.notes;
|
notesEl.value = fd.notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,13 +397,13 @@ function setupPortalForm(slug) {
|
|||||||
const reference = refEl ? refEl.value.trim() : '';
|
const reference = refEl ? refEl.value.trim() : '';
|
||||||
const notes = notesEl ? notesEl.value.trim() : '';
|
const notes = notesEl ? notesEl.value.trim() : '';
|
||||||
|
|
||||||
const req = portal.formRequired || {};
|
|
||||||
const missing = [];
|
const missing = [];
|
||||||
|
|
||||||
if (req.name && !name) missing.push('name');
|
// Only validate visible fields
|
||||||
if (req.email && !email) missing.push('email');
|
if (visible.name && req.name && !name) missing.push(labels.name || 'Name');
|
||||||
if (req.reference && !reference) missing.push('reference');
|
if (visible.email && req.email && !email) missing.push(labels.email || 'Email');
|
||||||
if (req.notes && !notes) missing.push('notes');
|
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) {
|
if (missing.length) {
|
||||||
showToast('Please fill in: ' + missing.join(', ') + '.');
|
showToast('Please fill in: ' + missing.join(', ') + '.');
|
||||||
@@ -176,8 +411,11 @@ function setupPortalForm(slug) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// default behavior when no specific required flags:
|
// 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 (!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.');
|
showToast('Please provide at least a name or email.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -285,6 +523,7 @@ function renderPortalInfo() {
|
|||||||
const footerEl = document.getElementById('portalFooter');
|
const footerEl = document.getElementById('portalFooter');
|
||||||
const drop = qs('portalDropzone');
|
const drop = qs('portalDropzone');
|
||||||
const card = document.querySelector('.portal-card');
|
const card = document.querySelector('.portal-card');
|
||||||
|
const logoImg = document.querySelector('.portal-logo img');
|
||||||
const formBtn = qs('portalFormSubmit');
|
const formBtn = qs('portalFormSubmit');
|
||||||
const refreshBtn = qs('portalRefreshBtn');
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
const filesSection = qs('portalFilesSection');
|
const filesSection = qs('portalFilesSection');
|
||||||
@@ -303,13 +542,51 @@ function renderPortalInfo() {
|
|||||||
const folder = portalFolder();
|
const folder = portalFolder();
|
||||||
descEl.textContent = 'Files you upload here go directly into: ' + folder;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
if (subtitleEl) {
|
if (subtitleEl) {
|
||||||
const parts = [];
|
let text = '';
|
||||||
if (portal.uploadOnly) parts.push('upload only');
|
if (uploadsEnabled && downloadsEnabled) {
|
||||||
if (portalCanDownload()) parts.push('download allowed');
|
text = 'Upload & download';
|
||||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
} else if (uploadsEnabled && !downloadsEnabled) {
|
||||||
|
text = 'Upload only';
|
||||||
|
} else if (!uploadsEnabled && downloadsEnabled) {
|
||||||
|
text = 'Download only';
|
||||||
|
} else {
|
||||||
|
text = 'Access only';
|
||||||
|
}
|
||||||
|
subtitleEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footerEl) {
|
if (footerEl) {
|
||||||
@@ -318,6 +595,26 @@ function renderPortalInfo() {
|
|||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formSection = qs('portalFormSection');
|
||||||
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
|
// If uploads are disabled, hide upload + form (form is only meaningful for uploads)
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
if (formSection) {
|
||||||
|
formSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
if (uploadSection) {
|
||||||
|
uploadSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = qs('portalStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = 'Uploads are disabled for this portal.';
|
||||||
|
statusEl.classList.remove('text-muted');
|
||||||
|
statusEl.classList.add('text-warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyPortalFormLabels();
|
||||||
const color = portal.brandColor && portal.brandColor.trim();
|
const color = portal.brandColor && portal.brandColor.trim();
|
||||||
if (color) {
|
if (color) {
|
||||||
// expose brand color as a CSS variable for gallery styling
|
// expose brand color as a CSS variable for gallery styling
|
||||||
@@ -497,12 +794,83 @@ async function loadPortalFiles() {
|
|||||||
// ----------------- Upload -----------------
|
// ----------------- Upload -----------------
|
||||||
async function uploadFiles(fileList) {
|
async function uploadFiles(fileList) {
|
||||||
if (!portal || !fileList || !fileList.length) return;
|
if (!portal || !fileList || !fileList.length) return;
|
||||||
|
|
||||||
|
if (!portalCanUpload()) {
|
||||||
|
showToast('Uploads are disabled for this portal.');
|
||||||
|
setStatus('Uploads are disabled for this portal.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (portal.requireForm && !portalFormDone) {
|
if (portal.requireForm && !portalFormDone) {
|
||||||
showToast('Please fill in your details before uploading.');
|
showToast('Please fill in your details before uploading.');
|
||||||
return;
|
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();
|
const folder = portalFolder();
|
||||||
|
|
||||||
setStatus('Uploading ' + files.length + ' file(s)…');
|
setStatus('Uploading ' + files.length + ' file(s)…');
|
||||||
@@ -575,9 +943,19 @@ async function uploadFiles(fileList) {
|
|||||||
showToast('Upload failed.');
|
showToast('Upload failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bump local daily counter by successful uploads
|
||||||
|
if (successCount > 0) {
|
||||||
|
bumpUploadRateCounter(successCount);
|
||||||
|
}
|
||||||
|
|
||||||
if (portalCanDownload()) {
|
if (portalCanDownload()) {
|
||||||
loadPortalFiles();
|
loadPortalFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional thank-you screen
|
||||||
|
if (successCount > 0 && portal.showThankYou) {
|
||||||
|
showThankYouScreen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------- Upload UI wiring -----------------
|
// ----------------- Upload UI wiring -----------------
|
||||||
@@ -586,7 +964,19 @@ function wireUploadUI() {
|
|||||||
const input = qs('portalFileInput');
|
const input = qs('portalFileInput');
|
||||||
const refreshBtn = qs('portalRefreshBtn');
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
|
||||||
if (drop && input) {
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
|
// Upload UI
|
||||||
|
if (drop) {
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
// Visually dim + disable clicks
|
||||||
|
drop.classList.add('portal-dropzone-disabled');
|
||||||
|
drop.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadsEnabled && drop && input) {
|
||||||
drop.addEventListener('click', () => input.click());
|
drop.addEventListener('click', () => input.click());
|
||||||
|
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
@@ -620,11 +1010,16 @@ function wireUploadUI() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download / refresh
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
|
if (!downloadsEnabled) {
|
||||||
|
refreshBtn.style.display = 'none';
|
||||||
|
} else {
|
||||||
refreshBtn.addEventListener('click', () => {
|
refreshBtn.addEventListener('click', () => {
|
||||||
loadPortalFiles();
|
loadPortalFiles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------- Slug + init -----------------
|
// ----------------- Slug + init -----------------
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ function wireFileInputChange(fileInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setUploadButtonVisible(visible) {
|
||||||
|
const btn = document.getElementById('uploadBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.style.display = visible ? 'block' : 'none';
|
||||||
|
btn.disabled = !visible;
|
||||||
|
}
|
||||||
|
|
||||||
function getUserDraftContext() {
|
function getUserDraftContext() {
|
||||||
const all = loadResumableDraftsAll();
|
const all = loadResumableDraftsAll();
|
||||||
const userKey = getCurrentUserKey();
|
const userKey = getCurrentUserKey();
|
||||||
@@ -346,6 +354,8 @@ function setDropAreaDefault() {
|
|||||||
const fileInput = dropArea.querySelector('#file');
|
const fileInput = dropArea.querySelector('#file');
|
||||||
wireFileInputChange(fileInput);
|
wireFileInputChange(fileInput);
|
||||||
wireChooseButton();
|
wireChooseButton();
|
||||||
|
|
||||||
|
setUploadButtonVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustFolderHelpExpansion() {
|
function adjustFolderHelpExpansion() {
|
||||||
@@ -464,6 +474,8 @@ function createFileEntry(file) {
|
|||||||
|
|
||||||
li.remove();
|
li.remove();
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||||
|
setUploadButtonVisible(anyItems);
|
||||||
});
|
});
|
||||||
li.removeBtn = removeBtn;
|
li.removeBtn = removeBtn;
|
||||||
li.appendChild(removeBtn);
|
li.appendChild(removeBtn);
|
||||||
@@ -674,6 +686,7 @@ function processFiles(filesInput) {
|
|||||||
|
|
||||||
window.selectedFiles = files;
|
window.selectedFiles = files;
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
setUploadButtonVisible(files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
@@ -770,6 +783,7 @@ async function initResumableUpload() {
|
|||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
updateResumableQuery();
|
updateResumableQuery();
|
||||||
|
setUploadButtonVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function (file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
@@ -931,6 +945,7 @@ async function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
||||||
showResumableDraftBanner();
|
showResumableDraftBanner();
|
||||||
|
setUploadButtonVisible(false);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
showToast("Some files failed to upload. Please check the list.");
|
||||||
@@ -1183,6 +1198,8 @@ function submitFiles(allFiles) {
|
|||||||
} else {
|
} else {
|
||||||
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
||||||
}
|
}
|
||||||
|
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||||
|
setUploadButtonVisible(anyItems);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error("Error fetching file list:", error);
|
console.error("Error fetching file list:", error);
|
||||||
@@ -1275,6 +1292,8 @@ function initUpload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUploadButtonVisible(false);
|
||||||
|
|
||||||
const hasResumableFiles =
|
const hasResumableFiles =
|
||||||
useResumable &&
|
useResumable &&
|
||||||
resumableInstance &&
|
resumableInstance &&
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v2.2.4';
|
window.APP_VERSION = 'v2.3.6';
|
||||||
|
|||||||
@@ -93,7 +93,9 @@
|
|||||||
<div class="portal-login-wrapper">
|
<div class="portal-login-wrapper">
|
||||||
<div class="portal-login-card">
|
<div class="portal-login-card">
|
||||||
<div class="portal-login-header">
|
<div class="portal-login-header">
|
||||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
<img id="portalLoginLogo"
|
||||||
|
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||||
|
alt="FileRise">
|
||||||
<div>
|
<div>
|
||||||
<div id="portalLoginTitle" class="portal-login-title">
|
<div id="portalLoginTitle" class="portal-login-title">
|
||||||
Sign in to Client Portal
|
Sign in to Client Portal
|
||||||
|
|||||||
@@ -169,6 +169,14 @@
|
|||||||
.portal-file-row:last-child {
|
.portal-file-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
.portal-required-star {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.portal-dropzone.portal-dropzone-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-style: solid;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -307,23 +315,23 @@
|
|||||||
Please fill in your information before uploading files.
|
Please fill in your information before uploading files.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:6px;">
|
<div id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
|
||||||
<label for="portalFormName">Name</label>
|
<label id="portalFormLabelName" for="portalFormName">Name</label>
|
||||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:6px;">
|
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
|
||||||
<label for="portalFormEmail">Email</label>
|
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
|
||||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:6px;">
|
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
|
||||||
<label for="portalFormReference">Reference / Case / Order #</label>
|
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
|
||||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom:8px;">
|
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
|
||||||
<label for="portalFormNotes">Notes</label>
|
<label id="portalFormLabelNotes" for="portalFormNotes">Notes</label>
|
||||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -332,6 +340,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="portalUploadSection">
|
<div id="portalUploadSection">
|
||||||
<div id="portalDropzone" class="portal-dropzone">
|
<div id="portalDropzone" class="portal-dropzone">
|
||||||
<div><strong>Drop files here</strong> or click to browse.</div>
|
<div><strong>Drop files here</strong> or click to browse.</div>
|
||||||
@@ -352,6 +361,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="portalFilesList" class="portal-files-list"></div>
|
<div id="portalFilesList" class="portal-files-list"></div>
|
||||||
</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"
|
<div id="portalFooter" class="text-muted"
|
||||||
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 488 KiB After Width: | Height: | Size: 562 KiB |
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 538 KiB |
BIN
resources/dark-client-portal3.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
resources/dark-client-portal4.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
resources/filerise-v2.3.2.png
Normal file
|
After Width: | Height: | Size: 1002 KiB |
BIN
resources/filerise-v2.3.4.png
Normal file
|
After Width: | Height: | Size: 914 KiB |
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -Eeuo pipefail
|
||||||
|
|
||||||
VER="v2.1.0"
|
VER="v2.3.2"
|
||||||
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||||
|
|
||||||
WEBROOT="/var/www"
|
WEBROOT="/var/www"
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ class AdminController
|
|||||||
$proType = $proPayload['type'] ?? null;
|
$proType = $proPayload['type'] ?? null;
|
||||||
$proEmail = $proPayload['email'] ?? null;
|
$proEmail = $proPayload['email'] ?? null;
|
||||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||||
|
$proPlan = $proPayload['plan'] ?? null;
|
||||||
|
$proExpiresAt = $proPayload['expiresAt'] ?? null;
|
||||||
|
$proMaxMajor = $proPayload['maxMajor'] ?? null;
|
||||||
|
|
||||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||||
$public = [
|
$public = [
|
||||||
@@ -169,6 +172,7 @@ class AdminController
|
|||||||
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
||||||
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
||||||
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
||||||
|
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||||
],
|
],
|
||||||
'pro' => [
|
'pro' => [
|
||||||
'active' => $proActive,
|
'active' => $proActive,
|
||||||
@@ -176,6 +180,9 @@ class AdminController
|
|||||||
'email' => $proEmail,
|
'email' => $proEmail,
|
||||||
'version' => $proVersion,
|
'version' => $proVersion,
|
||||||
'license' => $licenseString,
|
'license' => $licenseString,
|
||||||
|
'plan' => $proPlan,
|
||||||
|
'expiresAt' => $proExpiresAt,
|
||||||
|
'maxMajor' => $proMaxMajor,
|
||||||
],
|
],
|
||||||
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||||
];
|
];
|
||||||
@@ -314,20 +321,25 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
throw new InvalidArgumentException('Invalid portals format.');
|
throw new InvalidArgumentException('Invalid portals format.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal normalization; deeper validation can live inside ProPortals
|
|
||||||
$data = ['portals' => []];
|
$data = ['portals' => []];
|
||||||
|
$invalid = [];
|
||||||
|
|
||||||
foreach ($portalsPayload as $slug => $info) {
|
foreach ($portalsPayload as $slug => $info) {
|
||||||
$slug = trim((string)$slug);
|
$slug = trim((string)$slug);
|
||||||
if ($slug === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!is_array($info)) {
|
if (!is_array($info)) {
|
||||||
$info = [];
|
$info = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = trim((string)($info['label'] ?? $slug));
|
$label = trim((string)($info['label'] ?? $slug));
|
||||||
$folder = trim((string)($info['folder'] ?? ''));
|
$folder = trim((string)($info['folder'] ?? ''));
|
||||||
|
|
||||||
|
// Require both slug and folder; collect invalid ones so the UI can warn.
|
||||||
|
if ($slug === '' || $folder === '') {
|
||||||
|
$invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||||
$uploadOnly = !empty($info['uploadOnly']);
|
$uploadOnly = !empty($info['uploadOnly']);
|
||||||
$allowDownload = array_key_exists('allowDownload', $info)
|
$allowDownload = array_key_exists('allowDownload', $info)
|
||||||
@@ -335,24 +347,37 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
: true;
|
: true;
|
||||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||||
|
|
||||||
// Optional branding + form behavior
|
// Branding + form behavior
|
||||||
$title = trim((string)($info['title'] ?? ''));
|
$title = trim((string)($info['title'] ?? ''));
|
||||||
$introText = trim((string)($info['introText'] ?? ''));
|
$introText = trim((string)($info['introText'] ?? ''));
|
||||||
$requireForm = !empty($info['requireForm']);
|
$requireForm = !empty($info['requireForm']);
|
||||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||||
|
|
||||||
|
// 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'])
|
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||||
? $info['formDefaults']
|
? $info['formDefaults']
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Normalize defaults for known keys
|
|
||||||
$formDefaults = [
|
$formDefaults = [
|
||||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Required flags
|
||||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||||
? $info['formRequired']
|
? $info['formRequired']
|
||||||
: [];
|
: [];
|
||||||
@@ -362,11 +387,33 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
'email' => !empty($formRequired['email']),
|
'email' => !empty($formRequired['email']),
|
||||||
'reference' => !empty($formRequired['reference']),
|
'reference' => !empty($formRequired['reference']),
|
||||||
'notes' => !empty($formRequired['notes']),
|
'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']),
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
if ($folder === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['portals'][$slug] = [
|
$data['portals'][$slug] = [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
@@ -375,16 +422,30 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
'uploadOnly' => $uploadOnly,
|
'uploadOnly' => $uploadOnly,
|
||||||
'allowDownload' => $allowDownload,
|
'allowDownload' => $allowDownload,
|
||||||
'expiresAt' => $expiresAt,
|
'expiresAt' => $expiresAt,
|
||||||
// NEW
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'introText' => $introText,
|
'introText' => $introText,
|
||||||
'requireForm' => $requireForm,
|
'requireForm' => $requireForm,
|
||||||
'brandColor' => $brandColor,
|
'brandColor' => $brandColor,
|
||||||
'footerText' => $footerText,
|
'footerText' => $footerText,
|
||||||
|
'logoFile' => $logoFile,
|
||||||
|
'logoUrl' => $logoUrl,
|
||||||
|
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||||
|
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||||
|
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||||
|
'showThankYou' => $showThankYou,
|
||||||
|
'thankYouText' => $thankYouText,
|
||||||
'formDefaults' => $formDefaults,
|
'formDefaults' => $formDefaults,
|
||||||
'formRequired' => $formRequired,
|
'formRequired' => $formRequired,
|
||||||
|
'formLabels' => $formLabels,
|
||||||
|
'formVisible' => $formVisible,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (!empty($invalid)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
$ok = $store->savePortals($data);
|
$ok = $store->savePortals($data);
|
||||||
@@ -537,6 +598,28 @@ public function installProBundle(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works.
|
||||||
|
$basename = $origName;
|
||||||
|
if ($basename !== '') {
|
||||||
|
// Normalize slashes and then take basename
|
||||||
|
$basename = str_replace('\\', '/', $basename);
|
||||||
|
$basename = basename($basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the bundle version from the *basename*
|
||||||
|
// Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive)
|
||||||
|
$declaredVersion = null;
|
||||||
|
if (
|
||||||
|
$basename !== '' &&
|
||||||
|
preg_match(
|
||||||
|
'/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i',
|
||||||
|
$basename,
|
||||||
|
$m
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$declaredVersion = 'v' . $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare temp working dir
|
// Prepare temp working dir
|
||||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||||
@@ -680,19 +763,35 @@ public function installProBundle(): void
|
|||||||
@unlink($zipPath);
|
@unlink($zipPath);
|
||||||
@rmdir($workDir);
|
@rmdir($workDir);
|
||||||
|
|
||||||
|
// NEW: ensure OPcache picks up new Pro bundle code immediately
|
||||||
|
if (function_exists('opcache_invalidate')) {
|
||||||
|
foreach ($installed['src'] as $pathInfo) {
|
||||||
|
// strip " (overwritten)" suffix if present
|
||||||
|
$path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo);
|
||||||
|
if (is_string($path) && $path !== '' && is_file($path)) {
|
||||||
|
@opcache_invalidate($path, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reflect current Pro status in response if bootstrap was loaded
|
// Reflect current Pro status in response if bootstrap was loaded
|
||||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||||
|
|
||||||
|
$reportedVersion = $declaredVersion;
|
||||||
|
if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) {
|
||||||
|
$reportedVersion = FR_PRO_BUNDLE_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||||
? (FR_PRO_INFO['payload'] ?? null)
|
? (FR_PRO_INFO['payload'] ?? null)
|
||||||
: null;
|
: null;
|
||||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Pro bundle installed.',
|
'message' => 'Pro bundle installed.',
|
||||||
'installed' => $installed,
|
'installed' => $installed,
|
||||||
'proActive' => (bool)$proActive,
|
'proActive' => (bool)$proActive,
|
||||||
'proVersion' => $proVersion,
|
'proVersion' => $reportedVersion,
|
||||||
'proPayload' => $proPayload,
|
'proPayload' => $proPayload,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -765,6 +864,7 @@ public function installProBundle(): void
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -905,20 +1005,21 @@ public function installProBundle(): void
|
|||||||
$merged['onlyoffice'] = $oo;
|
$merged['onlyoffice'] = $oo;
|
||||||
}
|
}
|
||||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||||
$merged['branding'] = [
|
$merged['branding'] = [
|
||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
|
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) {
|
||||||
if (array_key_exists($key, $data['branding'])) {
|
if (array_key_exists($key, $data['branding'])) {
|
||||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = AdminModel::updateConfig($merged);
|
$result = AdminModel::updateConfig($merged);
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
final class PortalController
|
final class PortalController
|
||||||
{
|
{
|
||||||
@@ -15,12 +16,27 @@ final class PortalController
|
|||||||
* 'label' => string,
|
* 'label' => string,
|
||||||
* 'folder' => string,
|
* 'folder' => string,
|
||||||
* 'clientEmail' => string,
|
* 'clientEmail' => string,
|
||||||
* 'uploadOnly' => bool,
|
* 'uploadOnly' => bool, // stored flag (legacy name)
|
||||||
* 'allowDownload' => bool,
|
* 'allowDownload' => bool, // stored flag
|
||||||
* 'expiresAt' => string,
|
* 'expiresAt' => string,
|
||||||
* 'title' => string,
|
* 'title' => string,
|
||||||
* 'introText' => string,
|
* 'introText' => string,
|
||||||
* 'requireForm' => bool
|
* '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,
|
||||||
|
* 'canUpload' => bool, // ACL + portal flags
|
||||||
|
* 'canDownload' => bool, // ACL + portal flags
|
||||||
* ]
|
* ]
|
||||||
*/
|
*/
|
||||||
public static function getPortalBySlug(string $slug): array
|
public static function getPortalBySlug(string $slug): array
|
||||||
@@ -53,22 +69,52 @@ final class PortalController
|
|||||||
|
|
||||||
$p = $portals[$slug];
|
$p = $portals[$slug];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Normalize upload/download flags (old + new)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Storage:
|
||||||
|
// - OLD (no allowDownload):
|
||||||
|
// uploadOnly=true => upload yes, download no
|
||||||
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
//
|
||||||
|
// - NEW:
|
||||||
|
// "Allow upload" checkbox is stored as uploadOnly (🤮 name, but we keep it)
|
||||||
|
// "Allow download" checkbox is stored as allowDownload
|
||||||
|
//
|
||||||
|
// Normalized flags we want here:
|
||||||
|
// - $allowUpload (bool)
|
||||||
|
// - $allowDownload (bool)
|
||||||
|
$hasAllowDownload = array_key_exists('allowDownload', $p);
|
||||||
|
$rawUploadOnly = !empty($p['uploadOnly']); // legacy name
|
||||||
|
$rawAllowDownload = $hasAllowDownload ? !empty($p['allowDownload']) : null;
|
||||||
|
|
||||||
|
if ($hasAllowDownload) {
|
||||||
|
// New JSON – trust both checkboxes exactly
|
||||||
|
$allowUpload = $rawUploadOnly; // "Allow upload" in UI
|
||||||
|
$allowDownload = (bool)$rawAllowDownload;
|
||||||
|
} else {
|
||||||
|
// Legacy JSON – no separate allowDownload
|
||||||
|
// uploadOnly=true => upload yes, download no
|
||||||
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
$allowUpload = true;
|
||||||
|
$allowDownload = !$rawUploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
$label = trim((string)($p['label'] ?? $slug));
|
$label = trim((string)($p['label'] ?? $slug));
|
||||||
$folder = trim((string)($p['folder'] ?? ''));
|
$folder = trim((string)($p['folder'] ?? ''));
|
||||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||||
$uploadOnly = !empty($p['uploadOnly']);
|
|
||||||
$allowDownload = array_key_exists('allowDownload', $p)
|
|
||||||
? !empty($p['allowDownload'])
|
|
||||||
: true;
|
|
||||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||||
|
|
||||||
// NEW: optional branding + intake behavior
|
// Branding + intake behavior
|
||||||
$title = trim((string)($p['title'] ?? ''));
|
$title = trim((string)($p['title'] ?? ''));
|
||||||
$introText = trim((string)($p['introText'] ?? ''));
|
$introText = trim((string)($p['introText'] ?? ''));
|
||||||
$requireForm = !empty($p['requireForm']);
|
$requireForm = !empty($p['requireForm']);
|
||||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||||
|
|
||||||
|
// Defaults / required
|
||||||
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||||
? $p['formDefaults']
|
? $p['formDefaults']
|
||||||
: [];
|
: [];
|
||||||
@@ -79,6 +125,7 @@ final class PortalController
|
|||||||
'reference' => trim((string)($fd['reference'] ?? '')),
|
'reference' => trim((string)($fd['reference'] ?? '')),
|
||||||
'notes' => trim((string)($fd['notes'] ?? '')),
|
'notes' => trim((string)($fd['notes'] ?? '')),
|
||||||
];
|
];
|
||||||
|
|
||||||
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
||||||
? $p['formRequired']
|
? $p['formRequired']
|
||||||
: [];
|
: [];
|
||||||
@@ -90,6 +137,41 @@ final class PortalController
|
|||||||
'notes' => !empty($fr['notes']),
|
'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 === '') {
|
if ($folder === '') {
|
||||||
throw new RuntimeException('Portal misconfigured: empty folder.');
|
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||||
}
|
}
|
||||||
@@ -102,15 +184,49 @@ final class PortalController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────
|
||||||
|
// Capability flags (portal + ACL)
|
||||||
|
// ──────────────────────────────
|
||||||
|
//
|
||||||
|
// Base from portal config:
|
||||||
|
$canUpload = (bool)$allowUpload;
|
||||||
|
$canDownload = (bool)$allowDownload;
|
||||||
|
|
||||||
|
// Refine with ACL for the current logged-in user (if any)
|
||||||
|
$user = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($user !== '') {
|
||||||
|
// Upload: must also pass folder-level ACL
|
||||||
|
if ($canUpload && !ACL::canUpload($user, $perms, $folder)) {
|
||||||
|
$canUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download: require read or read_own
|
||||||
|
if (
|
||||||
|
$canDownload
|
||||||
|
&& !ACL::canRead($user, $perms, $folder)
|
||||||
|
&& !ACL::canReadOwn($user, $perms, $folder)
|
||||||
|
) {
|
||||||
|
$canDownload = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'folder' => $folder,
|
'folder' => $folder,
|
||||||
'clientEmail' => $clientEmail,
|
'clientEmail' => $clientEmail,
|
||||||
'uploadOnly' => $uploadOnly,
|
// Store flags as-is so old code / JSON stay compatible
|
||||||
'allowDownload' => $allowDownload,
|
'uploadOnly' => (bool)$rawUploadOnly,
|
||||||
|
'allowDownload' => $hasAllowDownload
|
||||||
|
? (bool)$rawAllowDownload
|
||||||
|
: $allowDownload,
|
||||||
'expiresAt' => $expiresAt,
|
'expiresAt' => $expiresAt,
|
||||||
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'introText' => $introText,
|
'introText' => $introText,
|
||||||
'requireForm' => $requireForm,
|
'requireForm' => $requireForm,
|
||||||
@@ -118,6 +234,18 @@ final class PortalController
|
|||||||
'footerText' => $footerText,
|
'footerText' => $footerText,
|
||||||
'formDefaults' => $formDefaults,
|
'formDefaults' => $formDefaults,
|
||||||
'formRequired' => $formRequired,
|
'formRequired' => $formRequired,
|
||||||
|
'formLabels' => $formLabels,
|
||||||
|
'formVisible' => $formVisible,
|
||||||
|
'logoFile' => $logoFile,
|
||||||
|
'logoUrl' => $logoUrl,
|
||||||
|
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||||
|
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||||
|
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||||
|
'showThankYou' => $showThankYou,
|
||||||
|
'thankYouText' => $thankYouText,
|
||||||
|
// New ACL-aware caps for portal.js
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canDownload' => $canDownload,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,6 +797,90 @@ class UserController
|
|||||||
exit;
|
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
|
public function siteConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'headerBgDark' => self::sanitizeColorHex(
|
'headerBgDark' => self::sanitizeColorHex(
|
||||||
$config['branding']['headerBgDark'] ?? ''
|
$config['branding']['headerBgDark'] ?? ''
|
||||||
),
|
),
|
||||||
],
|
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||||
|
],
|
||||||
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -261,27 +262,29 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
$configUpdate['onlyoffice'] = $norm;
|
$configUpdate['onlyoffice'] = $norm;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branding (Pro-only). Normalize and only persist when Pro is active.
|
|
||||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||||
$configUpdate['branding'] = [
|
$configUpdate['branding'] = [
|
||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||||
|
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
|
||||||
|
|
||||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||||
$configUpdate['branding']['headerBgLight'] = $light;
|
$configUpdate['branding']['headerBgLight'] = $light;
|
||||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||||
|
$configUpdate['branding']['footerHtml'] = $footer;
|
||||||
} else {
|
} else {
|
||||||
// Free mode: always clear branding customizations
|
|
||||||
$configUpdate['branding']['customLogoUrl'] = '';
|
$configUpdate['branding']['customLogoUrl'] = '';
|
||||||
$configUpdate['branding']['headerBgLight'] = '';
|
$configUpdate['branding']['headerBgLight'] = '';
|
||||||
$configUpdate['branding']['headerBgDark'] = '';
|
$configUpdate['branding']['headerBgDark'] = '';
|
||||||
|
$configUpdate['branding']['footerHtml'] = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +447,7 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
||||||
@@ -486,6 +490,7 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||