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

|

|
||||||
|
|
||||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ try {
|
|||||||
require_once $subPath;
|
require_once $subPath;
|
||||||
|
|
||||||
$submittedBy = (string)($_SESSION['username'] ?? '');
|
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||||
|
|
||||||
|
// ─────────────────────────────
|
||||||
|
// Better client IP detection
|
||||||
|
// ─────────────────────────────
|
||||||
|
$ip = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Can be a comma-separated list; use the first non-empty
|
||||||
|
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$candidate = trim($part);
|
||||||
|
if ($candidate !== '') {
|
||||||
|
$ip = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||||
|
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'portalLabel' => $portal['label'] ?? '',
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
@@ -69,7 +90,7 @@ try {
|
|||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
],
|
],
|
||||||
'submittedBy' => $submittedBy,
|
'submittedBy' => $submittedBy,
|
||||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
'ip' => $ip,
|
||||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
'createdAt' => gmdate('c'),
|
'createdAt' => gmdate('c'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -543,21 +543,22 @@ body{letter-spacing: 0.2px;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 5px;}
|
gap: 5px;}
|
||||||
#uploadBtn{font-size: 20px;
|
#uploadBtn{font-size: 18px;
|
||||||
padding: 10px 22px;
|
padding: 10px 18px;
|
||||||
align-items: center;}
|
align-items: center;
|
||||||
|
margin-top:20px;}
|
||||||
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
|
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
|
||||||
#customChooseBtn{background-color: #9E9E9E;
|
#customChooseBtn{background-color: #9E9E9E;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 18px;
|
padding: 8px 14px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;}
|
white-space: nowrap;}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#customChooseBtn{font-size: 14px;
|
#customChooseBtn{font-size: 12px;
|
||||||
padding: 6px 14px;}
|
padding: 6px 10px;}
|
||||||
}
|
}
|
||||||
.pause-resume-btn{background: none;
|
.pause-resume-btn{background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -772,7 +773,7 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
|
|||||||
text-align: left !important;
|
text-align: left !important;
|
||||||
line-height: 1.2 !important;
|
line-height: 1.2 !important;
|
||||||
vertical-align: middle !important;
|
vertical-align: middle !important;
|
||||||
padding: 8px 10px !important;
|
padding: 2px 4px !important;
|
||||||
max-width: 250px !important;
|
max-width: 250px !important;
|
||||||
min-width: 120px !important;}
|
min-width: 120px !important;}
|
||||||
@media (min-width: 500px) {
|
@media (min-width: 500px) {
|
||||||
@@ -1442,8 +1443,6 @@ label{font-size: 0.9rem;}
|
|||||||
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
|
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
min-height: 320px;
|
|
||||||
|
|
||||||
border-radius: var(--menu-radius);
|
border-radius: var(--menu-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--card-border, #e5e7eb);
|
border: 1px solid var(--card-border, #e5e7eb);
|
||||||
@@ -2924,4 +2923,21 @@ th[data-column="actions"]::after {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
clip: rect(0 0 0 0);
|
clip: rect(0 0 0 0);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--filr-muted-text, #777);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer span {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
|
||||||
<div class="card-body d-flex flex-column">
|
<div class="card-body d-flex flex-column">
|
||||||
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
|
||||||
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
|
<div class="form-group flex-grow-1" style="margin-bottom: 0rem;">
|
||||||
<div id="uploadDropArea"
|
<div id="uploadDropArea"
|
||||||
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
|
||||||
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto"
|
<button type="submit" id="uploadBtn" class="btn btn-primary mx-auto"
|
||||||
data-i18n-key="upload">Upload</button>
|
data-i18n-key="upload">Upload</button>
|
||||||
<div id="uploadProgressContainer"></div>
|
<div id="uploadProgressContainer"></div>
|
||||||
</form>
|
</form>
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
|
||||||
<div id="folderTreeContainer"></div>
|
<div id="folderTreeContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="folder-actions mt-3">
|
<div class="folder-actions">
|
||||||
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
|
||||||
<i class="material-icons">create_new_folder</i>
|
<i class="material-icons">create_new_folder</i>
|
||||||
</button>
|
</button>
|
||||||
@@ -474,25 +474,95 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
||||||
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
|
<button type="button" class="mi"
|
||||||
<div class="sep" data-when="always"></div>
|
data-action="create_file"
|
||||||
|
data-when="always">
|
||||||
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
|
<i class="material-icons">note_add</i>
|
||||||
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
|
<span>Create file</span>
|
||||||
<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>
|
||||||
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
|
<div class="sep" data-when="always"></div>
|
||||||
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
|
|
||||||
|
<button type="button" class="mi"
|
||||||
<div class="sep" data-when="any"></div>
|
data-action="delete_selected"
|
||||||
|
data-when="any">
|
||||||
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
|
<i class="material-icons">delete</i>
|
||||||
|
<span>Delete selected</span>
|
||||||
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
|
</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"
|
||||||
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
|
data-action="copy_selected"
|
||||||
</div>
|
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 id="removeUserModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
||||||
@@ -538,5 +608,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<footer id="siteFooter" class="site-footer">
|
||||||
|
<span>
|
||||||
|
© 2025
|
||||||
|
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
|
||||||
|
FileRise
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -20,7 +20,7 @@ function normalizeLogoPath(raw) {
|
|||||||
const version = window.APP_VERSION || "dev";
|
const version = window.APP_VERSION || "dev";
|
||||||
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
|
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
|
||||||
// Update this when I cut a new Pro ZIP.
|
// Update this when I cut a new Pro ZIP.
|
||||||
const PRO_LATEST_BUNDLE_VERSION = 'v1.2.0';
|
const PRO_LATEST_BUNDLE_VERSION = 'v1.2.1';
|
||||||
|
|
||||||
function getAdminTitle(isPro, proVersion) {
|
function getAdminTitle(isPro, proVersion) {
|
||||||
const corePill = `
|
const corePill = `
|
||||||
@@ -110,6 +110,25 @@ function applyHeaderColorsFromAdmin() {
|
|||||||
console.warn('Failed to live-update header colors from admin panel', e);
|
console.warn('Failed to live-update header colors from admin panel', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function applyFooterFromAdmin() {
|
||||||
|
try {
|
||||||
|
const footerEl = document.getElementById('siteFooter');
|
||||||
|
if (!footerEl) return;
|
||||||
|
|
||||||
|
const val = (document.getElementById('brandingFooterHtml')?.value || '').trim();
|
||||||
|
if (val) {
|
||||||
|
// Show raw text in the live preview; HTML will be rendered on real page load
|
||||||
|
footerEl.textContent = val;
|
||||||
|
} else {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
footerEl.innerHTML =
|
||||||
|
`© ${year} <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to live-update footer from admin panel', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateHeaderLogoFromAdmin() {
|
function updateHeaderLogoFromAdmin() {
|
||||||
try {
|
try {
|
||||||
const input = document.getElementById('brandingCustomLogoUrl');
|
const input = document.getElementById('brandingCustomLogoUrl');
|
||||||
@@ -295,6 +314,7 @@ function captureInitialAdminConfig() {
|
|||||||
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
||||||
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
||||||
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
||||||
|
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function hasUnsavedChanges() {
|
function hasUnsavedChanges() {
|
||||||
@@ -315,7 +335,8 @@ function hasUnsavedChanges() {
|
|||||||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
|
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
|
||||||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
|
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
|
||||||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
|
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
|
||||||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "")
|
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
|
||||||
|
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,13 +430,42 @@ export function initProBundleInstaller() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
|
// --- NEW: ask the server what version is now active via getConfig.php ---
|
||||||
|
let finalVersion = '';
|
||||||
|
try {
|
||||||
|
const cfgRes = await fetch('/api/admin/getConfig.php?ts=' + Date.now(), {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
|
});
|
||||||
|
const cfg = await safeJson(cfgRes).catch(() => null);
|
||||||
|
const cfgVersion = cfg && cfg.pro && cfg.pro.version;
|
||||||
|
if (cfgVersion) {
|
||||||
|
finalVersion = String(cfgVersion);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If this fails, just fall back to whatever installProBundle gave us.
|
||||||
|
console.warn('Failed to refresh config after Pro bundle install', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalVersion && data.proVersion) {
|
||||||
|
finalVersion = String(data.proVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionText = finalVersion ? ` (version ${finalVersion})` : '';
|
||||||
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
|
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
|
||||||
statusEl.className = 'small text-success';
|
statusEl.className = 'small text-success';
|
||||||
|
|
||||||
|
// Clear file input so repeat installs feel "fresh"
|
||||||
|
try { fileInput.value = ''; } catch (_) {}
|
||||||
|
|
||||||
|
// Keep existing behavior: refresh any admin config in the header, etc.
|
||||||
if (typeof loadAdminConfigFunc === 'function') {
|
if (typeof loadAdminConfigFunc === 'function') {
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
}
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
|
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
|
||||||
statusEl.className = 'small text-danger';
|
statusEl.className = 'small text-danger';
|
||||||
@@ -537,10 +587,19 @@ export function openAdminPanel() {
|
|||||||
const proEmail = proInfo.email || '';
|
const proEmail = proInfo.email || '';
|
||||||
const proVersion = proInfo.version || 'not installed';
|
const proVersion = proInfo.version || 'not installed';
|
||||||
const proLicense = proInfo.license || '';
|
const proLicense = proInfo.license || '';
|
||||||
|
// New: richer license metadata from FR_PRO_INFO / backend
|
||||||
|
const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly"
|
||||||
|
const proExpiresAt = proInfo.expiresAt || ''; // ISO timestamp string or ""
|
||||||
|
const proMaxMajor = (
|
||||||
|
typeof proInfo.maxMajor === 'number'
|
||||||
|
? proInfo.maxMajor
|
||||||
|
: (proInfo.maxMajor ? Number(proInfo.maxMajor) : null)
|
||||||
|
);
|
||||||
const brandingCfg = config.branding || {};
|
const brandingCfg = config.branding || {};
|
||||||
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
|
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
|
||||||
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
|
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
|
||||||
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
|
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
|
||||||
|
const brandingFooterHtml = brandingCfg.footerHtml || "";
|
||||||
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||||
const inner = `
|
const inner = `
|
||||||
background:${dark ? "#2c2c2c" : "#fff"};
|
background:${dark ? "#2c2c2c" : "#fff"};
|
||||||
@@ -569,7 +628,7 @@ export function openAdminPanel() {
|
|||||||
<form id="adminPanelForm">
|
<form id="adminPanelForm">
|
||||||
${[
|
${[
|
||||||
{ id: "userManagement", label: t("user_management") },
|
{ id: "userManagement", label: t("user_management") },
|
||||||
{ id: "headerSettings", label: t("header_settings") },
|
{ id: "headerSettings", label: tf("header_footer_settings", "Header & Footer settings") },
|
||||||
{ id: "loginOptions", label: t("login_options") },
|
{ id: "loginOptions", label: t("login_options") },
|
||||||
{ id: "webdav", label: "WebDAV Access" },
|
{ id: "webdav", label: "WebDAV Access" },
|
||||||
{ id: "onlyoffice", label: "ONLYOFFICE" },
|
{ id: "onlyoffice", label: "ONLYOFFICE" },
|
||||||
@@ -758,8 +817,8 @@ export function openAdminPanel() {
|
|||||||
</label>
|
</label>
|
||||||
<small class="text-muted d-block mb-1">
|
<small class="text-muted d-block mb-1">
|
||||||
${isPro
|
${isPro
|
||||||
? 'Upload a logo image or paste a local path.'
|
? 'Upload a logo image or paste a local path.'
|
||||||
: 'Requires FileRise Pro to enable custom header branding.'}
|
: 'Requires FileRise Pro to enable custom header branding.'}
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
@@ -818,12 +877,30 @@ export function openAdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted d-block mt-1">
|
<small class="text-muted d-block mt-1">
|
||||||
${isPro
|
${isPro
|
||||||
? 'If left empty, FileRise uses its default blue and dark header colors.'
|
? 'If left empty, FileRise uses its default blue and dark header colors.'
|
||||||
: 'Requires FileRise Pro to enable custom color branding.'}
|
: 'Requires FileRise Pro to enable custom color branding.'}
|
||||||
|
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pro: Footer text -->
|
||||||
|
<div class="form-group" style="margin-top:16px;">
|
||||||
|
<label for="brandingFooterHtml">
|
||||||
|
Footer text
|
||||||
|
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
|
||||||
|
</label>
|
||||||
|
<small class="text-muted d-block mb-1">
|
||||||
|
${isPro
|
||||||
|
? 'Shown at the bottom of every page. You can include simple HTML like links.'
|
||||||
|
: 'Requires FileRise Pro to customize footer text.'}
|
||||||
|
</small>
|
||||||
|
<textarea
|
||||||
|
id="brandingFooterHtml"
|
||||||
|
class="form-control"
|
||||||
|
rows="2"
|
||||||
|
placeholder="© 2025 Your Company. Powered by FileRise."
|
||||||
|
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingFooterHtml || '') : ''}</textarea>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
wireHeaderTitleLive();
|
wireHeaderTitleLive();
|
||||||
|
|
||||||
@@ -946,26 +1023,57 @@ export function openAdminPanel() {
|
|||||||
const hasLatest = !!norm(latestVersionRaw);
|
const hasLatest = !!norm(latestVersionRaw);
|
||||||
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
|
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
|
||||||
|
|
||||||
const proMetaHtml =
|
// Friendly description of plan + lifetime/expiry
|
||||||
isPro && (proType || proEmail || proVersion)
|
let planLabel = '';
|
||||||
? `
|
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
|
||||||
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
|
const mj = proMaxMajor || 1;
|
||||||
<div>
|
planLabel = `Early supporter – lifetime for FileRise Pro ${mj}.x`;
|
||||||
✅ ${proType ? `License type: ${proType}` : 'License active'}
|
} else if (proPlan) {
|
||||||
${proType && proEmail ? ' • ' : ''}
|
if (proPlan.startsWith('personal_') || proPlan === 'personal_yearly') {
|
||||||
${proEmail ? `Licensed to: ${proEmail}` : ''}
|
planLabel = 'Personal license';
|
||||||
</div>
|
} else if (proPlan.startsWith('business_') || proPlan === 'business_yearly') {
|
||||||
${hasCurrent ? `
|
planLabel = 'Business license';
|
||||||
<div>
|
} else {
|
||||||
Installed Pro bundle: v${norm(currentVersionRaw)}
|
planLabel = proPlan;
|
||||||
</div>` : ''}
|
}
|
||||||
${hasLatest ? `
|
}
|
||||||
<div>
|
|
||||||
Latest Pro bundle (UI hint): ${latestVersionRaw}
|
let expiryLabel = '';
|
||||||
</div>` : ''}
|
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
|
||||||
</div>
|
// Early supporters: we treat as lifetime for that major – do NOT show an expiry date
|
||||||
`
|
expiryLabel = 'Lifetime license (no expiry)';
|
||||||
: '';
|
} else if (proExpiresAt) {
|
||||||
|
expiryLabel = `Valid until ${proExpiresAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proMetaHtml =
|
||||||
|
isPro && (proType || proEmail || proVersion || planLabel || expiryLabel)
|
||||||
|
? `
|
||||||
|
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
|
||||||
|
<div>
|
||||||
|
✅ ${proType ? `License type: ${proType}` : 'License active'}
|
||||||
|
${proType && proEmail ? ' • ' : ''}
|
||||||
|
${proEmail ? `Licensed to: ${proEmail}` : ''}
|
||||||
|
</div>
|
||||||
|
${planLabel ? `
|
||||||
|
<div>
|
||||||
|
Plan: ${planLabel}
|
||||||
|
</div>` : ''}
|
||||||
|
${expiryLabel ? `
|
||||||
|
<div>
|
||||||
|
${expiryLabel}
|
||||||
|
</div>` : ''}
|
||||||
|
${hasCurrent ? `
|
||||||
|
<div>
|
||||||
|
Installed Pro bundle: v${norm(currentVersionRaw)}
|
||||||
|
</div>` : ''}
|
||||||
|
${hasLatest ? `
|
||||||
|
<div>
|
||||||
|
Latest Pro bundle (UI hint): ${latestVersionRaw}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
proContent.innerHTML = `
|
proContent.innerHTML = `
|
||||||
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
|
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
|
||||||
@@ -1309,6 +1417,7 @@ function handleSave() {
|
|||||||
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
||||||
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
||||||
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
||||||
|
footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1348,6 +1457,7 @@ function handleSave() {
|
|||||||
closeAdminPanel();
|
closeAdminPanel();
|
||||||
applyHeaderColorsFromAdmin();
|
applyHeaderColorsFromAdmin();
|
||||||
updateHeaderLogoFromAdmin();
|
updateHeaderLogoFromAdmin();
|
||||||
|
applyFooterFromAdmin();
|
||||||
})
|
})
|
||||||
.catch(() => showToast('Save failed.'));
|
.catch(() => showToast('Save failed.'));
|
||||||
}
|
}
|
||||||
@@ -1858,7 +1968,7 @@ export function openUserPermissionsModal() {
|
|||||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||||
background-color: ${overlayBackground};
|
background-color: ${overlayBackground};
|
||||||
display: flex; justify-content: center; align-items: center;
|
display: flex; justify-content: center; align-items: center;
|
||||||
z-index: 3500;
|
z-index: 10000;
|
||||||
`;
|
`;
|
||||||
userPermissionsModal.innerHTML = `
|
userPermissionsModal.innerHTML = `
|
||||||
<div class="modal-content" style="${modalContentStyles}">
|
<div class="modal-content" style="${modalContentStyles}">
|
||||||
|
|||||||
@@ -217,6 +217,9 @@ let __portalsCache = {};
|
|||||||
let __portalFolderListLoaded = false;
|
let __portalFolderListLoaded = false;
|
||||||
let __portalFolderOptions = [];
|
let __portalFolderOptions = [];
|
||||||
|
|
||||||
|
// Remember a newly-created portal to focus its folder field
|
||||||
|
let __portalSlugToFocus = null;
|
||||||
|
|
||||||
// Cache portal submissions per slug for CSV export
|
// Cache portal submissions per slug for CSV export
|
||||||
const __portalSubmissionsCache = {};
|
const __portalSubmissionsCache = {};
|
||||||
|
|
||||||
@@ -279,13 +282,38 @@ export async function openClientPortalsModal() {
|
|||||||
(and optionally download) files without seeing your full FileRise UI.
|
(and optionally download) files without seeing your full FileRise UI.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
||||||
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
<div>
|
||||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
||||||
<span style="margin-left:4px;">Add portal</span>
|
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||||
</button>
|
<span style="margin-left:4px;">Add portal</span>
|
||||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
</button>
|
||||||
</div>
|
|
||||||
|
<button type="button"
|
||||||
|
id="clientPortalsQuickAddUser"
|
||||||
|
class="btn btn-sm btn-outline-primary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">person_add</i>
|
||||||
|
<span style="margin-left:4px;">Add user…</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="clientPortalsOpenUserPerms"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">folder_shared</i>
|
||||||
|
<span style="margin-left:4px;">Folder access…</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="clientPortalsOpenUserGroups"
|
||||||
|
class="btn btn-sm btn-outline-secondary ms-1">
|
||||||
|
<i class="material-icons" style="font-size:16px; vertical-align:middle;">groups</i>
|
||||||
|
<span style="margin-left:4px;">User groups…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
|
<div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
|
||||||
${t('loading')}…
|
${t('loading')}…
|
||||||
@@ -303,6 +331,41 @@ export async function openClientPortalsModal() {
|
|||||||
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
||||||
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
||||||
document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
|
document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
|
||||||
|
const quickAddUserBtn = document.getElementById('clientPortalsQuickAddUser');
|
||||||
|
if (quickAddUserBtn) {
|
||||||
|
quickAddUserBtn.onclick = () => {
|
||||||
|
// Reuse existing admin add-user button / modal
|
||||||
|
const globalBtn = document.getElementById('adminOpenAddUser');
|
||||||
|
if (globalBtn) {
|
||||||
|
globalBtn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to add a new user.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const openPermsBtn = document.getElementById('clientPortalsOpenUserPerms');
|
||||||
|
if (openPermsBtn) {
|
||||||
|
openPermsBtn.onclick = () => {
|
||||||
|
const btn = document.getElementById('adminOpenUserPermissions');
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to edit folder access.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const openGroupsBtn = document.getElementById('clientPortalsOpenUserGroups');
|
||||||
|
if (openGroupsBtn) {
|
||||||
|
openGroupsBtn.onclick = () => {
|
||||||
|
const btn = document.getElementById('adminOpenUserGroups');
|
||||||
|
if (btn) {
|
||||||
|
btn.click();
|
||||||
|
} else {
|
||||||
|
showToast('Use the Users tab to manage user groups.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modal.style.background = overlayBg;
|
modal.style.background = overlayBg;
|
||||||
const content = modal.querySelector('.modal-content');
|
const content = modal.querySelector('.modal-content');
|
||||||
@@ -358,7 +421,22 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
const folder = p.folder || '';
|
const folder = p.folder || '';
|
||||||
const clientEmail = p.clientEmail || '';
|
const clientEmail = p.clientEmail || '';
|
||||||
const uploadOnly = !!p.uploadOnly;
|
const uploadOnly = !!p.uploadOnly;
|
||||||
const allowDownload = p.allowDownload !== false; // default true
|
|
||||||
|
// Backwards compat:
|
||||||
|
// - Old portals only had "uploadOnly":
|
||||||
|
// uploadOnly = true => upload yes, download no
|
||||||
|
// uploadOnly = false => upload yes, download yes
|
||||||
|
// - New portals have explicit allowDownload.
|
||||||
|
let allowDownload;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(p, 'allowDownload')) {
|
||||||
|
allowDownload = p.allowDownload !== false;
|
||||||
|
} else {
|
||||||
|
// Legacy: "upload only" meant no download
|
||||||
|
allowDownload = !uploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
|
const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
|
||||||
const brandColor = p.brandColor || '';
|
const brandColor = p.brandColor || '';
|
||||||
const footerText = p.footerText || '';
|
const footerText = p.footerText || '';
|
||||||
@@ -419,8 +497,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
|
|
||||||
<div class="portal-card-body">
|
<div class="portal-card-body">
|
||||||
<div class="portal-meta-row">
|
<div class="portal-meta-row">
|
||||||
<label style="font-weight:600;">
|
<label style="font-weight:600;">
|
||||||
Portal slug:
|
Portal slug<span class="text-danger">*</span>:
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm"
|
class="form-control form-control-sm"
|
||||||
data-portal-field="slug"
|
data-portal-field="slug"
|
||||||
@@ -439,8 +517,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
|
|
||||||
<div class="portal-meta-row">
|
<div class="portal-meta-row">
|
||||||
<div class="portal-folder-row">
|
<div class="portal-folder-row">
|
||||||
<label>
|
<label>
|
||||||
Folder:
|
Folder<span class="text-danger">*</span>:
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm portal-folder-input"
|
class="form-control form-control-sm portal-folder-input"
|
||||||
data-portal-field="folder"
|
data-portal-field="folder"
|
||||||
@@ -482,11 +560,11 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label style="display:flex; align-items:center; gap:4px;">
|
<label style="display:flex; align-items:center; gap:4px;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
data-portal-field="uploadOnly"
|
data-portal-field="uploadOnly"
|
||||||
${uploadOnly ? 'checked' : ''} />
|
${uploadOnly ? 'checked' : ''} />
|
||||||
<span>Upload only</span>
|
<span>Allow upload</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label style="display:flex; align-items:center; gap:4px;">
|
<label style="display:flex; align-items:center; gap:4px;">
|
||||||
@@ -495,7 +573,6 @@ async function loadClientPortalsList(useCacheOnly) {
|
|||||||
${allowDownload ? 'checked' : ''} />
|
${allowDownload ? 'checked' : ''} />
|
||||||
<span>Allow download</span>
|
<span>Allow download</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:8px;">
|
<div style="margin-top:8px;">
|
||||||
<div class="form-group" style="margin-bottom:6px;">
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
@@ -840,6 +917,32 @@ body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
|
|||||||
card.remove();
|
card.remove();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// After rendering, if we have a "new" portal to focus, expand it and focus Folder
|
||||||
|
if (__portalSlugToFocus) {
|
||||||
|
const focusSlug = __portalSlugToFocus;
|
||||||
|
__portalSlugToFocus = null;
|
||||||
|
|
||||||
|
const focusCard = body.querySelector(`.portal-card[data-portal-slug="${focusSlug}"]`);
|
||||||
|
if (focusCard) {
|
||||||
|
const header = focusCard.querySelector('.portal-card-header');
|
||||||
|
const bodyEl = focusCard.querySelector('.portal-card-body');
|
||||||
|
const caret = focusCard.querySelector('.portal-card-caret');
|
||||||
|
|
||||||
|
if (header && bodyEl) {
|
||||||
|
header.setAttribute('aria-expanded', 'true');
|
||||||
|
bodyEl.style.display = 'block';
|
||||||
|
if (caret) caret.textContent = '▾';
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderInput = focusCard.querySelector('[data-portal-field="folder"]');
|
||||||
|
if (folderInput) {
|
||||||
|
folderInput.focus();
|
||||||
|
folderInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Keep submissions viewer working
|
// Keep submissions viewer working
|
||||||
attachPortalSubmissionsUI();
|
attachPortalSubmissionsUI();
|
||||||
// Intake presets dropdowns
|
// Intake presets dropdowns
|
||||||
@@ -881,6 +984,8 @@ function addEmptyPortalRow() {
|
|||||||
expiresAt: ''
|
expiresAt: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// After re-render, auto-focus this portal's folder field
|
||||||
|
__portalSlugToFocus = slug;
|
||||||
loadClientPortalsList(true);
|
loadClientPortalsList(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1421,6 +1526,48 @@ async function saveClientPortalsFromUI() {
|
|||||||
|
|
||||||
const cards = body.querySelectorAll('.card[data-portal-slug]');
|
const cards = body.querySelectorAll('.card[data-portal-slug]');
|
||||||
const portals = {};
|
const portals = {};
|
||||||
|
const invalid = [];
|
||||||
|
let firstInvalidField = null;
|
||||||
|
|
||||||
|
// Clear previous visual errors
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.style.boxShadow = '';
|
||||||
|
card.style.borderColor = '';
|
||||||
|
card.classList.remove('portal-card-has-error');
|
||||||
|
|
||||||
|
const hint = card.querySelector('.portal-card-error-hint');
|
||||||
|
if (hint) hint.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const markCardMissingRequired = (card, message) => {
|
||||||
|
// Mark visually
|
||||||
|
card.classList.add('portal-card-has-error');
|
||||||
|
card.style.borderColor = '#dc3545';
|
||||||
|
card.style.boxShadow = '0 0 0 2px rgba(220,53,69,0.6)';
|
||||||
|
|
||||||
|
// Expand the card so the error is visible even if it was collapsed
|
||||||
|
const header = card.querySelector('.portal-card-header');
|
||||||
|
const bodyEl = card.querySelector('.portal-card-body') || card;
|
||||||
|
const caret = card.querySelector('.portal-card-caret');
|
||||||
|
|
||||||
|
if (header && bodyEl) {
|
||||||
|
header.setAttribute('aria-expanded', 'true');
|
||||||
|
bodyEl.style.display = 'block';
|
||||||
|
if (caret) caret.textContent = '▾';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small inline hint at top of the card body
|
||||||
|
let hint = bodyEl.querySelector('.portal-card-error-hint');
|
||||||
|
if (!hint) {
|
||||||
|
hint = document.createElement('div');
|
||||||
|
hint.className = 'portal-card-error-hint text-danger small';
|
||||||
|
hint.style.marginBottom = '6px';
|
||||||
|
hint.textContent = message || 'Slug and folder are required. This portal will not be saved until both are filled.';
|
||||||
|
bodyEl.insertBefore(hint, bodyEl.firstChild);
|
||||||
|
} else {
|
||||||
|
hint.textContent = message || hint.textContent;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
const origSlug = card.getAttribute('data-portal-slug') || '';
|
const origSlug = card.getAttribute('data-portal-slug') || '';
|
||||||
@@ -1453,21 +1600,22 @@ async function saveClientPortalsFromUI() {
|
|||||||
const lblRef = getVal('[data-portal-field="lblRef"]').trim();
|
const lblRef = getVal('[data-portal-field="lblRef"]').trim();
|
||||||
const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
|
const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
|
||||||
|
|
||||||
const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
|
const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
|
||||||
const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
|
const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
|
||||||
const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
|
const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
|
||||||
|
|
||||||
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
||||||
const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
|
const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
|
||||||
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
||||||
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
|
||||||
|
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
||||||
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
|
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
|
||||||
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
||||||
const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
|
const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
|
||||||
|
|
||||||
const reqName = reqNameEl ? !!reqNameEl.checked : false;
|
const reqName = reqNameEl ? !!reqNameEl.checked : false;
|
||||||
const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
|
const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
|
||||||
const reqRef = reqRefEl ? !!reqRefEl.checked : false;
|
const reqRef = reqRefEl ? !!reqRefEl.checked : false;
|
||||||
const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
|
const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
|
||||||
|
|
||||||
const visNameEl = card.querySelector('[data-portal-field="visName"]');
|
const visNameEl = card.querySelector('[data-portal-field="visName"]');
|
||||||
@@ -1487,63 +1635,106 @@ async function saveClientPortalsFromUI() {
|
|||||||
|
|
||||||
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
|
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
|
||||||
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
|
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
|
||||||
|
const folderInput = card.querySelector('[data-portal-field="folder"]');
|
||||||
const slugInput = card.querySelector('[data-portal-field="slug"]');
|
const slugInput = card.querySelector('[data-portal-field="slug"]');
|
||||||
if (slugInput) {
|
if (slugInput) {
|
||||||
const rawSlug = slugInput.value.trim();
|
const rawSlug = slugInput.value.trim();
|
||||||
if (rawSlug) slug = rawSlug;
|
if (rawSlug) slug = rawSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelForError = label || slug || origSlug || '(unnamed portal)';
|
||||||
|
|
||||||
|
// Validation: slug + folder required
|
||||||
if (!slug || !folder) {
|
if (!slug || !folder) {
|
||||||
|
invalid.push(labelForError);
|
||||||
|
|
||||||
|
// Remember the first problematic field so we can scroll exactly to it
|
||||||
|
if (!firstInvalidField) {
|
||||||
|
if (!folder && folderInput) {
|
||||||
|
firstInvalidField = folderInput;
|
||||||
|
} else if (!slug && slugInput) {
|
||||||
|
firstInvalidField = slugInput;
|
||||||
|
} else {
|
||||||
|
firstInvalidField = card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markCardMissingRequired(
|
||||||
|
card,
|
||||||
|
'Slug and folder are required. This portal will not be saved until both are filled.'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
portals[slug] = {
|
portals[slug] = {
|
||||||
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,
|
||||||
formDefaults: {
|
formDefaults: {
|
||||||
name: defName,
|
name: defName,
|
||||||
email: defEmail,
|
email: defEmail,
|
||||||
reference: defRef,
|
reference: defRef,
|
||||||
notes: defNotes
|
notes: defNotes
|
||||||
},
|
},
|
||||||
formRequired: {
|
formRequired: {
|
||||||
name: reqName,
|
name: reqName,
|
||||||
email: reqEmail,
|
email: reqEmail,
|
||||||
reference: reqRef,
|
reference: reqRef,
|
||||||
notes: reqNotes
|
notes: reqNotes
|
||||||
},
|
},
|
||||||
formLabels: {
|
formLabels: {
|
||||||
name: lblName,
|
name: lblName,
|
||||||
email: lblEmail,
|
email: lblEmail,
|
||||||
reference: lblRef,
|
reference: lblRef,
|
||||||
notes: lblNotes
|
notes: lblNotes
|
||||||
},
|
},
|
||||||
formVisible: {
|
formVisible: {
|
||||||
name: visName,
|
name: visName,
|
||||||
email: visEmail,
|
email: visEmail,
|
||||||
reference: visRef,
|
reference: visRef,
|
||||||
notes: visNotes
|
notes: visNotes
|
||||||
},
|
},
|
||||||
uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
|
uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
|
||||||
uploadExtWhitelist,
|
uploadExtWhitelist,
|
||||||
uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
|
uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
|
||||||
showThankYou,
|
showThankYou,
|
||||||
thankYouText,
|
thankYouText,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invalid.length) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Please fill slug and folder for highlighted portals.';
|
||||||
|
status.className = 'small text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll the *first missing field* into view so the admin sees exactly where to fix
|
||||||
|
const targetEl = firstInvalidField || body.querySelector('.portal-card-has-error');
|
||||||
|
if (targetEl) {
|
||||||
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// If it's an input, focus + select to make typing instant
|
||||||
|
if (typeof targetEl.focus === 'function') {
|
||||||
|
targetEl.focus();
|
||||||
|
if (typeof targetEl.select === 'function') {
|
||||||
|
targetEl.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Please set slug and folder for: ' + invalid.join(', '));
|
||||||
|
return; // Don’t hit the API if local validation failed
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.textContent = 'Saving…';
|
status.textContent = 'Saving…';
|
||||||
status.className = 'small text-muted';
|
status.className = 'small text-muted';
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export async function loadCsrfToken() {
|
|||||||
APP INIT (shared)
|
APP INIT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
export function initializeApp() {
|
export function initializeApp() {
|
||||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
const saved = parseInt(localStorage.getItem('rowHeight') || '44', 10);
|
||||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||||
|
|
||||||
const last = localStorage.getItem('lastOpenedFolder');
|
const last = localStorage.getItem('lastOpenedFolder');
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ function createCardGhost(card, rect, opts) {
|
|||||||
const ghost = card.cloneNode(true);
|
const ghost = card.cloneNode(true);
|
||||||
const cs = window.getComputedStyle(card);
|
const cs = window.getComputedStyle(card);
|
||||||
|
|
||||||
// Give the ghost the same “card” chrome even though it’s attached to <body>
|
|
||||||
Object.assign(ghost.style, {
|
Object.assign(ghost.style, {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: rect.left + 'px',
|
left: rect.left + 'px',
|
||||||
@@ -94,7 +93,6 @@ function createCardGhost(card, rect, opts) {
|
|||||||
transform: 'scale(' + scale + ')',
|
transform: 'scale(' + scale + ')',
|
||||||
opacity: String(opacity),
|
opacity: String(opacity),
|
||||||
|
|
||||||
// pull key visuals from the real card
|
|
||||||
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
|
||||||
borderRadius: cs.borderRadius || '',
|
borderRadius: cs.borderRadius || '',
|
||||||
boxShadow: cs.boxShadow || '',
|
boxShadow: cs.boxShadow || '',
|
||||||
@@ -102,8 +100,17 @@ function createCardGhost(card, rect, opts) {
|
|||||||
borderWidth: cs.borderWidth || '',
|
borderWidth: cs.borderWidth || '',
|
||||||
borderStyle: cs.borderStyle || '',
|
borderStyle: cs.borderStyle || '',
|
||||||
backdropFilter: cs.backdropFilter || '',
|
backdropFilter: cs.backdropFilter || '',
|
||||||
|
|
||||||
|
// ✨ make the ghost crisper
|
||||||
|
overflow: 'hidden',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
backfaceVisibility: 'hidden'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subtle: de-emphasize inner text so it doesn’t look “smeared”
|
||||||
|
const ghBody = ghost.querySelector('.card-body');
|
||||||
|
if (ghBody) ghBody.style.opacity = '0.6';
|
||||||
|
|
||||||
return ghost;
|
return ghost;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +403,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
return { card, rect };
|
return { card, rect };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show dock so icons exist / have positions
|
// Make sure header dock is visible so icons are laid out
|
||||||
showHeaderDockPersistent();
|
showHeaderDockPersistent();
|
||||||
|
|
||||||
// Move real cards into header (hidden container + icons)
|
// Move real cards into header (hidden container + icons)
|
||||||
@@ -410,16 +417,16 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
// remember the size for the expand animation later
|
// remember the size for the expand animation later
|
||||||
card.dataset.lastWidth = String(rect.width);
|
card.dataset.lastWidth = String(rect.width);
|
||||||
card.dataset.lastHeight = String(rect.height);
|
card.dataset.lastHeight = String(rect.height);
|
||||||
|
|
||||||
const iconBtn = card.headerIconButton;
|
const iconBtn = card.headerIconButton;
|
||||||
if (!iconBtn) return;
|
if (!iconBtn) return;
|
||||||
|
|
||||||
const iconRect = iconBtn.getBoundingClientRect();
|
const iconRect = iconBtn.getBoundingClientRect();
|
||||||
|
|
||||||
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
|
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 });
|
||||||
ghost.id = card.id + '-ghost-collapse';
|
ghost.id = card.id + '-ghost-collapse';
|
||||||
ghost.classList.add('card-collapse-ghost');
|
ghost.classList.add('card-collapse-ghost');
|
||||||
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
|
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||||
|
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
ghosts.push({ ghost, from: rect, to: iconRect });
|
ghosts.push({ ghost, from: rect, to: iconRect });
|
||||||
@@ -430,6 +437,7 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off motion on next frame
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ghosts.forEach(({ ghost, from, to }) => {
|
ghosts.forEach(({ ghost, from, to }) => {
|
||||||
const fromCx = from.left + from.width / 2;
|
const fromCx = from.left + from.width / 2;
|
||||||
@@ -441,17 +449,18 @@ function animateCardsIntoHeaderAndThen(done) {
|
|||||||
const dy = toCy - fromCy;
|
const dy = toCy - fromCy;
|
||||||
|
|
||||||
const rawScale = to.width / from.width;
|
const rawScale = to.width / from.width;
|
||||||
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
|
const scale = Math.max(0.35, Math.min(0.6, rawScale * 0.9));
|
||||||
|
|
||||||
|
// ✨ more readable: clear slide + shrink, but don’t fully vanish mid-flight
|
||||||
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
|
||||||
ghost.style.opacity = '0';
|
ghost.style.opacity = '0.35';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
|
||||||
done();
|
done();
|
||||||
}, 260);
|
}, 430); // a bit over the 0.4s transition
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTargetZoneForExpand(cardId) {
|
function resolveTargetZoneForExpand(cardId) {
|
||||||
@@ -508,9 +517,9 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
if (sb) sb.style.display = '';
|
if (sb) sb.style.display = '';
|
||||||
if (top) top.style.display = '';
|
if (top) top.style.display = '';
|
||||||
|
|
||||||
const SAFE_TOP = 16; // minimum distance from top of viewport
|
const SAFE_TOP = 16;
|
||||||
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
|
const START_OFFSET_Y = 32; // a touch closer to header
|
||||||
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
|
const DEST_EXTRA_Y = 120;
|
||||||
|
|
||||||
const ghosts = [];
|
const ghosts = [];
|
||||||
|
|
||||||
@@ -528,24 +537,20 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
const zoneRect = host.getBoundingClientRect();
|
const zoneRect = host.getBoundingClientRect();
|
||||||
if (!zoneRect.width) return;
|
if (!zoneRect.width) return;
|
||||||
|
|
||||||
// Where the ghost "comes from" (near the icon)
|
|
||||||
const fromCx = iconRect.left + iconRect.width / 2;
|
const fromCx = iconRect.left + iconRect.width / 2;
|
||||||
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
|
const fromCy = iconRect.bottom + START_OFFSET_Y;
|
||||||
|
|
||||||
// Where we want it to "land" (roughly center of the zone, a bit down)
|
|
||||||
let toCx = zoneRect.left + zoneRect.width / 2;
|
let toCx = zoneRect.left + zoneRect.width / 2;
|
||||||
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
|
||||||
|
|
||||||
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
|
|
||||||
if (zoneId === ZONES.SIDEBAR) {
|
if (zoneId === ZONES.SIDEBAR) {
|
||||||
if (card.id === 'uploadCard') {
|
if (card.id === 'uploadCard') {
|
||||||
toCy -= 48; // a bit higher
|
toCy -= 48;
|
||||||
} else if (card.id === 'folderManagementCard') {
|
} else if (card.id === 'folderManagementCard') {
|
||||||
toCy += 48; // a bit lower
|
toCy += 48;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to match the real card size we captured during collapse
|
|
||||||
const savedW = parseFloat(card.dataset.lastWidth || '');
|
const savedW = parseFloat(card.dataset.lastWidth || '');
|
||||||
const savedH = parseFloat(card.dataset.lastHeight || '');
|
const savedH = parseFloat(card.dataset.lastHeight || '');
|
||||||
const targetWidth = !Number.isNaN(savedW)
|
const targetWidth = !Number.isNaN(savedW)
|
||||||
@@ -553,10 +558,8 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
|
||||||
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
|
||||||
|
|
||||||
// Make sure the top of the ghost never goes above SAFE_TOP
|
|
||||||
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
|
||||||
|
|
||||||
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
|
|
||||||
const ghostRect = {
|
const ghostRect = {
|
||||||
left: fromCx - targetWidth / 2,
|
left: fromCx - targetWidth / 2,
|
||||||
top: startTop,
|
top: startTop,
|
||||||
@@ -564,13 +567,12 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
height: targetHeight
|
height: targetHeight
|
||||||
};
|
};
|
||||||
|
|
||||||
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
|
const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 });
|
||||||
ghost.id = card.id + '-ghost-expand';
|
ghost.id = card.id + '-ghost-expand';
|
||||||
ghost.classList.add('card-expand-ghost');
|
ghost.classList.add('card-expand-ghost');
|
||||||
|
|
||||||
// Override transform/transition for our flight animation
|
ghost.style.transform = 'translate(0,0) scale(0.75)';
|
||||||
ghost.style.transform = 'translate(0,0) scale(0.7)';
|
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
|
||||||
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
|
|
||||||
|
|
||||||
document.body.appendChild(ghost);
|
document.body.appendChild(ghost);
|
||||||
ghosts.push({
|
ghosts.push({
|
||||||
@@ -586,7 +588,6 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick off the flight on the next frame
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
ghosts.forEach(({ ghost, from, to }) => {
|
ghosts.forEach(({ ghost, from, to }) => {
|
||||||
const dx = to.cx - from.cx;
|
const dx = to.cx - from.cx;
|
||||||
@@ -596,13 +597,12 @@ function animateCardsOutOfHeaderThen(done) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up ghosts and then do real layout restore
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ghosts.forEach(({ ghost }) => {
|
ghosts.forEach(({ ghost }) => {
|
||||||
try { ghost.remove(); } catch {}
|
try { ghost.remove(); } catch {}
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
}, 280); // just over the 0.25s transition
|
}, 430);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- zones toggle (collapse to header) --------------------
|
// -------------------- zones toggle (collapse to header) --------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,11 @@ import {
|
|||||||
} from './fileActions.js?v={{APP_QVER}}';
|
} from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
import { canEditFile, fileData, downloadSelectedFilesIndividually } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
|
||||||
const MENU_ID = 'fileContextMenu';
|
const MENU_ID = 'fileContextMenu';
|
||||||
|
|
||||||
function qMenu() { return document.getElementById(MENU_ID); }
|
function qMenu() { return document.getElementById(MENU_ID); }
|
||||||
@@ -31,7 +32,9 @@ function localizeMenu() {
|
|||||||
'preview': 'preview',
|
'preview': 'preview',
|
||||||
'edit': 'edit',
|
'edit': 'edit',
|
||||||
'rename': 'rename',
|
'rename': 'rename',
|
||||||
'tag_file': 'tag_file'
|
'tag_file': 'tag_file',
|
||||||
|
// NEW:
|
||||||
|
'download_plain': 'download_plain'
|
||||||
};
|
};
|
||||||
Object.entries(map).forEach(([action, key]) => {
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
@@ -187,6 +190,10 @@ function menuClickDelegate(ev) {
|
|||||||
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
||||||
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
||||||
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
||||||
|
case 'download_plain':
|
||||||
|
// Uses current checkbox selection; limit enforced in fileListView
|
||||||
|
downloadSelectedFilesIndividually(s.files);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'tag_selected':
|
case 'tag_selected':
|
||||||
openMultiTagModal(s.files); // s.files are the real file objects
|
openMultiTagModal(s.files); // s.files are the real file objects
|
||||||
|
|||||||
@@ -503,6 +503,23 @@ export function previewFile(fileUrl, fileName) {
|
|||||||
const isVideo = VID_RE.test(lower);
|
const isVideo = VID_RE.test(lower);
|
||||||
const isAudio = AUD_RE.test(lower);
|
const isAudio = AUD_RE.test(lower);
|
||||||
|
|
||||||
|
// Base preview URL from the link we clicked
|
||||||
|
const baseUrl = fileUrl;
|
||||||
|
|
||||||
|
// Use the same preview endpoint, just swap the "file" param.
|
||||||
|
function siblingPreviewUrl(newName) {
|
||||||
|
try {
|
||||||
|
const u = new URL(baseUrl, window.location.origin);
|
||||||
|
u.searchParams.set('file', newName);
|
||||||
|
// cache-bust so we don’t get stale frames
|
||||||
|
u.searchParams.set('t', String(Date.now()));
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
// Fallback: go through generic download/inline endpoint
|
||||||
|
return buildPreviewUrl(folder, newName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setTitle(overlay, name);
|
setTitle(overlay, name);
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
const downloadBtn = makeDownloadButton(folder, () => name);
|
const downloadBtn = makeDownloadButton(folder, () => name);
|
||||||
@@ -582,7 +599,7 @@ const navigate = (dir) => {
|
|||||||
img.dataset.scale = 1;
|
img.dataset.scale = 1;
|
||||||
img.dataset.rotate = 0;
|
img.dataset.rotate = 0;
|
||||||
img.style.transform = 'scale(1) rotate(0deg)';
|
img.style.transform = 'scale(1) rotate(0deg)';
|
||||||
img.src = buildPreviewUrl(folder, newFile);
|
img.src = siblingPreviewUrl(newFile); // <-- changed
|
||||||
};
|
};
|
||||||
|
|
||||||
if (images.length > 1) {
|
if (images.length > 1) {
|
||||||
@@ -610,212 +627,226 @@ const navigate = (dir) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------- VIDEOS -------------------- */
|
/* -------------------- VIDEOS -------------------- */
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
let video = document.createElement("video");
|
let video = document.createElement("video");
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||||
video.style.maxWidth = "88vw";
|
video.style.maxWidth = "88vw";
|
||||||
video.style.maxHeight = "88vh";
|
video.style.maxHeight = "88vh";
|
||||||
video.style.objectFit = "contain";
|
video.style.objectFit = "contain";
|
||||||
container.appendChild(video);
|
container.appendChild(video);
|
||||||
|
|
||||||
// Apply last-used volume/mute, and persist future changes
|
|
||||||
loadSavedMediaVolume(video);
|
|
||||||
attachVolumePersistence(video);
|
|
||||||
|
|
||||||
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
|
// Apply last-used volume/mute, and persist future changes
|
||||||
let currentName = name;
|
loadSavedMediaVolume(video);
|
||||||
|
attachVolumePersistence(video);
|
||||||
|
|
||||||
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
// 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");
|
||||||
|
|
||||||
// Order: Download | Mark | Reset
|
// Track which file is currently active
|
||||||
actionWrap.appendChild(downloadBtn);
|
let currentName = name;
|
||||||
actionWrap.appendChild(markBtnIcon);
|
|
||||||
actionWrap.appendChild(clearBtnIcon);
|
|
||||||
|
|
||||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
// Use the URL we were passed in (old behavior) for the *first* video,
|
||||||
overlay.mediaType = 'video';
|
// fall back to API URL if for some reason it's empty.
|
||||||
overlay.mediaList = videos;
|
const initialUrl = fileUrl && fileUrl.trim()
|
||||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
? fileUrl
|
||||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
: buildPreviewUrl(folder, name);
|
||||||
|
|
||||||
const setVideoSrc = (nm) => {
|
const downloadBtn = makeDownloadButton(folder, () => currentName);
|
||||||
currentName = nm;
|
|
||||||
video.src = buildPreviewUrl(folder, nm);
|
// Order: Download | Mark | Reset
|
||||||
setTitle(overlay, nm);
|
actionWrap.appendChild(downloadBtn);
|
||||||
};
|
actionWrap.appendChild(markBtnIcon);
|
||||||
|
actionWrap.appendChild(clearBtnIcon);
|
||||||
const SAVE_INTERVAL_MS = 5000;
|
|
||||||
let lastSaveAt = 0;
|
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||||
let pending = false;
|
overlay.mediaType = 'video';
|
||||||
|
overlay.mediaList = videos;
|
||||||
async function getProgress(nm) {
|
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||||
try {
|
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
|
||||||
const data = await res.json();
|
// Helper: set src for a given video name
|
||||||
return data && data.state ? data.state : null;
|
const setVideoSrc = (nm) => {
|
||||||
} catch { return null; }
|
currentName = nm;
|
||||||
}
|
|
||||||
|
// For the current file, reuse the original working URL.
|
||||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
// For other files (next/prev), go through the API.
|
||||||
try {
|
const url = (nm === name) ? initialUrl : buildPreviewUrl(folder, nm);
|
||||||
pending = true;
|
|
||||||
const res = await fetch("/api/media/updateProgress.php", {
|
video.src = url;
|
||||||
method: "POST",
|
video.src = siblingPreviewUrl(nm);
|
||||||
credentials: "include",
|
setTitle(overlay, nm);
|
||||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
};
|
||||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
|
||||||
});
|
const SAVE_INTERVAL_MS = 5000;
|
||||||
const data = await res.json();
|
let lastSaveAt = 0;
|
||||||
pending = false;
|
let pending = false;
|
||||||
return data;
|
|
||||||
} catch (e) {
|
async function getProgress(nm) {
|
||||||
pending = false;
|
try {
|
||||||
console.error(e);
|
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||||
return null;
|
const data = await res.json();
|
||||||
}
|
return data && data.state ? data.state : null;
|
||||||
}
|
} catch { return null; }
|
||||||
|
}
|
||||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
|
||||||
|
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||||
function renderStatus(state) {
|
try {
|
||||||
if (!statusChip) return;
|
pending = true;
|
||||||
|
const res = await fetch("/api/media/updateProgress.php", {
|
||||||
// Completed
|
method: "POST",
|
||||||
if (state && state.completed) {
|
credentials: "include",
|
||||||
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
statusChip.style.display = 'inline-block';
|
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const data = await res.json();
|
||||||
video.addEventListener("timeupdate", async () => {
|
pending = false;
|
||||||
const now = Date.now();
|
return data;
|
||||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
} catch (e) {
|
||||||
lastSaveAt = now;
|
pending = false;
|
||||||
|
console.error(e);
|
||||||
const nm = currentName;
|
return null;
|
||||||
const seconds = Math.floor(video.currentTime || 0);
|
}
|
||||||
const duration = Math.floor(video.duration || 0);
|
}
|
||||||
|
|
||||||
sendProgress({ nm, seconds, duration });
|
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||||
setFileProgressBadge(nm, seconds, duration);
|
|
||||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
function renderStatus(state) {
|
||||||
renderStatus({ seconds, duration, completed: false });
|
if (!statusChip) return;
|
||||||
});
|
|
||||||
|
// Completed
|
||||||
video.addEventListener("ended", async () => {
|
if (state && state.completed) {
|
||||||
const nm = currentName;
|
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
|
||||||
const duration = Math.floor(video.duration || 0);
|
statusChip.style.display = 'inline-block';
|
||||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
|
||||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
statusChip.style.background = 'rgba(34,197,94,.15)';
|
||||||
showToast(t("marked_viewed") || "Marked as viewed");
|
statusChip.style.color = '#22c55e';
|
||||||
setFileWatchedBadge(nm, true);
|
markBtnIcon.style.display = 'none';
|
||||||
renderStatus({ seconds: duration, duration, completed: true });
|
clearBtnIcon.style.display = '';
|
||||||
});
|
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
|
|
||||||
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;
|
|
||||||
currentName = nm; // keep download button in sync
|
|
||||||
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";
|
|
||||||
return;
|
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 -------------------- */
|
/* -------------------- AUDIO / OTHER -------------------- */
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
const audio = document.createElement("audio");
|
const audio = document.createElement("audio");
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const translations = {
|
|||||||
|
|
||||||
// Admin Panel
|
// Admin Panel
|
||||||
"header_settings": "Header Settings",
|
"header_settings": "Header Settings",
|
||||||
|
"header_footer_settings": "Header & Footer Settings",
|
||||||
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
|
||||||
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
|
||||||
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
|
||||||
@@ -352,7 +353,20 @@ const translations = {
|
|||||||
"zoom_in": "Zoom In",
|
"zoom_in": "Zoom In",
|
||||||
"zoom_out": "Zoom Out",
|
"zoom_out": "Zoom Out",
|
||||||
"rotate_left": "Rotate Left",
|
"rotate_left": "Rotate Left",
|
||||||
"rotate_right": "Rotate Right"
|
"rotate_right": "Rotate Right",
|
||||||
|
"download_plain": "Download (no ZIP)",
|
||||||
|
"download_next": "Download next",
|
||||||
|
"nonzip_queue_title": "Files queued for download",
|
||||||
|
"nonzip_queue_subtitle": "{count} files queued. Click \"Download next\" for each file.",
|
||||||
|
"nonzip_queue_cleared": "Download queue cleared.",
|
||||||
|
"your_access": "Your access",
|
||||||
|
|
||||||
|
"perm_upload": "Upload",
|
||||||
|
"perm_move": "Move",
|
||||||
|
"perm_rename": "Rename",
|
||||||
|
"perm_share": "Share",
|
||||||
|
"perm_delete": "Delete"
|
||||||
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -445,107 +445,127 @@ function bindDarkMode() {
|
|||||||
m.content = val;
|
m.content = val;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- site config / auth ----------
|
// ---------- site config / auth ----------
|
||||||
function applySiteConfig(cfg, { phase = 'final' } = {}) {
|
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 ---
|
|
||||||
try {
|
try {
|
||||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||||
const customLogoUrl = branding.customLogoUrl || "";
|
|
||||||
const logoImg = document.querySelector('.header-logo img');
|
// Always keep <title> correct early (no visual flicker)
|
||||||
if (logoImg) {
|
document.title = title;
|
||||||
if (customLogoUrl) {
|
|
||||||
logoImg.setAttribute('src', customLogoUrl);
|
// --- Header logo (branding) in BOTH phases ---
|
||||||
logoImg.setAttribute('alt', 'Site logo');
|
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 {
|
} else {
|
||||||
// fall back to default FileRise logo
|
loginWrap.setAttribute('hidden', '');
|
||||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
loginWrap.style.display = '';
|
||||||
logoImg.setAttribute('alt', 'FileRise');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
// non-fatal; ignore branding issues
|
// 2) Toggle the pieces inside the wrapper
|
||||||
}
|
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||||
// --- Header colors (branding) in BOTH phases ---
|
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||||
try {
|
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||||
const root = document.documentElement;
|
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
|
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||||
const light = branding.headerBgLight || '';
|
|
||||||
const dark = branding.headerBgDark || '';
|
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||||
|
if (phase === 'final') {
|
||||||
if (light) root.style.setProperty('--header-bg-light', light);
|
const h1 = document.querySelector('.header-title h1');
|
||||||
else root.style.removeProperty('--header-bg-light');
|
if (h1) {
|
||||||
|
// prevent i18n or legacy from overwriting it
|
||||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||||
else root.style.removeProperty('--header-bg-dark');
|
|
||||||
} catch (e) {
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
// non-fatal
|
|
||||||
}
|
// lock it so late code can't stomp it
|
||||||
|
if (!h1.__titleLock) {
|
||||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
const mo = new MutationObserver(() => {
|
||||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
|
});
|
||||||
|
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||||
// be tolerant to key variants just in case
|
h1.__titleLock = mo;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch { }
|
||||||
} catch { }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function readyToReveal() {
|
async function readyToReveal() {
|
||||||
// Wait for CSS + fonts so the first revealed frame is fully styled
|
// Wait for CSS + fonts so the first revealed frame is fully styled
|
||||||
|
|||||||
@@ -10,10 +10,33 @@ function portalFolder() {
|
|||||||
return portal.folder || portal.targetFolder || portal.path || 'root';
|
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function portalCanUpload() {
|
||||||
|
if (!portal) return false;
|
||||||
|
|
||||||
|
// Prefer explicit flags from backend (PortalController)
|
||||||
|
if (typeof portal.canUpload !== 'undefined') {
|
||||||
|
return !!portal.canUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks for older bundles (if you ever add these)
|
||||||
|
if (typeof portal.allowUpload !== 'undefined') {
|
||||||
|
return !!portal.allowUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy behavior: portals were always upload-capable;
|
||||||
|
// uploadOnly only controlled download visibility.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function portalCanDownload() {
|
function portalCanDownload() {
|
||||||
if (!portal) return false;
|
if (!portal) return false;
|
||||||
|
|
||||||
// Prefer explicit flags if present
|
// Prefer explicit flag if present (PortalController)
|
||||||
|
if (typeof portal.canDownload !== 'undefined') {
|
||||||
|
return !!portal.canDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to allowDownload / allowDownloads (older payloads)
|
||||||
if (typeof portal.allowDownload !== 'undefined') {
|
if (typeof portal.allowDownload !== 'undefined') {
|
||||||
return !!portal.allowDownload;
|
return !!portal.allowDownload;
|
||||||
}
|
}
|
||||||
@@ -21,7 +44,7 @@ function portalCanDownload() {
|
|||||||
return !!portal.allowDownloads;
|
return !!portal.allowDownloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: uploadOnly = true => no downloads
|
// Legacy: uploadOnly = true => no downloads
|
||||||
if (typeof portal.uploadOnly !== 'undefined') {
|
if (typeof portal.uploadOnly !== 'undefined') {
|
||||||
return !portal.uploadOnly;
|
return !portal.uploadOnly;
|
||||||
}
|
}
|
||||||
@@ -260,7 +283,7 @@ function setupPortalForm(slug) {
|
|||||||
const formSection = qs('portalFormSection');
|
const formSection = qs('portalFormSection');
|
||||||
const uploadSection = qs('portalUploadSection');
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
if (!portal || !portal.requireForm) {
|
if (!portal || !portal.requireForm || !portalCanUpload()) {
|
||||||
if (formSection) formSection.style.display = 'none';
|
if (formSection) formSection.style.display = 'none';
|
||||||
if (uploadSection) uploadSection.style.opacity = '1';
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
return;
|
return;
|
||||||
@@ -549,11 +572,21 @@ function renderPortalInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
if (subtitleEl) {
|
if (subtitleEl) {
|
||||||
const parts = [];
|
let text = '';
|
||||||
if (portal.uploadOnly) parts.push('upload only');
|
if (uploadsEnabled && downloadsEnabled) {
|
||||||
if (portalCanDownload()) parts.push('download allowed');
|
text = 'Upload & download';
|
||||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
} else if (uploadsEnabled && !downloadsEnabled) {
|
||||||
|
text = 'Upload only';
|
||||||
|
} else if (!uploadsEnabled && downloadsEnabled) {
|
||||||
|
text = 'Download only';
|
||||||
|
} else {
|
||||||
|
text = 'Access only';
|
||||||
|
}
|
||||||
|
subtitleEl.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footerEl) {
|
if (footerEl) {
|
||||||
@@ -561,6 +594,26 @@ function renderPortalInfo() {
|
|||||||
? portal.footerText.trim()
|
? portal.footerText.trim()
|
||||||
: '';
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
applyPortalFormLabels();
|
||||||
const color = portal.brandColor && portal.brandColor.trim();
|
const color = portal.brandColor && portal.brandColor.trim();
|
||||||
if (color) {
|
if (color) {
|
||||||
@@ -741,6 +794,13 @@ async function loadPortalFiles() {
|
|||||||
// ----------------- Upload -----------------
|
// ----------------- Upload -----------------
|
||||||
async function uploadFiles(fileList) {
|
async function uploadFiles(fileList) {
|
||||||
if (!portal || !fileList || !fileList.length) return;
|
if (!portal || !fileList || !fileList.length) return;
|
||||||
|
|
||||||
|
if (!portalCanUpload()) {
|
||||||
|
showToast('Uploads are disabled for this portal.');
|
||||||
|
setStatus('Uploads are disabled for this portal.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (portal.requireForm && !portalFormDone) {
|
if (portal.requireForm && !portalFormDone) {
|
||||||
showToast('Please fill in your details before uploading.');
|
showToast('Please fill in your details before uploading.');
|
||||||
return;
|
return;
|
||||||
@@ -900,11 +960,23 @@ async function uploadFiles(fileList) {
|
|||||||
|
|
||||||
// ----------------- Upload UI wiring -----------------
|
// ----------------- Upload UI wiring -----------------
|
||||||
function wireUploadUI() {
|
function wireUploadUI() {
|
||||||
const drop = qs('portalDropzone');
|
const drop = qs('portalDropzone');
|
||||||
const input = qs('portalFileInput');
|
const input = qs('portalFileInput');
|
||||||
const refreshBtn = qs('portalRefreshBtn');
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
|
||||||
if (drop && input) {
|
const uploadsEnabled = portalCanUpload();
|
||||||
|
const downloadsEnabled = portalCanDownload();
|
||||||
|
|
||||||
|
// Upload UI
|
||||||
|
if (drop) {
|
||||||
|
if (!uploadsEnabled) {
|
||||||
|
// Visually dim + disable clicks
|
||||||
|
drop.classList.add('portal-dropzone-disabled');
|
||||||
|
drop.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadsEnabled && drop && input) {
|
||||||
drop.addEventListener('click', () => input.click());
|
drop.addEventListener('click', () => input.click());
|
||||||
|
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
@@ -938,10 +1010,15 @@ function wireUploadUI() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download / refresh
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
refreshBtn.addEventListener('click', () => {
|
if (!downloadsEnabled) {
|
||||||
loadPortalFiles();
|
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() {
|
function getUserDraftContext() {
|
||||||
const all = loadResumableDraftsAll();
|
const all = loadResumableDraftsAll();
|
||||||
const userKey = getCurrentUserKey();
|
const userKey = getCurrentUserKey();
|
||||||
@@ -346,6 +354,8 @@ function setDropAreaDefault() {
|
|||||||
const fileInput = dropArea.querySelector('#file');
|
const fileInput = dropArea.querySelector('#file');
|
||||||
wireFileInputChange(fileInput);
|
wireFileInputChange(fileInput);
|
||||||
wireChooseButton();
|
wireChooseButton();
|
||||||
|
|
||||||
|
setUploadButtonVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustFolderHelpExpansion() {
|
function adjustFolderHelpExpansion() {
|
||||||
@@ -464,6 +474,8 @@ function createFileEntry(file) {
|
|||||||
|
|
||||||
li.remove();
|
li.remove();
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||||
|
setUploadButtonVisible(anyItems);
|
||||||
});
|
});
|
||||||
li.removeBtn = removeBtn;
|
li.removeBtn = removeBtn;
|
||||||
li.appendChild(removeBtn);
|
li.appendChild(removeBtn);
|
||||||
@@ -674,6 +686,7 @@ function processFiles(filesInput) {
|
|||||||
|
|
||||||
window.selectedFiles = files;
|
window.selectedFiles = files;
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
setUploadButtonVisible(files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
@@ -770,6 +783,7 @@ async function initResumableUpload() {
|
|||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
updateResumableQuery();
|
updateResumableQuery();
|
||||||
|
setUploadButtonVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function (file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
@@ -931,6 +945,7 @@ async function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
||||||
showResumableDraftBanner();
|
showResumableDraftBanner();
|
||||||
|
setUploadButtonVisible(false);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
showToast("Some files failed to upload. Please check the list.");
|
showToast("Some files failed to upload. Please check the list.");
|
||||||
@@ -1183,6 +1198,8 @@ function submitFiles(allFiles) {
|
|||||||
} else {
|
} else {
|
||||||
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
|
||||||
}
|
}
|
||||||
|
const anyItems = !!document.querySelector('li.upload-progress-item');
|
||||||
|
setUploadButtonVisible(anyItems);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error("Error fetching file list:", error);
|
console.error("Error fetching file list:", error);
|
||||||
@@ -1275,6 +1292,8 @@ function initUpload() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUploadButtonVisible(false);
|
||||||
|
|
||||||
const hasResumableFiles =
|
const hasResumableFiles =
|
||||||
useResumable &&
|
useResumable &&
|
||||||
resumableInstance &&
|
resumableInstance &&
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v2.3.1';
|
window.APP_VERSION = 'v2.3.6';
|
||||||
|
|||||||
@@ -172,6 +172,11 @@
|
|||||||
.portal-required-star {
|
.portal-required-star {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
}
|
}
|
||||||
|
.portal-dropzone.portal-dropzone-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-style: solid;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
resources/filerise-v2.3.2.png
Normal file
BIN
resources/filerise-v2.3.2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1002 KiB |
BIN
resources/filerise-v2.3.4.png
Normal file
BIN
resources/filerise-v2.3.4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 914 KiB |
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
|
# === Update FileRise to v2.3.2 (safe rsync, no composer on demo) ===
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
VER="v2.1.0"
|
VER="v2.3.2"
|
||||||
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
|
||||||
|
|
||||||
WEBROOT="/var/www"
|
WEBROOT="/var/www"
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ class AdminController
|
|||||||
$proType = $proPayload['type'] ?? null;
|
$proType = $proPayload['type'] ?? null;
|
||||||
$proEmail = $proPayload['email'] ?? null;
|
$proEmail = $proPayload['email'] ?? null;
|
||||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||||
|
$proPlan = $proPayload['plan'] ?? null;
|
||||||
|
$proExpiresAt = $proPayload['expiresAt'] ?? null;
|
||||||
|
$proMaxMajor = $proPayload['maxMajor'] ?? null;
|
||||||
|
|
||||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||||
$public = [
|
$public = [
|
||||||
@@ -169,6 +172,7 @@ class AdminController
|
|||||||
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
||||||
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
||||||
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
||||||
|
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||||
],
|
],
|
||||||
'pro' => [
|
'pro' => [
|
||||||
'active' => $proActive,
|
'active' => $proActive,
|
||||||
@@ -176,6 +180,9 @@ class AdminController
|
|||||||
'email' => $proEmail,
|
'email' => $proEmail,
|
||||||
'version' => $proVersion,
|
'version' => $proVersion,
|
||||||
'license' => $licenseString,
|
'license' => $licenseString,
|
||||||
|
'plan' => $proPlan,
|
||||||
|
'expiresAt' => $proExpiresAt,
|
||||||
|
'maxMajor' => $proMaxMajor,
|
||||||
],
|
],
|
||||||
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
|
||||||
];
|
];
|
||||||
@@ -310,99 +317,103 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
|
|
||||||
require_once $proPortalsPath;
|
require_once $proPortalsPath;
|
||||||
|
|
||||||
if (!is_array($portalsPayload)) {
|
if (!is_array($portalsPayload)) {
|
||||||
throw new InvalidArgumentException('Invalid portals format.');
|
throw new InvalidArgumentException('Invalid portals format.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$data = ['portals' => []];
|
||||||
|
$invalid = [];
|
||||||
|
|
||||||
|
foreach ($portalsPayload as $slug => $info) {
|
||||||
|
$slug = trim((string)$slug);
|
||||||
|
|
||||||
|
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']),
|
||||||
|
];
|
||||||
|
|
||||||
$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'] ?? ''));
|
|
||||||
|
|
||||||
// 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']),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($folder === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['portals'][$slug] = [
|
$data['portals'][$slug] = [
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
@@ -429,6 +440,12 @@ public function saveProPortals(array $portalsPayload): void
|
|||||||
'formVisible' => $formVisible,
|
'formVisible' => $formVisible,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (!empty($invalid)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||||
$ok = $store->savePortals($data);
|
$ok = $store->savePortals($data);
|
||||||
@@ -581,6 +598,28 @@ public function installProBundle(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NEW: normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works.
|
||||||
|
$basename = $origName;
|
||||||
|
if ($basename !== '') {
|
||||||
|
// Normalize slashes and then take basename
|
||||||
|
$basename = str_replace('\\', '/', $basename);
|
||||||
|
$basename = basename($basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the bundle version from the *basename*
|
||||||
|
// Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive)
|
||||||
|
$declaredVersion = null;
|
||||||
|
if (
|
||||||
|
$basename !== '' &&
|
||||||
|
preg_match(
|
||||||
|
'/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i',
|
||||||
|
$basename,
|
||||||
|
$m
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$declaredVersion = 'v' . $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare temp working dir
|
// Prepare temp working dir
|
||||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||||
@@ -723,20 +762,36 @@ public function installProBundle(): void
|
|||||||
// Best-effort cleanup; ignore failures
|
// Best-effort cleanup; ignore failures
|
||||||
@unlink($zipPath);
|
@unlink($zipPath);
|
||||||
@rmdir($workDir);
|
@rmdir($workDir);
|
||||||
|
|
||||||
|
// NEW: ensure OPcache picks up new Pro bundle code immediately
|
||||||
|
if (function_exists('opcache_invalidate')) {
|
||||||
|
foreach ($installed['src'] as $pathInfo) {
|
||||||
|
// strip " (overwritten)" suffix if present
|
||||||
|
$path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo);
|
||||||
|
if (is_string($path) && $path !== '' && is_file($path)) {
|
||||||
|
@opcache_invalidate($path, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reflect current Pro status in response if bootstrap was loaded
|
// Reflect current Pro status in response if bootstrap was loaded
|
||||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||||
|
|
||||||
|
$reportedVersion = $declaredVersion;
|
||||||
|
if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) {
|
||||||
|
$reportedVersion = FR_PRO_BUNDLE_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||||
? (FR_PRO_INFO['payload'] ?? null)
|
? (FR_PRO_INFO['payload'] ?? null)
|
||||||
: null;
|
: null;
|
||||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Pro bundle installed.',
|
'message' => 'Pro bundle installed.',
|
||||||
'installed' => $installed,
|
'installed' => $installed,
|
||||||
'proActive' => (bool)$proActive,
|
'proActive' => (bool)$proActive,
|
||||||
'proVersion' => $proVersion,
|
'proVersion' => $reportedVersion,
|
||||||
'proPayload' => $proPayload,
|
'proPayload' => $proPayload,
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -809,6 +864,7 @@ public function installProBundle(): void
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -948,21 +1004,22 @@ public function installProBundle(): void
|
|||||||
|
|
||||||
$merged['onlyoffice'] = $oo;
|
$merged['onlyoffice'] = $oo;
|
||||||
}
|
}
|
||||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||||
$merged['branding'] = [
|
$merged['branding'] = [
|
||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
];
|
'footerHtml' => '',
|
||||||
}
|
];
|
||||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
|
}
|
||||||
if (array_key_exists($key, $data['branding'])) {
|
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) {
|
||||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
if (array_key_exists($key, $data['branding'])) {
|
||||||
}
|
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$result = AdminModel::updateConfig($merged);
|
$result = AdminModel::updateConfig($merged);
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
final class PortalController
|
final class PortalController
|
||||||
{
|
{
|
||||||
@@ -11,29 +12,31 @@ final class PortalController
|
|||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* [
|
* [
|
||||||
* 'slug' => string,
|
* 'slug' => string,
|
||||||
* 'label' => string,
|
* 'label' => string,
|
||||||
* 'folder' => string,
|
* 'folder' => string,
|
||||||
* 'clientEmail' => string,
|
* 'clientEmail' => string,
|
||||||
* 'uploadOnly' => bool,
|
* 'uploadOnly' => bool, // stored flag (legacy name)
|
||||||
* 'allowDownload' => bool,
|
* 'allowDownload' => bool, // stored flag
|
||||||
* 'expiresAt' => string,
|
* 'expiresAt' => string,
|
||||||
* 'title' => string,
|
* 'title' => string,
|
||||||
* 'introText' => string,
|
* 'introText' => string,
|
||||||
* 'requireForm' => bool,
|
* 'requireForm' => bool,
|
||||||
* 'brandColor' => string,
|
* 'brandColor' => string,
|
||||||
* 'footerText' => string,
|
* 'footerText' => string,
|
||||||
* 'formDefaults' => array,
|
* 'formDefaults' => array,
|
||||||
* 'formRequired' => array,
|
* 'formRequired' => array,
|
||||||
* 'formLabels' => array,
|
* 'formLabels' => array,
|
||||||
* 'formVisible' => array,
|
* 'formVisible' => array,
|
||||||
* 'logoFile' => string,
|
* 'logoFile' => string,
|
||||||
* 'logoUrl' => string,
|
* 'logoUrl' => string,
|
||||||
* 'uploadMaxSizeMb' => int,
|
* 'uploadMaxSizeMb' => int,
|
||||||
* 'uploadExtWhitelist' => string,
|
* 'uploadExtWhitelist' => string,
|
||||||
* 'uploadMaxPerDay' => int,
|
* 'uploadMaxPerDay' => int,
|
||||||
* 'showThankYou' => bool,
|
* 'showThankYou' => bool,
|
||||||
* 'thankYouText' => string,
|
* 'thankYouText' => string,
|
||||||
|
* 'canUpload' => bool, // ACL + portal flags
|
||||||
|
* 'canDownload' => bool, // ACL + portal flags
|
||||||
* ]
|
* ]
|
||||||
*/
|
*/
|
||||||
public static function getPortalBySlug(string $slug): array
|
public static function getPortalBySlug(string $slug): array
|
||||||
@@ -66,21 +69,50 @@ final class PortalController
|
|||||||
|
|
||||||
$p = $portals[$slug];
|
$p = $portals[$slug];
|
||||||
|
|
||||||
$label = trim((string)($p['label'] ?? $slug));
|
// ─────────────────────────────────────────────
|
||||||
$folder = trim((string)($p['folder'] ?? ''));
|
// Normalize upload/download flags (old + new)
|
||||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
// ─────────────────────────────────────────────
|
||||||
$uploadOnly = !empty($p['uploadOnly']);
|
//
|
||||||
$allowDownload = array_key_exists('allowDownload', $p)
|
// Storage:
|
||||||
? !empty($p['allowDownload'])
|
// - OLD (no allowDownload):
|
||||||
: true;
|
// uploadOnly=true => upload yes, download no
|
||||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
//
|
||||||
|
// - NEW:
|
||||||
|
// "Allow upload" checkbox is stored as uploadOnly (🤮 name, but we keep it)
|
||||||
|
// "Allow download" checkbox is stored as allowDownload
|
||||||
|
//
|
||||||
|
// Normalized flags we want here:
|
||||||
|
// - $allowUpload (bool)
|
||||||
|
// - $allowDownload (bool)
|
||||||
|
$hasAllowDownload = array_key_exists('allowDownload', $p);
|
||||||
|
$rawUploadOnly = !empty($p['uploadOnly']); // legacy name
|
||||||
|
$rawAllowDownload = $hasAllowDownload ? !empty($p['allowDownload']) : null;
|
||||||
|
|
||||||
|
if ($hasAllowDownload) {
|
||||||
|
// New JSON – trust both checkboxes exactly
|
||||||
|
$allowUpload = $rawUploadOnly; // "Allow upload" in UI
|
||||||
|
$allowDownload = (bool)$rawAllowDownload;
|
||||||
|
} else {
|
||||||
|
// Legacy JSON – no separate allowDownload
|
||||||
|
// uploadOnly=true => upload yes, download no
|
||||||
|
// uploadOnly=false => upload yes, download yes
|
||||||
|
$allowUpload = true;
|
||||||
|
$allowDownload = !$rawUploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = trim((string)($p['label'] ?? $slug));
|
||||||
|
$folder = trim((string)($p['folder'] ?? ''));
|
||||||
|
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||||
|
|
||||||
|
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||||
|
|
||||||
// Branding + intake behavior
|
// Branding + intake behavior
|
||||||
$title = trim((string)($p['title'] ?? ''));
|
$title = trim((string)($p['title'] ?? ''));
|
||||||
$introText = trim((string)($p['introText'] ?? ''));
|
$introText = trim((string)($p['introText'] ?? ''));
|
||||||
$requireForm = !empty($p['requireForm']);
|
$requireForm = !empty($p['requireForm']);
|
||||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||||
|
|
||||||
// Defaults / required
|
// Defaults / required
|
||||||
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||||
@@ -134,11 +166,11 @@ final class PortalController
|
|||||||
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
|
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
|
||||||
|
|
||||||
// Upload rules / thank-you behavior
|
// Upload rules / thank-you behavior
|
||||||
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
||||||
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
||||||
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
||||||
$showThankYou = !empty($p['showThankYou']);
|
$showThankYou = !empty($p['showThankYou']);
|
||||||
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
||||||
|
|
||||||
if ($folder === '') {
|
if ($folder === '') {
|
||||||
throw new RuntimeException('Portal misconfigured: empty folder.');
|
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||||
@@ -152,13 +184,48 @@ final class PortalController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────
|
||||||
|
// Capability flags (portal + ACL)
|
||||||
|
// ──────────────────────────────
|
||||||
|
//
|
||||||
|
// Base from portal config:
|
||||||
|
$canUpload = (bool)$allowUpload;
|
||||||
|
$canDownload = (bool)$allowDownload;
|
||||||
|
|
||||||
|
// Refine with ACL for the current logged-in user (if any)
|
||||||
|
$user = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($user !== '') {
|
||||||
|
// Upload: must also pass folder-level ACL
|
||||||
|
if ($canUpload && !ACL::canUpload($user, $perms, $folder)) {
|
||||||
|
$canUpload = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download: require read or read_own
|
||||||
|
if (
|
||||||
|
$canDownload
|
||||||
|
&& !ACL::canRead($user, $perms, $folder)
|
||||||
|
&& !ACL::canReadOwn($user, $perms, $folder)
|
||||||
|
) {
|
||||||
|
$canDownload = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
'folder' => $folder,
|
'folder' => $folder,
|
||||||
'clientEmail' => $clientEmail,
|
'clientEmail' => $clientEmail,
|
||||||
'uploadOnly' => $uploadOnly,
|
// Store flags as-is so old code / JSON stay compatible
|
||||||
'allowDownload' => $allowDownload,
|
'uploadOnly' => (bool)$rawUploadOnly,
|
||||||
|
'allowDownload' => $hasAllowDownload
|
||||||
|
? (bool)$rawAllowDownload
|
||||||
|
: $allowDownload,
|
||||||
'expiresAt' => $expiresAt,
|
'expiresAt' => $expiresAt,
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'introText' => $introText,
|
'introText' => $introText,
|
||||||
@@ -176,6 +243,9 @@ final class PortalController
|
|||||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||||
'showThankYou' => $showThankYou,
|
'showThankYou' => $showThankYou,
|
||||||
'thankYouText' => $thankYouText,
|
'thankYouText' => $thankYouText,
|
||||||
|
// New ACL-aware caps for portal.js
|
||||||
|
'canUpload' => $canUpload,
|
||||||
|
'canDownload' => $canDownload,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,17 +110,18 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
],
|
],
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||||
$config['branding']['customLogoUrl'] ?? ''
|
$config['branding']['customLogoUrl'] ?? ''
|
||||||
),
|
),
|
||||||
'headerBgLight' => self::sanitizeColorHex(
|
'headerBgLight' => self::sanitizeColorHex(
|
||||||
$config['branding']['headerBgLight'] ?? ''
|
$config['branding']['headerBgLight'] ?? ''
|
||||||
),
|
),
|
||||||
'headerBgDark' => self::sanitizeColorHex(
|
'headerBgDark' => self::sanitizeColorHex(
|
||||||
$config['branding']['headerBgDark'] ?? ''
|
$config['branding']['headerBgDark'] ?? ''
|
||||||
),
|
),
|
||||||
],
|
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
|
||||||
|
],
|
||||||
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -261,29 +262,31 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
$configUpdate['onlyoffice'] = $norm;
|
$configUpdate['onlyoffice'] = $norm;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Branding (Pro-only). Normalize and only persist when Pro is active.
|
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
$configUpdate['branding'] = [
|
||||||
$configUpdate['branding'] = [
|
'customLogoUrl' => '',
|
||||||
'customLogoUrl' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgDark' => '',
|
||||||
'headerBgDark' => '',
|
'footerHtml' => '',
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||||
|
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
|
||||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
|
||||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||||
$configUpdate['branding']['headerBgLight'] = $light;
|
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
$configUpdate['branding']['headerBgLight'] = $light;
|
||||||
} else {
|
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||||
// Free mode: always clear branding customizations
|
$configUpdate['branding']['footerHtml'] = $footer;
|
||||||
$configUpdate['branding']['customLogoUrl'] = '';
|
} else {
|
||||||
$configUpdate['branding']['headerBgLight'] = '';
|
$configUpdate['branding']['customLogoUrl'] = '';
|
||||||
$configUpdate['branding']['headerBgDark'] = '';
|
$configUpdate['branding']['headerBgLight'] = '';
|
||||||
}
|
$configUpdate['branding']['headerBgDark'] = '';
|
||||||
}
|
$configUpdate['branding']['footerHtml'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert configuration to JSON.
|
// Convert configuration to JSON.
|
||||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||||
@@ -444,6 +447,7 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
||||||
@@ -486,6 +490,7 @@ private static function sanitizeLogoUrl($url): string
|
|||||||
'customLogoUrl' => '',
|
'customLogoUrl' => '',
|
||||||
'headerBgLight' => '',
|
'headerBgLight' => '',
|
||||||
'headerBgDark' => '',
|
'headerBgDark' => '',
|
||||||
|
'footerHtml' => '',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user