Compare commits

...

3 Commits

Author SHA1 Message Date
e0de36e734 Add CLAUDE.md with comprehensive codebase documentation
Some checks failed
CI / php-lint (8.1) (push) Has been cancelled
CI / php-lint (8.2) (push) Has been cancelled
CI / php-lint (8.3) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / dockerfile-lint (push) Has been cancelled
CI / sanity (push) Has been cancelled
Added detailed guidance for Claude Code including:
- Project overview and tech stack
- Development setup instructions
- Architecture and directory structure
- ACL system and metadata patterns
- Common development tasks
- Code conventions and security requirements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 01:17:05 +00:00
github-actions[bot]
405ed7f925 chore(release): set APP_VERSION to v2.3.6 [skip ci] 2025-12-06 11:22:31 +00:00
Ryan
6491a7b1b3 release(v2.3.6): add non-zip multi-download, richer hover preview/peak, modified sort default 2025-12-06 06:22:20 -05:00
8 changed files with 964 additions and 83 deletions

View File

@@ -1,5 +1,42 @@
# 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

288
CLAUDE.md Normal file
View 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`

View File

@@ -474,25 +474,95 @@
</div>
</div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi"
data-action="create_file"
data-when="always">
<i class="material-icons">note_add</i>
<span>Create file</span>
</button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi"
data-action="delete_selected"
data-when="any">
<i class="material-icons">delete</i>
<span>Delete selected</span>
</button>
<button type="button" class="mi"
data-action="copy_selected"
data-when="any">
<i class="material-icons">content_copy</i>
<span>Copy selected</span>
</button>
<button type="button" class="mi"
data-action="move_selected"
data-when="any">
<i class="material-icons">drive_file_move</i>
<span>Move selected</span>
</button>
<button type="button" class="mi"
data-action="download_zip"
data-when="any">
<i class="material-icons">archive</i>
<span>Download as ZIP</span>
</button>
<!-- NEW: multi-download without ZIP -->
<button type="button" class="mi"
data-action="download_plain"
data-when="any">
<i class="material-icons">file_download</i>
<span>Download (no ZIP)</span>
</button>
<button type="button" class="mi"
data-action="extract_zip"
data-when="zip">
<i class="material-icons">unarchive</i>
<span>Extract ZIP</span>
</button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi"
data-action="tag_selected"
data-when="many">
<i class="material-icons">sell</i>
<span>Tag selected</span>
</button>
<button type="button" class="mi"
data-action="preview"
data-when="one">
<i class="material-icons">visibility</i>
<span>Preview</span>
</button>
<button type="button" class="mi"
data-action="edit"
data-when="can-edit">
<i class="material-icons">edit</i>
<span>Edit</span>
</button>
<button type="button" class="mi"
data-action="rename"
data-when="one">
<i class="material-icons">drive_file_rename_outline</i>
<span>Rename</span>
</button>
<button type="button" class="mi"
data-action="tag_file"
data-when="one">
<i class="material-icons">sell</i>
<span>Tag file</span>
</button>
</div>
<div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>

View File

@@ -83,7 +83,7 @@ export async function loadCsrfToken() {
APP INIT (shared)
========================= */
export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
const saved = parseInt(localStorage.getItem('rowHeight') || '44', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
const last = localStorage.getItem('lastOpenedFolder');

View File

@@ -37,7 +37,7 @@ import {
} from './fileDragDrop.js?v={{APP_QVER}}';
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
export let sortOrder = { column: "modified", ascending: false };
const FOLDER_STRIP_PAGE_SIZE = 50;
@@ -196,6 +196,13 @@ function renderFolderStripPaged(strip, subfolders) {
drawPage(1);
}
function _trimLabel(str, max = 40) {
if (!str) return "";
const s = String(str);
if (s.length <= max) return s;
return s.slice(0, max - 1) + "…";
}
// helper to repaint one strip item quickly
function repaintStripIcon(folder) {
const el = document.querySelector(`#folderStripContainer .folder-item[data-folder="${CSS.escape(folder)}"]`);
@@ -265,16 +272,29 @@ async function fillFileSnippet(file, snippetEl) {
if (!res.ok) throw 0;
const text = await res.text();
const MAX_LINES = 6;
const MAX_CHARS = 600;
const MAX_LINES = 6;
const MAX_CHARS_TOTAL = 600;
const MAX_LINE_CHARS = 20; // ← per-line cap (tweak to taste)
const allLines = text.split(/\r?\n/);
let visibleLines = allLines.slice(0, MAX_LINES);
let snippet = visibleLines.join("\n");
let truncated = allLines.length > MAX_LINES;
if (snippet.length > MAX_CHARS) {
snippet = snippet.slice(0, MAX_CHARS);
// Take the first few lines and trim each so they don't wrap forever
let visibleLines = allLines.slice(0, MAX_LINES).map(line =>
_trimLabel(line, MAX_LINE_CHARS)
);
let truncated =
allLines.length > MAX_LINES ||
visibleLines.some((line, idx) => {
const orig = allLines[idx] || "";
return orig.length > MAX_LINE_CHARS;
});
let snippet = visibleLines.join("\n");
// Also enforce an overall character ceiling just in case
if (snippet.length > MAX_CHARS_TOTAL) {
snippet = snippet.slice(0, MAX_CHARS_TOTAL);
truncated = true;
}
@@ -286,6 +306,7 @@ async function fillFileSnippet(file, snippetEl) {
_fileSnippetCache.set(key, finalSnippet);
snippetEl.textContent = finalSnippet;
} catch {
snippetEl.textContent = "";
snippetEl.style.display = "none";
@@ -571,6 +592,13 @@ window.addEventListener('folderColorChanged', (e) => {
// Hide "Edit" for files >10 MiB
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
// Max number of files allowed for non-ZIP multi-download
const MAX_NONZIP_MULTI_DOWNLOAD = 20;
// Global queue + panel ref for stepper-style downloads
window.__nonZipDownloadQueue = window.__nonZipDownloadQueue || [];
window.__nonZipDownloadPanel = window.__nonZipDownloadPanel || null;
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
let __fileListReqSeq = 0;
@@ -812,18 +840,26 @@ function fillHoverPreviewForRow(row) {
const propsEl = el.querySelector(".hover-preview-props");
const snippetEl = el.querySelector(".hover-preview-snippet");
if (!titleEl || !metaEl || !thumbEl || !propsEl || !snippetEl) return;
// Reset content
thumbEl.innerHTML = "";
propsEl.innerHTML = "";
snippetEl.textContent = "";
snippetEl.style.display = "none";
metaEl.textContent = "";
titleEl.textContent = "";
// Reset content
thumbEl.innerHTML = "";
propsEl.innerHTML = "";
snippetEl.textContent = "";
snippetEl.style.display = "none";
metaEl.textContent = "";
titleEl.textContent = "";
// Reset per-row sizing (we only make this tall for images)
thumbEl.style.minHeight = "0";
// reset snippet style defaults (for file previews)
snippetEl.style.whiteSpace = "pre-wrap";
snippetEl.style.overflowX = "auto";
snippetEl.style.textOverflow = "clip";
snippetEl.style.wordBreak = "break-word";
// Reset per-row sizing...
thumbEl.style.minHeight = "0";
const isFolder = row.classList.contains("folder-row");
@@ -841,23 +877,61 @@ function fillHoverPreviewForRow(row) {
folder: folderPath
};
// Right column: icon + path
const iconHtml = `
// Right column: icon + path (start props array so we can append later)
const props = [];
props.push(`
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
<strong>${t("folder") || "Folder"}</strong>
</div>
`;
`);
let propsHtml = iconHtml;
propsHtml += `
props.push(`
<div class="hover-prop-line">
<strong>${t("path") || "Path"}:</strong> ${escapeHTML(folderPath || "root")}
</div>
`;
propsEl.innerHTML = propsHtml;
`);
// Meta: counts + size
propsEl.innerHTML = props.join("");
// --- Owner + "Your access" (from capabilities) --------------------
fetchFolderCaps(folderPath).then(caps => {
if (!caps || !document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
const owner = caps.owner || caps.user || "";
if (owner) {
props.push(`
<div class="hover-prop-line">
<strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(owner)}
</div>
`);
}
// Summarize what the current user can do in this folder
const perms = [];
if (caps.canUpload || caps.canCreate) perms.push(t("perm_upload") || "Upload");
if (caps.canMoveFolder) perms.push(t("perm_move") || "Move");
if (caps.canRename) perms.push(t("perm_rename") || "Rename");
if (caps.canShareFolder) perms.push(t("perm_share") || "Share");
if (caps.canDeleteFolder || caps.canDelete)
perms.push(t("perm_delete") || "Delete");
if (perms.length) {
const label = t("your_access") || "Your access";
props.push(`
<div class="hover-prop-line">
<strong>${escapeHTML(label)}:</strong> ${escapeHTML(perms.join(", "))}
</div>
`);
}
propsEl.innerHTML = props.join("");
}).catch(() => {});
// ------------------------------------------------------------------
// --- Meta: counts + size + created/modified -----------------------
fetchFolderStats(folderPath).then(stats => {
if (!stats || !document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
@@ -884,22 +958,55 @@ function fillHoverPreviewForRow(row) {
metaEl.textContent = sizeLabel
? `${pieces.join(", ")}${sizeLabel}`
: pieces.join(", ");
}).catch(() => {});
// Left side: peek inside folder (first few children)
// Optional: created / modified range under the path/owner/access
const created = typeof stats.earliest_uploaded === "string" ? stats.earliest_uploaded : "";
const modified = typeof stats.latest_mtime === "string" ? stats.latest_mtime : "";
if (modified) {
props.push(`
<div class="hover-prop-line">
<strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(modified)}
</div>
`);
}
if (created) {
props.push(`
<div class="hover-prop-line">
<strong>${t("created") || "Created"}:</strong> ${escapeHTML(created)}
</div>
`);
}
propsEl.innerHTML = props.join("");
}).catch(() => {});
// ------------------------------------------------------------------
// Left side: peek inside folder (first few children)
fetchFolderPeek(folderPath).then(result => {
if (!document.body.contains(el)) return;
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
// Folder mode: force single-line-ish behavior and avoid wrapping
snippetEl.style.whiteSpace = "pre";
snippetEl.style.wordBreak = "normal";
snippetEl.style.overflowX = "hidden";
snippetEl.style.textOverflow = "ellipsis";
if (!result) {
snippetEl.style.display = "none";
const msg =
t("no_files_or_folders") ||
t("no_files_found") ||
"No files or folders";
snippetEl.textContent = msg;
snippetEl.style.display = "block";
return;
}
const { items, truncated } = result;
// If nothing inside, show a friendly message like files do
if (!items || !items.length) {
const msg =
t("no_files_or_folders") ||
@@ -911,12 +1018,15 @@ fetchFolderPeek(folderPath).then(result => {
return;
}
const MAX_LABEL_CHARS = 42; // tweak to taste
const lines = items.map(it => {
const prefix = it.type === "folder" ? "📁 " : "📄 ";
return prefix + it.name;
const trimmed = _trimLabel(it.name, MAX_LABEL_CHARS);
return prefix + trimmed;
});
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, turn the LAST line into "…"
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, show a clean final "…"
if (truncated && lines.length) {
lines[lines.length - 1] = "…";
}
@@ -1024,6 +1134,56 @@ fetchFolderPeek(folderPath).then(result => {
props.push(`<div class="hover-prop-line"><strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(file.uploader)}</div>`);
}
// --- NEW: Tags / Metadata line ------------------------------------
(function addMetaLine() {
// Tags from backend: file.tags = [{ name, color }, ...]
const tagNames = Array.isArray(file.tags)
? file.tags
.map(t => t && t.name ? String(t.name).trim() : "")
.filter(Boolean)
: [];
// Optional extra metadata if you ever add it to fileData
const mime =
file.mime ||
file.mimetype ||
file.contentType ||
"";
const extraPieces = [];
if (mime) extraPieces.push(mime);
// Example future fields; safe even if undefined
if (Number.isFinite(file.durationSeconds)) {
extraPieces.push(`${file.durationSeconds}s`);
}
if (file.width && file.height) {
extraPieces.push(`${file.width}×${file.height}`);
}
const parts = [];
if (tagNames.length) {
parts.push(tagNames.join(", "));
}
if (extraPieces.length) {
parts.push(extraPieces.join(" • "));
}
if (!parts.length) return; // nothing to show
const useMetadataLabel = parts.length > 1 || extraPieces.length > 0;
const labelKey = useMetadataLabel ? "metadata" : "tags";
const label = t(labelKey) || (useMetadataLabel ? "MetaData" : "Tags");
props.push(
`<div class="hover-prop-line"><strong>${escapeHTML(label)}:</strong> ${escapeHTML(parts.join(" • "))}</div>`
);
})();
// ------------------------------------------------------------------
propsEl.innerHTML = props.join("");
propsEl.innerHTML = props.join("");
// Text snippet (left) for smaller text/code files
@@ -1372,6 +1532,165 @@ function formatSize(totalBytes) {
}
}
function ensureNonZipDownloadPanel() {
if (window.__nonZipDownloadPanel) return window.__nonZipDownloadPanel;
const panel = document.createElement('div');
panel.id = 'nonZipDownloadPanel';
panel.setAttribute('role', 'status');
// Simple bottom-right card using Bootstrap-ish styles + inline layout tweaks
panel.style.position = 'fixed';
panel.style.top = '50%';
panel.style.left = '50%';
panel.style.transform = 'translate(-50%, -50%)';
panel.style.zIndex = '9999';
panel.style.width = 'min(440px, 95vw)';
panel.style.minWidth = '280px';
panel.style.maxWidth = '440px';
panel.style.padding = '14px 16px';
panel.style.borderRadius = '12px';
panel.style.boxShadow = '0 18px 40px rgba(0,0,0,0.35)';
panel.style.backgroundColor = 'var(--filr-menu-bg, #222)';
panel.style.color = 'var(--filr-menu-fg, #f9fafb)';
panel.style.fontSize = '0.9rem';
panel.style.display = 'none';
panel.innerHTML = `
<div class="nonzip-title" style="margin-bottom:6px; font-weight:600;"></div>
<div class="nonzip-sub" style="margin-bottom:8px; opacity:0.85;"></div>
<div class="nonzip-actions" style="display:flex; justify-content:flex-end; gap:6px;">
<button type="button"
class="btn btn-sm btn-secondary nonzip-cancel-btn">
${t('cancel') || 'Cancel'}
</button>
<button type="button"
class="btn btn-sm btn-primary nonzip-next-btn">
${t('download_next') || 'Download next'}
</button>
</div>
`;
document.body.appendChild(panel);
const nextBtn = panel.querySelector('.nonzip-next-btn');
const cancelBtn = panel.querySelector('.nonzip-cancel-btn');
if (nextBtn) {
nextBtn.addEventListener('click', () => {
triggerNextNonZipDownload();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
clearNonZipQueue(true);
});
}
window.__nonZipDownloadPanel = panel;
return panel;
}
function updateNonZipPanelText() {
const panel = ensureNonZipDownloadPanel();
const q = window.__nonZipDownloadQueue || [];
const count = q.length;
const titleEl = panel.querySelector('.nonzip-title');
const subEl = panel.querySelector('.nonzip-sub');
if (!titleEl || !subEl) return;
if (!count) {
titleEl.textContent = t('no_files_queued') || 'No files queued.';
subEl.textContent = '';
return;
}
const title =
t('nonzip_queue_title') ||
'Files queued for download';
const raw = t('nonzip_queue_subtitle') ||
'{count} files queued. Click "Download next" for each file.';
const msg = raw.replace('{count}', String(count));
titleEl.textContent = title;
subEl.textContent = msg;
}
function showNonZipPanel() {
const panel = ensureNonZipDownloadPanel();
updateNonZipPanelText();
panel.style.display = 'block';
}
function hideNonZipPanel() {
const panel = ensureNonZipDownloadPanel();
panel.style.display = 'none';
}
function clearNonZipQueue(showToastCancel = false) {
window.__nonZipDownloadQueue = [];
hideNonZipPanel();
if (showToastCancel) {
showToast(
t('nonzip_queue_cleared') || 'Download queue cleared.',
'info'
);
}
}
function triggerNextNonZipDownload() {
const q = window.__nonZipDownloadQueue || [];
if (!q.length) {
hideNonZipPanel();
showToast(
t('downloads_started') || 'All downloads started.',
'success'
);
return;
}
const { folder, name } = q.shift();
const url = apiFileUrl(folder || 'root', name, /* inline */ false);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.style.display = 'none';
document.body.appendChild(a);
try {
a.click();
} finally {
setTimeout(() => {
if (a && a.parentNode) {
a.parentNode.removeChild(a);
}
}, 500);
}
// Update queue + UI
window.__nonZipDownloadQueue = q;
if (q.length) {
updateNonZipPanelText();
} else {
hideNonZipPanel();
showToast(
t('downloads_started') || 'All downloads started.',
'success'
);
}
}
// Optional debug helpers if you want them globally:
window.triggerNextNonZipDownload = triggerNextNonZipDownload;
window.clearNonZipQueue = clearNonZipQueue;
/**
* Build the folder summary HTML using the filtered file list.
*/
@@ -1719,7 +2038,7 @@ export async function loadFileList(folderParam) {
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "44", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
@@ -2207,7 +2526,7 @@ if (iconSpan) {
}
function syncFolderIconSizeToRowHeight() {
const cs = getComputedStyle(document.documentElement);
const raw = cs.getPropertyValue('--file-row-height') || '48px';
const raw = cs.getPropertyValue('--file-row-height') || '44px';
const rowH = parseInt(raw, 10) || 60;
const FUDGE = 1;
@@ -2262,7 +2581,7 @@ async function sortSubfoldersForCurrentOrder(subfolders) {
return base;
}
// Size sort use folder stats (bytes); keep folders as a block above files
// Size sort use folder stats (bytes)
if (col === "size" || col === "filesize") {
const statsList = await Promise.all(
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
@@ -2306,6 +2625,72 @@ async function sortSubfoldersForCurrentOrder(subfolders) {
return decorated.map(d => d.sf);
}
// NEW: Created / Uploaded sort use earliest_uploaded from stats
if (col === "uploaded" || col === "created") {
const statsList = await Promise.all(
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
);
const decorated = base.map((sf, idx) => {
const stats = statsList[idx];
let ts = 0;
if (stats && typeof stats.earliest_uploaded === "string") {
ts = parseCustomDate(String(stats.earliest_uploaded));
if (!Number.isFinite(ts)) ts = 0;
}
return { sf, ts };
});
decorated.sort((a, b) => {
if (a.ts < b.ts) return -1 * dir;
if (a.ts > b.ts) return 1 * dir;
// tie-break by name
const n1 = (a.sf.name || "").toLowerCase();
const n2 = (b.sf.name || "").toLowerCase();
if (n1 < n2) return -1 * dir;
if (n1 > n2) return 1 * dir;
return 0;
});
return decorated.map(d => d.sf);
}
// NEW: Modified sort use latest_mtime from stats
if (col === "modified") {
const statsList = await Promise.all(
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
);
const decorated = base.map((sf, idx) => {
const stats = statsList[idx];
let ts = 0;
if (stats && typeof stats.latest_mtime === "string") {
ts = parseCustomDate(String(stats.latest_mtime));
if (!Number.isFinite(ts)) ts = 0;
}
return { sf, ts };
});
decorated.sort((a, b) => {
if (a.ts < b.ts) return -1 * dir;
if (a.ts > b.ts) return 1 * dir;
// tie-break by name
const n1 = (a.sf.name || "").toLowerCase();
const n2 = (b.sf.name || "").toLowerCase();
if (n1 < n2) return -1 * dir;
if (n1 > n2) return 1 * dir;
return 0;
});
return decorated.map(d => d.sf);
}
// Default: keep folders AZ by name regardless of other sorts
base.sort((a, b) =>
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
@@ -2343,7 +2728,12 @@ export async function renderFileTable(folder, container, subfolders) {
let currentPage = window.currentPage || 1;
// Files (filtered by search)
const filteredFiles = searchFiles(searchTerm);
let filteredFiles = searchFiles(searchTerm);
// Apply current sort (Modified desc by default for you)
if (Array.isArray(filteredFiles) && filteredFiles.length) {
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
}
// Inline folders: sort once (Explorer-style A→Z)
const allSubfolders = Array.isArray(window.currentSubfolders)
@@ -2812,7 +3202,11 @@ function getMaxImageHeight() {
export function renderGalleryView(folder, container) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const filteredFiles = searchFiles(searchTerm);
let filteredFiles = searchFiles(searchTerm);
if (Array.isArray(filteredFiles) && filteredFiles.length) {
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
}
// API preview base (well build per-file URLs)
const apiBase = `/api/file/download.php?folder=${encodeURIComponent(folder)}&file=`;
@@ -3166,6 +3560,97 @@ function updateSliderConstraints() {
window.addEventListener('load', updateSliderConstraints);
window.addEventListener('resize', updateSliderConstraints);
/**
* Fallback: derive selected files from DOM checkboxes if no explicit list
* of file objects is provided.
*/
function getSelectedFilesForDownload() {
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
if (!checks.length) return [];
// checkbox values are ESCAPED names
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
if (!selectedEsc.length) return [];
const escSet = new Set(selectedEsc);
const files = Array.isArray(fileData)
? fileData.filter(f => escSet.has(escapeHTML(f.name)))
: [];
return files.map(f => ({
folder: f.folder || window.currentFolder || 'root',
name: f.name
}));
}
/**
* Push selected files into a stepper queue and show the
* bottom-right panel with "Download next / Cancel".
*
* Expects `fileObjs` to be an array of file objects from `fileData`
* (e.g. currentSelection().files in fileMenu.js).
*/
export function downloadSelectedFilesIndividually(fileObjs) {
const src = Array.isArray(fileObjs) ? fileObjs : [];
if (!src.length) {
showToast(t('no_files_selected') || 'No files selected.', 'warning');
return;
}
const mapped = src.map(f => ({
folder: f.folder || window.currentFolder || 'root',
name: f.name
}));
const limit = window.maxNonZipDownloads || MAX_NONZIP_MULTI_DOWNLOAD;
if (mapped.length > limit) {
const msg =
t('too_many_plain_downloads') ||
`You selected ${mapped.length} files. For more than ${limit} files, please use "Download as ZIP".`;
showToast(msg, 'warning');
return;
}
// Replace any existing queue with the new one.
window.__nonZipDownloadQueue = mapped.slice();
// Show the panel; user will click "Download next" for each file.
showNonZipPanel();
// auto-fire the first file here:
triggerNextNonZipDownload();
}
function compareFilesForSort(a, b) {
const column = sortOrder?.column || "uploaded";
const ascending = sortOrder?.ascending !== false;
let valA = a[column] ?? "";
let valB = b[column] ?? "";
if (column === "size" || column === "filesize") {
// numeric size
valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0;
valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0;
} else if (column === "modified" || column === "uploaded") {
// date sort (newest/oldest)
const parsedA = parseCustomDate(String(valA || ""));
const parsedB = parseCustomDate(String(valB || ""));
valA = parsedA;
valB = parsedB;
} else {
if (typeof valA === "string") valA = valA.toLowerCase();
if (typeof valB === "string") valB = valB.toLowerCase();
}
if (valA < valB) return ascending ? -1 : 1;
if (valA > valB) return ascending ? 1 : -1;
return 0;
}
export function sortFiles(column, folder) {
if (sortOrder.column === column) {
sortOrder.ascending = !sortOrder.ascending;
@@ -3174,28 +3659,8 @@ export function sortFiles(column, folder) {
sortOrder.ascending = true;
}
fileData.sort((a, b) => {
let valA = a[column] || "";
let valB = b[column] || "";
if (column === "size" || column === "filesize") {
// numeric size
valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0;
valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0;
} else if (column === "modified" || column === "uploaded") {
const parsedA = parseCustomDate(valA);
const parsedB = parseCustomDate(valB);
valA = parsedA;
valB = parsedB;
} else if (typeof valA === "string") {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return sortOrder.ascending ? -1 : 1;
if (valA > valB) return sortOrder.ascending ? 1 : -1;
return 0;
});
// Re-sort master fileData
fileData.sort(compareFilesForSort);
if (window.viewMode === "gallery") {
renderGalleryView(folder);
@@ -3299,4 +3764,5 @@ window.loadFileList = loadFileList;
window.renderFileTable = renderFileTable;
window.renderGalleryView = renderGalleryView;
window.sortFiles = sortFiles;
window.toggleAdvancedSearch = toggleAdvancedSearch;
window.toggleAdvancedSearch = toggleAdvancedSearch;
window.downloadSelectedFilesIndividually = downloadSelectedFilesIndividually;

View File

@@ -8,10 +8,11 @@ import {
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { canEditFile, fileData, downloadSelectedFilesIndividually } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
const MENU_ID = 'fileContextMenu';
function qMenu() { return document.getElementById(MENU_ID); }
@@ -31,7 +32,9 @@ function localizeMenu() {
'preview': 'preview',
'edit': 'edit',
'rename': 'rename',
'tag_file': 'tag_file'
'tag_file': 'tag_file',
// NEW:
'download_plain': 'download_plain'
};
Object.entries(map).forEach(([action, key]) => {
const el = m.querySelector(`.mi[data-action="${action}"]`);
@@ -187,6 +190,10 @@ function menuClickDelegate(ev) {
case 'move_selected': handleMoveSelected(new Event('click')); break;
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
case 'download_plain':
// Uses current checkbox selection; limit enforced in fileListView
downloadSelectedFilesIndividually(s.files);
break;
case 'tag_selected':
openMultiTagModal(s.files); // s.files are the real file objects

View File

@@ -353,7 +353,20 @@ const translations = {
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"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: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

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