Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0de36e734 | |||
|
|
405ed7f925 | ||
|
|
6491a7b1b3 | ||
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad | ||
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 | ||
|
|
e2d1b705bd | ||
|
|
4798afa89e | ||
|
|
da968e51e1 | ||
|
|
c06452600d | ||
|
|
758ad7719b | ||
|
|
3587f5041c | ||
|
|
da14d204a6 | ||
|
|
2a87002e1f |
209
CHANGELOG.md
@@ -1,5 +1,214 @@
|
||||
# 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)
|
||||
|
||||
release(v2.2.4): fix(admin): ONLYOFFICE JWT save crash and respect replace/locked flags
|
||||
|
||||
- Prevented a JS crash when the ONLYOFFICE JWT field isn’t present by always initializing payload.onlyoffice before touching jwtSecret.
|
||||
- Tightened ONLYOFFICE JWT handling so the secret is only sent when config isn’t locked by PHP and the admin explicitly chooses Replace (or is setting it for the first time), instead of always pushing whatever is in the field.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/29/2025 (v2.2.3)
|
||||
|
||||
fix(preview): harden SVG handling and normalize mime type
|
||||
|
||||
288
CLAUDE.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
FileRise is a self-hosted web file manager / WebDAV server built with PHP 8.3+. It provides drag-and-drop uploads, granular ACL-based permissions, ONLYOFFICE integration, WebDAV support, and OIDC authentication. No external database is required - all data is stored in JSON files.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: PHP 8.3+ (no framework)
|
||||
- Frontend: Vanilla JavaScript, Bootstrap 4.5.2
|
||||
- WebDAV: sabre/dav
|
||||
- Dependencies: Composer (see composer.json)
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Running Locally (Docker - Recommended)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The docker-compose.yml file is configured for development. FileRise will be available at http://localhost:8080.
|
||||
|
||||
### Running with PHP Built-in Server
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
2. Create required directories:
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
3. Set environment variables and start:
|
||||
```bash
|
||||
export TIMEZONE="America/New_York"
|
||||
export TOTAL_UPLOAD_SIZE="10G"
|
||||
export SECURE="false"
|
||||
export PERSISTENT_TOKENS_KEY="dev_key_please_change"
|
||||
php -S localhost:8080 -t public/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
FileRise/
|
||||
├── config/
|
||||
│ └── config.php # Global configuration, session handling, encryption
|
||||
├── src/
|
||||
│ ├── controllers/ # Business logic for each feature area
|
||||
│ │ ├── FileController.php # File operations (download, preview, share)
|
||||
│ │ ├── FolderController.php # Folder operations (create, move, copy, delete)
|
||||
│ │ ├── UserController.php # User management
|
||||
│ │ ├── AuthController.php # Authentication (login, OIDC, TOTP)
|
||||
│ │ ├── AdminController.php # Admin panel operations
|
||||
│ │ ├── AclAdminController.php # ACL management
|
||||
│ │ ├── UploadController.php # File upload handling
|
||||
│ │ ├── MediaController.php # Media preview/streaming
|
||||
│ │ ├── OnlyOfficeController.php # ONLYOFFICE document editing
|
||||
│ │ └── PortalController.php # Client portal (Pro feature)
|
||||
│ ├── models/ # Data access layer
|
||||
│ │ ├── UserModel.php
|
||||
│ │ ├── FolderModel.php
|
||||
│ │ ├── FolderMeta.php
|
||||
│ │ ├── MediaModel.php
|
||||
│ │ └── AdminModel.php
|
||||
│ ├── lib/ # Core libraries
|
||||
│ │ ├── ACL.php # Central ACL enforcement (read, write, upload, share, etc.)
|
||||
│ │ └── FS.php # Filesystem utilities and safety checks
|
||||
│ ├── webdav/ # WebDAV implementation (using sabre/dav)
|
||||
│ │ ├── FileRiseFile.php
|
||||
│ │ ├── FileRiseDirectory.php
|
||||
│ │ └── CurrentUser.php
|
||||
│ ├── cli/ # CLI utilities
|
||||
│ └── openapi/ # OpenAPI spec generation
|
||||
├── public/ # Web root (served by Apache/Nginx)
|
||||
│ ├── index.html # Main SPA entry point
|
||||
│ ├── api.php # API documentation viewer
|
||||
│ ├── webdav.php # WebDAV endpoint
|
||||
│ ├── api/ # API endpoints (called by frontend)
|
||||
│ │ ├── *.php # Individual API endpoints
|
||||
│ │ └── pro/ # Pro-only API endpoints
|
||||
│ ├── js/ # Frontend JavaScript
|
||||
│ ├── css/ # Stylesheets
|
||||
│ ├── vendor/ # Client-side libraries (Bootstrap, CodeMirror, etc.)
|
||||
│ └── .htaccess # Apache rewrite rules
|
||||
├── scripts/
|
||||
│ └── scan_uploads.php # CLI tool to rebuild metadata from filesystem
|
||||
├── uploads/ # User file storage (created at runtime)
|
||||
├── users/ # User data, permissions, tokens (created at runtime)
|
||||
└── metadata/ # File metadata, tags, shares, ACLs (created at runtime)
|
||||
```
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
#### 1. ACL System (src/lib/ACL.php)
|
||||
|
||||
The ACL class is the **single source of truth** for all permission checks. It manages folder-level permissions with inheritance:
|
||||
|
||||
- **Buckets**: owners, read, write, share, read_own, create, upload, edit, rename, copy, move, delete, extract, share_file, share_folder
|
||||
- **Enforcement**: All controllers MUST call ACL methods (e.g., `ACL::canRead()`, `ACL::canWrite()`) before performing operations
|
||||
- **Storage**: Permissions stored in `metadata/folder_acl.json`
|
||||
- **Inheritance**: When a user is granted permissions on a folder, they typically have access to subfolders unless explicitly restricted
|
||||
|
||||
#### 2. Metadata System
|
||||
|
||||
FileRise stores metadata in JSON files rather than a database:
|
||||
|
||||
- **Per-folder metadata**: `metadata/{folder_key}_metadata.json`
|
||||
- Root folder: `root_metadata.json`
|
||||
- Subfolder "invoices/2025": `invoices-2025_metadata.json` (slashes/spaces replaced with hyphens)
|
||||
- **Global metadata**:
|
||||
- `users/users.txt` - User credentials (bcrypt hashed)
|
||||
- `users/userPermissions.json` - Per-user settings (encrypted)
|
||||
- `users/persistent_tokens.json` - "Remember me" tokens (encrypted)
|
||||
- `users/adminConfig.json` - Admin settings (encrypted)
|
||||
- `metadata/folder_acl.json` - All ACL rules
|
||||
- `metadata/folder_owners.json` - Folder ownership tracking
|
||||
|
||||
#### 3. Encryption
|
||||
|
||||
Sensitive data is encrypted using AES-256-CBC with the `PERSISTENT_TOKENS_KEY` environment variable:
|
||||
- Functions: `encryptData()` and `decryptData()` in config/config.php
|
||||
- Encrypted files: userPermissions.json, persistent_tokens.json, adminConfig.json, proLicense.json
|
||||
|
||||
#### 4. Session Management
|
||||
|
||||
- PHP sessions with configurable lifetime (default: 2 hours)
|
||||
- "Remember me" tokens stored separately with 30-day expiry
|
||||
- Session regeneration on login to prevent fixation attacks
|
||||
- Proxy authentication bypass mode (AUTH_BYPASS) for SSO integration
|
||||
|
||||
#### 5. WebDAV Integration
|
||||
|
||||
The WebDAV endpoint (`public/webdav.php`) uses sabre/dav with custom node classes:
|
||||
- `FileRiseFile` and `FileRiseDirectory` in `src/webdav/`
|
||||
- **All WebDAV operations respect ACL rules** via the same ACL class
|
||||
- Authentication via HTTP Basic Auth or proxy headers
|
||||
|
||||
#### 6. Pro Features
|
||||
|
||||
FileRise has a Pro version with additional features loaded dynamically:
|
||||
- Pro bundle located in `users/pro/` (configurable via FR_PRO_BUNDLE_DIR)
|
||||
- Bootstrap file: `users/pro/bootstrap_pro.php`
|
||||
- License validation sets FR_PRO_ACTIVE constant
|
||||
- Pro endpoints in `public/api/pro/`
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Testing ACL Changes
|
||||
|
||||
When modifying ACL logic:
|
||||
|
||||
1. Test with multiple user roles (admin, regular user, restricted user)
|
||||
2. Verify both UI and WebDAV respect the same rules
|
||||
3. Check inheritance behavior for nested folders
|
||||
4. Test edge cases: root folder, trash folder, special characters in paths
|
||||
|
||||
### Adding New API Endpoints
|
||||
|
||||
1. Create endpoint file in `public/api/` (e.g., `public/api/myFeature.php`)
|
||||
2. Include config: `require_once __DIR__ . '/../../config/config.php';`
|
||||
3. Check authentication: `if (empty($_SESSION['authenticated'])) { /* return 401 */ }`
|
||||
4. Perform ACL checks using `ACL::can*()` methods before operations
|
||||
5. Return JSON: `header('Content-Type: application/json'); echo json_encode($response);`
|
||||
|
||||
### Working with Metadata
|
||||
|
||||
Reading folder metadata:
|
||||
```php
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
$meta = FolderModel::getFolderMeta($folderKey); // e.g., "root" or "invoices/2025"
|
||||
```
|
||||
|
||||
Writing folder metadata:
|
||||
```php
|
||||
FolderModel::saveFolderMeta($folderKey, $metaArray);
|
||||
```
|
||||
|
||||
### Rebuilding Metadata from Filesystem
|
||||
|
||||
If files are added/removed outside FileRise:
|
||||
|
||||
```bash
|
||||
php scripts/scan_uploads.php
|
||||
```
|
||||
|
||||
This rebuilds all `*_metadata.json` files by scanning the uploads directory.
|
||||
|
||||
### Running in Docker
|
||||
|
||||
The Dockerfile and start.sh handle:
|
||||
- Setting PHP configuration (upload limits, timezone)
|
||||
- Running scan_uploads.php if SCAN_ON_START=true
|
||||
- Fixing permissions if CHOWN_ON_START=true
|
||||
- Starting Apache
|
||||
|
||||
Environment variables are processed in config/config.php (falls back to constants if not set).
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### File Organization
|
||||
|
||||
- Controllers handle HTTP requests and orchestrate business logic
|
||||
- Models handle data persistence (JSON file I/O)
|
||||
- ACL class is the **only** place for permission logic - never duplicate ACL checks
|
||||
- FS class provides filesystem utilities and path safety checks
|
||||
|
||||
### Security Requirements
|
||||
|
||||
- **Always validate user input** - use regex patterns from config.php (REGEX_FILE_NAME, REGEX_FOLDER_NAME)
|
||||
- **Always check ACLs** before file/folder operations
|
||||
- **Always use FS::safeReal()** to prevent path traversal via symlinks
|
||||
- **Never trust client-provided paths** - validate and sanitize all paths
|
||||
- **Use CSRF tokens** for state-changing operations (token in $_SESSION['csrf_token'])
|
||||
- **Sanitize output** when rendering user content (especially in previews)
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Return appropriate HTTP status codes (401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error)
|
||||
- Log errors using `error_log()` for debugging
|
||||
- Return user-friendly JSON error messages
|
||||
|
||||
### Path Handling
|
||||
|
||||
- Use DIRECTORY_SEPARATOR for cross-platform compatibility
|
||||
- Always normalize folder keys with `ACL::normalizeFolder()`
|
||||
- Convert between absolute paths and folder keys consistently:
|
||||
- Absolute: `/var/www/uploads/invoices/2025/`
|
||||
- Folder key: `invoices/2025` (relative to uploads, forward slashes)
|
||||
- Root folder key: `root`
|
||||
|
||||
## Testing
|
||||
|
||||
FileRise does not currently have automated tests. When making changes:
|
||||
|
||||
1. Test manually in browser UI
|
||||
2. Test WebDAV operations (if applicable)
|
||||
3. Test with different user permission levels
|
||||
4. Test ACL inheritance behavior
|
||||
5. Check error cases (invalid input, insufficient permissions, missing files)
|
||||
|
||||
## CI/CD
|
||||
|
||||
GitHub Actions workflows (in `.github/workflows/`):
|
||||
- `ci.yml` - Basic CI checks
|
||||
- `release-on-version.yml` - Automated releases when version changes
|
||||
- `sync-changelog.yml` - Changelog synchronization
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **No ORM/framework**: This is vanilla PHP - all database operations are manual JSON file I/O
|
||||
- **Session-based auth**: Not JWT - sessions stored server-side, persistent tokens for "remember me"
|
||||
- **Metadata consistency**: If you modify files directly, run scan_uploads.php to rebuild metadata
|
||||
- **ACL is central**: Never bypass ACL checks - all file operations must go through ACL validation
|
||||
- **Encryption key**: PERSISTENT_TOKENS_KEY must be set in production (default is insecure)
|
||||
- **Pro features**: Some functionality is dynamically loaded from the Pro bundle - check FR_PRO_ACTIVE before calling Pro code
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- FileRise is designed to scale to **100k+ folders** in the sidebar tree
|
||||
- Metadata files are loaded on-demand (not all at once)
|
||||
- Large directory scans use scandir() with filtering - avoid recursive operations when possible
|
||||
- WebDAV PROPFIND operations should be optimized (limit depth)
|
||||
|
||||
## Debugging
|
||||
|
||||
Enable PHP error reporting in development:
|
||||
```php
|
||||
ini_set('display_errors', '1');
|
||||
error_reporting(E_ALL);
|
||||
```
|
||||
|
||||
Check logs:
|
||||
- Apache error log: `/var/log/apache2/error.log` (or similar)
|
||||
- PHP error_log() output: check Docker logs with `docker logs filerise`
|
||||
|
||||
## Documentation
|
||||
|
||||
- Main docs: GitHub Wiki at https://github.com/error311/FileRise/wiki
|
||||
- API docs: Available at `/api.php` when logged in (Redoc interface)
|
||||
- OpenAPI spec: `openapi.json.dist`
|
||||
135
README.md
@@ -7,6 +7,7 @@
|
||||
[](https://demo.filerise.net)
|
||||
[](https://github.com/error311/FileRise/releases)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/7WN6f56X2e)
|
||||
[](https://github.com/sponsors/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)
|
||||
|
||||

|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
@@ -41,21 +42,22 @@ Full list of features available at [Full Feature Wiki](https://github.com/error3
|
||||
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
||||
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
|
||||
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. What FileRise does
|
||||
|
||||
FileRise turns a folder on your server into a **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
|
||||
- Multi‑file/folder drag‑and‑drop uploads
|
||||
- Multi-file/folder drag-and-drop uploads
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
@@ -65,8 +67,22 @@ Everything flows through a single ACL engine, so permissions are enforced consis
|
||||
|
||||
The easiest way to run FileRise is the official Docker image.
|
||||
|
||||
### Option A – Quick start (docker run)
|
||||
|
||||
```bash
|
||||
docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e TOTAL_UPLOAD_SIZE="10G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
Then visit:
|
||||
@@ -77,22 +93,97 @@ http://your-server-ip:8080
|
||||
|
||||
On first launch you’ll be guided through creating the **initial admin user**.
|
||||
|
||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
||||
[nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
||||
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
||||
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||
> 💡 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.
|
||||
|
||||
> ⚠️ **Uploads folder recommendation**
|
||||
>
|
||||
> 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)
|
||||
|
||||
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**
|
||||
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
|
||||
|
||||
---
|
||||
|
||||
@@ -146,14 +237,14 @@ See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs 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**:
|
||||
|
||||
- Enable ONLYOFFICE
|
||||
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||
- 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)
|
||||
|
||||
@@ -174,8 +265,8 @@ Please report vulnerabilities responsibly via the channels listed in **SECURITY.
|
||||
## 6. Community, support & contributing
|
||||
|
||||
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||
- 💬 **Unraid forum thread:** for Unraid‑specific setup and tuning.
|
||||
- 🌍 **Reddit / self‑hosting communities:** occasional release posts & feedback threads.
|
||||
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
|
||||
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
|
||||
|
||||
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
@@ -183,16 +274,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
|
||||
|
||||
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||
- ☕ [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).
|
||||
|
||||
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.
|
||||
It bundles a small set of well-known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||
All third-party code remains under its original licenses.
|
||||
|
||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ $public = [
|
||||
'introText' => (string)($portal['introText'] ?? ''),
|
||||
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||
'logoFile' => (string)($portal['logoFile'] ?? ''),
|
||||
];
|
||||
|
||||
echo json_encode([
|
||||
|
||||
@@ -58,6 +58,27 @@ try {
|
||||
require_once $subPath;
|
||||
|
||||
$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 = [
|
||||
'slug' => $slug,
|
||||
'portalLabel' => $portal['label'] ?? '',
|
||||
@@ -69,7 +90,7 @@ try {
|
||||
'notes' => $notes,
|
||||
],
|
||||
'submittedBy' => $submittedBy,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'ip' => $ip,
|
||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'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;
|
||||
align-items: flex-end;
|
||||
gap: 5px;}
|
||||
#uploadBtn{font-size: 20px;
|
||||
padding: 10px 22px;
|
||||
align-items: center;}
|
||||
#uploadBtn{font-size: 18px;
|
||||
padding: 10px 18px;
|
||||
align-items: center;
|
||||
margin-top:20px;}
|
||||
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
|
||||
#customChooseBtn{background-color: #9E9E9E;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 18px;
|
||||
font-size: 16px;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;}
|
||||
@media (max-width: 768px) {
|
||||
#customChooseBtn{font-size: 14px;
|
||||
padding: 6px 14px;}
|
||||
#customChooseBtn{font-size: 12px;
|
||||
padding: 6px 10px;}
|
||||
}
|
||||
.pause-resume-btn{background: none;
|
||||
border: none;
|
||||
@@ -772,7 +773,7 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
|
||||
text-align: left !important;
|
||||
line-height: 1.2 !important;
|
||||
vertical-align: middle !important;
|
||||
padding: 8px 10px !important;
|
||||
padding: 2px 4px !important;
|
||||
max-width: 250px !important;
|
||||
min-width: 120px !important;}
|
||||
@media (min-width: 500px) {
|
||||
@@ -1442,8 +1443,6 @@ label{font-size: 0.9rem;}
|
||||
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
min-height: 320px;
|
||||
|
||||
border-radius: var(--menu-radius);
|
||||
overflow: hidden;
|
||||
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;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;}
|
||||
.card-header{font-size: 1.2rem;
|
||||
.card-header{font-size: 1.1rem;
|
||||
font-weight: bold;}
|
||||
.custom-folder-card-body{padding-top: 5px !important;
|
||||
padding-right: 0 !important;
|
||||
@@ -2250,4 +2249,695 @@ body:not(.dark-mode) .header-zoom-controls .btn-icon.zoom-btn .material-icons{
|
||||
border-radius: 16px;
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Modal sizing */
|
||||
#adminPanelModal .modal-content {
|
||||
max-width: 1100px;
|
||||
width: 60% !important;
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
#adminPanelModal .modal-content {
|
||||
width: 90% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; }
|
||||
.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; }
|
||||
.dark-mode .form-control::placeholder { color:#888; }
|
||||
|
||||
.section-header {
|
||||
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
|
||||
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
|
||||
}
|
||||
.section-header:first-of-type { margin-top:0; }
|
||||
.section-header.collapsed .material-icons { transform:rotate(-90deg); }
|
||||
.section-header .material-icons { transition:transform .3s; color:#444; }
|
||||
.dark-mode .section-header { background:#3a3a3a; color:#eee; }
|
||||
.dark-mode .section-header .material-icons { color:#ccc; }
|
||||
|
||||
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
|
||||
|
||||
#adminPanelModal .editor-close-btn {
|
||||
position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
|
||||
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000; width:32px; height:32px; border-radius:50%;
|
||||
text-align:center; line-height:30px; color:#ff4d4d; background:rgba(255,255,255,0.9);
|
||||
border:2px solid transparent; transition:all .3s;
|
||||
}
|
||||
#adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); }
|
||||
.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; }
|
||||
|
||||
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
|
||||
|
||||
/* ---------- Folder access editor ---------- */
|
||||
.folder-access-toolbar {
|
||||
display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
|
||||
}
|
||||
.folder-access-list {
|
||||
--col-perm: 84px;
|
||||
--col-folder-min: 340px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
.dark-mode .folder-access-list { border-color:#555; }
|
||||
|
||||
.folder-access-header,
|
||||
.folder-access-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(var(--col-folder-min), 1fr) repeat(14, var(--col-perm));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.folder-access-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: #fff;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.12);
|
||||
}
|
||||
.dark-mode .folder-access-header { background:#2c2c2c; }
|
||||
|
||||
.folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||
.folder-access-row:last-child { border-bottom: none; }
|
||||
|
||||
.perm-col { text-align:center; white-space:nowrap; }
|
||||
.folder-access-header > div { white-space: nowrap; }
|
||||
|
||||
.folder-badge {
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
font-weight:600; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.muted { opacity:.65; font-size:.9em; }
|
||||
|
||||
/* Inheritance visuals */
|
||||
.inherited-row {
|
||||
opacity: 0.8;
|
||||
background: rgba(32, 132, 255, 0.06);
|
||||
}
|
||||
.inherited-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(32,132,255,0.12);
|
||||
color: #2064ff;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.dark-mode .inherited-row { background: rgba(32,132,255,0.12); }
|
||||
.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.folder-access-list { --col-perm: 72px; --col-folder-min: 240px; }
|
||||
}
|
||||
|
||||
/* Folder cell: horizontal-only scroll */
|
||||
.folder-cell{
|
||||
overflow-x:auto;
|
||||
overflow-y:hidden;
|
||||
white-space:nowrap;
|
||||
-webkit-overflow-scrolling:touch;
|
||||
}
|
||||
/* nicer thin scrollbar (supported browsers) */
|
||||
.folder-cell::-webkit-scrollbar{ height:8px; }
|
||||
.folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; }
|
||||
.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); }
|
||||
|
||||
/* Badge now doesn't clip; let the wrapper handle scroll */
|
||||
.folder-badge{
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
font-weight:600;
|
||||
min-width:0; /* allow child to be as wide as needed inside scroller */
|
||||
}
|
||||
.group-members-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.group-member-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background-color: #1e88e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark-mode .group-member-pill {
|
||||
background-color: #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
/* Client portal cards */
|
||||
#clientPortalsBody .portal-card {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px 12px 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-card {
|
||||
border-color: #555;
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.portal-card-header {
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
cursor:pointer;
|
||||
padding:4px 4px 4px 0;
|
||||
}
|
||||
.portal-card-header .portal-card-caret {
|
||||
display:inline-block;
|
||||
font-size:14px;
|
||||
transform:rotate(-90deg);
|
||||
transition:transform .15s ease;
|
||||
}
|
||||
.portal-card-header[aria-expanded="true"] .portal-card-caret {
|
||||
transform:rotate(0deg);
|
||||
}
|
||||
.portal-card-header-main {
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:6px;
|
||||
align-items:baseline;
|
||||
}
|
||||
.portal-card-header-main strong {
|
||||
font-size:.9rem;
|
||||
}
|
||||
.portal-card-header-main .portal-card-slug {
|
||||
font-family:monospace;
|
||||
font-size:.8rem;
|
||||
opacity:.75;
|
||||
}
|
||||
|
||||
.portal-card-delete,
|
||||
.group-card-delete {
|
||||
position:absolute;
|
||||
top:10px;
|
||||
right:6px;
|
||||
width:30px;
|
||||
height:30px;
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:0;
|
||||
}
|
||||
.group-card-delete {
|
||||
|
||||
top:4px;
|
||||
|
||||
}
|
||||
|
||||
.portal-card-body {
|
||||
margin-top:6px;
|
||||
}
|
||||
|
||||
#clientPortalsBody .portal-meta-row {
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
margin-top:6px;
|
||||
}
|
||||
#clientPortalsBody .portal-meta-row label {
|
||||
margin:0;
|
||||
font-size:.8rem;
|
||||
}
|
||||
|
||||
/* Make date input look consistent */
|
||||
#clientPortalsBody input[type="date"].form-control-sm {
|
||||
border-radius:.25rem;
|
||||
}
|
||||
/* -------- Client portals: Expires alignment + date styling -------- */
|
||||
#clientPortalsBody .portal-expires-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#clientPortalsBody .portal-expires-group label {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
#clientPortalsBody .portal-expiry-input {
|
||||
max-width: 170px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-expiry-input {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#clientPortalsBody .portal-submissions-block {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed rgba(0,0,0,0.1);
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-list {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: rgba(0,0,0,0.02);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dark-mode #clientPortalsBody .portal-submissions-list {
|
||||
border-color: #555;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-item {
|
||||
padding: 4px 2px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
#clientPortalsBody .portal-submissions-meta {
|
||||
opacity: 0.75;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Client portal submissions load button */
|
||||
.portal-submissions-block .portal-submissions-load-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(108, 117, 125, 0.9); /* ~Bootstrap secondary */
|
||||
background: rgba(108, 117, 125, 0.06);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-submissions-block .portal-submissions-load-btn:hover,
|
||||
.portal-submissions-block .portal-submissions-load-btn:focus-visible {
|
||||
background: rgba(108, 117, 125, 0.18);
|
||||
}
|
||||
|
||||
body.dark-mode .portal-submissions-block .portal-submissions-load-btn {
|
||||
border-color: rgba(200, 200, 200, 0.7);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:hover,
|
||||
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
/* ============================================
|
||||
TABLE ACTIONS: 3-dot header + row buttons
|
||||
============================================ */
|
||||
|
||||
/* Compact "Actions" column */
|
||||
th[data-column="actions"],
|
||||
td.actions-cell,
|
||||
td.folder-actions-cell {
|
||||
width: 40px;
|
||||
max-width: 40px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hide "Actions" text but keep it for screen readers */
|
||||
th[data-column="actions"] {
|
||||
position: relative;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
/* Show a 3-dot Material icon in the header instead */
|
||||
th[data-column="actions"]::after {
|
||||
content: "more_horiz";
|
||||
font-family: "Material Icons";
|
||||
text-indent: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.dark-mode th[data-column="actions"]::after,
|
||||
[data-theme="dark"] th[data-column="actions"]::after {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Row-level 3-dot button */
|
||||
.btn-actions-ellipsis {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
border-radius: 999px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.16s ease-out,
|
||||
box-shadow 0.16s ease-out,
|
||||
transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
.btn-actions-ellipsis .material-icons {
|
||||
font-size: 20px;
|
||||
color: var(--filr-icon-muted, #6b7280);
|
||||
}
|
||||
|
||||
/* Dark theme icon color */
|
||||
.dark-mode .btn-actions-ellipsis .material-icons,
|
||||
[data-theme="dark"] .btn-actions-ellipsis .material-icons {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Glassy hover for 3-dot trigger (light) */
|
||||
.btn-actions-ellipsis:hover,
|
||||
.btn-actions-ellipsis:focus-visible {
|
||||
outline: none;
|
||||
background-color: rgba(148, 163, 184, 0.18);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(148, 163, 184, 0.4),
|
||||
0 6px 14px rgba(15, 23, 42, 0.22);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Glassy hover for 3-dot trigger (dark) */
|
||||
.dark-mode .btn-actions-ellipsis:hover,
|
||||
.dark-mode .btn-actions-ellipsis:focus-visible,
|
||||
[data-theme="dark"] .btn-actions-ellipsis:hover,
|
||||
[data-theme="dark"] .btn-actions-ellipsis:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--fr-border-dark),
|
||||
0 10px 24px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.btn-actions-ellipsis.btn-link,
|
||||
.btn-actions-ellipsis.btn-link:hover,
|
||||
.btn-actions-ellipsis.btn-link:focus,
|
||||
.btn-actions-ellipsis.btn-link:focus-visible {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HOVER PREVIEW CARD – glassmorphism
|
||||
============================================ */
|
||||
/* Clickable glass hover card */
|
||||
#hoverPreview {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* === DARK THEME GLASS CARD (no banding) ======================= */
|
||||
.hover-preview-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 420px;
|
||||
max-width: 640px;
|
||||
min-height: 220px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
|
||||
/* Base: semi-opaque dark, no banding */
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--fr-surface-dark, #0f172a) 78%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
/* Very subtle linear sheen (small contrast = no visible bands) */
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0.0)
|
||||
);
|
||||
|
||||
border: 1px solid color-mix(
|
||||
in srgb,
|
||||
var(--fr-border-dark, #1f2937) 70%,
|
||||
transparent
|
||||
);
|
||||
|
||||
box-shadow:
|
||||
0 18px 40px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
|
||||
color: #e5e7eb;
|
||||
font-size: 12px;
|
||||
|
||||
/* Glass feel: blur + mild saturation */
|
||||
backdrop-filter: blur(18px) saturate(135%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(135%);
|
||||
}
|
||||
|
||||
/* === LIGHT THEME GLASS CARD =================================== */
|
||||
[data-theme="light"] .hover-preview-card {
|
||||
background-color: rgba(255, 255, 255, 0.86) !important;
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.98),
|
||||
rgba(249, 250, 251, 0.80)
|
||||
);
|
||||
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
box-shadow:
|
||||
0 16px 32px rgba(15, 23, 42, 0.16),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.9);
|
||||
|
||||
color: #111827;
|
||||
|
||||
backdrop-filter: blur(16px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(130%);
|
||||
}
|
||||
|
||||
/* Two-column inner layout */
|
||||
.hover-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(260px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center; /* center LEFT + RIGHT in the same row */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left column: image + snippet */
|
||||
.hover-preview-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center; /* center inside its own grid cell */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Right column: title + meta + props */
|
||||
.hover-preview-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center; /* center inside its own grid cell */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Thumb area */
|
||||
.hover-preview-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 140px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Text / folder peek snippet block */
|
||||
.hover-preview-snippet {
|
||||
margin-top: 4px;
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
/* Dark chip so it always has contrast vs the card */
|
||||
background-color: rgba(39, 39, 39, 0.92) !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
/* You can keep this same in light mode (still looks good), or tweak slightly */
|
||||
[data-theme="light"] .hover-preview-snippet {
|
||||
background-color: rgba(39, 39, 39, 0.92) !important;
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
/* Title + meta + props */
|
||||
.hover-preview-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hover-preview-meta {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hover-preview-meta {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hover-preview-props {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.hover-prop-line {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Icon color */
|
||||
.hover-preview-icon.material-icons {
|
||||
font-size: 26px;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
[data-theme="light"] .hover-preview-icon.material-icons {
|
||||
color: #2563eb;
|
||||
}
|
||||
/* Row-level 3-dot button: shared between file list + folder tree */
|
||||
.btn-actions-ellipsis,
|
||||
.folder-kebab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
border-radius: 999px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.16s ease-out,
|
||||
box-shadow 0.16s ease-out,
|
||||
transform 0.12s ease-out;
|
||||
}
|
||||
|
||||
/* Icon sizing + base color */
|
||||
.btn-actions-ellipsis .material-icons,
|
||||
.folder-kebab.material-icons {
|
||||
font-size: 20px;
|
||||
color: var(--filr-icon-muted, #6b7280);
|
||||
}
|
||||
|
||||
/* Dark theme icon color */
|
||||
.dark-mode .btn-actions-ellipsis .material-icons,
|
||||
[data-theme="dark"] .btn-actions-ellipsis .material-icons,
|
||||
.dark-mode .folder-kebab.material-icons,
|
||||
[data-theme="dark"] .folder-kebab.material-icons {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Glassy hover for 3-dot trigger (light) */
|
||||
.btn-actions-ellipsis:hover,
|
||||
.btn-actions-ellipsis:focus-visible,
|
||||
.folder-kebab:hover,
|
||||
.folder-kebab:focus-visible {
|
||||
outline: none;
|
||||
background-color: rgba(148, 163, 184, 0.18);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(148, 163, 184, 0.4),
|
||||
0 6px 14px rgba(15, 23, 42, 0.22);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Glassy hover for 3-dot trigger (dark) */
|
||||
.dark-mode .btn-actions-ellipsis:hover,
|
||||
.dark-mode .btn-actions-ellipsis:focus-visible,
|
||||
[data-theme="dark"] .btn-actions-ellipsis:hover,
|
||||
[data-theme="dark"] .btn-actions-ellipsis:focus-visible,
|
||||
.dark-mode .folder-kebab:hover,
|
||||
.dark-mode .folder-kebab:focus-visible,
|
||||
[data-theme="dark"] .folder-kebab:hover,
|
||||
[data-theme="dark"] .folder-kebab:focus-visible {
|
||||
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--fr-border-dark),
|
||||
0 10px 24px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* Keep folder modals in DOM for JS, but hide the old toolbar icons */
|
||||
.folder-actions {
|
||||
/* still exists so modals can be found + detached */
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Hide the icon buttons, keep their IDs for JS wiring */
|
||||
.folder-actions > button {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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-body 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"
|
||||
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
|
||||
@@ -199,7 +199,7 @@
|
||||
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
||||
</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>
|
||||
<div id="uploadProgressContainer"></div>
|
||||
</form>
|
||||
@@ -216,7 +216,7 @@
|
||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||
<div id="folderTreeContainer"></div>
|
||||
</div>
|
||||
<div class="folder-actions mt-3">
|
||||
<div class="folder-actions">
|
||||
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
||||
<i class="material-icons">create_new_folder</i>
|
||||
</button>
|
||||
@@ -474,25 +474,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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" 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>
|
||||
<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>
|
||||
|
||||
<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" 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="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>
|
||||
<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"
|
||||
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>
|
||||
|
||||
<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"
|
||||
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 class="modal-content">
|
||||
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
||||
@@ -538,5 +608,14 @@
|
||||
</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>
|
||||
|
||||
</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)
|
||||
========================= */
|
||||
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');
|
||||
|
||||
const last = localStorage.getItem('lastOpenedFolder');
|
||||
|
||||
@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
|
||||
//window.__lastLoginData = data;
|
||||
}
|
||||
|
||||
function isHoverPreviewDisabled() {
|
||||
if (window.disableHoverPreview === true) return true;
|
||||
try {
|
||||
return localStorage.getItem('disableHoverPreview') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openTOTPLoginModal() {
|
||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||
@@ -454,6 +463,43 @@ export async function openUserPanel() {
|
||||
}
|
||||
});
|
||||
|
||||
// 4) Disable hover preview
|
||||
const hoverLabel = document.createElement('label');
|
||||
hoverLabel.style.cursor = 'pointer';
|
||||
hoverLabel.style.display = 'block';
|
||||
hoverLabel.style.marginTop = '4px';
|
||||
|
||||
const hoverCb = document.createElement('input');
|
||||
hoverCb.type = 'checkbox';
|
||||
hoverCb.id = 'disableHoverPreview';
|
||||
hoverCb.style.verticalAlign = 'middle';
|
||||
|
||||
{
|
||||
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||
hoverCb.checked = storedHover === 'true';
|
||||
// also mirror into a global flag for runtime checks
|
||||
window.disableHoverPreview = hoverCb.checked;
|
||||
}
|
||||
|
||||
hoverLabel.appendChild(hoverCb);
|
||||
hoverLabel.append(
|
||||
` ${t('disable_hover_preview') || 'Disable file hover preview'}`
|
||||
);
|
||||
dispFs.appendChild(hoverLabel);
|
||||
|
||||
// Handler: toggle hover preview
|
||||
hoverCb.addEventListener('change', () => {
|
||||
const disabled = hoverCb.checked;
|
||||
localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
|
||||
window.disableHoverPreview = disabled;
|
||||
|
||||
// Hide any currently-visible preview right away
|
||||
const preview = document.getElementById('hoverPreview');
|
||||
if (preview) {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
inlineCb.addEventListener('change', () => {
|
||||
window.showInlineFolders = inlineCb.checked;
|
||||
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||
@@ -524,6 +570,13 @@ export async function openUserPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const hoverCb = modal.querySelector('#disableHoverPreview');
|
||||
if (hoverCb) {
|
||||
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||
hoverCb.checked = storedHover === 'true';
|
||||
window.disableHoverPreview = hoverCb.checked;
|
||||
}
|
||||
|
||||
// show
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ export function buildFileTableHeader(sortOrder) {
|
||||
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th data-column="size" class="sortable-col"> ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} </th>
|
||||
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||
<th>${t("actions")}</th>
|
||||
<th data-column="actions" class="actions-col">${t("actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
`;
|
||||
@@ -175,99 +175,32 @@ export function buildFileTableRow(file, folderPath) {
|
||||
const safeFileName = escapeHTML(file.name);
|
||||
const safeModified = escapeHTML(file.modified);
|
||||
const safeUploaded = escapeHTML(file.uploaded);
|
||||
const safeSize = escapeHTML(file.size);
|
||||
const safeSize = escapeHTML(file.size);
|
||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||
|
||||
let previewButton = "";
|
||||
|
||||
const isSvg = /\.svg$/i.test(file.name);
|
||||
|
||||
// IMPORTANT: do NOT treat SVG as previewable
|
||||
if (
|
||||
!isSvg &&
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
|
||||
.test(file.name)
|
||||
) {
|
||||
let previewIcon = "";
|
||||
|
||||
// images (SVG explicitly excluded)
|
||||
if (
|
||||
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
|
||||
.test(file.name)
|
||||
) {
|
||||
previewIcon = `<i class="material-icons">image</i>`;
|
||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
||||
} else if (/\.pdf$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
|
||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
||||
}
|
||||
|
||||
previewButton = `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-info preview-btn"
|
||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
||||
data-preview-name="${safeFileName}"
|
||||
title="${t('preview')}">
|
||||
${previewIcon}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||
</td>
|
||||
<td class="file-name-cell">${safeFileName}</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||
<td class="hide-small nowrap">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-success download-btn"
|
||||
data-download-name="${file.name}"
|
||||
data-download-folder="${file.folder || 'root'}"
|
||||
title="${t('download')}">
|
||||
<i class="material-icons">file_download</i>
|
||||
</button>
|
||||
|
||||
${file.editable ? `
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary edit-btn"
|
||||
data-edit-name="${file.name}"
|
||||
data-edit-folder="${file.folder || 'root'}"
|
||||
title="${t('edit')}">
|
||||
<i class="material-icons">edit</i>
|
||||
</button>` : ""}
|
||||
|
||||
${previewButton}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-warning rename-btn"
|
||||
data-rename-name="${file.name}"
|
||||
data-rename-folder="${file.folder || 'root'}"
|
||||
title="${t('rename')}">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
</button>
|
||||
<!-- share -->
|
||||
<button
|
||||
<tr class="clickable-row" data-file-name="${safeFileName}">
|
||||
<td>
|
||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||
</td>
|
||||
<td class="file-name-cell name-cell">
|
||||
${safeFileName}
|
||||
</td>
|
||||
<td class="hide-small nowrap">${safeModified}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||
<td class="hide-small nowrap size-cell">${safeSize}</td>
|
||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||
<td class="actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
||||
data-file="${safeFileName}"
|
||||
title="${t('share')}">
|
||||
<i class="material-icons">share</i>
|
||||
class="btn btn-link btn-actions-ellipsis"
|
||||
title="${t("more_actions")}"
|
||||
>
|
||||
<span class="material-icons">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
export function buildBottomControls(itemsPerPageSetting) {
|
||||
|
||||
@@ -80,7 +80,6 @@ function createCardGhost(card, rect, opts) {
|
||||
const ghost = card.cloneNode(true);
|
||||
const cs = window.getComputedStyle(card);
|
||||
|
||||
// Give the ghost the same “card” chrome even though it’s attached to <body>
|
||||
Object.assign(ghost.style, {
|
||||
position: 'fixed',
|
||||
left: rect.left + 'px',
|
||||
@@ -94,7 +93,6 @@ function createCardGhost(card, rect, opts) {
|
||||
transform: 'scale(' + scale + ')',
|
||||
opacity: String(opacity),
|
||||
|
||||
// pull key visuals from the real card
|
||||
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
||||
borderRadius: cs.borderRadius || '',
|
||||
boxShadow: cs.boxShadow || '',
|
||||
@@ -102,8 +100,17 @@ function createCardGhost(card, rect, opts) {
|
||||
borderWidth: cs.borderWidth || '',
|
||||
borderStyle: cs.borderStyle || '',
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -396,7 +403,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
return { card, rect };
|
||||
});
|
||||
|
||||
// Show dock so icons exist / have positions
|
||||
// Make sure header dock is visible so icons are laid out
|
||||
showHeaderDockPersistent();
|
||||
|
||||
// Move real cards into header (hidden container + icons)
|
||||
@@ -410,16 +417,16 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
// remember the size for the expand animation later
|
||||
card.dataset.lastWidth = String(rect.width);
|
||||
card.dataset.lastHeight = String(rect.height);
|
||||
|
||||
|
||||
const iconBtn = card.headerIconButton;
|
||||
if (!iconBtn) return;
|
||||
|
||||
|
||||
const iconRect = iconBtn.getBoundingClientRect();
|
||||
|
||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
|
||||
|
||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 });
|
||||
ghost.id = card.id + '-ghost-collapse';
|
||||
ghost.classList.add('card-collapse-ghost');
|
||||
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||
@@ -430,6 +437,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off motion on next frame
|
||||
requestAnimationFrame(() => {
|
||||
ghosts.forEach(({ ghost, from, to }) => {
|
||||
const fromCx = from.left + from.width / 2;
|
||||
@@ -441,17 +449,18 @@ function animateCardsIntoHeaderAndThen(done) {
|
||||
const dy = toCy - fromCy;
|
||||
|
||||
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.opacity = '0';
|
||||
ghost.style.opacity = '0.35';
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||
done();
|
||||
}, 260);
|
||||
}, 430); // a bit over the 0.4s transition
|
||||
}
|
||||
|
||||
function resolveTargetZoneForExpand(cardId) {
|
||||
@@ -508,9 +517,9 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
if (sb) sb.style.display = '';
|
||||
if (top) top.style.display = '';
|
||||
|
||||
const SAFE_TOP = 16; // minimum distance from top of viewport
|
||||
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
|
||||
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
|
||||
const SAFE_TOP = 16;
|
||||
const START_OFFSET_Y = 32; // a touch closer to header
|
||||
const DEST_EXTRA_Y = 120;
|
||||
|
||||
const ghosts = [];
|
||||
|
||||
@@ -528,24 +537,20 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
const zoneRect = host.getBoundingClientRect();
|
||||
if (!zoneRect.width) return;
|
||||
|
||||
// Where the ghost "comes from" (near the icon)
|
||||
const fromCx = iconRect.left + iconRect.width / 2;
|
||||
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
|
||||
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 toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||
|
||||
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
|
||||
if (zoneId === ZONES.SIDEBAR) {
|
||||
if (card.id === 'uploadCard') {
|
||||
toCy -= 48; // a bit higher
|
||||
toCy -= 48;
|
||||
} 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 savedH = parseFloat(card.dataset.lastHeight || '');
|
||||
const targetWidth = !Number.isNaN(savedW)
|
||||
@@ -553,10 +558,8 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
||||
|
||||
// Make sure the top of the ghost never goes above SAFE_TOP
|
||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||
|
||||
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
|
||||
const ghostRect = {
|
||||
left: fromCx - targetWidth / 2,
|
||||
top: startTop,
|
||||
@@ -564,13 +567,12 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
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.classList.add('card-expand-ghost');
|
||||
|
||||
// Override transform/transition for our flight animation
|
||||
ghost.style.transform = 'translate(0,0) scale(0.7)';
|
||||
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
||||
ghost.style.transform = 'translate(0,0) scale(0.75)';
|
||||
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||
|
||||
document.body.appendChild(ghost);
|
||||
ghosts.push({
|
||||
@@ -586,7 +588,6 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Kick off the flight on the next frame
|
||||
requestAnimationFrame(() => {
|
||||
ghosts.forEach(({ ghost, from, to }) => {
|
||||
const dx = to.cx - from.cx;
|
||||
@@ -596,13 +597,12 @@ function animateCardsOutOfHeaderThen(done) {
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up ghosts and then do real layout restore
|
||||
setTimeout(() => {
|
||||
ghosts.forEach(({ ghost }) => {
|
||||
try { ghost.remove(); } catch {}
|
||||
});
|
||||
done();
|
||||
}, 280); // just over the 0.25s transition
|
||||
}, 430);
|
||||
}
|
||||
|
||||
// -------------------- zones toggle (collapse to header) --------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// fileDragDrop.js
|
||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
|
||||
|
||||
/* ---------------- helpers ---------------- */
|
||||
function getRowEl(el) {
|
||||
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||
|
||||
/* ---------------- drag start (rows/cards) ---------------- */
|
||||
export function fileDragStartHandler(event) {
|
||||
try { cancelHoverPreview(); } catch {}
|
||||
const row = getRowEl(event.currentTarget);
|
||||
if (!row) return;
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
} from './fileActions.js?v={{APP_QVER}}';
|
||||
import { previewFile, buildPreviewUrl } from './filePreview.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 { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
|
||||
|
||||
const MENU_ID = 'fileContextMenu';
|
||||
|
||||
function qMenu() { return document.getElementById(MENU_ID); }
|
||||
@@ -31,7 +32,9 @@ function localizeMenu() {
|
||||
'preview': 'preview',
|
||||
'edit': 'edit',
|
||||
'rename': 'rename',
|
||||
'tag_file': 'tag_file'
|
||||
'tag_file': 'tag_file',
|
||||
// NEW:
|
||||
'download_plain': 'download_plain'
|
||||
};
|
||||
Object.entries(map).forEach(([action, key]) => {
|
||||
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||
@@ -187,6 +190,10 @@ function menuClickDelegate(ev) {
|
||||
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
||||
case 'download_zip': handleDownloadZipSelected(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':
|
||||
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()}`;
|
||||
}
|
||||
|
||||
// New: build a download URL (attachment)
|
||||
export function buildDownloadUrl(folder, name) {
|
||||
const f = (!folder || folder === '') ? 'root' : String(folder);
|
||||
const params = new URLSearchParams({
|
||||
folder: f,
|
||||
file: name,
|
||||
inline: '0',
|
||||
t: String(Date.now())
|
||||
});
|
||||
return `/api/file/download.php?${params.toString()}`;
|
||||
}
|
||||
|
||||
const MEDIA_VOLUME_KEY = 'frMediaVolume';
|
||||
const MEDIA_MUTED_KEY = 'frMediaMuted';
|
||||
|
||||
function loadSavedMediaVolume(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
const v = localStorage.getItem(MEDIA_VOLUME_KEY);
|
||||
if (v !== null) {
|
||||
const vol = parseFloat(v);
|
||||
if (!Number.isNaN(vol)) {
|
||||
el.volume = Math.max(0, Math.min(1, vol));
|
||||
}
|
||||
}
|
||||
const m = localStorage.getItem(MEDIA_MUTED_KEY);
|
||||
if (m !== null) {
|
||||
el.muted = (m === '1');
|
||||
}
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function attachVolumePersistence(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
el.addEventListener('volumechange', () => {
|
||||
try {
|
||||
localStorage.setItem(MEDIA_VOLUME_KEY, String(el.volume));
|
||||
localStorage.setItem(MEDIA_MUTED_KEY, el.muted ? '1' : '0');
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||
export function openShareModal(file, folder) {
|
||||
const existing = document.getElementById("shareModal");
|
||||
@@ -338,6 +388,27 @@ function setTitle(overlay, name) {
|
||||
}
|
||||
}
|
||||
|
||||
// New: Download icon that uses current file name
|
||||
function makeDownloadButton(folder, getName) {
|
||||
const btn = makeTopIcon('download', t('download') || 'Download');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nm = getName && getName();
|
||||
if (!nm) return;
|
||||
|
||||
const url = buildDownloadUrl(folder, nm);
|
||||
|
||||
// Use a temporary <a> with download attribute for nicer behavior
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = nm;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// Topbar icon (theme-aware) used for image tools + video actions
|
||||
function makeTopIcon(name, title) {
|
||||
const b = document.createElement('button');
|
||||
@@ -432,8 +503,28 @@ export function previewFile(fileUrl, fileName) {
|
||||
const isVideo = VID_RE.test(lower);
|
||||
const isAudio = AUD_RE.test(lower);
|
||||
|
||||
// Base preview URL from the link we clicked
|
||||
const baseUrl = fileUrl;
|
||||
|
||||
// Use the same preview endpoint, just swap the "file" param.
|
||||
function siblingPreviewUrl(newName) {
|
||||
try {
|
||||
const u = new URL(baseUrl, window.location.origin);
|
||||
u.searchParams.set('file', newName);
|
||||
// cache-bust so we 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);
|
||||
if (isSvg) {
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
container.textContent =
|
||||
t("svg_preview_disabled") ||
|
||||
"SVG preview is disabled for security. Use Download to view this file.";
|
||||
@@ -452,12 +543,17 @@ export function previewFile(fileUrl, fileName) {
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
container.appendChild(img);
|
||||
|
||||
|
||||
let currentName = name;
|
||||
|
||||
// topbar-aligned, theme-aware icons
|
||||
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
|
||||
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
actionWrap.appendChild(zoomInBtn);
|
||||
actionWrap.appendChild(zoomOutBtn);
|
||||
actionWrap.appendChild(rotateLeft);
|
||||
@@ -489,21 +585,22 @@ export function previewFile(fileUrl, fileName) {
|
||||
});
|
||||
|
||||
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
overlay.mediaType = 'image';
|
||||
overlay.mediaList = images;
|
||||
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = buildPreviewUrl(folder, newFile);
|
||||
};
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||
currentName = newFile; // keep download button pointing to the right file
|
||||
setTitle(overlay, newFile);
|
||||
img.dataset.scale = 1;
|
||||
img.dataset.rotate = 0;
|
||||
img.style.transform = 'scale(1) rotate(0deg)';
|
||||
img.src = siblingPreviewUrl(newFile); // <-- changed
|
||||
};
|
||||
|
||||
if (images.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
@@ -530,203 +627,226 @@ export function previewFile(fileUrl, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
// Track which file is currently active
|
||||
let currentName = name;
|
||||
|
||||
const setVideoSrc = (nm) => {
|
||||
currentName = nm;
|
||||
video.src = buildPreviewUrl(folder, nm);
|
||||
setTitle(overlay, nm);
|
||||
};
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
pending = false;
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c';
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
const nm = currentName;
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// Apply last-used volume/mute, and persist future changes
|
||||
loadSavedMediaVolume(video);
|
||||
attachVolumePersistence(video);
|
||||
|
||||
// Top-right action icons (Material icons, theme-aware)
|
||||
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
|
||||
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
|
||||
|
||||
// Track which file is currently active
|
||||
let currentName = name;
|
||||
|
||||
// Use the URL we were passed in (old behavior) for the *first* video,
|
||||
// fall back to API URL if for some reason it's empty.
|
||||
const initialUrl = fileUrl && fileUrl.trim()
|
||||
? fileUrl
|
||||
: buildPreviewUrl(folder, name);
|
||||
|
||||
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||
|
||||
// Order: Download | Mark | Reset
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
actionWrap.appendChild(markBtnIcon);
|
||||
actionWrap.appendChild(clearBtnIcon);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
// Helper: set src for a given video name
|
||||
const setVideoSrc = (nm) => {
|
||||
currentName = nm;
|
||||
|
||||
// For the current file, reuse the original working URL.
|
||||
// For other files (next/prev), go through the API.
|
||||
const url = (nm === name) ? initialUrl : buildPreviewUrl(folder, nm);
|
||||
|
||||
video.src = url;
|
||||
video.src = siblingPreviewUrl(nm);
|
||||
setTitle(overlay, nm);
|
||||
};
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
|
||||
const nm = currentName;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
|
||||
clearBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
overlay.style.display = "flex";
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
pending = false;
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
if (!statusChip) return;
|
||||
|
||||
// Completed
|
||||
if (state && state.completed) {
|
||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||
statusChip.style.display = 'inline-block';
|
||||
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||
statusChip.style.color = '#22c55e';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c';
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
|
||||
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
const nm = currentName;
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||
video.currentTime = state.seconds;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||
} else {
|
||||
const ls = localStorage.getItem(lsKey(nm));
|
||||
if (ls) video.currentTime = parseFloat(ls);
|
||||
}
|
||||
renderStatus(state || null);
|
||||
} catch {
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
|
||||
const nm = currentName;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
showToast(t("marked_viewed") || "Marked as viewed");
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
};
|
||||
|
||||
clearBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
renderStatus(null);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
// Kick off first video using the original working URL
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
@@ -735,8 +855,19 @@ export function previewFile(fileUrl, fileName) {
|
||||
audio.className = "audio-modal";
|
||||
audio.style.maxWidth = "88vw";
|
||||
container.appendChild(audio);
|
||||
|
||||
// Share the same volume/mute behavior with videos
|
||||
loadSavedMediaVolume(audio);
|
||||
attachVolumePersistence(audio);
|
||||
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
overlay.style.display = "flex";
|
||||
} else {
|
||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||
actionWrap.appendChild(downloadBtn);
|
||||
|
||||
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||
overlay.style.display = "flex";
|
||||
}
|
||||
|
||||
@@ -1066,6 +1066,41 @@ export function openColorFolderModal(folder) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addFolderActionButton(rowEl, folderPath) {
|
||||
if (!rowEl || !folderPath) return;
|
||||
if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
// share styling with file list kebab
|
||||
btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
|
||||
btn.textContent = 'more_vert';
|
||||
|
||||
const label = t('folder_actions') || 'Folder actions';
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
|
||||
// only control visibility/layout here; let CSS handle colors/hover
|
||||
Object.assign(btn.style, {
|
||||
display: 'none',
|
||||
marginLeft: '4px',
|
||||
flexShrink: '0'
|
||||
});
|
||||
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const x = rect.right;
|
||||
const y = rect.bottom;
|
||||
const opt = rowEl.querySelector('.folder-option');
|
||||
await openFolderActionsMenu(folderPath, opt, x, y);
|
||||
});
|
||||
|
||||
rowEl.appendChild(btn);
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
DOM builders & DnD
|
||||
----------------------*/
|
||||
@@ -1125,6 +1160,10 @@ function makeChildLi(parentPath, item) {
|
||||
|
||||
opt.append(icon, label);
|
||||
row.append(spacer, opt);
|
||||
|
||||
// Add 3-dot actions button for unlocked folders
|
||||
if (!locked) addFolderActionButton(row, fullPath);
|
||||
|
||||
li.append(row);
|
||||
|
||||
// <ul class="folder-tree collapsed" role="group"></ul>
|
||||
@@ -1300,6 +1339,28 @@ function getULForFolder(folder) {
|
||||
const li = opt ? opt.closest('li[role="treeitem"]') : null;
|
||||
return li ? li.querySelector(':scope > ul.folder-tree') : null;
|
||||
}
|
||||
|
||||
function updateFolderActionButtons() {
|
||||
const container = document.getElementById('folderTreeContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Hide all kebabs by default
|
||||
container.querySelectorAll('.folder-kebab').forEach(btn => {
|
||||
btn.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show only for the currently selected, unlocked folder
|
||||
const selectedOpt = container.querySelector('.folder-option.selected');
|
||||
if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
|
||||
|
||||
const row = selectedOpt.closest('.folder-row');
|
||||
if (!row) return;
|
||||
const kebab = row.querySelector('.folder-kebab');
|
||||
if (kebab) {
|
||||
kebab.style.display = 'inline-flex';
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder(selected) {
|
||||
const container = document.getElementById('folderTreeContainer');
|
||||
if (!container) return;
|
||||
@@ -1368,6 +1429,9 @@ async function selectFolder(selected) {
|
||||
saveFolderTreeState(st);
|
||||
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
|
||||
}
|
||||
|
||||
// Keep the 3-dot action aligned to the active folder
|
||||
updateFolderActionButtons();
|
||||
}
|
||||
|
||||
/* ----------------------
|
||||
@@ -1432,6 +1496,12 @@ export async function loadFolderTree(selectedFolder) {
|
||||
`;
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add 3-dot actions button for root
|
||||
const rootRow = document.getElementById('rootRow');
|
||||
if (rootRow) {
|
||||
addFolderActionButton(rootRow, effectiveRoot);
|
||||
}
|
||||
|
||||
// Determine root's lock state
|
||||
const rootOpt = container.querySelector('.root-folder-option');
|
||||
let rootLocked = false;
|
||||
@@ -1654,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
|
||||
if (menu) menu.hidden = true;
|
||||
}
|
||||
|
||||
async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
|
||||
if (!folder) return;
|
||||
|
||||
window.currentFolder = folder;
|
||||
await applyFolderCapabilities(folder);
|
||||
|
||||
// Clear previous selection in tree + breadcrumb
|
||||
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
|
||||
|
||||
// Mark the clicked thing selected (folder-option or breadcrumb)
|
||||
if (targetEl) targetEl.classList.add('selected');
|
||||
|
||||
// Also sync selection in the tree if we invoked from a breadcrumb or kebab
|
||||
const tree = document.getElementById('folderTreeContainer');
|
||||
if (tree) {
|
||||
const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
|
||||
if (inTree) inTree.classList.add('selected');
|
||||
}
|
||||
|
||||
// Show the kebab only for this selected folder
|
||||
updateFolderActionButtons();
|
||||
|
||||
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t('create_folder'),
|
||||
action: () => {
|
||||
const modal = document.getElementById('createFolderModal');
|
||||
const input = document.getElementById('newFolderName');
|
||||
if (modal) modal.style.display = 'block';
|
||||
if (input) input.focus();
|
||||
}
|
||||
},
|
||||
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
|
||||
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
|
||||
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
|
||||
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
|
||||
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(clientX, clientY, menuItems);
|
||||
}
|
||||
|
||||
async function folderManagerContextMenuHandler(e) {
|
||||
const target = e.target.closest('.folder-option, .breadcrumb-link');
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Toggle-only for locked nodes
|
||||
// Toggle-only for locked nodes (no menu)
|
||||
if (target.classList && target.classList.contains('locked')) {
|
||||
const folder = target.getAttribute('data-folder') || '';
|
||||
const ul = getULForFolder(folder);
|
||||
@@ -1679,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
|
||||
const folder = target.getAttribute('data-folder');
|
||||
if (!folder) return;
|
||||
|
||||
window.currentFolder = folder;
|
||||
await applyFolderCapabilities(folder);
|
||||
|
||||
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
|
||||
target.classList.add('selected');
|
||||
|
||||
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
|
||||
|
||||
const menuItems = [
|
||||
{ label: t('create_folder'), action: () => {
|
||||
const modal = document.getElementById('createFolderModal');
|
||||
const input = document.getElementById('newFolderName');
|
||||
if (modal) modal.style.display = 'block';
|
||||
if (input) input.focus();
|
||||
}},
|
||||
{ label: t('move_folder'), action: () => openMoveFolderUI(folder) },
|
||||
{ label: t('rename_folder'), action: () => openRenameFolderModal() },
|
||||
...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
|
||||
{ label: t('folder_share'), action: () => openFolderShareModal(folder) },
|
||||
{ label: t('delete_folder'), action: () => openDeleteFolderModal() },
|
||||
];
|
||||
|
||||
showFolderManagerContextMenu(e.clientX, e.clientY, menuItems);
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
await openFolderActionsMenu(folder, target, x, y);
|
||||
}
|
||||
|
||||
function bindFolderManagerContextMenu() {
|
||||
|
||||
@@ -187,6 +187,7 @@ const translations = {
|
||||
|
||||
// Admin Panel
|
||||
"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": "Shared Max Upload Size (bytes)",
|
||||
"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",
|
||||
"preview_not_available": "Preview is not available for this file type.",
|
||||
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
|
||||
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
|
||||
"svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
|
||||
"no_files_or_folders": "No files or folders to display.",
|
||||
"no_preview_available": "No preview available.",
|
||||
"more_actions": "More Actions",
|
||||
"folder_actions": "Folder Actions",
|
||||
"disable_hover_preview": "Disable hover preview in file list",
|
||||
"zoom_in": "Zoom In",
|
||||
"zoom_out": "Zoom Out",
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right",
|
||||
"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: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -445,107 +445,127 @@ function bindDarkMode() {
|
||||
m.content = val;
|
||||
};
|
||||
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||
try {
|
||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||
|
||||
// Always keep <title> correct early (no visual flicker)
|
||||
document.title = title;
|
||||
// --- Header logo (branding) in BOTH phases ---
|
||||
// ---------- site config / auth ----------
|
||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const customLogoUrl = branding.customLogoUrl || "";
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (logoImg) {
|
||||
if (customLogoUrl) {
|
||||
logoImg.setAttribute('src', customLogoUrl);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||
|
||||
// Always keep <title> correct early (no visual flicker)
|
||||
document.title = title;
|
||||
|
||||
// --- Header logo (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const customLogoUrl = branding.customLogoUrl || "";
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (logoImg) {
|
||||
if (customLogoUrl) {
|
||||
logoImg.setAttribute('src', customLogoUrl);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal; ignore branding issues
|
||||
}
|
||||
|
||||
// --- Header colors (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = branding.headerBgLight || '';
|
||||
const dark = branding.headerBgDark || '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
// 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) ---
|
||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal; ignore branding issues
|
||||
}
|
||||
// --- Header colors (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = branding.headerBgLight || '';
|
||||
const dark = branding.headerBgDark || '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||
|
||||
|
||||
// be tolerant to key variants just in case
|
||||
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||
|
||||
const showForm = !disableForm;
|
||||
const showOIDC = !disableOIDC;
|
||||
const showBasic = !disableBasic;
|
||||
|
||||
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||
const authForm = $('#authForm'); // inner username/password form
|
||||
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
|
||||
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||
if (loginWrap) {
|
||||
const anyMethod = showForm || showOIDC || showBasic;
|
||||
if (anyMethod) {
|
||||
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||
loginWrap.style.display = ''; // let CSS decide
|
||||
} else {
|
||||
loginWrap.setAttribute('hidden', '');
|
||||
loginWrap.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
});
|
||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||
h1.__titleLock = mo;
|
||||
|
||||
// 2) Toggle the pieces inside the wrapper
|
||||
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||
|
||||
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||
if (phase === 'final') {
|
||||
const h1 = document.querySelector('.header-title h1');
|
||||
if (h1) {
|
||||
// prevent i18n or legacy from overwriting it
|
||||
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
|
||||
// lock it so late code can't stomp it
|
||||
if (!h1.__titleLock) {
|
||||
const mo = new MutationObserver(() => {
|
||||
if (h1.textContent !== title) h1.textContent = title;
|
||||
});
|
||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||
h1.__titleLock = mo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
async function readyToReveal() {
|
||||
// Wait for CSS + fonts so the first revealed frame is fully styled
|
||||
|
||||
@@ -218,6 +218,7 @@ function getRedirectTarget() {
|
||||
const headingEl = document.getElementById('portalLoginTitle');
|
||||
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||||
const footerEl = document.getElementById('portalLoginFooter');
|
||||
const logoEl = document.getElementById('portalLoginLogo');
|
||||
|
||||
if (headingEl) {
|
||||
headingEl.textContent = 'Sign in to ' + title;
|
||||
@@ -237,6 +238,24 @@ function getRedirectTarget() {
|
||||
footerEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 🔹 Portal logo: use logoFile from metadata if present
|
||||
if (logoEl) {
|
||||
let logoSrc = null;
|
||||
|
||||
// If you ever decide to store a direct URL:
|
||||
if (portal.logoUrl && portal.logoUrl.trim()) {
|
||||
logoSrc = portal.logoUrl.trim();
|
||||
} else if (portal.logoFile && portal.logoFile.trim()) {
|
||||
// Same convention as portal.html: files live in uploads/profile_pics
|
||||
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
|
||||
}
|
||||
|
||||
if (logoSrc) {
|
||||
logoEl.src = logoSrc;
|
||||
logoEl.alt = title;
|
||||
}
|
||||
}
|
||||
|
||||
// Document title
|
||||
try {
|
||||
|
||||
@@ -10,10 +10,33 @@ function portalFolder() {
|
||||
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() {
|
||||
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') {
|
||||
return !!portal.allowDownload;
|
||||
}
|
||||
@@ -21,7 +44,7 @@ function portalCanDownload() {
|
||||
return !!portal.allowDownloads;
|
||||
}
|
||||
|
||||
// Fallback: uploadOnly = true => no downloads
|
||||
// Legacy: uploadOnly = true => no downloads
|
||||
if (typeof portal.uploadOnly !== 'undefined') {
|
||||
return !portal.uploadOnly;
|
||||
}
|
||||
@@ -30,6 +53,127 @@ function portalCanDownload() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPortalSlug() {
|
||||
return portal && (portal.slug || portal.label || '') || '';
|
||||
}
|
||||
|
||||
function normalizeExtList(raw) {
|
||||
if (!raw) return [];
|
||||
return String(raw)
|
||||
.split(/[,\s]+/)
|
||||
.map(x => x.trim().replace(/^\./, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getAllowedExts() {
|
||||
if (!portal || !portal.uploadExtWhitelist) return [];
|
||||
return normalizeExtList(portal.uploadExtWhitelist);
|
||||
}
|
||||
|
||||
function getMaxSizeBytes() {
|
||||
if (!portal || !portal.uploadMaxSizeMb) return 0;
|
||||
const n = parseInt(portal.uploadMaxSizeMb, 10);
|
||||
if (!n || n <= 0) return 0;
|
||||
return n * 1024 * 1024;
|
||||
}
|
||||
|
||||
// Simple per-browser-per-day counter; not true IP-based.
|
||||
function applyUploadRateLimit(desiredCount) {
|
||||
if (!portal || !portal.uploadMaxPerDay) return desiredCount;
|
||||
|
||||
const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
|
||||
if (!maxPerDay || maxPerDay <= 0) return desiredCount;
|
||||
|
||||
const slug = getPortalSlug() || 'default';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const key = 'portalUploadRate:' + slug;
|
||||
|
||||
let state = { date: today, count: 0 };
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.date === today && typeof parsed.count === 'number') {
|
||||
state = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (state.count >= maxPerDay) {
|
||||
showToast('Daily upload limit reached for this portal.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const remaining = maxPerDay - state.count;
|
||||
if (desiredCount > remaining) {
|
||||
showToast('You can only upload ' + remaining + ' more file(s) today for this portal.');
|
||||
return remaining;
|
||||
}
|
||||
|
||||
return desiredCount;
|
||||
}
|
||||
|
||||
function bumpUploadRateCounter(delta) {
|
||||
if (!portal || !portal.uploadMaxPerDay || !delta) return;
|
||||
|
||||
const maxPerDay = parseInt(portal.uploadMaxPerDay, 10);
|
||||
if (!maxPerDay || maxPerDay <= 0) return;
|
||||
|
||||
const slug = getPortalSlug() || 'default';
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const key = 'portalUploadRate:' + slug;
|
||||
|
||||
let state = { date: today, count: 0 };
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.date === today && typeof parsed.count === 'number') {
|
||||
state = parsed.date === today ? parsed : state;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (state.date !== today) {
|
||||
state = { date: today, count: 0 };
|
||||
}
|
||||
|
||||
state.count += delta;
|
||||
if (state.count < 0) state.count = 0;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function showThankYouScreen() {
|
||||
if (!portal || !portal.showThankYou) return;
|
||||
|
||||
const section = qs('portalThankYouSection');
|
||||
const msgEl = document.getElementById('portalThankYouMessage');
|
||||
const upload = qs('portalUploadSection');
|
||||
|
||||
if (msgEl) {
|
||||
const text =
|
||||
(portal.thankYouText && portal.thankYouText.trim()) ||
|
||||
'Thank you. Your files have been uploaded successfully.';
|
||||
msgEl.textContent = text;
|
||||
}
|
||||
|
||||
if (section) {
|
||||
section.style.display = 'block';
|
||||
}
|
||||
if (upload) {
|
||||
upload.style.opacity = '0.3';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- DOM helpers / status -----------------
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
@@ -45,6 +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 -----------------
|
||||
async function submitPortalForm(slug, formData) {
|
||||
const payload = {
|
||||
@@ -109,10 +280,10 @@ async function sendRequest(url, method = 'GET', data = null, customHeaders = {})
|
||||
|
||||
// ----------------- Portal form wiring -----------------
|
||||
function setupPortalForm(slug) {
|
||||
const formSection = qs('portalFormSection');
|
||||
const formSection = qs('portalFormSection');
|
||||
const uploadSection = qs('portalUploadSection');
|
||||
|
||||
if (!portal || !portal.requireForm) {
|
||||
if (!portal || !portal.requireForm || !portalCanUpload()) {
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
return;
|
||||
@@ -136,39 +307,103 @@ function setupPortalForm(slug) {
|
||||
const notesEl = qs('portalFormNotes');
|
||||
const submitBtn = qs('portalFormSubmit');
|
||||
|
||||
const fd = portal.formDefaults || {};
|
||||
const groupName = qs('portalFormGroupName');
|
||||
const groupEmail = qs('portalFormGroupEmail');
|
||||
const groupReference = qs('portalFormGroupReference');
|
||||
const groupNotes = qs('portalFormGroupNotes');
|
||||
|
||||
if (nameEl && fd.name && !nameEl.value) {
|
||||
const labelName = qs('portalFormLabelName');
|
||||
const labelEmail = qs('portalFormLabelEmail');
|
||||
const labelReference = qs('portalFormLabelReference');
|
||||
const labelNotes = qs('portalFormLabelNotes');
|
||||
|
||||
const fd = portal.formDefaults || {};
|
||||
const labels = portal.formLabels || {};
|
||||
const visRaw = portal.formVisible || portal.formVisibility || {};
|
||||
const req = portal.formRequired || {};
|
||||
|
||||
// default: visible when not specified
|
||||
const visible = {
|
||||
name: visRaw.name !== false,
|
||||
email: visRaw.email !== false,
|
||||
reference: visRaw.reference !== false,
|
||||
notes: visRaw.notes !== false,
|
||||
};
|
||||
|
||||
// Apply labels (fallback to defaults)
|
||||
if (labelName) labelName.textContent = labels.name || 'Name';
|
||||
if (labelEmail) labelEmail.textContent = labels.email || 'Email';
|
||||
if (labelReference) labelReference.textContent = labels.reference || 'Reference / Case / Order #';
|
||||
if (labelNotes) labelNotes.textContent = labels.notes || 'Notes';
|
||||
|
||||
// Helper to (re)add the required star spans
|
||||
const setStar = (labelEl, isVisible, isRequired) => {
|
||||
if (!labelEl) return;
|
||||
// remove any previous star
|
||||
const old = labelEl.querySelector('.portal-required-star');
|
||||
if (old) old.remove();
|
||||
if (isVisible && isRequired) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'portal-required-star';
|
||||
s.textContent = ' *';
|
||||
labelEl.appendChild(s);
|
||||
}
|
||||
};
|
||||
|
||||
// Show/hide groups
|
||||
if (groupName) groupName.style.display = visible.name ? '' : 'none';
|
||||
if (groupEmail) groupEmail.style.display = visible.email ? '' : 'none';
|
||||
if (groupReference) groupReference.style.display = visible.reference ? '' : 'none';
|
||||
if (groupNotes) groupNotes.style.display = visible.notes ? '' : 'none';
|
||||
|
||||
// Apply stars AFTER labels and visibility
|
||||
setStar(labelName, visible.name, !!req.name);
|
||||
setStar(labelEmail, visible.email, !!req.email);
|
||||
setStar(labelReference, visible.reference, !!req.reference);
|
||||
setStar(labelNotes, visible.notes, !!req.notes);
|
||||
|
||||
// If literally no fields are visible, just treat as no form
|
||||
if (!visible.name && !visible.email && !visible.reference && !visible.notes) {
|
||||
portalFormDone = true;
|
||||
sessionStorage.setItem(key, '1');
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefill defaults only for visible fields
|
||||
if (nameEl && visible.name && fd.name && !nameEl.value) {
|
||||
nameEl.value = fd.name;
|
||||
}
|
||||
if (emailEl && fd.email && !emailEl.value) {
|
||||
emailEl.value = fd.email;
|
||||
} else if (emailEl && portal.clientEmail && !emailEl.value) {
|
||||
// fallback to clientEmail
|
||||
emailEl.value = portal.clientEmail;
|
||||
if (emailEl && visible.email) {
|
||||
if (fd.email && !emailEl.value) {
|
||||
emailEl.value = fd.email;
|
||||
} else if (portal.clientEmail && !emailEl.value) {
|
||||
emailEl.value = portal.clientEmail;
|
||||
}
|
||||
}
|
||||
if (refEl && fd.reference && !refEl.value) {
|
||||
if (refEl && visible.reference && fd.reference && !refEl.value) {
|
||||
refEl.value = fd.reference;
|
||||
}
|
||||
if (notesEl && fd.notes && !notesEl.value) {
|
||||
if (notesEl && visible.notes && fd.notes && !notesEl.value) {
|
||||
notesEl.value = fd.notes;
|
||||
}
|
||||
|
||||
if (!submitBtn) return;
|
||||
|
||||
submitBtn.onclick = async () => {
|
||||
const name = nameEl ? nameEl.value.trim() : '';
|
||||
const name = nameEl ? nameEl.value.trim() : '';
|
||||
const email = emailEl ? emailEl.value.trim() : '';
|
||||
const reference = refEl ? refEl.value.trim() : '';
|
||||
const reference = refEl ? refEl.value.trim() : '';
|
||||
const notes = notesEl ? notesEl.value.trim() : '';
|
||||
|
||||
const req = portal.formRequired || {};
|
||||
const missing = [];
|
||||
|
||||
if (req.name && !name) missing.push('name');
|
||||
if (req.email && !email) missing.push('email');
|
||||
if (req.reference && !reference) missing.push('reference');
|
||||
if (req.notes && !notes) missing.push('notes');
|
||||
// Only validate visible fields
|
||||
if (visible.name && req.name && !name) missing.push(labels.name || 'Name');
|
||||
if (visible.email && req.email && !email) missing.push(labels.email || 'Email');
|
||||
if (visible.reference && req.reference && !reference) missing.push(labels.reference || 'Reference');
|
||||
if (visible.notes && req.notes && !notes) missing.push(labels.notes || 'Notes');
|
||||
|
||||
if (missing.length) {
|
||||
showToast('Please fill in: ' + missing.join(', ') + '.');
|
||||
@@ -176,8 +411,11 @@ function setupPortalForm(slug) {
|
||||
}
|
||||
|
||||
// default behavior when no specific required flags:
|
||||
// at least name or email, but only if those fields are visible
|
||||
if (!req.name && !req.email && !req.reference && !req.notes) {
|
||||
if (!name && !email) {
|
||||
const hasNameField = visible.name;
|
||||
const hasEmailField = visible.email;
|
||||
if ((hasNameField || hasEmailField) && !name && !email) {
|
||||
showToast('Please provide at least a name or email.');
|
||||
return;
|
||||
}
|
||||
@@ -285,6 +523,7 @@ function renderPortalInfo() {
|
||||
const footerEl = document.getElementById('portalFooter');
|
||||
const drop = qs('portalDropzone');
|
||||
const card = document.querySelector('.portal-card');
|
||||
const logoImg = document.querySelector('.portal-logo img');
|
||||
const formBtn = qs('portalFormSubmit');
|
||||
const refreshBtn = qs('portalRefreshBtn');
|
||||
const filesSection = qs('portalFilesSection');
|
||||
@@ -303,13 +542,51 @@ function renderPortalInfo() {
|
||||
const folder = portalFolder();
|
||||
descEl.textContent = 'Files you upload here go directly into: ' + folder;
|
||||
}
|
||||
|
||||
const bits = [];
|
||||
|
||||
if (portal.uploadMaxSizeMb) {
|
||||
bits.push('Max file size: ' + portal.uploadMaxSizeMb + ' MB');
|
||||
}
|
||||
|
||||
const exts = getAllowedExts();
|
||||
if (exts.length) {
|
||||
bits.push('Allowed types: ' + exts.join(', '));
|
||||
}
|
||||
|
||||
if (portal.uploadMaxPerDay) {
|
||||
bits.push('Daily upload limit: ' + portal.uploadMaxPerDay + ' file(s)');
|
||||
}
|
||||
|
||||
if (bits.length) {
|
||||
descEl.textContent += ' (' + bits.join(' • ') + ')';
|
||||
}
|
||||
}
|
||||
|
||||
if (logoImg) {
|
||||
if (portal.logoUrl && portal.logoUrl.trim()) {
|
||||
logoImg.src = portal.logoUrl.trim();
|
||||
} else if (portal.logoFile && portal.logoFile.trim()) {
|
||||
// Fallback if backend only supplies logoFile
|
||||
logoImg.src = '/uploads/profile_pics/' + encodeURIComponent(portal.logoFile.trim());
|
||||
}
|
||||
}
|
||||
|
||||
const uploadsEnabled = portalCanUpload();
|
||||
const downloadsEnabled = portalCanDownload();
|
||||
|
||||
if (subtitleEl) {
|
||||
const parts = [];
|
||||
if (portal.uploadOnly) parts.push('upload only');
|
||||
if (portalCanDownload()) parts.push('download allowed');
|
||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
||||
let text = '';
|
||||
if (uploadsEnabled && downloadsEnabled) {
|
||||
text = 'Upload & download';
|
||||
} else if (uploadsEnabled && !downloadsEnabled) {
|
||||
text = 'Upload only';
|
||||
} else if (!uploadsEnabled && downloadsEnabled) {
|
||||
text = 'Download only';
|
||||
} else {
|
||||
text = 'Access only';
|
||||
}
|
||||
subtitleEl.textContent = text;
|
||||
}
|
||||
|
||||
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();
|
||||
if (color) {
|
||||
// expose brand color as a CSS variable for gallery styling
|
||||
@@ -497,12 +794,83 @@ async function loadPortalFiles() {
|
||||
// ----------------- Upload -----------------
|
||||
async function uploadFiles(fileList) {
|
||||
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) {
|
||||
showToast('Please fill in your details before uploading.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(fileList);
|
||||
let files = Array.from(fileList);
|
||||
if (!files.length) return;
|
||||
|
||||
// 1) Filter by max size
|
||||
const maxBytes = getMaxSizeBytes();
|
||||
if (maxBytes > 0) {
|
||||
const tooBigNames = [];
|
||||
files = files.filter(f => {
|
||||
if (f.size && f.size > maxBytes) {
|
||||
tooBigNames.push(f.name || 'unnamed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (tooBigNames.length) {
|
||||
showToast(
|
||||
'Skipped ' +
|
||||
tooBigNames.length +
|
||||
' file(s) over ' +
|
||||
portal.uploadMaxSizeMb +
|
||||
' MB.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Filter by allowed extensions
|
||||
const allowedExts = getAllowedExts();
|
||||
if (allowedExts.length) {
|
||||
const skipped = [];
|
||||
files = files.filter(f => {
|
||||
const name = f.name || '';
|
||||
const parts = name.split('.');
|
||||
const ext = parts.length > 1 ? parts.pop().trim().toLowerCase() : '';
|
||||
if (!ext || !allowedExts.includes(ext)) {
|
||||
skipped.push(name || 'unnamed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (skipped.length) {
|
||||
showToast(
|
||||
'Skipped ' +
|
||||
skipped.length +
|
||||
' file(s) not matching allowed types: ' +
|
||||
allowedExts.join(', ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
setStatus('No files to upload after applying portal rules.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Rate-limit per day (simple per-browser guard)
|
||||
const requestedCount = files.length;
|
||||
const allowedCount = applyUploadRateLimit(requestedCount);
|
||||
if (!allowedCount) {
|
||||
setStatus('Upload blocked by daily limit.', true);
|
||||
return;
|
||||
}
|
||||
if (allowedCount < requestedCount) {
|
||||
files = files.slice(0, allowedCount);
|
||||
}
|
||||
|
||||
const folder = portalFolder();
|
||||
|
||||
setStatus('Uploading ' + files.length + ' file(s)…');
|
||||
@@ -575,18 +943,40 @@ async function uploadFiles(fileList) {
|
||||
showToast('Upload failed.');
|
||||
}
|
||||
|
||||
// Bump local daily counter by successful uploads
|
||||
if (successCount > 0) {
|
||||
bumpUploadRateCounter(successCount);
|
||||
}
|
||||
|
||||
if (portalCanDownload()) {
|
||||
loadPortalFiles();
|
||||
}
|
||||
|
||||
// Optional thank-you screen
|
||||
if (successCount > 0 && portal.showThankYou) {
|
||||
showThankYouScreen();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- Upload UI wiring -----------------
|
||||
function wireUploadUI() {
|
||||
const drop = qs('portalDropzone');
|
||||
const input = qs('portalFileInput');
|
||||
const drop = qs('portalDropzone');
|
||||
const input = qs('portalFileInput');
|
||||
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());
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
@@ -620,10 +1010,15 @@ function wireUploadUI() {
|
||||
});
|
||||
}
|
||||
|
||||
// Download / refresh
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPortalFiles();
|
||||
});
|
||||
if (!downloadsEnabled) {
|
||||
refreshBtn.style.display = 'none';
|
||||
} else {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPortalFiles();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
const all = loadResumableDraftsAll();
|
||||
const userKey = getCurrentUserKey();
|
||||
@@ -346,6 +354,8 @@ function setDropAreaDefault() {
|
||||
const fileInput = dropArea.querySelector('#file');
|
||||
wireFileInputChange(fileInput);
|
||||
wireChooseButton();
|
||||
|
||||
setUploadButtonVisible(false);
|
||||
}
|
||||
|
||||
function adjustFolderHelpExpansion() {
|
||||
@@ -464,6 +474,8 @@ function createFileEntry(file) {
|
||||
|
||||
li.remove();
|
||||
updateFileInfoCount();
|
||||
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||
setUploadButtonVisible(anyItems);
|
||||
});
|
||||
li.removeBtn = removeBtn;
|
||||
li.appendChild(removeBtn);
|
||||
@@ -674,6 +686,7 @@ function processFiles(filesInput) {
|
||||
|
||||
window.selectedFiles = files;
|
||||
updateFileInfoCount();
|
||||
setUploadButtonVisible(files.length > 0);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------
|
||||
@@ -770,6 +783,7 @@ async function initResumableUpload() {
|
||||
list.appendChild(li);
|
||||
updateFileInfoCount();
|
||||
updateResumableQuery();
|
||||
setUploadButtonVisible(true);
|
||||
});
|
||||
|
||||
resumableInstance.on("fileProgress", function (file) {
|
||||
@@ -931,6 +945,7 @@ async function initResumableUpload() {
|
||||
}
|
||||
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
||||
showResumableDraftBanner();
|
||||
setUploadButtonVisible(false);
|
||||
}, 5000);
|
||||
} else {
|
||||
showToast("Some files failed to upload. Please check the list.");
|
||||
@@ -1183,6 +1198,8 @@ function submitFiles(allFiles) {
|
||||
} else {
|
||||
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
||||
}
|
||||
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||
setUploadButtonVisible(anyItems);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching file list:", error);
|
||||
@@ -1275,6 +1292,8 @@ function initUpload() {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadButtonVisible(false);
|
||||
|
||||
const hasResumableFiles =
|
||||
useResumable &&
|
||||
resumableInstance &&
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v2.2.3';
|
||||
window.APP_VERSION = 'v2.3.6';
|
||||
|
||||
@@ -92,17 +92,19 @@
|
||||
<body data-theme="light">
|
||||
<div class="portal-login-wrapper">
|
||||
<div class="portal-login-card">
|
||||
<div class="portal-login-header">
|
||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-login-header">
|
||||
<img id="portalLoginLogo"
|
||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||
alt="FileRise">
|
||||
<div>
|
||||
<div id="portalLoginTitle" class="portal-login-title">
|
||||
Sign in to Client Portal
|
||||
</div>
|
||||
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||
to access this client portal
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||
|
||||
|
||||
@@ -169,6 +169,14 @@
|
||||
.portal-file-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.portal-required-star {
|
||||
color: #dc3545;
|
||||
}
|
||||
.portal-dropzone.portal-dropzone-disabled {
|
||||
opacity: 0.5;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -300,37 +308,38 @@
|
||||
</div>
|
||||
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
|
||||
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
|
||||
|
||||
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||
Please fill in your information before uploading files.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||
Please fill in your information before uploading files.
|
||||
</p>
|
||||
|
||||
<div id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelName" for="portalFormName">Name</label>
|
||||
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
|
||||
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
|
||||
<label id="portalFormLabelNotes" for="portalFormNotes">Notes</label>
|
||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormEmail">Email</label>
|
||||
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
<label for="portalFormReference">Reference / Case / Order #</label>
|
||||
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom:8px;">
|
||||
<label for="portalFormNotes">Notes</label>
|
||||
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="portalUploadSection">
|
||||
<div id="portalDropzone" class="portal-dropzone">
|
||||
@@ -352,6 +361,16 @@
|
||||
</div>
|
||||
<div id="portalFilesList" class="portal-files-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="portalThankYouSection"
|
||||
style="margin-top:12px; display:none;">
|
||||
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
|
||||
<strong>Thank you!</strong>
|
||||
<span id="portalThankYouMessage">
|
||||
Your files have been uploaded.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="portalFooter" class="text-muted"
|
||||
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||
</div>
|
||||
|
||||
|
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
|
||||
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
|
||||
# === Update FileRise to v2.3.2 (safe rsync, no composer on demo) ===
|
||||
set -Eeuo pipefail
|
||||
|
||||
VER="v2.1.0"
|
||||
VER="v2.3.2"
|
||||
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||
|
||||
WEBROOT="/var/www"
|
||||
|
||||
@@ -144,6 +144,9 @@ class AdminController
|
||||
$proType = $proPayload['type'] ?? null;
|
||||
$proEmail = $proPayload['email'] ?? 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)
|
||||
$public = [
|
||||
@@ -169,6 +172,7 @@ class AdminController
|
||||
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
||||
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
||||
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
||||
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||
],
|
||||
'pro' => [
|
||||
'active' => $proActive,
|
||||
@@ -176,6 +180,9 @@ class AdminController
|
||||
'email' => $proEmail,
|
||||
'version' => $proVersion,
|
||||
'license' => $licenseString,
|
||||
'plan' => $proPlan,
|
||||
'expiresAt' => $proExpiresAt,
|
||||
'maxMajor' => $proMaxMajor,
|
||||
],
|
||||
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||
];
|
||||
@@ -310,81 +317,135 @@ public function saveProPortals(array $portalsPayload): void
|
||||
|
||||
require_once $proPortalsPath;
|
||||
|
||||
if (!is_array($portalsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid portals format.');
|
||||
}
|
||||
|
||||
// Minimal normalization; deeper validation can live inside ProPortals
|
||||
$data = ['portals' => []];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
if (!is_array($portalsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid portals format.');
|
||||
}
|
||||
|
||||
$data = ['portals' => []];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
|
||||
// Optional branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$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'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// Branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($info['showThankYou']);
|
||||
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
|
||||
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
|
||||
// Required flags
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
|
||||
// Labels
|
||||
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
|
||||
? $info['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($formLabels['name'] ?? 'Name')),
|
||||
'email' => trim((string)($formLabels['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Visibility
|
||||
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
|
||||
? $info['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
|
||||
'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
|
||||
'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
|
||||
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
||||
];
|
||||
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
// Normalize defaults for known keys
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
|
||||
if ($folder === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data['portals'][$slug] = [
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
// NEW
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'logoFile' => $logoFile,
|
||||
'logoUrl' => $logoUrl,
|
||||
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||
'showThankYou' => $showThankYou,
|
||||
'thankYouText' => $thankYouText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'formLabels' => $formLabels,
|
||||
'formVisible' => $formVisible,
|
||||
];
|
||||
}
|
||||
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);
|
||||
$ok = $store->savePortals($data);
|
||||
@@ -537,6 +598,28 @@ public function installProBundle(): void
|
||||
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
|
||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||
@@ -679,20 +762,36 @@ public function installProBundle(): void
|
||||
// Best-effort cleanup; ignore failures
|
||||
@unlink($zipPath);
|
||||
@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
|
||||
$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)
|
||||
? (FR_PRO_INFO['payload'] ?? null)
|
||||
: null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Pro bundle installed.',
|
||||
'installed' => $installed,
|
||||
'proActive' => (bool)$proActive,
|
||||
'proVersion' => $proVersion,
|
||||
'proVersion' => $reportedVersion,
|
||||
'proPayload' => $proPayload,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -765,6 +864,7 @@ public function installProBundle(): void
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -904,21 +1004,22 @@ public function installProBundle(): void
|
||||
|
||||
$merged['onlyoffice'] = $oo;
|
||||
}
|
||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||
$merged['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
}
|
||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
|
||||
if (array_key_exists($key, $data['branding'])) {
|
||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||
}
|
||||
}
|
||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||
$merged['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
}
|
||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) {
|
||||
if (array_key_exists($key, $data['branding'])) {
|
||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
final class PortalController
|
||||
{
|
||||
@@ -11,16 +12,31 @@ final class PortalController
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'slug' => string,
|
||||
* 'label' => string,
|
||||
* 'folder' => string,
|
||||
* 'clientEmail' => string,
|
||||
* 'uploadOnly' => bool,
|
||||
* 'allowDownload' => bool,
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool
|
||||
* 'slug' => string,
|
||||
* 'label' => string,
|
||||
* 'folder' => string,
|
||||
* 'clientEmail' => string,
|
||||
* 'uploadOnly' => bool, // stored flag (legacy name)
|
||||
* 'allowDownload' => bool, // stored flag
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool,
|
||||
* 'brandColor' => string,
|
||||
* 'footerText' => string,
|
||||
* 'formDefaults' => array,
|
||||
* 'formRequired' => array,
|
||||
* 'formLabels' => array,
|
||||
* 'formVisible' => array,
|
||||
* 'logoFile' => string,
|
||||
* 'logoUrl' => string,
|
||||
* 'uploadMaxSizeMb' => int,
|
||||
* 'uploadExtWhitelist' => string,
|
||||
* 'uploadMaxPerDay' => int,
|
||||
* 'showThankYou' => bool,
|
||||
* 'thankYouText' => string,
|
||||
* 'canUpload' => bool, // ACL + portal flags
|
||||
* 'canDownload' => bool, // ACL + portal flags
|
||||
* ]
|
||||
*/
|
||||
public static function getPortalBySlug(string $slug): array
|
||||
@@ -53,22 +69,52 @@ final class PortalController
|
||||
|
||||
$p = $portals[$slug];
|
||||
|
||||
$label = trim((string)($p['label'] ?? $slug));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($p['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $p)
|
||||
? !empty($p['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
// ─────────────────────────────────────────────
|
||||
// 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;
|
||||
|
||||
// NEW: optional branding + intake behavior
|
||||
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));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
|
||||
// Branding + intake behavior
|
||||
$title = trim((string)($p['title'] ?? ''));
|
||||
$introText = trim((string)($p['introText'] ?? ''));
|
||||
$requireForm = !empty($p['requireForm']);
|
||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||
|
||||
// Defaults / required
|
||||
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||
? $p['formDefaults']
|
||||
: [];
|
||||
@@ -79,16 +125,52 @@ final class PortalController
|
||||
'reference' => trim((string)($fd['reference'] ?? '')),
|
||||
'notes' => trim((string)($fd['notes'] ?? '')),
|
||||
];
|
||||
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
||||
? $p['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($fr['name']),
|
||||
'email' => !empty($fr['email']),
|
||||
'reference' => !empty($fr['reference']),
|
||||
'notes' => !empty($fr['notes']),
|
||||
];
|
||||
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
|
||||
? $p['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($fr['name']),
|
||||
'email' => !empty($fr['email']),
|
||||
'reference' => !empty($fr['reference']),
|
||||
'notes' => !empty($fr['notes']),
|
||||
];
|
||||
|
||||
// Optional formLabels
|
||||
$fl = isset($p['formLabels']) && is_array($p['formLabels'])
|
||||
? $p['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($fl['name'] ?? 'Name')),
|
||||
'email' => trim((string)($fl['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($fl['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($fl['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Optional visibility
|
||||
$fv = isset($p['formVisible']) && is_array($p['formVisible'])
|
||||
? $p['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $fv) || !empty($fv['name']),
|
||||
'email' => !array_key_exists('email', $fv) || !empty($fv['email']),
|
||||
'reference' => !array_key_exists('reference', $fv) || !empty($fv['reference']),
|
||||
'notes' => !array_key_exists('notes', $fv) || !empty($fv['notes']),
|
||||
];
|
||||
|
||||
// Optional per-portal logo
|
||||
$logoFile = trim((string)($p['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($p['showThankYou']);
|
||||
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
||||
|
||||
if ($folder === '') {
|
||||
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||
@@ -102,22 +184,68 @@ 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 [
|
||||
'slug' => $slug,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'slug' => $slug,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
// Store flags as-is so old code / JSON stay compatible
|
||||
'uploadOnly' => (bool)$rawUploadOnly,
|
||||
'allowDownload' => $hasAllowDownload
|
||||
? (bool)$rawAllowDownload
|
||||
: $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
'requireForm' => $requireForm,
|
||||
'brandColor' => $brandColor,
|
||||
'footerText' => $footerText,
|
||||
'formDefaults' => $formDefaults,
|
||||
'formRequired' => $formRequired,
|
||||
'formLabels' => $formLabels,
|
||||
'formVisible' => $formVisible,
|
||||
'logoFile' => $logoFile,
|
||||
'logoUrl' => $logoUrl,
|
||||
'uploadMaxSizeMb' => $uploadMaxSizeMb,
|
||||
'uploadExtWhitelist' => $uploadExtWhitelist,
|
||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||
'showThankYou' => $showThankYou,
|
||||
'thankYouText' => $thankYouText,
|
||||
// New ACL-aware caps for portal.js
|
||||
'canUpload' => $canUpload,
|
||||
'canDownload' => $canDownload,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -797,6 +797,90 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a logo for a specific client portal (Pro-only; admin, CSRF).
|
||||
* Stores the file in UPLOAD_DIR/profile_pics and returns filename + URL.
|
||||
*/
|
||||
public function uploadPortalLogo(): void
|
||||
{
|
||||
self::jsonHeaders();
|
||||
|
||||
// Auth, admin & CSRF
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
if (empty($_FILES['portal_logo']) || $_FILES['portal_logo']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = $_FILES['portal_logo'];
|
||||
|
||||
// Optional: which portal (used only for filename prefix)
|
||||
$slugRaw = isset($_POST['slug']) ? (string)$_POST['slug'] : '';
|
||||
$slug = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slugRaw) ?: 'portal';
|
||||
|
||||
// Validate MIME & size (same rules as uploadPicture / uploadBrandLogo)
|
||||
$allowed = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
];
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset($allowed[$mime])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($file['size'] > 2 * 1024 * 1024) { // 2MB
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'File too large']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Destination: reuse profile_pics directory
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
|
||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$filename = 'portal_' . $slug . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Build a web path similar to uploadBrandLogo
|
||||
$fsPath = $uploadDir . '/' . $filename;
|
||||
|
||||
$root = rtrim(PROJECT_ROOT, '/\\');
|
||||
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
|
||||
|
||||
if ($url === '' || $url[0] !== '/') {
|
||||
$url = '/' . ltrim($url, '/\\');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'fileName' => $filename,
|
||||
'url' => $url,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function siteConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
@@ -110,17 +110,18 @@ private static function sanitizeLogoUrl($url): string
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
),
|
||||
'headerBgLight' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
),
|
||||
'headerBgDark' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
),
|
||||
'headerBgLight' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
),
|
||||
'headerBgDark' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||
],
|
||||
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||
];
|
||||
|
||||
@@ -261,29 +262,31 @@ private static function sanitizeLogoUrl($url): string
|
||||
$configUpdate['onlyoffice'] = $norm;
|
||||
}
|
||||
|
||||
// Branding (Pro-only). Normalize and only persist when Pro is active.
|
||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||
$configUpdate['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
} else {
|
||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||
|
||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||
$configUpdate['branding']['headerBgLight'] = $light;
|
||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||
} else {
|
||||
// Free mode: always clear branding customizations
|
||||
$configUpdate['branding']['customLogoUrl'] = '';
|
||||
$configUpdate['branding']['headerBgLight'] = '';
|
||||
$configUpdate['branding']['headerBgDark'] = '';
|
||||
}
|
||||
}
|
||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||
$configUpdate['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
} else {
|
||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
|
||||
|
||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||
$configUpdate['branding']['headerBgLight'] = $light;
|
||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||
$configUpdate['branding']['footerHtml'] = $footer;
|
||||
} else {
|
||||
$configUpdate['branding']['customLogoUrl'] = '';
|
||||
$configUpdate['branding']['headerBgLight'] = '';
|
||||
$configUpdate['branding']['headerBgDark'] = '';
|
||||
$configUpdate['branding']['footerHtml'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
@@ -444,6 +447,7 @@ private static function sanitizeLogoUrl($url): string
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
];
|
||||
} else {
|
||||
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
||||
@@ -486,6 +490,7 @@ private static function sanitizeLogoUrl($url): string
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||