Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0de36e734 | |||
|
|
405ed7f925 | ||
|
|
6491a7b1b3 | ||
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad | ||
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 | ||
|
|
e2d1b705bd | ||
|
|
4798afa89e | ||
|
|
da968e51e1 | ||
|
|
c06452600d | ||
|
|
758ad7719b | ||
|
|
3587f5041c | ||
|
|
da14d204a6 | ||
|
|
2a87002e1f | ||
|
|
4b83facc97 | ||
|
|
3e473d57b4 | ||
|
|
f2ce43f18f | ||
|
|
a50fa30db2 | ||
|
|
d6631adc2d | ||
|
|
997e5067d3 | ||
|
|
1c0ac50048 | ||
|
|
8fc716387b | ||
|
|
fe3a58924b | ||
|
|
47b4cc4489 | ||
|
|
3f0d1780a1 | ||
|
|
3b62e27c7c | ||
|
|
f967134631 | ||
|
|
6b93d65d6a | ||
|
|
1856325b1f | ||
|
|
9e6da52691 | ||
|
|
959206c91c | ||
|
|
837deddec5 | ||
|
|
2810b97568 | ||
|
|
175c5f962f | ||
|
|
827e65e367 | ||
|
|
fd8029a6bf | ||
|
|
de79395c3d | ||
|
|
aa6f40bc24 | ||
|
|
abc105e087 | ||
|
|
d3bcac4db0 | ||
|
|
0b065111b0 | ||
|
|
3589a1c232 | ||
|
|
1b4a93b060 | ||
|
|
bf077b142b | ||
|
|
f78e2f3f16 | ||
|
|
08a84419f0 | ||
|
|
49d3588322 | ||
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d | ||
|
|
5613710411 | ||
|
|
08f7ffccbc | ||
|
|
ad1d41fad8 | ||
|
|
99662cd2f2 | ||
|
|
060a548af4 | ||
|
|
9880adb417 | ||
|
|
a56641e81c | ||
|
|
3b636f69d8 | ||
|
|
930ed954ec | ||
|
|
402f590163 | ||
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 | ||
|
|
a9fe342175 | ||
|
|
7669f5a10b | ||
|
|
34a4e06a23 | ||
|
|
d00faf5fe7 | ||
|
|
ad8cbc601a | ||
|
|
40e000b5bc | ||
|
|
eee25a4dc6 | ||
|
|
d66f4d93cb | ||
|
|
f4f7f8ef38 | ||
|
|
0ccba45c40 | ||
|
|
620c916eb3 | ||
|
|
f809cc09d2 | ||
|
|
6758b5f73d | ||
|
|
30a0aaf05e | ||
|
|
c843f00738 | ||
|
|
4bb9d81370 | ||
|
|
29e0497730 | ||
|
|
dd3a7a5145 | ||
|
|
d00db803c3 |
157
.github/workflows/release-on-version.yml
vendored
@@ -2,13 +2,18 @@
|
|||||||
name: Release on version.js update
|
name: Release on version.js update
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: ["master"]
|
|
||||||
paths:
|
|
||||||
- public/js/version.js
|
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Ref (branch/sha) to build from (default: master)"
|
||||||
|
required: false
|
||||||
|
version:
|
||||||
|
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||||
|
required: false
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -16,32 +21,64 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: |
|
||||||
|
github.event_name == 'push' ||
|
||||||
|
github.event_name == 'workflow_dispatch'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}-${{ github.sha }}
|
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Resolve source ref
|
||||||
|
id: pickref
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||||
|
REF_IN="${{ github.event.inputs.ref }}"
|
||||||
|
else
|
||||||
|
REF_IN="master"
|
||||||
|
fi
|
||||||
|
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
|
||||||
|
REF="$REF_IN"
|
||||||
|
else
|
||||||
|
REF="$REF_IN"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
REF="${{ github.sha }}"
|
||||||
|
fi
|
||||||
|
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Using ref=$REF"
|
||||||
|
|
||||||
|
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ steps.pickref.outputs.ref }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Ensure tags available
|
- name: Determine version
|
||||||
run: |
|
|
||||||
git fetch --tags --force --prune --quiet
|
|
||||||
|
|
||||||
- name: Read version from version.js
|
|
||||||
id: ver
|
id: ver
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")
|
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||||
if [[ -z "$VER" ]]; then
|
VER="${{ github.event.inputs.version }}"
|
||||||
echo "Could not parse APP_VERSION from version.js" >&2
|
else
|
||||||
exit 1
|
if [[ ! -f public/js/version.js ]]; then
|
||||||
|
echo "public/js/version.js not found; cannot auto-detect version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
|
||||||
|
if [[ -z "$VER" ]]; then
|
||||||
|
echo "Could not parse APP_VERSION from public/js/version.js" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||||
echo "Parsed version: $VER"
|
echo "Detected version: $VER"
|
||||||
|
|
||||||
- name: Skip if tag already exists
|
- name: Skip if tag already exists
|
||||||
id: tagcheck
|
id: tagcheck
|
||||||
@@ -55,8 +92,7 @@ jobs:
|
|||||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure the stamper is executable and has LF endings (helps if edited on Windows)
|
- name: Prepare stamp script
|
||||||
- name: Prep stamper script
|
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -64,55 +100,89 @@ jobs:
|
|||||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||||
chmod +x scripts/stamp-assets.sh
|
chmod +x scripts/stamp-assets.sh
|
||||||
|
|
||||||
- name: Build zip artifact (stamped)
|
- name: Build stamped staging tree
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VER="${{ steps.ver.outputs.version }}" # e.g. v1.6.12
|
VER="${{ steps.ver.outputs.version }}"
|
||||||
ZIP="FileRise-${VER}.zip"
|
|
||||||
|
|
||||||
# Clean staging copy (exclude dotfiles you don’t want)
|
|
||||||
rm -rf staging
|
rm -rf staging
|
||||||
rsync -a \
|
rsync -a \
|
||||||
--exclude '.git' --exclude '.github' \
|
--exclude '.git' --exclude '.github' \
|
||||||
--exclude 'resources' \
|
--exclude 'resources' \
|
||||||
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
|
--exclude '.dockerignore' --exclude '.gitattributes' --exclude '.gitignore' \
|
||||||
./ staging/
|
./ staging/
|
||||||
|
|
||||||
# Stamp IN THE STAGING COPY (invoke via bash to avoid exec-bit issues)
|
|
||||||
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
|
||||||
|
|
||||||
- name: Verify placeholders are gone (staging)
|
# --- PHP + Composer for vendor/ (production) ---
|
||||||
|
- name: Setup PHP
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
id: php
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
tools: composer:v2
|
||||||
|
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
|
||||||
|
coverage: none
|
||||||
|
ini-values: memory_limit=-1
|
||||||
|
|
||||||
|
- name: Cache Composer downloads
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.composer/cache
|
||||||
|
~/.cache/composer
|
||||||
|
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
|
||||||
|
|
||||||
|
- name: Install PHP dependencies into staging
|
||||||
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
|
env:
|
||||||
|
COMPOSER_MEMORY_LIMIT: -1
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pushd staging >/dev/null
|
||||||
|
if [[ -f composer.json ]]; then
|
||||||
|
composer install \
|
||||||
|
--no-dev \
|
||||||
|
--prefer-dist \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress \
|
||||||
|
--optimize-autoloader \
|
||||||
|
--classmap-authoritative
|
||||||
|
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
|
||||||
|
else
|
||||||
|
echo "No composer.json in staging; skipping vendor install."
|
||||||
|
fi
|
||||||
|
popd >/dev/null
|
||||||
|
# --- end Composer ---
|
||||||
|
|
||||||
|
- name: Verify placeholders removed (skip vendor/)
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(pwd)/staging"
|
ROOT="$(pwd)/staging"
|
||||||
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
||||||
|
--exclude-dir=vendor --exclude-dir=vendor-bin \
|
||||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
|
||||||
echo "---- DEBUG (show 10 hits with context) ----"
|
echo "Unreplaced placeholders found in staging." >&2
|
||||||
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
|
|
||||||
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
|
|
||||||
| head -n 10 | while IFS=: read -r file line _; do
|
|
||||||
echo ">>> $file:$line"
|
|
||||||
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
|
|
||||||
echo "----------------------------------------"
|
|
||||||
done
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: No unreplaced placeholders in staging."
|
echo "OK: No unreplaced placeholders."
|
||||||
|
|
||||||
- name: Zip stamped staging
|
- name: Zip artifact (includes vendor/)
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VER="${{ steps.ver.outputs.version }}"
|
VER="${{ steps.ver.outputs.version }}"
|
||||||
ZIP="FileRise-${VER}.zip"
|
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||||
(cd staging && zip -r "../$ZIP" . >/dev/null)
|
|
||||||
|
|
||||||
- name: Compute SHA-256 checksum
|
- name: Compute SHA-256
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
id: sum
|
id: sum
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -157,9 +227,9 @@ jobs:
|
|||||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||||
fi
|
fi
|
||||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||||
echo "Previous tag or baseline: $PREV"
|
echo "Previous tag/baseline: $PREV"
|
||||||
|
|
||||||
- name: Build release body (snippet + full changelog + checksum)
|
- name: Build release body
|
||||||
if: steps.tagcheck.outputs.exists == 'false'
|
if: steps.tagcheck.outputs.exists == 'false'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -170,7 +240,6 @@ jobs:
|
|||||||
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
|
COMPARE_URL="https://github.com/${REPO}/compare/${PREV}...${VER}"
|
||||||
ZIP="FileRise-${VER}.zip"
|
ZIP="FileRise-${VER}.zip"
|
||||||
SHA="${{ steps.sum.outputs.sha }}"
|
SHA="${{ steps.sum.outputs.sha }}"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo
|
echo
|
||||||
if [[ -s CHANGELOG_SNIPPET.md ]]; then
|
if [[ -s CHANGELOG_SNIPPET.md ]]; then
|
||||||
@@ -186,8 +255,6 @@ jobs:
|
|||||||
echo "${SHA} ${ZIP}"
|
echo "${SHA} ${ZIP}"
|
||||||
echo '```'
|
echo '```'
|
||||||
} > RELEASE_BODY.md
|
} > RELEASE_BODY.md
|
||||||
|
|
||||||
echo "Release body:"
|
|
||||||
sed -n '1,200p' RELEASE_BODY.md
|
sed -n '1,200p' RELEASE_BODY.md
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
@@ -195,7 +262,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.ver.outputs.version }}
|
tag_name: ${{ steps.ver.outputs.version }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ steps.pickref.outputs.ref }}
|
||||||
name: ${{ steps.ver.outputs.version }}
|
name: ${{ steps.ver.outputs.version }}
|
||||||
body_path: RELEASE_BODY.md
|
body_path: RELEASE_BODY.md
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
|
|||||||
1211
CHANGELOG.md
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`
|
||||||
514
README.md
@@ -7,172 +7,107 @@
|
|||||||
[](https://demo.filerise.net)
|
[](https://demo.filerise.net)
|
||||||
[](https://github.com/error311/FileRise/releases)
|
[](https://github.com/error311/FileRise/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://discord.gg/7WN6f56X2e)
|
||||||
[](https://github.com/sponsors/error311)
|
[](https://github.com/sponsors/error311)
|
||||||
[](https://ko-fi.com/error311)
|
[](https://ko-fi.com/error311)
|
||||||
|
|
||||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [FAQ](#faq--troubleshooting)
|
**FileRise** is a modern, self-hosted web file manager / WebDAV server.
|
||||||
|
Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||||
|
|
||||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
- 💾 **Self-hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||||
Upload, organize, and share files or folders through a sleek, responsive web interface.
|
- 🔐 **Granular per-folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||||
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
|
- 🔄 **Fast drag-and-drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||||
|
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||||
|
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||||
|
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||||
|
- 📊 **Storage / disk usage summary** – CLI scanner with snapshots, total usage, and per-volume breakdowns in the admin panel.
|
||||||
|
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||||
|
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||||
|
- 👥 **Pro: user groups, client portals & storage explorer** – Group-based ACLs, brandable client upload portals, and an ncdu-style explorer to drill into folders, largest files, and clean up storage inline.
|
||||||
|
|
||||||
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
|
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||||
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
|
|
||||||
|
|
||||||
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
|

|
||||||
|
|
||||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
> 💡 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).
|
||||||
**10/25/2025 Video demo:**
|
|
||||||
|
|
||||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
|
||||||
|
|
||||||
**Dark mode:**
|
|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
## Quick links
|
||||||
|
|
||||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
|
- 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
|
||||||
|
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
|
||||||
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
|
- [Features overview](https://github.com/error311/FileRise/wiki/Features)
|
||||||
|
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||||
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
|
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||||
|
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
||||||
- 🔐 **Granular Access Control (ACL):**
|
- 💬 **Discord:** [Join the FileRise server](https://discord.gg/YOUR_CODE_HERE)
|
||||||
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||||
Each grant controls specific actions across the UI, API, and WebDAV:
|
|
||||||
|
|
||||||
| Permission | Description |
|
|
||||||
|-------------|-------------|
|
|
||||||
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
|
|
||||||
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
|
|
||||||
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
|
|
||||||
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
|
|
||||||
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
|
|
||||||
| **Upload** | Allows uploading new files without granting full write privileges. |
|
|
||||||
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
|
|
||||||
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
|
|
||||||
|
|
||||||
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
|
||||||
|
|
||||||
ACL enforcement is centralized and atomic across:
|
|
||||||
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
|
|
||||||
- **API Endpoints:** All file/folder operations validate server-side.
|
|
||||||
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
|
|
||||||
|
|
||||||
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
|
|
||||||
- Listings require **View** or **View (Own)**.
|
|
||||||
- Uploads require **Upload**.
|
|
||||||
- Overwrites require **Edit**.
|
|
||||||
- Deletes require **Delete**.
|
|
||||||
- Creating folders requires **Create** or **Manage**.
|
|
||||||
- All ACLs and ownership rules are enforced exactly as in the web UI.
|
|
||||||
|
|
||||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
|
||||||
|
|
||||||
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
|
||||||
|
|
||||||
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
|
||||||
|
|
||||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
|
||||||
|
|
||||||
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
|
||||||
|
|
||||||
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
|
|
||||||
|
|
||||||
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
|
|
||||||
|
|
||||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
|
|
||||||
|
|
||||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Live Demo
|
## 1. What FileRise does
|
||||||
|
|
||||||
[](https://demo.filerise.net)
|
FileRise turns a folder on your server into a **web-based file explorer** with:
|
||||||
**Demo credentials:** `demo` / `demo`
|
|
||||||
|
|
||||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). **The demo is read-only for security.** Explore the interface, switch themes, preview files, and see FileRise in action!
|
- Folder tree + breadcrumbs for fast navigation
|
||||||
|
- Multi-file/folder drag-and-drop uploads
|
||||||
|
- Move / copy / rename / delete / extract ZIP
|
||||||
|
- Public share links (optionally password-protected & expiring)
|
||||||
|
- Tagging and search by name, tag, uploader, and content
|
||||||
|
- Trash with restore/purge
|
||||||
|
- Inline previews (images, audio, video, PDF) and a built-in code editor
|
||||||
|
|
||||||
|
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation & Setup
|
## 2. Install (Docker – recommended)
|
||||||
|
|
||||||
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
|
The easiest way to run FileRise is the official Docker image.
|
||||||
|
|
||||||
---
|
### Option A – Quick start (docker run)
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `TIMEZONE` | `UTC` | PHP/app timezone. |
|
|
||||||
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
|
|
||||||
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
|
|
||||||
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
|
|
||||||
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
|
|
||||||
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
|
|
||||||
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
|
|
||||||
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
|
|
||||||
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1) Running with Docker (Recommended)
|
|
||||||
|
|
||||||
#### Pull the image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker pull error311/filerise-docker:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Run a container
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name filerise \
|
--name filerise \
|
||||||
-p 8080:80 \
|
-p 8080:80 \
|
||||||
-e TIMEZONE="America/New_York" \
|
-e TIMEZONE="America/New_York" \
|
||||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
-e TOTAL_UPLOAD_SIZE="10G" \
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
|
||||||
-e SECURE="false" \
|
-e SECURE="false" \
|
||||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||||
-e PUID="1000" \
|
|
||||||
-e PGID="1000" \
|
|
||||||
-e CHOWN_ON_START="true" \
|
|
||||||
-e SCAN_ON_START="true" \
|
-e SCAN_ON_START="true" \
|
||||||
-e SHARE_URL="" \
|
-e CHOWN_ON_START="true" \
|
||||||
-v ~/filerise/uploads:/var/www/uploads \
|
-v ~/filerise/uploads:/var/www/uploads \
|
||||||
-v ~/filerise/users:/var/www/users \
|
-v ~/filerise/users:/var/www/users \
|
||||||
-v ~/filerise/metadata:/var/www/metadata \
|
-v ~/filerise/metadata:/var/www/metadata \
|
||||||
error311/filerise-docker:latest
|
error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
|
Then visit:
|
||||||
|
|
||||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
```text
|
||||||
|
http://your-server-ip:8080
|
||||||
**Notes**
|
|
||||||
|
|
||||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
|
||||||
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
|
|
||||||
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
|
||||||
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
|
|
||||||
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
|
||||||
|
|
||||||
**Verify ownership mapping (optional)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it filerise id www-data
|
|
||||||
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Using Docker Compose
|
On first launch you’ll be guided through creating the **initial admin user**.
|
||||||
|
|
||||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
> 💡 After the first run, you can set `CHOWN_ON_START="false"` if permissions are already correct and you don’t want a recursive `chown` on every start.
|
||||||
|
|
||||||
|
> ⚠️ **Uploads folder recommendation**
|
||||||
|
>
|
||||||
|
> It’s strongly recommended to bind `/var/www/uploads` to a **dedicated folder**
|
||||||
|
> (for example `~/filerise/uploads` or `/mnt/user/appdata/FileRise/uploads`),
|
||||||
|
> not the root of a huge media share.
|
||||||
|
>
|
||||||
|
> If you really want FileRise to sit “on top of” an existing share, use a
|
||||||
|
> subfolder (e.g. `/mnt/user/media/filerise_root`) instead of the share root,
|
||||||
|
> so scans and permission changes stay scoped to that folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B – docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -182,252 +117,177 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
TIMEZONE: "UTC"
|
TIMEZONE: "America/New_York"
|
||||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
TOTAL_UPLOAD_SIZE: "10G"
|
||||||
SECURE: "false"
|
SECURE: "false"
|
||||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||||
# Ownership & indexing
|
SCAN_ON_START: "true" # auto-index existing files on startup
|
||||||
PUID: "1000" # Unraid users often use 99
|
CHOWN_ON_START: "true" # fix permissions on uploads/users/metadata on startup
|
||||||
PGID: "1000" # Unraid users often use 100
|
|
||||||
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
|
||||||
SCAN_ON_START: "true" # index files added outside the UI at boot
|
|
||||||
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
|
||||||
SHARE_URL: ""
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/var/www/uploads
|
- ./uploads:/var/www/uploads
|
||||||
- ./users:/var/www/users
|
- ./users:/var/www/users
|
||||||
- ./metadata:/var/www/metadata
|
- ./metadata:/var/www/metadata
|
||||||
restart: unless-stopped
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Access at `http://localhost:8080` (or your server’s IP).
|
Bring it up with:
|
||||||
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
|
||||||
|
|
||||||
- “`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
|
```bash
|
||||||
|
docker compose up -d
|
||||||
**First-time Setup**
|
```
|
||||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2) Manual Installation (PHP/Apache)
|
### Common environment variables
|
||||||
|
|
||||||
If you prefer a traditional web server (LAMP stack or similar):
|
| Variable | Required | Example | What it does |
|
||||||
|
|-------------------------|----------|----------------------------------|-------------------------------------------------------------------------------|
|
||||||
|
| `TIMEZONE` | ✅ | `America/New_York` | PHP / container timezone. |
|
||||||
|
| `TOTAL_UPLOAD_SIZE` | ✅ | `10G` | Max total upload size per request (e.g. `5G`, `10G`). |
|
||||||
|
| `SECURE` | ✅ | `false` | `true` when running behind HTTPS / reverse proxy, else `false`. |
|
||||||
|
| `PERSISTENT_TOKENS_KEY` | ✅ | `default_please_change_this_key` | Secret used to sign “remember me” tokens. **Change this.** |
|
||||||
|
| `SCAN_ON_START` | Optional | `true` | If `true`, scan `uploads/` on startup and index existing files. |
|
||||||
|
| `CHOWN_ON_START` | Optional | `true` | If `true`, chown `uploads/`, `users/`, `metadata/` on startup. |
|
||||||
|
| `DATE_TIME_FORMAT` | Optional | `Y-m-d H:i` | Overrides `DATE_TIME_FORMAT` in `config.php` (controls how dates are shown). |
|
||||||
|
|
||||||
|
> If `DATE_TIME_FORMAT` is not set, FileRise uses the default from `config/config.php`
|
||||||
|
> (currently `m/d/y h:iA`).
|
||||||
|
> 🗂 **Using an existing folder tree**
|
||||||
|
>
|
||||||
|
> - Point `/var/www/uploads` at the folder you want FileRise to manage.
|
||||||
|
> - Set `SCAN_ON_START="true"` on the first run to index existing files, then
|
||||||
|
> usually set it to `"false"` so the container doesn’t rescan on every restart.
|
||||||
|
> - `CHOWN_ON_START="true"` is handy on first run to fix permissions. If you map
|
||||||
|
> a large share or already manage ownership yourself, set it to `"false"` to
|
||||||
|
> avoid recursive `chown` on every start.
|
||||||
|
>
|
||||||
|
> Volumes:
|
||||||
|
> - `/var/www/uploads` – your actual files
|
||||||
|
> - `/var/www/users` – user & pro jsons
|
||||||
|
> - `/var/www/metadata` – tags, search index, share links, etc.
|
||||||
|
|
||||||
|
**More Docker / orchestration options (Unraid, Portainer, k8s, reverse proxy, etc.)**
|
||||||
|
- [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
||||||
|
- [Nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
||||||
|
- [FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
||||||
|
- [Kubernetes / k8s deployment](https://github.com/error311/FileRise/wiki/Kubernetes---k8s-deployment)
|
||||||
|
- Portainer templates: add this URL in Portainer → Settings → App Templates:
|
||||||
|
`https://raw.githubusercontent.com/error311/filerise-portainer-templates/refs/heads/main/templates.json`
|
||||||
|
- See also the Docker repo: [error311/filerise-docker](https://github.com/error311/filerise-docker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Manual install (PHP web server)
|
||||||
|
|
||||||
|
Prefer bare-metal or your own stack? FileRise is just PHP + a few extensions.
|
||||||
|
|
||||||
**Requirements**
|
**Requirements**
|
||||||
|
|
||||||
- PHP **8.3+**
|
- PHP **8.3+**
|
||||||
- Apache (mod_php) or another web server configured for PHP
|
- Web server (Apache / Nginx / Caddy + PHP-FPM)
|
||||||
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
|
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
||||||
|
- No database required
|
||||||
|
|
||||||
**Download Files**
|
**Steps**
|
||||||
|
|
||||||
```bash
|
1. Clone or download FileRise into your web root:
|
||||||
git clone https://github.com/error311/FileRise.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
```bash
|
||||||
|
git clone https://github.com/error311/FileRise.git
|
||||||
|
```
|
||||||
|
|
||||||
**Composer (if applicable)**
|
2. Create data directories and set permissions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer install
|
cd FileRise
|
||||||
```
|
mkdir -p uploads users metadata
|
||||||
|
chown -R www-data:www-data uploads users metadata # adjust for your web user
|
||||||
|
chmod -R 775 uploads users metadata
|
||||||
|
```
|
||||||
|
|
||||||
**Folders & Permissions**
|
3. (Optional) Install PHP dependencies with Composer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p uploads users metadata
|
composer install
|
||||||
chown -R www-data:www-data uploads users metadata # use your web user
|
```
|
||||||
chmod -R 775 uploads users metadata
|
|
||||||
```
|
|
||||||
|
|
||||||
- `uploads/`: actual files
|
4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
|
||||||
- `users/`: credentials & token storage
|
- Apache: allow `.htaccess` or copy its rules into your vhost.
|
||||||
- `metadata/`: file metadata (tags, share links, etc.)
|
- Nginx/Caddy: mirror the basic protections (no directory listing, block sensitive files).
|
||||||
|
|
||||||
**Configuration**
|
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
||||||
|
|
||||||
Edit `config.php`:
|
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki [Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup).
|
||||||
|
|
||||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
|
||||||
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
|
||||||
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
|
||||||
|
|
||||||
**Share link base URL**
|
|
||||||
|
|
||||||
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
|
||||||
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
|
||||||
- If neither is set, FileRise auto-detects from the current host/scheme.
|
|
||||||
|
|
||||||
**Web server config**
|
|
||||||
|
|
||||||
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
|
||||||
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
|
||||||
|
|
||||||
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3) Admins
|
## 4. WebDAV & ONLYOFFICE (optional)
|
||||||
|
|
||||||
> **Admins in ACL UI**
|
### WebDAV
|
||||||
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
|
|
||||||
|
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
|
||||||
|
|
||||||
|
- **macOS Finder** – Go → Connect to Server → `https://your-host/webdav.php/`
|
||||||
|
- **Windows File Explorer** – Map Network Drive → `https://your-host/webdav.php/`
|
||||||
|
- **Linux (GVFS/Nautilus)** – `dav://your-host/webdav.php/`
|
||||||
|
- Clients like **Cyberduck**, **WinSCP**, etc.
|
||||||
|
|
||||||
|
WebDAV operations honor the same ACLs as the web UI.
|
||||||
|
|
||||||
|
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||||
|
|
||||||
|
### ONLYOFFICE integration
|
||||||
|
|
||||||
|
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view-only).
|
||||||
|
|
||||||
|
Configure it in **Admin → ONLYOFFICE**:
|
||||||
|
|
||||||
|
- Enable ONLYOFFICE
|
||||||
|
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||||
|
- Configure a shared JWT secret
|
||||||
|
- Copy the suggested Content-Security-Policy header into your reverse proxy
|
||||||
|
|
||||||
|
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unraid
|
## 5. Security & updates
|
||||||
|
|
||||||
- Install from **Community Apps** → search **FileRise**.
|
- FileRise is actively maintained and has published security advisories.
|
||||||
- Default **bridge**: access at `http://SERVER_IP:8080/`.
|
- See **SECURITY.md** and GitHub Security Advisories for details.
|
||||||
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
|
- To upgrade:
|
||||||
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
|
- **Docker:** `docker pull error311/filerise-docker:latest` and recreate the container with the same volumes.
|
||||||
|
- **Manual:** replace app files with the latest release (keep `uploads/`, `users/`, `metadata/`, and your config).
|
||||||
|
|
||||||
|
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Upgrade
|
## 6. Community, support & contributing
|
||||||
|
|
||||||
```bash
|
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||||
docker pull error311/filerise-docker:latest
|
- 💬 **Unraid forum thread:** for Unraid-specific setup and tuning.
|
||||||
docker stop filerise && docker rm filerise
|
- 🌍 **Reddit / self-hosting communities:** occasional release posts & feedback threads.
|
||||||
# re-run with the same -v and -e flags you used originally
|
|
||||||
```
|
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||||
|
|
||||||
|
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
|
||||||
|
|
||||||
|
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||||
|
- ☕ [Ko-fi](https://ko-fi.com/error311)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick-start: Mount via WebDAV
|
## 7. License & third-party code
|
||||||
|
|
||||||
Once FileRise is running, enable WebDAV in the admin panel.
|
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||||
|
|
||||||
```bash
|
It bundles a small set of well-known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||||
# Linux (GVFS/GIO)
|
All third-party code remains under its original licenses.
|
||||||
gio mount dav://demo@your-host/webdav.php/
|
|
||||||
|
|
||||||
# macOS (Finder → Go → Connect to Server…)
|
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||||
https://your-host/webdav.php/
|
|
||||||
```
|
|
||||||
|
|
||||||
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
|
## 8. Press
|
||||||
|
|
||||||
### Windows (File Explorer)
|
- [Heise / iX Magazin – “FileRise 2.0: Web-Dateimanager mit Client Portals” (DE)](https://www.heise.de/news/FileRise-2-0-Web-Dateimanager-mit-Client-Portals-11092171.html)
|
||||||
|
- [Heise / iX Magazin – “FileRise 2.0: Web File Manager with Client Portals” (EN)](https://www.heise.de/en/news/FileRise-2-0-Web-File-Manager-with-Client-Portals-11092376.html)
|
||||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
|
||||||
- Choose a drive letter (e.g., `Z:`).
|
|
||||||
- In **Folder**, enter:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://your-host/webdav.php/
|
|
||||||
```
|
|
||||||
|
|
||||||
- Check **Connect using different credentials**, then enter your FileRise username/password.
|
|
||||||
- Click **Finish**.
|
|
||||||
|
|
||||||
> **Important:**
|
|
||||||
> Windows requires HTTPS (SSL) for WebDAV connections by default.
|
|
||||||
> If your server uses plain HTTP, you must adjust a registry setting:
|
|
||||||
>
|
|
||||||
> 1. Open **Registry Editor** (`regedit.exe`).
|
|
||||||
> 2. Navigate to:
|
|
||||||
>
|
|
||||||
> ```text
|
|
||||||
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
|
|
||||||
> 4. Set its value to `2`.
|
|
||||||
> 5. Restart the **WebClient** service or reboot.
|
|
||||||
|
|
||||||
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ / Troubleshooting
|
|
||||||
|
|
||||||
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
|
||||||
|
|
||||||
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
|
||||||
|
|
||||||
- **Changing Admin or resetting password:** Admin can change any user’s password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
|
|
||||||
|
|
||||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
|
|
||||||
|
|
||||||
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
|
|
||||||
|
|
||||||
For more Q&A or to ask for help, open a Discussion or Issue.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security posture
|
|
||||||
|
|
||||||
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
|
|
||||||
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
|
|
||||||
If you’re running ≤1.4.x, please upgrade.
|
|
||||||
|
|
||||||
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
||||||
Areas to help: translations, bug fixes, UI polish, integrations.
|
|
||||||
If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💖 Sponsor FileRise
|
|
||||||
|
|
||||||
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
|
|
||||||
|
|
||||||
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
|
|
||||||
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
|
|
||||||
|
|
||||||
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Community and Support
|
|
||||||
|
|
||||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
|
||||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
|
||||||
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
|
|
||||||
|
|
||||||
[](https://star-history.com/#error311/FileRise&Date)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### PHP Libraries
|
|
||||||
|
|
||||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
|
||||||
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
|
|
||||||
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
|
|
||||||
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
|
|
||||||
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
|
|
||||||
|
|
||||||
### Client-Side Libraries
|
|
||||||
|
|
||||||
- **Google Fonts** – [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
|
|
||||||
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
|
|
||||||
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) – For code editing functionality.
|
|
||||||
- **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) – For file uploads.
|
|
||||||
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) – For sanitizing HTML.
|
|
||||||
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) – For indexed, fuzzy searching.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License & Credits
|
|
||||||
|
|
||||||
MIT License – see [LICENSE](LICENSE).
|
|
||||||
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
|
|
||||||
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
|
|
||||||
|
|
||||||
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
// config.php
|
// config.php
|
||||||
|
|
||||||
// Define constants
|
// Define constants
|
||||||
@@ -16,6 +17,7 @@ define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[.
|
|||||||
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
|
||||||
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
|
||||||
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
|
||||||
|
define('FR_DEMO_MODE', false);
|
||||||
|
|
||||||
date_default_timezone_set(TIMEZONE);
|
date_default_timezone_set(TIMEZONE);
|
||||||
|
|
||||||
@@ -25,6 +27,17 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
|
|||||||
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
|
||||||
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
|
||||||
define('ACL_INHERIT_ON_CREATE', true);
|
define('ACL_INHERIT_ON_CREATE', true);
|
||||||
|
// ONLYOFFICE integration overrides (uncomment and set as needed)
|
||||||
|
/*
|
||||||
|
define('ONLYOFFICE_ENABLED', false);
|
||||||
|
define('ONLYOFFICE_JWT_SECRET', 'test123456');
|
||||||
|
define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
|
||||||
|
define('ONLYOFFICE_DEBUG', true);
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
|
||||||
|
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
|
||||||
|
}
|
||||||
|
|
||||||
// Encryption helpers
|
// Encryption helpers
|
||||||
function encryptData($data, $encryptionKey)
|
function encryptData($data, $encryptionKey)
|
||||||
@@ -89,10 +102,15 @@ $secure = ($envSecure !== false)
|
|||||||
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
? filter_var($envSecure, FILTER_VALIDATE_BOOLEAN)
|
||||||
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
: (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||||
|
|
||||||
// Choose session lifetime based on "remember me" cookie
|
|
||||||
|
// PHP session lifetime (independent of "remember me")
|
||||||
|
// Keep this reasonably short; "remember me" uses its own token.
|
||||||
$defaultSession = 7200; // 2 hours
|
$defaultSession = 7200; // 2 hours
|
||||||
|
$sessionLifetime = $defaultSession;
|
||||||
|
|
||||||
|
// "Remember me" window (how long the persistent token itself is valid)
|
||||||
|
// This is used in persistent_tokens.json, *not* for PHP session lifetime.
|
||||||
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
$persistentDays = 30 * 24 * 60 * 60; // 30 days
|
||||||
$sessionLifetime = isset($_COOKIE['remember_me_token']) ? $persistentDays : $defaultSession;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start session idempotently:
|
* Start session idempotently:
|
||||||
@@ -143,6 +161,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
|||||||
if (!empty($tokens[$token])) {
|
if (!empty($tokens[$token])) {
|
||||||
$data = $tokens[$token];
|
$data = $tokens[$token];
|
||||||
if ($data['expiry'] >= time()) {
|
if ($data['expiry'] >= time()) {
|
||||||
|
// NEW: mitigate session fixation
|
||||||
|
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
$_SESSION["authenticated"] = true;
|
$_SESSION["authenticated"] = true;
|
||||||
$_SESSION["username"] = $data["username"];
|
$_SESSION["username"] = $data["username"];
|
||||||
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
$_SESSION["folderOnly"] = loadUserPermissions($data["username"]);
|
||||||
@@ -150,7 +173,11 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
|
|||||||
} else {
|
} else {
|
||||||
// expired — clean up
|
// expired — clean up
|
||||||
unset($tokens[$token]);
|
unset($tokens[$token]);
|
||||||
file_put_contents($tokFile, encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey), LOCK_EX);
|
file_put_contents(
|
||||||
|
$tokFile,
|
||||||
|
encryptData(json_encode($tokens, JSON_PRETTY_PRINT), $encryptionKey),
|
||||||
|
LOCK_EX
|
||||||
|
);
|
||||||
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
setcookie('remember_me_token', '', time() - 3600, '/', '', $secure, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,4 +254,59 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Final: env var wins, else fallback
|
// Final: env var wins, else fallback
|
||||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// FileRise Pro bootstrap wiring
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
|
||||||
|
if (!defined('FR_PRO_LICENSE')) {
|
||||||
|
$envLicense = getenv('FR_PRO_LICENSE');
|
||||||
|
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON license file used by AdminController::setLicense()
|
||||||
|
if (!defined('PRO_LICENSE_FILE')) {
|
||||||
|
define('PRO_LICENSE_FILE', rtrim(USERS_DIR, "/\\") . '/proLicense.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional plain-text license file (used as fallback in bootstrap)
|
||||||
|
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||||
|
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||||
|
if ($lf === false || $lf === '') {
|
||||||
|
$lf = rtrim(USERS_DIR, "/\\") . '/proLicense.txt';
|
||||||
|
}
|
||||||
|
define('FR_PRO_LICENSE_FILE', $lf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where Pro code lives by default → inside users volume
|
||||||
|
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||||
|
if ($proDir === false || $proDir === '') {
|
||||||
|
$proDir = rtrim(USERS_DIR, "/\\") . '/pro';
|
||||||
|
}
|
||||||
|
$proDir = rtrim($proDir, "/\\");
|
||||||
|
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||||
|
define('FR_PRO_BUNDLE_DIR', $proDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load Pro bootstrap if enabled + present
|
||||||
|
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
|
||||||
|
if (@is_file($proBootstrap)) {
|
||||||
|
require_once $proBootstrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If bootstrap didn’t define these, give safe defaults
|
||||||
|
if (!defined('FR_PRO_ACTIVE')) {
|
||||||
|
define('FR_PRO_ACTIVE', false);
|
||||||
|
}
|
||||||
|
if (!defined('FR_PRO_INFO')) {
|
||||||
|
define('FR_PRO_INFO', [
|
||||||
|
'valid' => false,
|
||||||
|
'error' => null,
|
||||||
|
'payload' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||||
|
define('FR_PRO_BUNDLE_VERSION', null);
|
||||||
|
}
|
||||||
@@ -1,38 +1,64 @@
|
|||||||
# --------------------------------
|
# --------------------------------
|
||||||
# Base: safe in most environments
|
# FileRise portable .htaccess
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
Options -Indexes
|
Options -Indexes -Multiviews
|
||||||
DirectoryIndex index.html
|
DirectoryIndex index.html
|
||||||
|
|
||||||
|
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||||
|
AcceptPathInfo On
|
||||||
|
|
||||||
|
# ---------------- Security: dotfiles ----------------
|
||||||
<IfModule mod_authz_core.c>
|
<IfModule mod_authz_core.c>
|
||||||
<FilesMatch "^\.">
|
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||||
|
<FilesMatch "^\..*">
|
||||||
Require all denied
|
Require all denied
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
|
# ---------------- Rewrites ----------------
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
# Never redirect local/dev hosts
|
|
||||||
|
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||||
|
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||||
|
RewriteRule - - [L]
|
||||||
|
|
||||||
|
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||||
|
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||||
|
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||||
|
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
|
||||||
|
|
||||||
|
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
|
||||||
|
# - allow /api/*.php (API endpoints)
|
||||||
|
# - allow /api.php (ReDoc/spec page)
|
||||||
|
# - allow /webdav.php (SabreDAV front)
|
||||||
|
RewriteCond %{REQUEST_URI} !^/api/ [NC]
|
||||||
|
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
|
||||||
|
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
|
||||||
|
RewriteRule \.php$ - [F,L]
|
||||||
|
|
||||||
|
# 3) Never redirect local/dev hosts
|
||||||
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
|
||||||
RewriteRule ^ - [L]
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
# --- HTTPS redirect ---
|
# 4) HTTPS redirect (enable ONE of these, comment the other)
|
||||||
# Use ONE of these blocks.
|
|
||||||
|
|
||||||
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
|
# A) Direct TLS on this server
|
||||||
#RewriteCond %{HTTPS} off
|
#RewriteCond %{HTTPS} !=on
|
||||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto
|
# B) Behind reverse proxy that sets X-Forwarded-Proto
|
||||||
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
|
#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR]
|
||||||
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
|
#RewriteCond %{HTTP:X-Forwarded-Proto} ^$
|
||||||
#RewriteCond %{HTTPS} !=on
|
#RewriteCond %{HTTPS} !=on
|
||||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
# Don't interfere with ACME/http-01 if you do your own certs
|
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
|
||||||
#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
|
||||||
#RewriteRule - - [L]
|
RewriteRule ^ - [E=IS_VER:1]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
# --- MIME types (fonts/SVG/ESM) ---
|
# ---------------- MIME types ----------------
|
||||||
<IfModule mod_mime.c>
|
<IfModule mod_mime.c>
|
||||||
AddType font/woff2 .woff2
|
AddType font/woff2 .woff2
|
||||||
AddType font/woff .woff
|
AddType font/woff .woff
|
||||||
@@ -40,7 +66,7 @@ RewriteRule ^ - [L]
|
|||||||
AddType application/javascript .mjs
|
AddType application/javascript .mjs
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# --- Security headers ---
|
# ---------------- Security headers ----------------
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
Header always set X-Frame-Options "SAMEORIGIN"
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
Header always set X-XSS-Protection "1; mode=block"
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
@@ -51,59 +77,53 @@ RewriteRule ^ - [L]
|
|||||||
Header always set Expect-CT "max-age=86400, enforce"
|
Header always set Expect-CT "max-age=86400, enforce"
|
||||||
Header always set Cross-Origin-Resource-Policy "same-origin"
|
Header always set Cross-Origin-Resource-Policy "same-origin"
|
||||||
Header always set X-Permitted-Cross-Domain-Policies "none"
|
Header always set X-Permitted-Cross-Domain-Policies "none"
|
||||||
# HSTS only when actually on HTTPS
|
|
||||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'"
|
|
||||||
|
|
||||||
# CSP (modules, blobs, workers, etc.)
|
# HSTS only when HTTPS (safe for .htaccess)
|
||||||
|
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" env=HTTPS
|
||||||
|
|
||||||
|
# CSP — keep this SHA-256 in sync with your inline pre-theme script
|
||||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'"
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# --- Caching (query-string based, no env vars needed) ---
|
# ---------------- Caching ----------------
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
# HTML/PHP: no cache (only if PHP didn’t already set it)
|
# HTML/PHP: no cache
|
||||||
<FilesMatch "\.(html?|php)$">
|
<FilesMatch "\.(html?|php)$">
|
||||||
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
Header setifempty Pragma "no-cache"
|
Header setifempty Pragma "no-cache"
|
||||||
Header setifempty Expires "0"
|
Header setifempty Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# version.js: always non-cacheable
|
# version.js: never cache
|
||||||
<FilesMatch "^js/version\.js$">
|
<FilesMatch "^js/version\.js$">
|
||||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
Header set Pragma "no-cache"
|
Header set Pragma "no-cache"
|
||||||
Header set Expires "0"
|
Header set Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Unversioned JS/CSS: 1 hour
|
# JS/CSS: long cache if ?v= present, else 1h
|
||||||
<FilesMatch "\.(?:m?js|css)$">
|
<FilesMatch "\.(?:m?js|css)$">
|
||||||
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||||
|
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!IS_VER
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Unversioned static (images/fonts): 7 days
|
# Images/fonts: long cache if ?v= present, else 7d
|
||||||
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||||
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
Header set Cache-Control "public, max-age=31536000, immutable" env=IS_VER
|
||||||
|
Header set Cache-Control "public, max-age=604800" env=!IS_VER
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# --- Versioned assets (?v=...) : 1 year + immutable (override anything else) ---
|
|
||||||
<IfModule mod_headers.c>
|
|
||||||
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
|
||||||
# Only when query string has v=
|
|
||||||
Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
|
||||||
Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
|
||||||
Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
|
||||||
</FilesMatch>
|
|
||||||
</IfModule>
|
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# --- Compression ---
|
# ---------------- Compression ----------------
|
||||||
<IfModule mod_brotli.c>
|
<IfModule mod_brotli.c>
|
||||||
BrotliCompressionQuality 5
|
|
||||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
||||||
</IfModule>
|
</IfModule>
|
||||||
<IfModule mod_deflate.c>
|
<IfModule mod_deflate.c>
|
||||||
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# --- Disable TRACE ---
|
# ---------------- Disable TRACE ----------------
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||||
RewriteRule .* - [F]
|
RewriteRule .* - [F]
|
||||||
|
</IfModule>
|
||||||
@@ -3,83 +3,26 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../../config/config.php';
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
|
||||||
|
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||||
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = trim((string)($_GET['user'] ?? ''));
|
$user = trim((string)($_GET['user'] ?? ''));
|
||||||
if ($user === '' || !preg_match(REGEX_USER, $user)) {
|
|
||||||
http_response_code(400); echo json_encode(['error'=>'Invalid user']); exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the folder list (admin sees all)
|
|
||||||
$folders = [];
|
|
||||||
try {
|
try {
|
||||||
$rows = FolderModel::getFolderList();
|
$ctrl = new AclAdminController();
|
||||||
if (is_array($rows)) {
|
$grants = $ctrl->getUserGrants($user);
|
||||||
foreach ($rows as $r) {
|
echo json_encode(['grants' => $grants], JSON_UNESCAPED_SLASHES);
|
||||||
$f = is_array($r) ? ($r['folder'] ?? '') : (string)$r;
|
} catch (InvalidArgumentException $e) {
|
||||||
if ($f !== '') $folders[$f] = true;
|
http_response_code(400);
|
||||||
}
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
}
|
} catch (Throwable $e) {
|
||||||
} catch (Throwable $e) { /* ignore */ }
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to load grants', 'detail' => $e->getMessage()]);
|
||||||
if (empty($folders)) {
|
}
|
||||||
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
|
||||||
if (is_file($aclPath)) {
|
|
||||||
$data = json_decode((string)@file_get_contents($aclPath), true);
|
|
||||||
if (is_array($data['folders'] ?? null)) {
|
|
||||||
foreach ($data['folders'] as $name => $_) $folders[$name] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$folderList = array_keys($folders);
|
|
||||||
if (!in_array('root', $folderList, true)) array_unshift($folderList, 'root');
|
|
||||||
|
|
||||||
$has = function(array $arr, string $u): bool {
|
|
||||||
foreach ($arr as $x) if (strcasecmp((string)$x, $u) === 0) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
$out = [];
|
|
||||||
foreach ($folderList as $f) {
|
|
||||||
$rec = ACL::explicitAll($f); // legacy + granular
|
|
||||||
|
|
||||||
$isOwner = $has($rec['owners'], $user);
|
|
||||||
$canViewAll = $isOwner || $has($rec['read'], $user);
|
|
||||||
$canViewOwn = $has($rec['read_own'], $user);
|
|
||||||
$canShare = $isOwner || $has($rec['share'], $user);
|
|
||||||
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
|
|
||||||
|
|
||||||
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|
|
||||||
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|
|
||||||
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|
|
||||||
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
|
|
||||||
$out[$f] = [
|
|
||||||
'view' => $canViewAll,
|
|
||||||
'viewOwn' => $canViewOwn,
|
|
||||||
'write' => $has($rec['write'], $user) || $isOwner,
|
|
||||||
'manage' => $isOwner,
|
|
||||||
'share' => $canShare, // legacy
|
|
||||||
'create' => $isOwner || $has($rec['create'], $user),
|
|
||||||
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
|
|
||||||
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
|
|
||||||
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
|
|
||||||
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
|
|
||||||
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
|
|
||||||
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
|
|
||||||
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
|
|
||||||
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
|
|
||||||
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
|
|
||||||
@@ -3,12 +3,11 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../../config/config.php';
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/controllers/AclAdminController.php';
|
||||||
|
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
// ---- Auth + CSRF -----------------------------------------------------------
|
|
||||||
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
echo json_encode(['error' => 'Unauthorized']);
|
||||||
@@ -24,98 +23,17 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ---------------------------------------------------------------
|
|
||||||
function normalize_caps(array $row): array {
|
|
||||||
// booleanize known keys
|
|
||||||
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
|
|
||||||
$k = [
|
|
||||||
'view','viewOwn','upload','manage','share',
|
|
||||||
'create','edit','rename','copy','move','delete','extract',
|
|
||||||
'shareFile','shareFolder','write'
|
|
||||||
];
|
|
||||||
$out = [];
|
|
||||||
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
|
|
||||||
|
|
||||||
// BUSINESS RULES:
|
|
||||||
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
|
|
||||||
if ($out['shareFolder'] && !$out['view']) {
|
|
||||||
$out['view'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
|
|
||||||
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
|
|
||||||
$out['viewOwn'] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize_grants_map(array $grants): array {
|
|
||||||
$out = [];
|
|
||||||
foreach ($grants as $folder => $caps) {
|
|
||||||
if (!is_string($folder)) $folder = (string)$folder;
|
|
||||||
if (!is_array($caps)) $caps = [];
|
|
||||||
$out[$folder] = normalize_caps($caps);
|
|
||||||
}
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function valid_user(string $u): bool {
|
|
||||||
return ($u !== '' && preg_match(REGEX_USER, $u));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Read JSON body --------------------------------------------------------
|
|
||||||
$raw = file_get_contents('php://input');
|
$raw = file_get_contents('php://input');
|
||||||
$in = json_decode((string)$raw, true);
|
$in = json_decode((string)$raw, true);
|
||||||
if (!is_array($in)) {
|
|
||||||
|
try {
|
||||||
|
$ctrl = new AclAdminController();
|
||||||
|
$res = $ctrl->saveUserGrantsPayload($in ?? []);
|
||||||
|
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Invalid JSON']);
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
exit;
|
} catch (Throwable $e) {
|
||||||
}
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
||||||
// ---- Single user mode: { user, grants } ------------------------------------
|
}
|
||||||
if (isset($in['user']) && isset($in['grants']) && is_array($in['grants'])) {
|
|
||||||
$user = trim((string)$in['user']);
|
|
||||||
if (!valid_user($user)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Invalid user']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$grants = sanitize_grants_map($in['grants']);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$res = ACL::applyUserGrantsAtomic($user, $grants);
|
|
||||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
|
||||||
exit;
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['error' => 'Failed to save grants', 'detail' => $e->getMessage()]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Batch mode: { changes: [ { user, grants }, ... ] } --------------------
|
|
||||||
if (isset($in['changes']) && is_array($in['changes'])) {
|
|
||||||
$updated = [];
|
|
||||||
foreach ($in['changes'] as $chg) {
|
|
||||||
if (!is_array($chg)) continue;
|
|
||||||
$user = trim((string)($chg['user'] ?? ''));
|
|
||||||
$gr = $chg['grants'] ?? null;
|
|
||||||
if (!valid_user($user) || !is_array($gr)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$res = ACL::applyUserGrantsAtomic($user, sanitize_grants_map($gr));
|
|
||||||
$updated[$user] = $res['updated'] ?? [];
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$updated[$user] = ['error' => $e->getMessage()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
echo json_encode(['ok' => true, 'updated' => $updated], JSON_UNESCAPED_SLASHES);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Fallback --------------------------------------------------------------
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
|
|
||||||
41
public/api/admin/diskUsageSummary.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/diskUsageSummary.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$authenticated = !empty($_SESSION['authenticated']);
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if (!$authenticated || !$isAdmin) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Unauthorized',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional tuning via query params
|
||||||
|
$topFolders = isset($_GET['topFolders']) ? max(1, (int)$_GET['topFolders']) : 5;
|
||||||
|
$topFiles = isset($_GET['topFiles']) ? max(0, (int)$_GET['topFiles']) : 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$summary = DiskUsageModel::getSummary($topFolders, $topFiles);
|
||||||
|
http_response_code($summary['ok'] ? 200 : 404);
|
||||||
|
echo json_encode($summary, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
102
public/api/admin/diskUsageTriggerScan.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/admin/diskUsageTriggerScan.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/DiskUsageModel.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release session lock early so the scanner/other requests aren't blocked
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// NOTE: previously this endpoint was Pro-only. Now it works on all instances.
|
||||||
|
// Pro-only gate removed so free FileRise can also use the Rescan button.
|
||||||
|
|
||||||
|
/*
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
try {
|
||||||
|
$worker = realpath(PROJECT_ROOT . '/src/cli/disk_usage_scan.php');
|
||||||
|
if (!$worker || !is_file($worker)) {
|
||||||
|
throw new RuntimeException('disk_usage_scan.php not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a PHP CLI binary that actually works (same idea as zip_worker)
|
||||||
|
$candidates = array_values(array_filter([
|
||||||
|
PHP_BINARY ?: null,
|
||||||
|
'/usr/local/bin/php',
|
||||||
|
'/usr/bin/php',
|
||||||
|
'/bin/php',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$php = null;
|
||||||
|
foreach ($candidates as $bin) {
|
||||||
|
if (!$bin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rc = 1;
|
||||||
|
@exec(escapeshellcmd($bin) . ' -v >/dev/null 2>&1', $out, $rc);
|
||||||
|
if ($rc === 0) {
|
||||||
|
$php = $bin;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$php) {
|
||||||
|
throw new RuntimeException('No working php CLI found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = rtrim((string)META_DIR, '/\\');
|
||||||
|
$logDir = $meta . DIRECTORY_SEPARATOR . 'logs';
|
||||||
|
@mkdir($logDir, 0775, true);
|
||||||
|
$logFile = $logDir . DIRECTORY_SEPARATOR . 'disk_usage_scan.log';
|
||||||
|
|
||||||
|
// nohup php disk_usage_scan.php >> log 2>&1 & echo $!
|
||||||
|
$cmdStr =
|
||||||
|
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) .
|
||||||
|
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
|
||||||
|
|
||||||
|
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
|
||||||
|
$pid = is_string($pid) ? (int)trim($pid) : 0;
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'pid' => $pid > 0 ? $pid : null,
|
||||||
|
'message' => 'Disk usage scan started in the background.',
|
||||||
|
'logFile' => $logFile,
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
8
public/api/admin/installProBundle.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
$controller = new AdminController();
|
||||||
|
$controller->installProBundle();
|
||||||
8
public/api/admin/setLicense.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$ctrl->setLicense();
|
||||||
24
public/api/file/downloadZipFile.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/downloadZipFile.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/downloadZipFile.php",
|
||||||
|
* summary="Download a finished ZIP by token",
|
||||||
|
* description="Streams the zip once; token is one-shot.",
|
||||||
|
* operationId="downloadZipFile",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
|
||||||
|
* @OA\Response(response=200, description="ZIP stream"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->downloadZipFile();
|
||||||
23
public/api/file/zipStatus.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/file/zipStatus.php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/file/zipStatus.php",
|
||||||
|
* summary="Check status of a background ZIP build",
|
||||||
|
* description="Returns status for the authenticated user's token.",
|
||||||
|
* operationId="zipStatus",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"cookieAuth": {}}},
|
||||||
|
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
|
||||||
|
* @OA\Response(response=200, description="Status payload"),
|
||||||
|
* @OA\Response(response=401, description="Unauthorized"),
|
||||||
|
* @OA\Response(response=404, description="Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
|
||||||
|
|
||||||
|
$controller = new FileController();
|
||||||
|
$controller->zipStatus();
|
||||||
@@ -1,245 +1,18 @@
|
|||||||
<?php
|
<?php
|
||||||
// public/api/folder/capabilities.php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/folder/capabilities.php",
|
|
||||||
* summary="Get effective capabilities for the current user in a folder",
|
|
||||||
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
|
|
||||||
* operationId="getFolderCapabilities",
|
|
||||||
* tags={"Folders"},
|
|
||||||
* security={{"cookieAuth": {}}},
|
|
||||||
*
|
|
||||||
* @OA\Parameter(
|
|
||||||
* name="folder",
|
|
||||||
* in="query",
|
|
||||||
* required=false,
|
|
||||||
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
|
|
||||||
* @OA\Schema(type="string"),
|
|
||||||
* example="projects/acme"
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="Capabilities computed successfully.",
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
|
|
||||||
* @OA\Property(property="user", type="string", example="alice"),
|
|
||||||
* @OA\Property(property="folder", type="string", example="projects/acme"),
|
|
||||||
* @OA\Property(property="isAdmin", type="boolean", example=false),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="flags",
|
|
||||||
* type="object",
|
|
||||||
* required={"folderOnly","readOnly","disableUpload"},
|
|
||||||
* @OA\Property(property="folderOnly", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="readOnly", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
|
||||||
* ),
|
|
||||||
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
|
|
||||||
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
|
|
||||||
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
|
|
||||||
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
|
|
||||||
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
|
|
||||||
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
|
|
||||||
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
|
|
||||||
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
* @OA\Response(response=400, description="Invalid folder name."),
|
|
||||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
|
||||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
// --- auth ---
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
$username = $_SESSION['username'] ?? '';
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
if ($username === '') {
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['error' => 'Unauthorized']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
function loadPermsFor(string $u): array {
|
|
||||||
try {
|
|
||||||
if (function_exists('loadUserPermissions')) {
|
|
||||||
$p = loadUserPermissions($u);
|
|
||||||
return is_array($p) ? $p : [];
|
|
||||||
}
|
|
||||||
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
|
||||||
$all = userModel::getUserPermissions();
|
|
||||||
if (is_array($all)) {
|
|
||||||
if (isset($all[$u])) return (array)$all[$u];
|
|
||||||
$lk = strtolower($u);
|
|
||||||
if (isset($all[$lk])) return (array)$all[$lk];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Throwable $e) {}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
|
||||||
$f = ACL::normalizeFolder($folder);
|
|
||||||
// direct owner
|
|
||||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
|
||||||
// ancestor owner
|
|
||||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
|
||||||
$pos = strrpos($f, '/');
|
|
||||||
if ($pos === false) break;
|
|
||||||
$f = substr($f, 0, $pos);
|
|
||||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
|
||||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* folder-only scope:
|
|
||||||
* - Admins: always in scope
|
|
||||||
* - Non folder-only accounts: always in scope
|
|
||||||
* - Folder-only accounts: in scope iff:
|
|
||||||
* - folder == username OR subpath of username, OR
|
|
||||||
* - user is owner of this folder (or any ancestor)
|
|
||||||
*/
|
|
||||||
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
|
||||||
if ($isAdmin) return true;
|
|
||||||
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
|
||||||
//if (!$folderOnly) return true;
|
|
||||||
|
|
||||||
$f = ACL::normalizeFolder($folder);
|
|
||||||
if ($f === 'root' || $f === '') {
|
|
||||||
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
|
||||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
|
||||||
|
|
||||||
// Treat ownership as in-scope
|
|
||||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- inputs ---
|
|
||||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
|
||||||
|
|
||||||
// validate folder path
|
|
||||||
if ($folder !== 'root') {
|
|
||||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
|
||||||
if (empty($parts)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Invalid folder name.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
foreach ($parts as $seg) {
|
|
||||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'Invalid folder name.']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$folder = implode('/', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- user + flags ---
|
|
||||||
$perms = loadPermsFor($username);
|
|
||||||
$isAdmin = ACL::isAdmin($perms);
|
|
||||||
$readOnly = !empty($perms['readOnly']);
|
|
||||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
|
||||||
|
|
||||||
// --- ACL base abilities ---
|
|
||||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
|
||||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
|
||||||
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
|
||||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
|
||||||
|
|
||||||
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
|
||||||
|
|
||||||
// granular base
|
|
||||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
|
||||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
|
||||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
|
||||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
|
||||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
|
||||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
|
||||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
|
||||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
|
||||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
|
||||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
|
||||||
|
|
||||||
// --- Apply scope + flags to effective UI actions ---
|
|
||||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
|
||||||
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
|
||||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
|
||||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
|
||||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
|
||||||
// Destination can receive items if user can create/write (or manage) here
|
|
||||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
|
||||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
|
||||||
$canMoveIn = $canReceive;
|
|
||||||
$canMoveAlias = $canMoveIn;
|
|
||||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
|
||||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
|
||||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
|
||||||
|
|
||||||
// Sharing respects scope; optionally also gate on readOnly
|
|
||||||
$canShare = $canShareBase && $inScope; // legacy umbrella
|
|
||||||
$canShareFileEff = $gShareFile && $inScope;
|
|
||||||
$canShareFoldEff = $gShareFolder && $inScope;
|
|
||||||
|
|
||||||
// never allow destructive ops on root
|
|
||||||
$isRoot = ($folder === 'root');
|
|
||||||
if ($isRoot) {
|
|
||||||
$canRename = false;
|
|
||||||
$canDelete = false;
|
|
||||||
$canShareFoldEff = false;
|
|
||||||
$canMoveFolder = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$isRoot) {
|
|
||||||
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
|
||||||
&& !$readOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
$owner = null;
|
|
||||||
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'user' => $username,
|
|
||||||
'folder' => $folder,
|
|
||||||
'isAdmin' => $isAdmin,
|
|
||||||
'flags' => [
|
|
||||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
|
||||||
'readOnly' => $readOnly,
|
|
||||||
],
|
|
||||||
'owner' => $owner,
|
|
||||||
|
|
||||||
// viewing
|
|
||||||
'canView' => $canView,
|
|
||||||
'canViewOwn' => $canViewOwn,
|
|
||||||
|
|
||||||
// write-ish
|
|
||||||
'canUpload' => $canUpload,
|
|
||||||
'canCreate' => $canCreate,
|
|
||||||
'canRename' => $canRename,
|
|
||||||
'canDelete' => $canDelete,
|
|
||||||
'canMoveIn' => $canMoveIn,
|
|
||||||
'canMove' => $canMoveAlias,
|
|
||||||
'canMoveFolder'=> $canMoveFolder,
|
|
||||||
'canEdit' => $canEdit,
|
|
||||||
'canCopy' => $canCopy,
|
|
||||||
'canExtract' => $canExtract,
|
|
||||||
|
|
||||||
// sharing
|
|
||||||
'canShare' => $canShare, // legacy
|
|
||||||
'canShareFile' => $canShareFileEff,
|
|
||||||
'canShareFolder' => $canShareFoldEff,
|
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
17
public/api/folder/getFolderColors.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->getFolderColors(); // echoes JSON + status codes
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('getFolderColors failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
28
public/api/folder/isEmpty.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// Fast ACL-aware peek for tree icons/chevrons
|
||||||
|
declare(strict_types=1);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||||
|
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||||
|
];
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
|
|
||||||
|
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);
|
||||||
31
public/api/folder/listChildren.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$perms = [
|
||||||
|
'role' => $_SESSION['role'] ?? null,
|
||||||
|
'admin' => $_SESSION['admin'] ?? null,
|
||||||
|
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||||
|
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||||
|
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||||
|
];
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
$folder = str_replace('\\', '/', trim($folder));
|
||||||
|
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||||
|
|
||||||
|
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
|
||||||
|
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
|
||||||
|
|
||||||
|
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
|
||||||
|
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||||
17
public/api/folder/saveFolderColor.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctl = new FolderController();
|
||||||
|
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('saveFolderColor failed: ' . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'Internal server error']);
|
||||||
|
}
|
||||||
7
public/api/media/getProgress.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/getProgress.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->getProgress();
|
||||||
7
public/api/media/getViewedMap.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/getViewedMap.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->getViewedMap();
|
||||||
7
public/api/media/updateProgress.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/updateProgress.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->updateProgress();
|
||||||
13
public/api/onlyoffice/callback.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/onlyoffice/callback.php",
|
||||||
|
* summary="ONLYOFFICE save callback",
|
||||||
|
* tags={"ONLYOFFICE"},
|
||||||
|
* @OA\Response(response=200, description="OK / error JSON")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||||
|
(new OnlyOfficeController())->callback();
|
||||||
17
public/api/onlyoffice/config.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/onlyoffice/config.php",
|
||||||
|
* summary="Get editor config for a file (signed URLs, callback)",
|
||||||
|
* tags={"ONLYOFFICE"},
|
||||||
|
* @OA\Parameter(name="folder", in="query", @OA\Schema(type="string")),
|
||||||
|
* @OA\Parameter(name="file", in="query", @OA\Schema(type="string")),
|
||||||
|
* @OA\Response(response=200, description="Editor config"),
|
||||||
|
* @OA\Response(response=403, description="Forbidden"),
|
||||||
|
* @OA\Response(response=404, description="Disabled / Not found")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||||
|
(new OnlyOfficeController())->config();
|
||||||
15
public/api/onlyoffice/signed-download.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/onlyoffice/signed-download.php",
|
||||||
|
* summary="Serve a signed file blob to ONLYOFFICE",
|
||||||
|
* tags={"ONLYOFFICE"},
|
||||||
|
* @OA\Parameter(name="tok", in="query", required=true, @OA\Schema(type="string")),
|
||||||
|
* @OA\Response(response=200, description="File stream"),
|
||||||
|
* @OA\Response(response=403, description="Signature/expiry invalid")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||||
|
(new OnlyOfficeController())->signedDownload();
|
||||||
13
public/api/onlyoffice/status.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/onlyoffice/status.php",
|
||||||
|
* summary="ONLYOFFICE availability & supported extensions",
|
||||||
|
* tags={"ONLYOFFICE"},
|
||||||
|
* @OA\Response(response=200, description="Status JSON")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/OnlyOfficeController.php';
|
||||||
|
(new OnlyOfficeController())->status();
|
||||||
53
public/api/pro/diskUsageChildren.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageChildren.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release session lock to avoid blocking parallel requests
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// Pro-only gate: require Pro active AND ProDiskUsage class available
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderKey = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var array $result */
|
||||||
|
$result = ProDiskUsage::getChildren($folderKey);
|
||||||
|
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
55
public/api/pro/diskUsageDeleteFilePermanent.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageDeleteFilePermanent.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FileModel.php';
|
||||||
|
|
||||||
|
// Pro-only gate: make sure Pro is really active
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body) || empty($body['name'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = isset($body['folder']) ? (string)$body['folder'] : 'root';
|
||||||
|
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
|
||||||
|
$name = (string)$body['name'];
|
||||||
|
|
||||||
|
$res = FileModel::deleteFilesPermanent($folder, [$name]);
|
||||||
|
if (!empty($res['error'])) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => $res['error']]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'File deleted.']);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('diskUsageDeleteFilePermanent error: '.$e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Internal error']);
|
||||||
|
}
|
||||||
60
public/api/pro/diskUsageDeleteFolderRecursive.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageDeleteFolderRecursive.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||||
|
|
||||||
|
// Pro-only gate
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'FileRise Pro is not active on this instance.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body) || !isset($body['folder'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Invalid input']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder = (string)$body['folder'];
|
||||||
|
$folder = $folder === '' ? 'root' : trim($folder, "/\\ ");
|
||||||
|
|
||||||
|
if (strtolower($folder) === 'root') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Cannot deep delete root folder.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$res = FolderModel::deleteFolderRecursiveAdmin($folder);
|
||||||
|
if (!empty($res['error'])) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => $res['error']]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['ok' => true, 'success' => $res['success'] ?? 'Folder deleted.']);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('diskUsageDeleteFolderRecursive error: '.$e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'Internal error']);
|
||||||
|
}
|
||||||
51
public/api/pro/diskUsageTopFiles.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/diskUsageTopFiles.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
// Basic auth / admin check
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
// Pro-only gate: require Pro active AND ProDiskUsage class
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !class_exists('ProDiskUsage')) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($_GET['limit']) ? max(1, (int)$_GET['limit']) : 100;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = ProDiskUsage::getTopFiles($limit);
|
||||||
|
http_response_code(!empty($result['ok']) ? 200 : 404);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'internal_error',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
32
public/api/pro/groups/list.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/groups/list.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$groups = $ctrl->getProGroups();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'groups' => $groups,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Error loading groups: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
51
public/api/pro/groups/save.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/groups/save.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON payload.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = $body['groups'] ?? null;
|
||||||
|
if (!is_array($groups)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid groups format.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$ctrl->saveProGroups($groups);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Error saving groups: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
27
public/api/pro/portals/get.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/get.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
|
||||||
|
|
||||||
|
// For v1: we do NOT require auth here; this is just metadata,
|
||||||
|
// real ACL/access control must still be enforced at upload/download endpoints.
|
||||||
|
$portal = PortalController::getPortalBySlug($slug);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portal' => $portal,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
32
public/api/pro/portals/list.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/list.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$portals = $ctrl->getProPortals();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portals' => $portals,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
109
public/api/pro/portals/publicMeta.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/publicMeta.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
|
||||||
|
// --- Basic Pro checks ---
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'FileRise Pro is not active.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Missing portal slug.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Locate portals.json written by saveProPortals() ---
|
||||||
|
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
|
||||||
|
if ($bundleDir === '' || !is_dir($bundleDir)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Pro bundle directory not found.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
|
||||||
|
if (!is_file($jsonPath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'No portals defined.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($jsonPath);
|
||||||
|
if ($raw === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Could not read portals store.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Invalid portals store.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portals = $data['portals'] ?? [];
|
||||||
|
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Portal not found.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portal = $portals[$slug];
|
||||||
|
|
||||||
|
// Optional: handle expiry if you’re using expiresAt as ISO date string
|
||||||
|
if (!empty($portal['expiresAt'])) {
|
||||||
|
$ts = strtotime((string)$portal['expiresAt']);
|
||||||
|
if ($ts !== false && $ts < time()) {
|
||||||
|
http_response_code(410); // Gone
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'This portal has expired.',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only expose the bits the login page needs (no folder, email, etc.)
|
||||||
|
$public = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'label' => (string)($portal['label'] ?? ''),
|
||||||
|
'title' => (string)($portal['title'] ?? ''),
|
||||||
|
'introText' => (string)($portal['introText'] ?? ''),
|
||||||
|
'brandColor' => (string)($portal['brandColor'] ?? ''),
|
||||||
|
'footerText' => (string)($portal['footerText'] ?? ''),
|
||||||
|
'logoFile' => (string)($portal['logoFile'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'portal' => $public,
|
||||||
|
]);
|
||||||
51
public/api/pro/portals/save.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/save.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireAdmin();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$portals = $body['portals'] ?? null;
|
||||||
|
if (!is_array($portals)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ctrl = new AdminController();
|
||||||
|
$ctrl->saveProPortals($portals);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
64
public/api/pro/portals/submissions.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
|
||||||
|
@session_start();
|
||||||
|
|
||||||
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
|
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
|
||||||
|
|
||||||
|
if ($username === '' || !$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Forbidden',
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot done, release lock for concurrency
|
||||||
|
@session_write_close();
|
||||||
|
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
throw new InvalidArgumentException('Missing slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use your ProPortalSubmissions helper from the bundle
|
||||||
|
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||||
|
if (!is_file($proSubmissionsPath)) {
|
||||||
|
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
require_once $proSubmissionsPath;
|
||||||
|
|
||||||
|
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
|
||||||
|
$submissions = $store->listBySlug($slug, 200);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'slug' => $slug,
|
||||||
|
'submissions' => $submissions,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Server error: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
112
public/api/pro/portals/submitForm.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/submitForm.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, portal forms still require a logged-in user
|
||||||
|
AdminController::requireAuth();
|
||||||
|
AdminController::requireCsrf();
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = isset($body['slug']) ? trim((string)$body['slug']) : '';
|
||||||
|
if ($slug === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing portal slug']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = isset($body['form']) && is_array($body['form']) ? $body['form'] : [];
|
||||||
|
$name = trim((string)($form['name'] ?? ''));
|
||||||
|
$email = trim((string)($form['email'] ?? ''));
|
||||||
|
$reference = trim((string)($form['reference'] ?? ''));
|
||||||
|
$notes = trim((string)($form['notes'] ?? ''));
|
||||||
|
|
||||||
|
// Make sure portal exists and is not expired
|
||||||
|
$portal = PortalController::getPortalBySlug($slug);
|
||||||
|
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
|
||||||
|
throw new RuntimeException('FileRise Pro is not active.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$subPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
|
||||||
|
if (!is_file($subPath)) {
|
||||||
|
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
|
||||||
|
}
|
||||||
|
require_once $subPath;
|
||||||
|
|
||||||
|
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||||
|
|
||||||
|
// ─────────────────────────────
|
||||||
|
// Better client IP detection
|
||||||
|
// ─────────────────────────────
|
||||||
|
$ip = '';
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Can be a comma-separated list; use the first non-empty
|
||||||
|
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$candidate = trim($part);
|
||||||
|
if ($candidate !== '') {
|
||||||
|
$ip = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||||
|
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||||
|
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||||
|
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
|
'folder' => $portal['folder'] ?? '',
|
||||||
|
'form' => [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'reference' => $reference,
|
||||||
|
'notes' => $notes,
|
||||||
|
],
|
||||||
|
'submittedBy' => $submittedBy,
|
||||||
|
'ip' => $ip,
|
||||||
|
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||||
|
'createdAt' => gmdate('c'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$store = new ProPortalSubmissions(FR_PRO_BUNDLE_DIR);
|
||||||
|
$ok = $store->store($slug, $payload);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RuntimeException('Failed to store portal submission.');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$code = $e instanceof InvalidArgumentException ? 400 : 500;
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
30
public/api/pro/portals/uploadLogo.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/portals/uploadLogo.php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Pro-only gate
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctrl = new UserController();
|
||||||
|
$ctrl->uploadPortalLogo();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
28
public/api/pro/uploadBrandLogo.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/pro/uploadBrandLogo.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
// Pro-only gate
|
||||||
|
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'FileRise Pro is not active on this instance.'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ctrl = new UserController();
|
||||||
|
$ctrl->uploadBrandLogo();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Exception: ' . $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/icons/base-1024.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/assets/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/icons/maskable-512.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,21 +3,29 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
|
||||||
<style id="pretheme-css">
|
<style id="pretheme-css">
|
||||||
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||||
</style>
|
</style>
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Critical CSS -->
|
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||||
|
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Critical CSS -->
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Fonts (ok to keep as real preloads) -->
|
<!-- Fonts -->
|
||||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
<link rel="preload" as="font" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" type="font/woff2" crossorigin>
|
||||||
|
|
||||||
@@ -27,8 +35,8 @@
|
|||||||
|
|
||||||
<!-- App entry -->
|
<!-- App entry -->
|
||||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||||
</head>
|
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="appRoot" style="visibility:hidden">
|
<div id="appRoot" style="visibility:hidden">
|
||||||
<header class="header-container">
|
<header class="header-container">
|
||||||
@@ -53,7 +61,27 @@
|
|||||||
<h1>FileRise</h1>
|
<h1>FileRise</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
<!-- Zoom controls FIRST on the right -->
|
||||||
|
<div class="header-zoom-controls">
|
||||||
|
<!-- Left stack: + / - -->
|
||||||
|
<div class="zoom-vertical">
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="in" title="Zoom in">
|
||||||
|
<span class="material-icons">add</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="out" title="Zoom out">
|
||||||
|
<span class="material-icons">remove</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right stack: 100% / reset -->
|
||||||
|
<div class="zoom-meta">
|
||||||
|
<span id="zoomDisplay" class="zoom-display">100%</span>
|
||||||
|
<button class="btn-icon zoom-btn" data-zoom="reset" title="Reset zoom">
|
||||||
|
<span class="material-icons">refresh</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-buttons-wrapper" style="display: flex; align-items: center;">
|
||||||
|
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
@@ -73,7 +101,7 @@
|
|||||||
<!-- Trash items will be loaded here -->
|
<!-- Trash items will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
<div style="text-align: right;">
|
||||||
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
|
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
|
||||||
Selected</button>
|
Selected</button>
|
||||||
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
||||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
||||||
@@ -104,6 +132,7 @@
|
|||||||
<!-- Custom Toast Container -->
|
<!-- Custom Toast Container -->
|
||||||
<div id="customToast"></div>
|
<div id="customToast"></div>
|
||||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||||
|
<div id="appZoomShell">
|
||||||
<main id="main" hidden>
|
<main id="main" hidden>
|
||||||
<div class="row mt-4" id="loginForm">
|
<div class="row mt-4" id="loginForm">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -159,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
|
||||||
@@ -170,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>
|
||||||
@@ -182,16 +211,12 @@
|
|||||||
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
<div id="folderManagementCard" class="card" style="width: 100%; position: relative;">
|
||||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
<span data-i18n-key="folder_navigation">Folder Navigation & Management</span>
|
||||||
<button id="folderHelpBtn" class="btn btn-link" data-i18n-title="folder_help"
|
|
||||||
style="padding: 0; border: none; background: none;">
|
|
||||||
<i class="material-icons folder-help-icon" style="font-size: 24px;">info</i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body custom-folder-card-body">
|
<div class="card-body custom-folder-card-body">
|
||||||
<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>
|
||||||
@@ -244,6 +269,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
|
||||||
|
<i class="material-icons">palette</i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
|
||||||
<i class="material-icons">share</i>
|
<i class="material-icons">share</i>
|
||||||
@@ -265,17 +293,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
|
||||||
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
|
|
||||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
|
||||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
|
||||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
|
||||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
|
||||||
subfolder.</li>
|
|
||||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
|
||||||
the appropriate button.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,6 +361,10 @@
|
|||||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
<span data-i18n-key="create_folder">Create folder</span>
|
<span data-i18n-key="create_folder">Create folder</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
<span data-i18n-key="upload">Upload file(s)</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
@@ -376,7 +397,7 @@
|
|||||||
</div> <!-- end container-fluid -->
|
</div> <!-- end container-fluid -->
|
||||||
</div> <!-- end mainColumn -->
|
</div> <!-- end mainColumn -->
|
||||||
</div> <!-- end main-wrapper -->
|
</div> <!-- end main-wrapper -->
|
||||||
|
</div>
|
||||||
<!-- Download Progress Modal -->
|
<!-- Download Progress Modal -->
|
||||||
<div id="downloadProgressModal" class="modal" style="display: none;">
|
<div id="downloadProgressModal" class="modal" style="display: none;">
|
||||||
<div class="modal-content" style="text-align: center; padding: 20px;">
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
@@ -452,6 +473,96 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
@@ -483,7 +594,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="uploadModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:900px;width:92vw;">
|
||||||
|
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h3 style="margin:0;">Upload</h3>
|
||||||
|
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- we will MOVE #uploadCard into here while open -->
|
||||||
|
<div id="uploadModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer id="siteFooter" class="site-footer">
|
||||||
|
<span>
|
||||||
|
© 2025
|
||||||
|
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
|
||||||
|
FileRise
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
511
public/js/adminOnlyOffice.js
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
// public/js/adminOnlyOffice.js
|
||||||
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate with fallback
|
||||||
|
*/
|
||||||
|
const tf = (key, fallback) => {
|
||||||
|
const v = t(key);
|
||||||
|
return (v && v !== key) ? v : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local masked-input renderer (copied from adminPanel.js style)
|
||||||
|
*/
|
||||||
|
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
|
||||||
|
const type = isSecret ? 'password' : 'text';
|
||||||
|
const disabled = hasValue
|
||||||
|
? 'disabled data-replace="0" placeholder="•••••• (saved)"'
|
||||||
|
: 'data-replace="1"';
|
||||||
|
const replaceBtn = hasValue
|
||||||
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||||
|
: '';
|
||||||
|
const note = hasValue
|
||||||
|
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="${id}">${label}:</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<input type="${type}" id="${id}" class="form-control" ${disabled} />
|
||||||
|
${replaceBtn}
|
||||||
|
</div>
|
||||||
|
${note}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local "Replace" wiring (copied from adminPanel.js style, but scoped)
|
||||||
|
*/
|
||||||
|
function wireReplaceButtons(scope = document) {
|
||||||
|
scope.querySelectorAll('[data-replace-for]').forEach(btn => {
|
||||||
|
if (btn.__wired) return;
|
||||||
|
btn.__wired = true;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const id = btn.getAttribute('data-replace-for');
|
||||||
|
const inp = scope.querySelector('#' + id);
|
||||||
|
if (!inp) return;
|
||||||
|
inp.disabled = false;
|
||||||
|
inp.dataset.replace = '1';
|
||||||
|
inp.placeholder = '';
|
||||||
|
inp.value = '';
|
||||||
|
btn.textContent = 'Keep saved value';
|
||||||
|
btn.removeAttribute('data-replace-for');
|
||||||
|
btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true });
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted origin helper (mirror of your inline logic)
|
||||||
|
*/
|
||||||
|
function getTrustedDocsOrigin(raw) {
|
||||||
|
try {
|
||||||
|
const u = new URL(String(raw || '').trim());
|
||||||
|
if (!/^https?:$/.test(u.protocol)) return null; // only http/https
|
||||||
|
if (u.username || u.password) return null; // no creds in URL
|
||||||
|
return u.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOnlyOfficeApiUrl(origin) {
|
||||||
|
const u = new URL('/web-apps/apps/api/documents/api.js', origin);
|
||||||
|
u.searchParams.set('probe', String(Date.now()));
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight JSON helper for this module
|
||||||
|
*/
|
||||||
|
async function safeJsonLocal(res) {
|
||||||
|
const txt = await res.text();
|
||||||
|
let body = null;
|
||||||
|
try { body = txt ? JSON.parse(txt) : null; } catch { /* ignore */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
(body && (body.error || body.message)) ||
|
||||||
|
(txt && txt.trim()) ||
|
||||||
|
`HTTP ${res.status}`;
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return body ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script probe for api.js (mirrors old ooProbeScript)
|
||||||
|
*/
|
||||||
|
async function ooProbeScript(docsOrigin) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const base = getTrustedDocsOrigin(docsOrigin);
|
||||||
|
if (!base) { resolve({ ok: false }); return; }
|
||||||
|
|
||||||
|
const src = buildOnlyOfficeApiUrl(base);
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.id = 'ooProbeScript';
|
||||||
|
s.async = true;
|
||||||
|
s.src = src;
|
||||||
|
|
||||||
|
const nonce = document.querySelector('meta[name="csp-nonce"]')?.content;
|
||||||
|
if (nonce) s.setAttribute('nonce', nonce);
|
||||||
|
|
||||||
|
const cleanup = () => { try { s.remove(); } catch { /* ignore */ } };
|
||||||
|
|
||||||
|
s.onload = () => { cleanup(); resolve({ ok: true }); };
|
||||||
|
s.onerror = () => { cleanup(); resolve({ ok: false }); };
|
||||||
|
|
||||||
|
// origin is validated, path is fixed => safe
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iframe probe for DS (mirrors old ooProbeFrame)
|
||||||
|
*/
|
||||||
|
async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const base = getTrustedDocsOrigin(docsOrigin);
|
||||||
|
if (!base) { resolve({ ok: false }); return; }
|
||||||
|
|
||||||
|
const f = document.createElement('iframe');
|
||||||
|
f.id = 'ooProbeFrame';
|
||||||
|
f.src = base;
|
||||||
|
f.style.display = 'none';
|
||||||
|
|
||||||
|
const cleanup = () => { try { f.remove(); } catch { /* ignore */ } };
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve({ ok: false, timeout: true });
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
f.onload = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
cleanup();
|
||||||
|
resolve({ ok: true });
|
||||||
|
};
|
||||||
|
f.onerror = () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
cleanup();
|
||||||
|
resolve({ ok: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
// src constrained to validated http/https origin
|
||||||
|
document.body.appendChild(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy helpers (same behavior you had before)
|
||||||
|
*/
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.setAttribute('readonly', '');
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectElementContents(el) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the ONLYOFFICE test card and wires Run tests button
|
||||||
|
*/
|
||||||
|
function attachOnlyOfficeTests(container) {
|
||||||
|
const testBox = document.createElement('div');
|
||||||
|
testBox.className = 'card';
|
||||||
|
testBox.style.marginTop = '12px';
|
||||||
|
testBox.innerHTML = `
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px;">
|
||||||
|
<strong>Test ONLYOFFICE connection</strong>
|
||||||
|
<button type="button" id="ooTestBtn" class="btn btn-sm btn-primary">Run tests</button>
|
||||||
|
<span id="ooTestSpinner" style="display:none;">⏳</span>
|
||||||
|
</div>
|
||||||
|
<ul id="ooTestResults" class="list-unstyled" style="margin:0;"></ul>
|
||||||
|
<small class="text-muted">
|
||||||
|
These tests check FileRise config, callback reachability, CSP/script loading, and iframe embedding.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(testBox);
|
||||||
|
|
||||||
|
const spinner = testBox.querySelector('#ooTestSpinner');
|
||||||
|
const out = testBox.querySelector('#ooTestResults');
|
||||||
|
|
||||||
|
function ooRow(label, status, detail = '') {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.style.margin = '6px 0';
|
||||||
|
const icon = status === 'ok' ? '✅' : status === 'warn' ? '⚠️' : '❌';
|
||||||
|
li.innerHTML =
|
||||||
|
`<span style="min-width:1.2em;display:inline-block">${icon}</span>` +
|
||||||
|
` <strong>${label}</strong>` +
|
||||||
|
(detail ? ` — <span>${detail}</span>` : '');
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ooClear() {
|
||||||
|
while (out.firstChild) out.removeChild(out.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOnlyOfficeTests() {
|
||||||
|
const docsOrigin = (document.getElementById('ooDocsOrigin')?.value || '').trim();
|
||||||
|
|
||||||
|
spinner.style.display = 'inline';
|
||||||
|
ooClear();
|
||||||
|
|
||||||
|
// 1) FileRise status
|
||||||
|
let statusOk = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||||
|
const statusJson = await r.json().catch(() => ({}));
|
||||||
|
if (r.ok) {
|
||||||
|
if (statusJson.enabled) {
|
||||||
|
out.appendChild(ooRow('FileRise status', 'ok', 'Enabled and ready'));
|
||||||
|
statusOk = true;
|
||||||
|
} else {
|
||||||
|
out.appendChild(ooRow('FileRise status', 'warn', 'Disabled — check JWT Secret and Document Server Origin'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.appendChild(ooRow('FileRise status', 'fail', `HTTP ${r.status}`));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.appendChild(ooRow('FileRise status', 'fail', (e && e.message) || 'Network error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Secret presence (fresh read)
|
||||||
|
try {
|
||||||
|
const cfg = await fetch('/api/admin/getConfig.php', {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(r => r.json());
|
||||||
|
const hasSecret = !!(cfg.onlyoffice && cfg.onlyoffice.hasJwtSecret);
|
||||||
|
out.appendChild(
|
||||||
|
ooRow(
|
||||||
|
'JWT secret saved',
|
||||||
|
hasSecret ? 'ok' : 'fail',
|
||||||
|
hasSecret ? 'Present' : 'Missing'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
out.appendChild(ooRow('JWT secret saved', 'warn', 'Could not verify'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Callback reachable
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/onlyoffice/callback.php?ping=1', {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
if (r.ok) out.appendChild(ooRow('Callback endpoint', 'ok', 'Reachable'));
|
||||||
|
else out.appendChild(ooRow('Callback endpoint', 'fail', `HTTP ${r.status}`));
|
||||||
|
} catch {
|
||||||
|
out.appendChild(ooRow('Callback endpoint', 'fail', 'Network error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic sanity on origin
|
||||||
|
if (!/^https?:\/\//i.test(docsOrigin)) {
|
||||||
|
out.appendChild(
|
||||||
|
ooRow(
|
||||||
|
'Document Server Origin',
|
||||||
|
'fail',
|
||||||
|
'Enter a valid http(s) origin (e.g., https://docs.example.com)'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4a) api.js
|
||||||
|
const sRes = await ooProbeScript(docsOrigin);
|
||||||
|
out.appendChild(
|
||||||
|
ooRow(
|
||||||
|
'Load api.js',
|
||||||
|
sRes.ok ? 'ok' : 'fail',
|
||||||
|
sRes.ok ? 'Loaded' : 'Blocked (check CSP script-src and origin)'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4b) iframe
|
||||||
|
const fRes = await ooProbeFrame(docsOrigin);
|
||||||
|
out.appendChild(
|
||||||
|
ooRow(
|
||||||
|
'Embed DS iframe',
|
||||||
|
fRes.ok ? 'ok' : 'fail',
|
||||||
|
fRes.ok ? 'Allowed' : 'Blocked (check CSP frame-src)'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!statusOk || !sRes.ok || !fRes.ok) {
|
||||||
|
const tip = document.createElement('li');
|
||||||
|
tip.style.marginTop = '8px';
|
||||||
|
tip.innerHTML =
|
||||||
|
'💡 <em>Tip:</em> Use the CSP helper below to include your Document Server in ' +
|
||||||
|
'<code>script-src</code>, <code>connect-src</code>, and <code>frame-src</code>.';
|
||||||
|
out.appendChild(tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
testBox.querySelector('#ooTestBtn')?.addEventListener('click', runOnlyOfficeTests);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSP helper card (Apache + Nginx snippets)
|
||||||
|
*/
|
||||||
|
function attachOnlyOfficeCspHelper(container) {
|
||||||
|
const cspHelp = document.createElement('div');
|
||||||
|
cspHelp.className = 'alert alert-info';
|
||||||
|
cspHelp.style.marginTop = '12px';
|
||||||
|
cspHelp.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||||
|
<strong>Content-Security-Policy help</strong>
|
||||||
|
<button type="button" id="copyOoCsp" class="btn btn-sm btn-outline-secondary">Copy</button>
|
||||||
|
<button type="button" id="selectOoCsp" class="btn btn-sm btn-outline-secondary">Select</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text" style="margin-bottom:8px;">
|
||||||
|
Add/replace this line in <code>public/.htaccess</code> (Apache). It allows loading ONLYOFFICE's <code>api.js</code>,
|
||||||
|
embedding the editor iframe, and letting the script make XHR to your Document Server.
|
||||||
|
</div>
|
||||||
|
<pre id="ooCspSnippet" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7;"></pre>
|
||||||
|
<div class="form-text" style="margin-top:8px;">
|
||||||
|
If you terminate SSL or set CSP at a reverse proxy (e.g. Nginx), update it there instead.
|
||||||
|
Also note: if your site is <code>https://</code>, your ONLYOFFICE server must be <code>https://</code> too,
|
||||||
|
otherwise the browser will block it as mixed content.
|
||||||
|
</div>
|
||||||
|
<details style="margin-top:8px;">
|
||||||
|
<summary>Nginx equivalent</summary>
|
||||||
|
<pre id="ooCspSnippetNginx" style="white-space:pre-wrap;user-select:text;padding:8px;border:1px solid #ccc;border-radius:6px;background:#f7f7f7; margin-top:6px;"></pre>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
container.appendChild(cspHelp);
|
||||||
|
|
||||||
|
const INLINE_SHA = "sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=";
|
||||||
|
|
||||||
|
function buildCspApache(originRaw) {
|
||||||
|
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
|
||||||
|
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||||
|
return `Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCspNginx(originRaw) {
|
||||||
|
const o = (originRaw || 'https://your-onlyoffice-server.example.com').replace(/\/+$/, '');
|
||||||
|
const api = `${o}/web-apps/apps/api/documents/api.js`;
|
||||||
|
return `add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' '${INLINE_SHA}' ${o} ${api}; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ${o}; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' ${o}" always;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ooDocsInput = document.getElementById('ooDocsOrigin');
|
||||||
|
const cspPre = document.getElementById('ooCspSnippet');
|
||||||
|
const cspPreNgx = document.getElementById('ooCspSnippetNginx');
|
||||||
|
|
||||||
|
function refreshCsp() {
|
||||||
|
const raw = (ooDocsInput?.value || '').trim();
|
||||||
|
const base = getTrustedDocsOrigin(raw) || raw;
|
||||||
|
cspPre.textContent = buildCspApache(base);
|
||||||
|
cspPreNgx.textContent = buildCspNginx(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
ooDocsInput?.addEventListener('input', refreshCsp);
|
||||||
|
refreshCsp();
|
||||||
|
|
||||||
|
document.getElementById('copyOoCsp')?.addEventListener('click', async () => {
|
||||||
|
const txt = (cspPre.textContent || '').trim();
|
||||||
|
const ok = await copyToClipboard(txt);
|
||||||
|
if (ok) {
|
||||||
|
showToast('CSP line copied.');
|
||||||
|
} else {
|
||||||
|
try { selectElementContents(cspPre); } catch { /* ignore */ }
|
||||||
|
const reason = window.isSecureContext ? '' : ' (page is not HTTPS or localhost)';
|
||||||
|
showToast('Copy failed' + reason + '. Press Ctrl/Cmd+C to copy.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('selectOoCsp')?.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
selectElementContents(cspPre);
|
||||||
|
showToast('Selected — press Ctrl/Cmd+C');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public: build + wire ONLYOFFICE admin section
|
||||||
|
*/
|
||||||
|
export function initOnlyOfficeUI({ config }) {
|
||||||
|
const sec = document.getElementById('onlyofficeContent');
|
||||||
|
if (!sec) return;
|
||||||
|
|
||||||
|
const onlyCfg = config.onlyoffice || {};
|
||||||
|
const hasOOSecret = !!onlyCfg.hasJwtSecret;
|
||||||
|
window.__HAS_OO_SECRET = hasOOSecret;
|
||||||
|
|
||||||
|
// Base content
|
||||||
|
sec.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="checkbox" id="ooEnabled" />
|
||||||
|
<label for="ooEnabled">Enable ONLYOFFICE integration</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ooDocsOrigin">Document Server Origin:</label>
|
||||||
|
<input type="url" id="ooDocsOrigin" class="form-control" placeholder="e.g. https://docs.example.com" />
|
||||||
|
<small class="text-muted">
|
||||||
|
Must be reachable by your browser (for api.js) and by FileRise (for callbacks). Avoid “localhost”.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${renderMaskedInput({
|
||||||
|
id: 'ooJwtSecret',
|
||||||
|
label: 'JWT Secret',
|
||||||
|
hasValue: hasOOSecret,
|
||||||
|
isSecret: true
|
||||||
|
})}
|
||||||
|
`;
|
||||||
|
|
||||||
|
wireReplaceButtons(sec);
|
||||||
|
|
||||||
|
// Tests + CSP helper
|
||||||
|
attachOnlyOfficeTests(sec);
|
||||||
|
attachOnlyOfficeCspHelper(sec);
|
||||||
|
|
||||||
|
// Initial values
|
||||||
|
const enabled = !!onlyCfg.enabled;
|
||||||
|
const docsOrigin = onlyCfg.docsOrigin || '';
|
||||||
|
|
||||||
|
const enabledEl = document.getElementById('ooEnabled');
|
||||||
|
const originEl = document.getElementById('ooDocsOrigin');
|
||||||
|
|
||||||
|
if (enabledEl) enabledEl.checked = enabled;
|
||||||
|
if (originEl) originEl.value = docsOrigin;
|
||||||
|
|
||||||
|
// Locking (managed in config.php)
|
||||||
|
const locked = !!onlyCfg.lockedByPhp;
|
||||||
|
window.__OO_LOCKED = locked;
|
||||||
|
if (locked) {
|
||||||
|
sec.querySelectorAll('input,button').forEach(el => {
|
||||||
|
el.disabled = true;
|
||||||
|
});
|
||||||
|
const note = document.createElement('div');
|
||||||
|
note.className = 'form-text';
|
||||||
|
note.style.marginTop = '6px';
|
||||||
|
note.textContent = 'Managed by config.php — edit ONLYOFFICE_* constants there.';
|
||||||
|
sec.appendChild(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public: inject ONLYOFFICE settings into payload (used in handleSave)
|
||||||
|
*/
|
||||||
|
export function collectOnlyOfficeSettingsForSave(payload) {
|
||||||
|
const ooEnabledEl = document.getElementById('ooEnabled');
|
||||||
|
const ooDocsOriginEl = document.getElementById('ooDocsOrigin');
|
||||||
|
const ooSecretEl = document.getElementById('ooJwtSecret');
|
||||||
|
|
||||||
|
const onlyoffice = {
|
||||||
|
enabled: !!(ooEnabledEl && ooEnabledEl.checked),
|
||||||
|
docsOrigin: (ooDocsOriginEl && ooDocsOriginEl.value.trim()) || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!window.__OO_LOCKED && ooSecretEl) {
|
||||||
|
const val = ooSecretEl.value.trim();
|
||||||
|
const hasSaved = !!window.__HAS_OO_SECRET;
|
||||||
|
const shouldReplace = ooSecretEl.dataset.replace === '1' || !hasSaved;
|
||||||
|
if (shouldReplace && val !== '') {
|
||||||
|
onlyoffice.jwtSecret = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.onlyoffice = onlyoffice;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
1765
public/js/adminPortals.js
Normal file
118
public/js/adminSponsor.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// public/js/adminSponsor.js
|
||||||
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
// Tiny "translate with fallback" helper, same as in adminPanel.js
|
||||||
|
const tf = (key, fallback) => {
|
||||||
|
const v = t(key);
|
||||||
|
return (v && v !== key) ? v : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SPONSOR_GH = 'https://github.com/sponsors/error311';
|
||||||
|
const SPONSOR_KOFI = 'https://ko-fi.com/error311';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Sponsor / Donations section inside the Admin Panel.
|
||||||
|
* Safe to call multiple times; it no-ops after the first run.
|
||||||
|
*/
|
||||||
|
export function initAdminSponsorSection() {
|
||||||
|
const container = document.getElementById('sponsorContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Avoid double-wiring if initAdminSponsorSection gets called again
|
||||||
|
if (container.__sponsorInited) return;
|
||||||
|
container.__sponsorInited = true;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="form-group" style="margin-bottom:12px;">
|
||||||
|
<label for="sponsorGitHub">${tf("github_sponsors_url", "GitHub Sponsors URL")}:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="sponsorGitHub"
|
||||||
|
class="form-control"
|
||||||
|
value="${SPONSOR_GH}"
|
||||||
|
readonly
|
||||||
|
data-ignore-dirty="1"
|
||||||
|
/>
|
||||||
|
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">
|
||||||
|
${tf("copy", "Copy")}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
id="openSponsorGitHub"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
${tf("open", "Open")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:12px;">
|
||||||
|
<label for="sponsorKoFi">${tf("ko_fi_url", "Ko-fi URL")}:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="sponsorKoFi"
|
||||||
|
class="form-control"
|
||||||
|
value="${SPONSOR_KOFI}"
|
||||||
|
readonly
|
||||||
|
data-ignore-dirty="1"
|
||||||
|
/>
|
||||||
|
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">
|
||||||
|
${tf("copy", "Copy")}
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
id="openSponsorKoFi"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
${tf("open", "Open")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small class="text-muted">
|
||||||
|
${tf("sponsor_note_fixed", "Please consider supporting ongoing development.")}
|
||||||
|
</small>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ghInput = document.getElementById('sponsorGitHub');
|
||||||
|
const kfInput = document.getElementById('sponsorKoFi');
|
||||||
|
const copyGhBtn = document.getElementById('copySponsorGitHub');
|
||||||
|
const copyKfBtn = document.getElementById('copySponsorKoFi');
|
||||||
|
const openGh = document.getElementById('openSponsorGitHub');
|
||||||
|
const openKf = document.getElementById('openSponsorKoFi');
|
||||||
|
|
||||||
|
if (openGh) openGh.href = SPONSOR_GH;
|
||||||
|
if (openKf) openKf.href = SPONSOR_KOFI;
|
||||||
|
|
||||||
|
async function copyToClipboardSafe(text) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
showToast(tf("copied", "Copied!"));
|
||||||
|
} catch {
|
||||||
|
showToast(tf("copy_failed", "Could not copy. Please copy manually."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copyGhBtn && ghInput) {
|
||||||
|
copyGhBtn.addEventListener('click', () => copyToClipboardSafe(ghInput.value));
|
||||||
|
}
|
||||||
|
if (copyKfBtn && kfInput) {
|
||||||
|
copyKfBtn.addEventListener('click', () => copyToClipboardSafe(kfInput.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
1684
public/js/adminStorage.js
Normal file
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
|||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
window.__pendingDropData = null;
|
||||||
|
|
||||||
|
function waitFor(selector, timeout = 1200) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
(function tick() {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) return resolve(el);
|
||||||
|
if (performance.now() - t0 >= timeout) return resolve(null);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||||
const _nativeFetch = window.fetch.bind(window);
|
const _nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
@@ -69,14 +83,33 @@ 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');
|
||||||
window.currentFolder = last ? last : "root";
|
window.currentFolder = last ? last : "root";
|
||||||
|
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
const stored = localStorage.getItem('showFoldersInList');
|
||||||
window.showFoldersInList = stored === null ? true : stored === 'true';
|
// default: false (unchecked)
|
||||||
|
window.showFoldersInList = stored === 'true';
|
||||||
|
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (zoomWrap) {
|
||||||
|
const hideZoom = localStorage.getItem('hideZoomControls') === 'true';
|
||||||
|
if (hideZoom) {
|
||||||
|
zoomWrap.style.display = 'none';
|
||||||
|
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||||
|
} else {
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always load zoom.js once app is running
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`).catch(err => {
|
||||||
|
console.warn('[zoom] failed to load zoom.js', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load public site config early (safe subset)
|
// Load public site config early (safe subset)
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
@@ -84,27 +117,56 @@ export function initializeApp() {
|
|||||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
// Hook DnD relay from fileList area into upload area
|
// Hook DnD relay from fileList area into upload area
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileList');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
|
||||||
if (fileListArea && uploadArea) {
|
if (fileListArea) {
|
||||||
|
let hoverTimer = null;
|
||||||
|
|
||||||
fileListArea.addEventListener('dragover', e => {
|
fileListArea.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.add('drop-hover');
|
fileListArea.classList.add('drop-hover');
|
||||||
|
// (optional) auto-open after brief hover so users see the drop target
|
||||||
|
if (!hoverTimer) {
|
||||||
|
hoverTimer = setTimeout(() => {
|
||||||
|
if (typeof window.openUploadModal === 'function') window.openUploadModal();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fileListArea.addEventListener('dragleave', () => {
|
fileListArea.addEventListener('dragleave', () => {
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
});
|
});
|
||||||
fileListArea.addEventListener('drop', e => {
|
|
||||||
|
fileListArea.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
dataTransfer: e.dataTransfer,
|
|
||||||
bubbles: true,
|
// 1) open the same modal that the Create menu uses
|
||||||
cancelable: true
|
openUploadModal();
|
||||||
}));
|
// 2) wait until the upload area exists *in the modal*, then relay the drop
|
||||||
|
// Prefer a scoped selector first to avoid duplicate IDs.
|
||||||
|
const uploadArea =
|
||||||
|
(await waitFor('#uploadModal #uploadDropArea')) ||
|
||||||
|
(await waitFor('#uploadDropArea'));
|
||||||
|
if (!uploadArea) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Many browsers make dataTransfer read-only; we try the direct attach first
|
||||||
|
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
|
||||||
|
uploadArea.dispatchEvent(relay);
|
||||||
|
} catch {
|
||||||
|
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
|
||||||
|
window.__pendingDropData = e.dataTransfer || null;
|
||||||
|
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}*/
|
||||||
|
|
||||||
// App subsystems
|
// App subsystems
|
||||||
initDragAndDrop();
|
initDragAndDrop();
|
||||||
@@ -132,6 +194,25 @@ export function initializeApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Zoom controls: load only for logged-in app ----
|
||||||
|
(function loadZoomControls() {
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (!zoomWrap) return;
|
||||||
|
|
||||||
|
// show container (keep CSS default = hidden)
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.style.alignItems = 'center';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
import(`/js/zoom.js?v=${encodeURIComponent(QVER)}`)
|
||||||
|
.catch(err => console.warn('[zoom] failed to load:', err));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[zoom] load error:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
LOGOUT (shared)
|
LOGOUT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
|
|||||||
@@ -34,18 +34,19 @@ window.currentOIDCConfig = currentOIDCConfig;
|
|||||||
|
|
||||||
|
|
||||||
(function installToastFilter() {
|
(function installToastFilter() {
|
||||||
const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net';
|
|
||||||
|
|
||||||
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
window.__FR_TOAST_FILTER__ = function (msgKeyOrText) {
|
||||||
|
const isDemoMode = !!window.__FR_DEMO__;
|
||||||
|
|
||||||
// Suppress the nag while doing TOTP step-up
|
// Suppress the nag while doing TOTP step-up
|
||||||
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' ||
|
||||||
/please log in/i.test(String(msgKeyOrText)))) {
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return null; // suppress
|
return null; // suppress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Demo host
|
// Demo mode: swap login prompt for demo creds
|
||||||
if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' ||
|
if (isDemoMode &&
|
||||||
/please log in/i.test(String(msgKeyOrText)))) {
|
(msgKeyOrText === 'please_log_in_to_continue' ||
|
||||||
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
return "Demo site — use:\nUsername: demo\nPassword: demo";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +82,16 @@ window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_requi
|
|||||||
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
// override showToast to suppress the "Please log in to continue." toast during TOTP
|
||||||
|
|
||||||
function showToast(msgKeyOrText, type) {
|
function showToast(msgKeyOrText, type) {
|
||||||
const isDemoHost = window.location.hostname.toLowerCase() === "demo.filerise.net";
|
const isDemoMode = !!window.__FR_DEMO__;
|
||||||
|
|
||||||
// If it's the pre-login prompt and we're on the demo site, show demo creds instead.
|
// For the pre-login prompt in demo mode, show demo creds instead
|
||||||
if (isDemoHost) {
|
if (isDemoMode &&
|
||||||
|
(msgKeyOrText === "please_log_in_to_continue" ||
|
||||||
|
/please log in/i.test(String(msgKeyOrText)))) {
|
||||||
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
return originalShowToast("Demo site — use: \nUsername: demo\nPassword: demo", 12000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don’t nag during pending TOTP, as you already had
|
// Don’t nag during pending TOTP
|
||||||
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
if (window.pendingTOTP && msgKeyOrText === "please_log_in_to_continue") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,11 +100,10 @@ function showToast(msgKeyOrText, type) {
|
|||||||
let msg = msgKeyOrText;
|
let msg = msgKeyOrText;
|
||||||
try {
|
try {
|
||||||
const translated = t(msgKeyOrText);
|
const translated = t(msgKeyOrText);
|
||||||
// If t() changed it or it's a key-like string, use the translation
|
|
||||||
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
if (typeof translated === "string" && translated !== msgKeyOrText) {
|
||||||
msg = translated;
|
msg = translated;
|
||||||
}
|
}
|
||||||
} catch { /* if t() isn’t available here, just use the original */ }
|
} catch { }
|
||||||
|
|
||||||
return originalShowToast(msg);
|
return originalShowToast(msg);
|
||||||
}
|
}
|
||||||
@@ -351,26 +353,8 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
if (r) r.style.display = "none";
|
if (r) r.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
// b) admin panel button only on demo.filerise.net
|
|
||||||
if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
|
|
||||||
let a = document.getElementById("adminPanelBtn");
|
|
||||||
if (!a) {
|
|
||||||
a = document.createElement("button");
|
|
||||||
a.id = "adminPanelBtn";
|
|
||||||
a.classList.add("btn", "btn-info");
|
|
||||||
a.setAttribute("data-i18n-title", "admin_panel");
|
|
||||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
|
||||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
|
||||||
a.addEventListener("click", openAdminPanel);
|
|
||||||
}
|
|
||||||
a.style.display = "block";
|
|
||||||
} else {
|
|
||||||
const a = document.getElementById("adminPanelBtn");
|
|
||||||
if (a) a.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// c) user dropdown on non-demo
|
// c) user dropdown on non-demo
|
||||||
if (window.location.hostname !== "demo.filerise.net") {
|
{
|
||||||
let dd = document.getElementById("userDropdown");
|
let dd = document.getElementById("userDropdown");
|
||||||
|
|
||||||
// choose icon *or* img
|
// choose icon *or* img
|
||||||
@@ -866,6 +850,10 @@ function initAuth() {
|
|||||||
});
|
});
|
||||||
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
document.getElementById("cancelRemoveUserBtn").addEventListener("click", closeRemoveUserModal);
|
||||||
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
document.getElementById("changePasswordBtn").addEventListener("click", function () {
|
||||||
|
if (window.__FR_DEMO__) {
|
||||||
|
showToast("Password changes are disabled on the public demo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
document.getElementById("changePasswordModal").style.display = "block";
|
document.getElementById("changePasswordModal").style.display = "block";
|
||||||
document.getElementById("oldPassword").focus();
|
document.getElementById("oldPassword").focus();
|
||||||
});
|
});
|
||||||
@@ -873,6 +861,10 @@ function initAuth() {
|
|||||||
document.getElementById("changePasswordModal").style.display = "none";
|
document.getElementById("changePasswordModal").style.display = "none";
|
||||||
});
|
});
|
||||||
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
document.getElementById("saveNewPasswordBtn").addEventListener("click", function () {
|
||||||
|
if (window.__FR_DEMO__) {
|
||||||
|
showToast("Password changes are disabled on the public demo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const oldPassword = document.getElementById("oldPassword").value.trim();
|
const oldPassword = document.getElementById("oldPassword").value.trim();
|
||||||
const newPassword = document.getElementById("newPassword").value.trim();
|
const newPassword = document.getElementById("newPassword").value.trim();
|
||||||
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
const confirmPassword = document.getElementById("confirmPassword").value.trim();
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
|
|||||||
//window.__lastLoginData = data;
|
//window.__lastLoginData = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHoverPreviewDisabled() {
|
||||||
|
if (window.disableHoverPreview === true) return true;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('disableHoverPreview') === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openTOTPLoginModal() {
|
export function openTOTPLoginModal() {
|
||||||
let totpLoginModal = document.getElementById("totpLoginModal");
|
let totpLoginModal = document.getElementById("totpLoginModal");
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
@@ -195,8 +204,7 @@ export async function openUserPanel() {
|
|||||||
color: ${isDark ? '#e0e0e0' : '#000'};
|
color: ${isDark ? '#e0e0e0' : '#000'};
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px; width:90%;
|
max-width: 600px; width:90%;
|
||||||
border-radius: 8px;
|
overflow-y: auto; max-height: 600px;
|
||||||
overflow-y: auto; max-height: 500px;
|
|
||||||
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
@@ -352,30 +360,152 @@ export async function openUserPanel() {
|
|||||||
langFs.appendChild(langSel);
|
langFs.appendChild(langSel);
|
||||||
content.appendChild(langFs);
|
content.appendChild(langFs);
|
||||||
|
|
||||||
// --- Display fieldset: “Show folders above files” ---
|
// --- Display fieldset: strip + inline folder rows ---
|
||||||
const dispFs = document.createElement('fieldset');
|
const dispFs = document.createElement('fieldset');
|
||||||
dispFs.style.marginBottom = '15px';
|
dispFs.style.marginBottom = '15px';
|
||||||
const dispLegend = document.createElement('legend');
|
|
||||||
dispLegend.textContent = t('display');
|
const dispLegend = document.createElement('legend');
|
||||||
dispFs.appendChild(dispLegend);
|
dispLegend.textContent = t('display');
|
||||||
const dispLabel = document.createElement('label');
|
dispFs.appendChild(dispLegend);
|
||||||
dispLabel.style.cursor = 'pointer';
|
|
||||||
const dispCb = document.createElement('input');
|
// 1) Show folder strip above list
|
||||||
dispCb.type = 'checkbox';
|
const stripLabel = document.createElement('label');
|
||||||
dispCb.id = 'showFoldersInList';
|
stripLabel.style.cursor = 'pointer';
|
||||||
dispCb.style.verticalAlign = 'middle';
|
stripLabel.style.display = 'block';
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
stripLabel.style.marginBottom = '4px';
|
||||||
dispCb.checked = stored === null ? true : stored === 'true';
|
|
||||||
dispLabel.appendChild(dispCb);
|
const stripCb = document.createElement('input');
|
||||||
dispLabel.append(` ${t('show_folders_above_files')}`);
|
stripCb.type = 'checkbox';
|
||||||
dispFs.appendChild(dispLabel);
|
stripCb.id = 'showFoldersInList';
|
||||||
content.appendChild(dispFs);
|
stripCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||||
|
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
stripLabel.appendChild(stripCb);
|
||||||
|
stripLabel.append(` ${t('show_folders_above_files')}`);
|
||||||
|
dispFs.appendChild(stripLabel);
|
||||||
|
|
||||||
|
// 2) Show inline folder rows above files in table view
|
||||||
|
const inlineLabel = document.createElement('label');
|
||||||
|
inlineLabel.style.cursor = 'pointer';
|
||||||
|
inlineLabel.style.display = 'block';
|
||||||
|
|
||||||
|
const inlineCb = document.createElement('input');
|
||||||
|
inlineCb.type = 'checkbox';
|
||||||
|
inlineCb.id = 'showInlineFolders';
|
||||||
|
inlineCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedInline = localStorage.getItem('showInlineFolders');
|
||||||
|
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
inlineLabel.appendChild(inlineCb);
|
||||||
|
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||||
|
dispFs.appendChild(inlineLabel);
|
||||||
|
|
||||||
|
// 3) Hide header zoom controls
|
||||||
|
const zoomLabel = document.createElement('label');
|
||||||
|
zoomLabel.style.cursor = 'pointer';
|
||||||
|
zoomLabel.style.display = 'block';
|
||||||
|
zoomLabel.style.marginTop = '4px';
|
||||||
|
|
||||||
|
const zoomCb = document.createElement('input');
|
||||||
|
zoomCb.type = 'checkbox';
|
||||||
|
zoomCb.id = 'hideHeaderZoomControls';
|
||||||
|
zoomCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedZoom = localStorage.getItem('hideZoomControls');
|
||||||
|
zoomCb.checked = storedZoom === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomLabel.appendChild(zoomCb);
|
||||||
|
zoomLabel.append(` ${t('hide_header_zoom_controls') || 'Hide zoom controls in header'}`);
|
||||||
|
dispFs.appendChild(zoomLabel);
|
||||||
|
|
||||||
|
content.appendChild(dispFs);
|
||||||
|
|
||||||
|
// Handlers: toggle + refresh list
|
||||||
|
stripCb.addEventListener('change', () => {
|
||||||
|
window.showFoldersInList = stripCb.checked;
|
||||||
|
localStorage.setItem('showFoldersInList', stripCb.checked);
|
||||||
|
if (typeof window.loadFileList === 'function') {
|
||||||
|
window.loadFileList(window.currentFolder || 'root');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inlineCb.addEventListener('change', () => {
|
||||||
|
window.showInlineFolders = inlineCb.checked;
|
||||||
|
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||||
|
if (typeof window.loadFileList === 'function') {
|
||||||
|
window.loadFileList(window.currentFolder || 'root');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// NEW: zoom hide/show handler
|
||||||
|
zoomCb.addEventListener('change', () => {
|
||||||
|
const hideZoom = zoomCb.checked;
|
||||||
|
localStorage.setItem('hideZoomControls', hideZoom ? 'true' : 'false');
|
||||||
|
|
||||||
|
const zoomWrap = document.querySelector('.header-zoom-controls');
|
||||||
|
if (!zoomWrap) return;
|
||||||
|
|
||||||
|
if (hideZoom) {
|
||||||
|
zoomWrap.style.display = 'none';
|
||||||
|
zoomWrap.setAttribute('aria-hidden', 'true');
|
||||||
|
} else {
|
||||||
|
zoomWrap.style.display = 'flex';
|
||||||
|
zoomWrap.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
dispCb.addEventListener('change', () => {
|
// 4) Disable hover preview
|
||||||
window.showFoldersInList = dispCb.checked;
|
const hoverLabel = document.createElement('label');
|
||||||
localStorage.setItem('showFoldersInList', dispCb.checked);
|
hoverLabel.style.cursor = 'pointer';
|
||||||
// re‐load the entire file list (and strip) in one go:
|
hoverLabel.style.display = 'block';
|
||||||
loadFileList(window.currentFolder);
|
hoverLabel.style.marginTop = '4px';
|
||||||
|
|
||||||
|
const hoverCb = document.createElement('input');
|
||||||
|
hoverCb.type = 'checkbox';
|
||||||
|
hoverCb.id = 'disableHoverPreview';
|
||||||
|
hoverCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||||
|
hoverCb.checked = storedHover === 'true';
|
||||||
|
// also mirror into a global flag for runtime checks
|
||||||
|
window.disableHoverPreview = hoverCb.checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverLabel.appendChild(hoverCb);
|
||||||
|
hoverLabel.append(
|
||||||
|
` ${t('disable_hover_preview') || 'Disable file hover preview'}`
|
||||||
|
);
|
||||||
|
dispFs.appendChild(hoverLabel);
|
||||||
|
|
||||||
|
// Handler: toggle hover preview
|
||||||
|
hoverCb.addEventListener('change', () => {
|
||||||
|
const disabled = hoverCb.checked;
|
||||||
|
localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
|
||||||
|
window.disableHoverPreview = disabled;
|
||||||
|
|
||||||
|
// Hide any currently-visible preview right away
|
||||||
|
const preview = document.getElementById('hoverPreview');
|
||||||
|
if (preview) {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inlineCb.addEventListener('change', () => {
|
||||||
|
window.showInlineFolders = inlineCb.checked;
|
||||||
|
localStorage.setItem('showInlineFolders', inlineCb.checked);
|
||||||
|
if (typeof window.loadFileList === 'function') {
|
||||||
|
window.loadFileList(window.currentFolder || 'root');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// wire up image‐input change
|
// wire up image‐input change
|
||||||
@@ -426,6 +556,25 @@ export async function openUserPanel() {
|
|||||||
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
|
||||||
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
|
||||||
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
|
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
|
||||||
|
|
||||||
|
// sync display toggles from localStorage
|
||||||
|
const stripCb = modal.querySelector('#showFoldersInList');
|
||||||
|
const inlineCb = modal.querySelector('#showInlineFolders');
|
||||||
|
if (stripCb) {
|
||||||
|
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||||
|
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
|
||||||
|
}
|
||||||
|
if (inlineCb) {
|
||||||
|
const storedInline = localStorage.getItem('showInlineFolders');
|
||||||
|
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverCb = modal.querySelector('#disableHoverPreview');
|
||||||
|
if (hoverCb) {
|
||||||
|
const storedHover = localStorage.getItem('disableHoverPreview');
|
||||||
|
hoverCb.checked = storedHover === 'true';
|
||||||
|
window.disableHoverPreview = hoverCb.checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// show
|
// show
|
||||||
|
|||||||
@@ -156,16 +156,16 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
|||||||
|
|
||||||
export function buildFileTableHeader(sortOrder) {
|
export function buildFileTableHeader(sortOrder) {
|
||||||
return `
|
return `
|
||||||
<table class="table">
|
<table class="table filr-table table-hover table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||||
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th data-column="size" class="hide-small sortable-col">${t("file_size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="size" class="sortable-col"> ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} </th>
|
||||||
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
|
||||||
<th>${t("actions")}</th>
|
<th data-column="actions" class="actions-col">${t("actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
`;
|
`;
|
||||||
@@ -175,84 +175,32 @@ export function buildFileTableRow(file, folderPath) {
|
|||||||
const safeFileName = escapeHTML(file.name);
|
const safeFileName = escapeHTML(file.name);
|
||||||
const safeModified = escapeHTML(file.modified);
|
const safeModified = escapeHTML(file.modified);
|
||||||
const safeUploaded = escapeHTML(file.uploaded);
|
const safeUploaded = escapeHTML(file.uploaded);
|
||||||
const safeSize = escapeHTML(file.size);
|
const safeSize = escapeHTML(file.size);
|
||||||
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
const safeUploader = escapeHTML(file.uploader || "Unknown");
|
||||||
|
|
||||||
let previewButton = "";
|
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i.test(file.name)) {
|
|
||||||
let previewIcon = "";
|
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tif|tiff|eps|heic)$/i.test(file.name)) {
|
|
||||||
previewIcon = `<i class="material-icons">image</i>`;
|
|
||||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
|
|
||||||
previewIcon = `<i class="material-icons">videocam</i>`;
|
|
||||||
} else if (/\.pdf$/i.test(file.name)) {
|
|
||||||
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
|
|
||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
|
|
||||||
previewIcon = `<i class="material-icons">audiotrack</i>`;
|
|
||||||
}
|
|
||||||
previewButton = `<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-info preview-btn"
|
|
||||||
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
|
|
||||||
data-preview-name="${safeFileName}"
|
|
||||||
title="${t('preview')}">
|
|
||||||
${previewIcon}
|
|
||||||
</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="clickable-row">
|
<tr class="clickable-row" data-file-name="${safeFileName}">
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
|
||||||
</td>
|
</td>
|
||||||
<td class="file-name-cell">${safeFileName}</td>
|
<td class="file-name-cell name-cell">
|
||||||
<td class="hide-small nowrap">${safeModified}</td>
|
${safeFileName}
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
</td>
|
||||||
<td class="hide-small nowrap">${safeSize}</td>
|
<td class="hide-small nowrap">${safeModified}</td>
|
||||||
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
|
||||||
<td>
|
<td class="hide-small nowrap size-cell">${safeSize}</td>
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
|
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
|
||||||
<button
|
<td class="actions-cell">
|
||||||
type="button"
|
<button
|
||||||
class="btn btn-sm btn-success download-btn"
|
type="button"
|
||||||
data-download-name="${file.name}"
|
class="btn btn-link btn-actions-ellipsis"
|
||||||
data-download-folder="${file.folder || 'root'}"
|
title="${t("more_actions")}"
|
||||||
title="${t('download')}">
|
>
|
||||||
<i class="material-icons">file_download</i>
|
<span class="material-icons">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
|
</td>
|
||||||
${file.editable ? `
|
</tr>
|
||||||
<button
|
`;
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-secondary edit-btn"
|
|
||||||
data-edit-name="${file.name}"
|
|
||||||
data-edit-folder="${file.folder || 'root'}"
|
|
||||||
title="${t('edit')}">
|
|
||||||
<i class="material-icons">edit</i>
|
|
||||||
</button>` : ""}
|
|
||||||
|
|
||||||
${previewButton}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-warning rename-btn"
|
|
||||||
data-rename-name="${file.name}"
|
|
||||||
data-rename-folder="${file.folder || 'root'}"
|
|
||||||
title="${t('rename')}">
|
|
||||||
<i class="material-icons">drive_file_rename_outline</i>
|
|
||||||
</button>
|
|
||||||
<!-- share -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary btn-sm share-btn ms-1"
|
|
||||||
data-file="${safeFileName}"
|
|
||||||
title="${t('share')}">
|
|
||||||
<i class="material-icons">share</i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBottomControls(itemsPerPageSetting) {
|
export function buildBottomControls(itemsPerPageSetting) {
|
||||||
@@ -283,9 +231,9 @@ export function updateRowHighlight(checkbox) {
|
|||||||
const row = checkbox.closest('tr');
|
const row = checkbox.closest('tr');
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (checkbox.checked) {
|
if (checkbox.checked) {
|
||||||
row.classList.add('row-selected');
|
row.classList.add('row-selected', 'selected');
|
||||||
} else {
|
} else {
|
||||||
row.classList.remove('row-selected');
|
row.classList.remove('row-selected', 'selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function handleDeleteSelected(e) {
|
export function handleDeleteSelected(e) {
|
||||||
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
|
|||||||
showToast("no_files_selected");
|
showToast("no_files_selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||||
const count = window.filesToDelete.length;
|
const count = window.filesToDelete.length;
|
||||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||||
@@ -20,6 +20,73 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILE_MODAL_IDS = [
|
||||||
|
'deleteFilesModal',
|
||||||
|
'downloadZipModal',
|
||||||
|
'downloadProgressModal',
|
||||||
|
'createFileModal',
|
||||||
|
'downloadFileModal',
|
||||||
|
'copyFilesModal',
|
||||||
|
'moveFilesModal',
|
||||||
|
'renameFileModal',
|
||||||
|
'createFolderModal', // if this exists in your HTML
|
||||||
|
];
|
||||||
|
|
||||||
|
function portalFileModalsToBody() {
|
||||||
|
FILE_MODAL_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el.parentNode !== document.body) {
|
||||||
|
document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Upload modal "portal" support ---
|
||||||
|
let _uploadCardSentinel = null;
|
||||||
|
|
||||||
|
export function openUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const body = document.getElementById('uploadModalBody');
|
||||||
|
const card = document.getElementById('uploadCard'); // <-- your existing card
|
||||||
|
window.openUploadModal = openUploadModal;
|
||||||
|
window.__pendingDropData = null;
|
||||||
|
if (!modal || !body || !card) {
|
||||||
|
console.warn('Upload modal or upload card not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a hidden sentinel so we can put the card back in place later
|
||||||
|
if (!_uploadCardSentinel) {
|
||||||
|
_uploadCardSentinel = document.createElement('div');
|
||||||
|
_uploadCardSentinel.id = 'uploadCardSentinel';
|
||||||
|
_uploadCardSentinel.style.display = 'none';
|
||||||
|
card.parentNode.insertBefore(_uploadCardSentinel, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the actual card node into the modal (keeps all existing listeners)
|
||||||
|
body.appendChild(card);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Focus the chooser for quick keyboard flow
|
||||||
|
setTimeout(() => {
|
||||||
|
const chooseBtn = document.getElementById('customChooseBtn');
|
||||||
|
if (chooseBtn) chooseBtn.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const card = document.getElementById('uploadCard');
|
||||||
|
|
||||||
|
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
|
||||||
|
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
|
||||||
|
}
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||||
if (cancelDelete) {
|
if (cancelDelete) {
|
||||||
@@ -47,6 +114,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files deleted successfully!");
|
showToast("Selected files deleted successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not delete files"));
|
showToast("Error: " + (data.error || "Could not delete files"));
|
||||||
}
|
}
|
||||||
@@ -119,7 +187,7 @@ export async function handleCreateFile(e) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type':'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': window.csrfToken
|
'X-CSRF-Token': window.csrfToken
|
||||||
},
|
},
|
||||||
// ⚠️ must send `name`, not `filename`
|
// ⚠️ must send `name`, not `filename`
|
||||||
@@ -129,6 +197,7 @@ export async function handleCreateFile(e) {
|
|||||||
if (!js.success) throw new Error(js.error);
|
if (!js.success) throw new Error(js.error);
|
||||||
showToast(t('file_created'));
|
showToast(t('file_created'));
|
||||||
loadFileList(folder);
|
loadFileList(folder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +208,7 @@ export async function handleCreateFile(e) {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const cancel = document.getElementById('cancelCreateFile');
|
const cancel = document.getElementById('cancelCreateFile');
|
||||||
const confirm = document.getElementById('confirmCreateFile');
|
const confirm = document.getElementById('confirmCreateFile');
|
||||||
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
|
||||||
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
if (confirm) confirm.addEventListener('click', handleCreateFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +334,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
const cancelZipBtn = document.getElementById("cancelDownloadZip");
|
||||||
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
const confirmZipBtn = document.getElementById("confirmDownloadZip");
|
||||||
const cancelCreate = document.getElementById('cancelCreateFile');
|
const cancelCreate = document.getElementById('cancelCreateFile');
|
||||||
|
|
||||||
if (cancelCreate) {
|
if (cancelCreate) {
|
||||||
cancelCreate.addEventListener('click', () => {
|
cancelCreate.addEventListener('click', () => {
|
||||||
document.getElementById('createFileModal').style.display = 'none';
|
document.getElementById('createFileModal').style.display = 'none';
|
||||||
@@ -300,12 +369,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
showToast(t('file_created_successfully'));
|
showToast(t('file_created_successfully'));
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(folder);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
showToast(err.message || t('error_creating_file'));
|
showToast(err.message || t('error_creating_file'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
attachEnterKeyListener('createFileModal','confirmCreateFile');
|
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Cancel button hides the name modal
|
// 1) Cancel button hides the name modal
|
||||||
@@ -321,63 +391,187 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
confirmZipBtn.addEventListener("click", async () => {
|
confirmZipBtn.addEventListener("click", async () => {
|
||||||
// a) Validate ZIP filename
|
// a) Validate ZIP filename
|
||||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||||
if (!zipName) {
|
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
|
||||||
showToast("Please enter a name for the zip file.");
|
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!zipName.toLowerCase().endsWith(".zip")) {
|
|
||||||
zipName += ".zip";
|
|
||||||
}
|
|
||||||
|
|
||||||
// b) Hide the name‐input modal, show the spinner modal
|
// b) Hide the name‐input modal, show the progress modal
|
||||||
zipNameModal.style.display = "none";
|
zipNameModal.style.display = "none";
|
||||||
progressModal.style.display = "block";
|
progressModal.style.display = "block";
|
||||||
|
|
||||||
// c) (Optional) update the “Preparing…” text if you gave it an ID
|
// c) Title text (optional)
|
||||||
const titleEl = document.getElementById("downloadProgressTitle");
|
const titleEl = document.getElementById("downloadProgressTitle");
|
||||||
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
if (titleEl) titleEl.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
try {
|
// d) Queue the job
|
||||||
// d) POST and await the ZIP blob
|
const res = await fetch("/api/file/downloadZip.php", {
|
||||||
const res = await fetch("/api/file/downloadZip.php", {
|
method: "POST",
|
||||||
method: "POST",
|
credentials: "include",
|
||||||
credentials: "include",
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
headers: {
|
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
|
||||||
"Content-Type": "application/json",
|
});
|
||||||
"X-CSRF-Token": window.csrfToken
|
const jsr = await res.json().catch(() => ({}));
|
||||||
},
|
if (!res.ok || !jsr.ok) {
|
||||||
body: JSON.stringify({
|
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
|
||||||
folder: window.currentFolder || "root",
|
throw new Error(msg);
|
||||||
files: window.filesToDownload
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text();
|
|
||||||
throw new Error(txt || `Status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await res.blob();
|
|
||||||
if (!blob || blob.size === 0) {
|
|
||||||
throw new Error("Received empty ZIP file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// e) Hand off to the browser’s download manager
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = zipName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
a.remove();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error downloading ZIP:", err);
|
|
||||||
showToast("Error: " + err.message);
|
|
||||||
} finally {
|
|
||||||
// f) Always hide spinner modal
|
|
||||||
progressModal.style.display = "none";
|
|
||||||
}
|
}
|
||||||
|
const token = jsr.token;
|
||||||
|
const statusUrl = jsr.statusUrl;
|
||||||
|
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
|
||||||
|
|
||||||
|
// Ensure a progress UI exists in the modal
|
||||||
|
function ensureZipProgressUI() {
|
||||||
|
const modalEl = document.getElementById("downloadProgressModal");
|
||||||
|
if (!modalEl) {
|
||||||
|
// really shouldn't happen, but fall back to body
|
||||||
|
console.warn("downloadProgressModal not found; falling back to document.body");
|
||||||
|
}
|
||||||
|
// Prefer a dedicated content node inside the modal
|
||||||
|
let host =
|
||||||
|
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".modal-content")) ||
|
||||||
|
(modalEl && modalEl.querySelector(".content")) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// If no suitable container, create one inside the modal
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement("div");
|
||||||
|
host.id = "downloadProgressContent";
|
||||||
|
(modalEl || document.body).appendChild(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: ensure/move an element with given id into host
|
||||||
|
function ensureInHost(id, tag, init) {
|
||||||
|
let el = document.getElementById(id);
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tag);
|
||||||
|
el.id = id;
|
||||||
|
if (typeof init === "function") init(el);
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
|
||||||
|
el.style.marginBottom = "8px";
|
||||||
|
el.textContent = "Preparing…";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Progress bar (native <progress>)
|
||||||
|
const bar = (function () {
|
||||||
|
let el = document.getElementById("downloadProgressBar");
|
||||||
|
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement("progress");
|
||||||
|
el.id = "downloadProgressBar";
|
||||||
|
host.appendChild(el);
|
||||||
|
}
|
||||||
|
el.max = 100;
|
||||||
|
el.value = 0;
|
||||||
|
el.style.display = ""; // override any inline display:none
|
||||||
|
el.style.width = "100%";
|
||||||
|
el.style.height = "1.1em";
|
||||||
|
return el;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Text line
|
||||||
|
const text = ensureInHost("downloadProgressText", "div", (el) => {
|
||||||
|
el.style.marginTop = "8px";
|
||||||
|
el.style.fontSize = "0.9rem";
|
||||||
|
el.style.whiteSpace = "nowrap";
|
||||||
|
el.style.overflow = "hidden";
|
||||||
|
el.style.textOverflow = "ellipsis";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optional spinner hider
|
||||||
|
const hideSpinner = () => {
|
||||||
|
const sp = document.getElementById("downloadSpinner");
|
||||||
|
if (sp) sp.style.display = "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
return { bar, text, title, hideSpinner };
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanBytes(n) {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "";
|
||||||
|
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
|
||||||
|
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
|
||||||
|
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
|
||||||
|
}
|
||||||
|
function mmss(sec) {
|
||||||
|
sec = Math.max(0, sec | 0);
|
||||||
|
const m = (sec / 60) | 0, s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ui = ensureZipProgressUI();
|
||||||
|
const t0 = Date.now();
|
||||||
|
|
||||||
|
// e) Poll until ready
|
||||||
|
while (true) {
|
||||||
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
|
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
|
||||||
|
credentials: "include", cache: "no-store",
|
||||||
|
}).then(r => r.json());
|
||||||
|
|
||||||
|
if (s.error) throw new Error(s.error);
|
||||||
|
if (ui.title) ui.title.textContent = `Preparing ${zipName}…`;
|
||||||
|
|
||||||
|
// --- RENDER PROGRESS ---
|
||||||
|
if (typeof s.pct === "number" && ui.bar && ui.text) {
|
||||||
|
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
|
||||||
|
ui.hideSpinner && ui.hideSpinner();
|
||||||
|
const filesDone = s.filesDone ?? 0;
|
||||||
|
const filesTotal = s.filesTotal ?? 0;
|
||||||
|
const bytesDone = s.bytesDone ?? 0;
|
||||||
|
const bytesTotal = s.bytesTotal ?? 0;
|
||||||
|
|
||||||
|
// Determinate 0–98% while enumerating
|
||||||
|
const pct = Math.max(0, Math.min(98, s.pct | 0));
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
|
||||||
|
ui.bar.value = pct;
|
||||||
|
ui.text.textContent =
|
||||||
|
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
|
||||||
|
} else {
|
||||||
|
// FINALIZING: keep progress at 100% and show timer + selected totals
|
||||||
|
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
|
||||||
|
ui.bar.value = 100; // lock at 100 during finalizing
|
||||||
|
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
|
||||||
|
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
|
||||||
|
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
|
||||||
|
ui.text.textContent = `Finalizing… ${mmss(since)} — ${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
|
||||||
|
}
|
||||||
|
} else if (ui.text) {
|
||||||
|
ui.text.textContent = "Still preparing…";
|
||||||
|
}
|
||||||
|
// --- /RENDER ---
|
||||||
|
|
||||||
|
if (s.ready) {
|
||||||
|
// Snap to 100 and close modal just before download
|
||||||
|
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
|
||||||
|
progressModal.style.display = "none";
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
|
||||||
|
}
|
||||||
|
|
||||||
|
// f) Trigger download
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = zipName;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
|
||||||
|
// g) Reset for next time
|
||||||
|
if (ui.bar) ui.bar.value = 0;
|
||||||
|
if (ui.text) ui.text.textContent = "";
|
||||||
|
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -509,6 +703,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files copied successfully!", 5000);
|
showToast("Selected files copied successfully!", 5000);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||||
}
|
}
|
||||||
@@ -561,6 +756,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast("Selected files moved successfully!");
|
showToast("Selected files moved successfully!");
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
refreshFolderIcon(targetFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
} else {
|
} else {
|
||||||
showToast("Error: " + (data.error || "Could not move files"));
|
showToast("Error: " + (data.error || "Could not move files"));
|
||||||
}
|
}
|
||||||
@@ -642,6 +839,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
// Expose initFileActions so it can be called from fileManager.js
|
// Expose initFileActions so it can be called from fileManager.js
|
||||||
export function initFileActions() {
|
export function initFileActions() {
|
||||||
|
portalFileModalsToBody();
|
||||||
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
const deleteSelectedBtn = document.getElementById("deleteSelectedBtn");
|
||||||
if (deleteSelectedBtn) {
|
if (deleteSelectedBtn) {
|
||||||
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
deleteSelectedBtn.replaceWith(deleteSelectedBtn.cloneNode(true));
|
||||||
@@ -694,10 +892,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const btn = document.getElementById('createBtn');
|
const btn = document.getElementById('createBtn');
|
||||||
const menu = document.getElementById('createMenu');
|
const menu = document.getElementById('createMenu');
|
||||||
const fileOpt = document.getElementById('createFileOption');
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
const folderOpt= document.getElementById('createFolderOption');
|
const folderOpt = document.getElementById('createFolderOption');
|
||||||
|
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||||
|
|
||||||
// Toggle dropdown on click
|
// Toggle dropdown on click
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -722,6 +921,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
menu.style.display = 'none';
|
menu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
if (uploadOpt) {
|
||||||
|
uploadOpt.addEventListener('click', () => {
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
openUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close buttons / backdrop
|
||||||
|
const upModal = document.getElementById('uploadModal');
|
||||||
|
const closeX = document.getElementById('closeUploadModal');
|
||||||
|
|
||||||
|
if (closeX) closeX.addEventListener('click', closeUploadModal);
|
||||||
|
|
||||||
|
// click outside content to close
|
||||||
|
if (upModal) {
|
||||||
|
upModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === upModal) closeUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
|
||||||
|
closeUploadModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -1,125 +1,165 @@
|
|||||||
// fileDragDrop.js
|
// fileDragDrop.js
|
||||||
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function fileDragStartHandler(event) {
|
/* ---------------- helpers ---------------- */
|
||||||
const row = event.currentTarget;
|
function getRowEl(el) {
|
||||||
let fileNames = [];
|
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
|
||||||
|
}
|
||||||
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
|
function getNameFromAny(el) {
|
||||||
if (selectedCheckboxes.length > 1) {
|
const row = getRowEl(el);
|
||||||
selectedCheckboxes.forEach(chk => {
|
if (!row) return null;
|
||||||
const parentRow = chk.closest("tr");
|
// 1) canonical
|
||||||
if (parentRow) {
|
const n = row.getAttribute('data-file-name');
|
||||||
const cell = parentRow.querySelector("td:nth-child(2)");
|
if (n) return n;
|
||||||
if (cell) {
|
// 2) filename-only span
|
||||||
let rawName = cell.textContent.trim();
|
const span = row.querySelector('.filename-text');
|
||||||
const tagContainer = cell.querySelector(".tag-badges");
|
if (span) return span.textContent.trim();
|
||||||
if (tagContainer) {
|
return null;
|
||||||
const tagText = tagContainer.innerText.trim();
|
}
|
||||||
if (rawName.endsWith(tagText)) {
|
function getSelectedFileNames() {
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
|
||||||
}
|
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
|
||||||
}
|
// de-dup just in case
|
||||||
fileNames.push(rawName);
|
return Array.from(new Set(names));
|
||||||
}
|
}
|
||||||
}
|
function makeDragImage(labelText, iconName = 'insert_drive_file') {
|
||||||
});
|
const wrap = document.createElement('div');
|
||||||
} else {
|
Object.assign(wrap.style, {
|
||||||
const fileNameCell = row.querySelector("td:nth-child(2)");
|
display: 'inline-flex',
|
||||||
if (fileNameCell) {
|
maxWidth: '420px',
|
||||||
let rawName = fileNameCell.textContent.trim();
|
padding: '6px 10px',
|
||||||
const tagContainer = fileNameCell.querySelector(".tag-badges");
|
backgroundColor: '#333',
|
||||||
if (tagContainer) {
|
color: '#fff',
|
||||||
const tagText = tagContainer.innerText.trim();
|
border: '1px solid #555',
|
||||||
if (rawName.endsWith(tagText)) {
|
borderRadius: '6px',
|
||||||
rawName = rawName.slice(0, -tagText.length).trim();
|
alignItems: 'center',
|
||||||
}
|
gap: '6px',
|
||||||
}
|
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
|
||||||
fileNames.push(rawName);
|
fontSize: '12px',
|
||||||
}
|
pointerEvents: 'none'
|
||||||
}
|
});
|
||||||
|
const icon = document.createElement('span');
|
||||||
if (fileNames.length === 0) return;
|
icon.className = 'material-icons';
|
||||||
|
icon.textContent = iconName;
|
||||||
const dragData = fileNames.length === 1
|
const label = document.createElement('span');
|
||||||
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
|
// trim long single-name labels
|
||||||
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
|
const txt = String(labelText || '');
|
||||||
|
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
|
||||||
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
wrap.appendChild(icon);
|
||||||
|
wrap.appendChild(label);
|
||||||
let dragImage = document.createElement("div");
|
document.body.appendChild(wrap);
|
||||||
dragImage.style.display = "inline-flex";
|
return wrap;
|
||||||
dragImage.style.width = "auto";
|
|
||||||
dragImage.style.maxWidth = "fit-content";
|
|
||||||
dragImage.style.padding = "6px 10px";
|
|
||||||
dragImage.style.backgroundColor = "#333";
|
|
||||||
dragImage.style.color = "#fff";
|
|
||||||
dragImage.style.border = "1px solid #555";
|
|
||||||
dragImage.style.borderRadius = "4px";
|
|
||||||
dragImage.style.alignItems = "center";
|
|
||||||
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "material-icons";
|
|
||||||
icon.textContent = "insert_drive_file";
|
|
||||||
icon.style.marginRight = "4px";
|
|
||||||
const label = document.createElement("span");
|
|
||||||
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
|
|
||||||
dragImage.appendChild(icon);
|
|
||||||
dragImage.appendChild(label);
|
|
||||||
|
|
||||||
document.body.appendChild(dragImage);
|
|
||||||
event.dataTransfer.setDragImage(dragImage, 5, 5);
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(dragImage);
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- drag start (rows/cards) ---------------- */
|
||||||
|
export function fileDragStartHandler(event) {
|
||||||
|
try { cancelHoverPreview(); } catch {}
|
||||||
|
const row = getRowEl(event.currentTarget);
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
// Use current selection if present; otherwise drag just this row’s file
|
||||||
|
let names = getSelectedFileNames();
|
||||||
|
if (names.length === 0) {
|
||||||
|
const single = getNameFromAny(row);
|
||||||
|
if (single) names = [single];
|
||||||
|
}
|
||||||
|
if (names.length === 0) return;
|
||||||
|
|
||||||
|
const sourceFolder = window.currentFolder || 'root';
|
||||||
|
const payload = { files: names, sourceFolder };
|
||||||
|
|
||||||
|
// primary payload
|
||||||
|
event.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||||
|
// fallback (lets some environments read something human)
|
||||||
|
event.dataTransfer.setData('text/plain', names.join('\n'));
|
||||||
|
|
||||||
|
// nicer drag image
|
||||||
|
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
|
||||||
|
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
|
||||||
|
event.dataTransfer.setDragImage(ghost, 6, 6);
|
||||||
|
// clean up the ghost as soon as the browser has captured it
|
||||||
|
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- folder targets ---------------- */
|
||||||
export function folderDragOverHandler(event) {
|
export function folderDragOverHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.add("drop-hover");
|
event.currentTarget.classList.add('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDragLeaveHandler(event) {
|
export function folderDragLeaveHandler(event) {
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function folderDropHandler(event) {
|
export async function folderDropHandler(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.currentTarget.classList.remove("drop-hover");
|
event.currentTarget.classList.remove('drop-hover');
|
||||||
const dropFolder = event.currentTarget.getAttribute("data-folder");
|
|
||||||
let dragData;
|
const dropFolder = event.currentTarget.getAttribute('data-folder')
|
||||||
|
|| event.currentTarget.getAttribute('data-dest-folder')
|
||||||
|
|| 'root';
|
||||||
|
|
||||||
|
// parse drag payload
|
||||||
|
let dragData = null;
|
||||||
try {
|
try {
|
||||||
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
|
const raw = event.dataTransfer.getData('application/json') || '{}';
|
||||||
} catch (e) {
|
dragData = JSON.parse(raw);
|
||||||
console.error("Invalid drag data");
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!dragData) {
|
||||||
|
showToast('Invalid drag data.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!dragData || !dragData.fileName) return;
|
|
||||||
fetch("/api/file/moveFiles.php", {
|
// normalize names
|
||||||
method: "POST",
|
let names = Array.isArray(dragData.files) ? dragData.files.slice()
|
||||||
credentials: "include",
|
: dragData.fileName ? [dragData.fileName]
|
||||||
headers: {
|
: [];
|
||||||
"Content-Type": "application/json",
|
names = names.filter(v => typeof v === 'string' && v.length > 0);
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
|
|
||||||
},
|
if (names.length === 0) {
|
||||||
body: JSON.stringify({
|
showToast('No files to move.');
|
||||||
source: dragData.sourceFolder,
|
return;
|
||||||
files: [dragData.fileName],
|
}
|
||||||
destination: dropFolder
|
|
||||||
})
|
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
|
||||||
})
|
if (dropFolder === sourceFolder) {
|
||||||
.then(response => response.json())
|
showToast('Source and destination are the same.');
|
||||||
.then(data => {
|
return;
|
||||||
if (data.success) {
|
}
|
||||||
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
|
|
||||||
loadFileList(dragData.sourceFolder);
|
// POST move
|
||||||
} else {
|
try {
|
||||||
showToast("Error moving file: " + (data.error || "Unknown error"));
|
const res = await fetch('/api/file/moveFiles.php', {
|
||||||
}
|
method: 'POST',
|
||||||
})
|
credentials: 'include',
|
||||||
.catch(error => {
|
headers: {
|
||||||
console.error("Error moving file via drop:", error);
|
'Content-Type': 'application/json',
|
||||||
showToast("Error moving file.");
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-Token': window.csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
source: sourceFolder,
|
||||||
|
files: names,
|
||||||
|
destination: dropFolder
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok && data && data.success) {
|
||||||
|
const msg = (names.length === 1)
|
||||||
|
? `Moved "${names[0]}" to ${dropFolder}.`
|
||||||
|
: `Moved ${names.length} files to ${dropFolder}.`;
|
||||||
|
showToast(msg);
|
||||||
|
// Refresh whatever view the user is currently looking at
|
||||||
|
loadFileList(window.currentFolder || sourceFolder);
|
||||||
|
} else {
|
||||||
|
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
|
||||||
|
showToast('Error moving file(s): ' + err);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error moving file(s):', e);
|
||||||
|
showToast('Error moving file(s).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -65,18 +65,52 @@ function normalizeModeName(modeOption) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ONLYOFFICE integration -----------------------------------------------
|
||||||
|
|
||||||
|
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
|
||||||
|
|
||||||
|
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
|
||||||
|
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
|
||||||
|
|
||||||
|
async function fetchOnlyOfficeCapsOnce() {
|
||||||
|
if (__ooCaps.fetched) return __ooCaps;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/onlyoffice/status.php', { credentials: 'include' });
|
||||||
|
if (r.ok) {
|
||||||
|
const j = await r.json();
|
||||||
|
__ooCaps.enabled = !!j.enabled;
|
||||||
|
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
|
||||||
|
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
|
||||||
|
}
|
||||||
|
} catch { /* ignore; keep defaults */ }
|
||||||
|
__ooCaps.fetched = true;
|
||||||
|
return __ooCaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldUseOnlyOffice(fileName) {
|
||||||
|
const { enabled, exts } = await fetchOnlyOfficeCapsOnce();
|
||||||
|
return enabled && exts.has(getExt(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
|
||||||
|
|
||||||
|
// ---- script/css single-load with timeout guards ----
|
||||||
const _loadedScripts = new Set();
|
const _loadedScripts = new Set();
|
||||||
const _loadedCss = new Set();
|
const _loadedCss = new Set();
|
||||||
let _corePromise = null;
|
let _corePromise = null;
|
||||||
|
|
||||||
function loadScriptOnce(url) {
|
function loadScriptOnce(url, timeoutMs = 12000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (_loadedScripts.has(url)) return resolve();
|
if (_loadedScripts.has(url)) return resolve();
|
||||||
const s = document.createElement("script");
|
const s = document.createElement("script");
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { s.remove(); } catch { }
|
||||||
|
reject(new Error(`Timeout loading: ${url}`));
|
||||||
|
}, timeoutMs);
|
||||||
s.src = url;
|
s.src = url;
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
|
||||||
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
|
||||||
document.head.appendChild(s);
|
document.head.appendChild(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -109,7 +143,6 @@ async function ensureCore() {
|
|||||||
async function loadSingleMode(name) {
|
async function loadSingleMode(name) {
|
||||||
const rel = MODE_URL[name];
|
const rel = MODE_URL[name];
|
||||||
if (!rel) return;
|
if (!rel) return;
|
||||||
// prepend base if needed
|
|
||||||
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
|
||||||
await loadScriptOnce(url);
|
await loadScriptOnce(url);
|
||||||
}
|
}
|
||||||
@@ -134,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public helper for callers (we keep your existing function name in use):
|
// Public helper for callers (we keep your existing function name in use):
|
||||||
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
|
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
|
||||||
// ==== /CodeMirror lazy loader ===============================================
|
// ==== /CodeMirror lazy loader ===============================================
|
||||||
|
|
||||||
|
// ---- OO preconnect / prewarm ----
|
||||||
|
function injectOOPreconnect(origin) {
|
||||||
|
try {
|
||||||
|
if (!origin || !isAbsoluteHttpUrl(origin)) return;
|
||||||
|
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
|
||||||
|
document.head.appendChild(make('dns-prefetch'));
|
||||||
|
document.head.appendChild(make('preconnect'));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
|
||||||
|
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
|
||||||
|
let src = srcFromConfig;
|
||||||
|
if (!src) {
|
||||||
|
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
|
||||||
|
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
|
||||||
|
} else {
|
||||||
|
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
|
||||||
|
// Try once; if it times out and we derived from origin, fall back to the default prefix path
|
||||||
|
try {
|
||||||
|
console.time('oo:api.js');
|
||||||
|
await loadScriptOnce(src);
|
||||||
|
} catch (e) {
|
||||||
|
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
|
||||||
|
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
console.timeEnd('oo:api.js');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
|
||||||
|
const ALWAYS_WARM_OO = true; // warm EVERY time
|
||||||
|
const OO_WARM_MS = 300;
|
||||||
|
|
||||||
|
function ensureOoModalCss() {
|
||||||
|
const prev = document.getElementById('ooEditorModalCss');
|
||||||
|
if (prev) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'ooEditorModalCss';
|
||||||
|
style.textContent = `
|
||||||
|
#ooEditorModal{
|
||||||
|
--oo-header-h: 40px;
|
||||||
|
--oo-header-pad-v: 12px;
|
||||||
|
--oo-header-pad-h: 18px;
|
||||||
|
--oo-logo-h: 26px; /* tweak logo size */
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal{
|
||||||
|
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
|
||||||
|
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
|
||||||
|
background:var(--oo-modal-bg,#111)!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: logo (left) + title (fill) + absolute close (right) */
|
||||||
|
#ooEditorModal .editor-header{
|
||||||
|
position:relative; display:flex; align-items:center; gap:12px;
|
||||||
|
min-height:var(--oo-header-h);
|
||||||
|
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
|
||||||
|
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
|
||||||
|
border-bottom:1px solid rgba(0,0,0,.15);
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-logo{
|
||||||
|
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
|
||||||
|
display:block; user-select:none; -webkit-user-drag:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ooEditorModal .editor-title{
|
||||||
|
margin:0; font-size:18px; font-weight:700; line-height:1.2;
|
||||||
|
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
|
||||||
|
flex:1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Your scoped close button style */
|
||||||
|
#ooEditorModal .editor-close-btn{
|
||||||
|
position:absolute; top:5px; right:10px;
|
||||||
|
display:flex; justify-content:center; align-items:center;
|
||||||
|
font-size:20px; font-weight:bold; cursor:pointer; z-index:1000;
|
||||||
|
width:32px; height:32px; border-radius:50%; text-align:center; line-height:30px;
|
||||||
|
color:#ff4d4d; background-color:rgba(255,255,255,.9); border:2px solid transparent;
|
||||||
|
transition:all .3s ease-in-out;
|
||||||
|
}
|
||||||
|
#ooEditorModal .editor-close-btn:hover{
|
||||||
|
color:#fff; background-color:#ff4d4d;
|
||||||
|
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
|
||||||
|
}
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
|
||||||
|
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
|
||||||
|
|
||||||
|
#ooEditorModal .editor-body{
|
||||||
|
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
|
||||||
|
}
|
||||||
|
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
|
||||||
|
|
||||||
|
#ooEditorModal .oo-warm-overlay{
|
||||||
|
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
||||||
|
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme-aware background so there’s no white/gray edge
|
||||||
|
function applyModalBg(modal){
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|
||||||
|
|| (isDark ? '#121212' : '#ffffff');
|
||||||
|
modal.style.setProperty('--oo-modal-bg', bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPageScroll(on){
|
||||||
|
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOoFullscreenModal(){
|
||||||
|
ensureOoModalCss();
|
||||||
|
let modal = document.getElementById('ooEditorModal');
|
||||||
|
if (!modal){
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'ooEditorModal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="editor-header">
|
||||||
|
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
|
||||||
|
<h3 class="editor-title"></h3>
|
||||||
|
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-body">
|
||||||
|
<div id="oo-editor"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
} else {
|
||||||
|
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
|
||||||
|
// ensure logo exists and is placed before title when reusing
|
||||||
|
const header = modal.querySelector('.editor-header');
|
||||||
|
if (!header.querySelector('.editor-logo')){
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'editor-logo';
|
||||||
|
img.src = '/assets/logo.svg';
|
||||||
|
img.alt = 'FileRise logo';
|
||||||
|
header.insertBefore(img, header.querySelector('.editor-title'));
|
||||||
|
} else {
|
||||||
|
// make sure order is logo -> title
|
||||||
|
const logo = header.querySelector('.editor-logo');
|
||||||
|
const title = header.querySelector('.editor-title');
|
||||||
|
if (logo.nextElementSibling !== title){
|
||||||
|
header.insertBefore(logo, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
modal.focus();
|
||||||
|
lockPageScroll(true);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay lives INSIDE the modal body
|
||||||
|
function setOoBusy(modal, on, label='Preparing editor…'){
|
||||||
|
if (!modal) return;
|
||||||
|
const body = modal.querySelector('.editor-body');
|
||||||
|
let ov = body.querySelector('.oo-warm-overlay');
|
||||||
|
if (on){
|
||||||
|
if (!ov){
|
||||||
|
ov = document.createElement('div');
|
||||||
|
ov.className = 'oo-warm-overlay';
|
||||||
|
ov.textContent = label;
|
||||||
|
body.appendChild(ov);
|
||||||
|
}
|
||||||
|
} else if (ov){
|
||||||
|
ov.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
|
||||||
|
async function warmDocServerOnce(cfg){
|
||||||
|
let host = null, warmEditor = null;
|
||||||
|
try{
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
|
||||||
|
Object.assign(host.style, {
|
||||||
|
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
|
||||||
|
});
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const warmCfg = JSON.parse(JSON.stringify(cfg));
|
||||||
|
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
|
||||||
|
|
||||||
|
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
|
||||||
|
await new Promise(res => setTimeout(res, OO_WARM_MS));
|
||||||
|
}catch{} finally{
|
||||||
|
try{ warmEditor?.destroyEditor?.(); }catch{}
|
||||||
|
try{ host?.remove(); }catch{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-screen OO open with hidden warm-up EVERY click, then real editor
|
||||||
|
async function openOnlyOffice(fileName, folder){
|
||||||
|
let editor = null;
|
||||||
|
let removeThemeListener = () => {};
|
||||||
|
let cfg = null;
|
||||||
|
let userClosed = false;
|
||||||
|
|
||||||
|
// Build our full-screen modal
|
||||||
|
const modal = ensureOoFullscreenModal();
|
||||||
|
const titleEl = modal.querySelector('.editor-title');
|
||||||
|
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
|
||||||
|
|
||||||
|
const destroy = (removeModal = true) => {
|
||||||
|
try { editor?.destroyEditor?.(); } catch {}
|
||||||
|
try { removeThemeListener(); } catch {}
|
||||||
|
if (removeModal) { try { modal.remove(); } catch {} }
|
||||||
|
lockPageScroll(false);
|
||||||
|
};
|
||||||
|
const onClose = () => { userClosed = true; destroy(true); };
|
||||||
|
|
||||||
|
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
|
||||||
|
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
|
||||||
|
|
||||||
|
try{
|
||||||
|
// 1) Fetch config
|
||||||
|
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
|
||||||
|
const resp = await fetch(url, { credentials: 'include' });
|
||||||
|
const text = await resp.text();
|
||||||
|
|
||||||
|
try { cfg = JSON.parse(text); } catch {
|
||||||
|
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
|
||||||
|
|
||||||
|
// 2) Preconnect + load DocsAPI
|
||||||
|
injectOOPreconnect(cfg.documentServerOrigin || null);
|
||||||
|
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
|
||||||
|
|
||||||
|
// 3) Theme + base events
|
||||||
|
const isDark = document.documentElement.classList.contains('dark-mode')
|
||||||
|
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
|
||||||
|
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
|
||||||
|
cfg.editorConfig = cfg.editorConfig || {};
|
||||||
|
cfg.editorConfig.customization = Object.assign(
|
||||||
|
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
|
||||||
|
);
|
||||||
|
cfg.events.onRequestClose = () => onClose();
|
||||||
|
|
||||||
|
// 4) Warm EVERY click
|
||||||
|
if (ALWAYS_WARM_OO && !userClosed){
|
||||||
|
setOoBusy(modal, true); // overlay INSIDE modal body
|
||||||
|
await warmDocServerOnce(cfg);
|
||||||
|
if (userClosed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Launch visible editor in full-screen modal
|
||||||
|
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
|
||||||
|
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
|
||||||
|
|
||||||
|
// Live theme switching + keep modal bg in sync
|
||||||
|
const darkToggle = document.getElementById('darkModeToggle');
|
||||||
|
const onDarkToggle = () => {
|
||||||
|
const nowDark = document.documentElement.classList.contains('dark-mode');
|
||||||
|
if (editor && typeof editor.setTheme === 'function') {
|
||||||
|
editor.setTheme(nowDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
applyModalBg(modal);
|
||||||
|
};
|
||||||
|
if (darkToggle) {
|
||||||
|
darkToggle.addEventListener('click', onDarkToggle);
|
||||||
|
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
console.error('[ONLYOFFICE] failed to open:', e);
|
||||||
|
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
|
||||||
|
destroy(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ---- /ONLYOFFICE integration ----------------------------------------------
|
||||||
|
|
||||||
|
// ==== Editor (CodeMirror) path =============================================
|
||||||
|
|
||||||
function getModeForFile(fileName) {
|
function getModeForFile(fileName) {
|
||||||
const dot = fileName.lastIndexOf(".");
|
const dot = fileName.lastIndexOf(".");
|
||||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
@@ -196,7 +519,7 @@ function observeModalResize(modal) {
|
|||||||
}
|
}
|
||||||
export { observeModalResize };
|
export { observeModalResize };
|
||||||
|
|
||||||
export function editFile(fileName, folder) {
|
export async function editFile(fileName, folder) {
|
||||||
// destroy any previous editor
|
// destroy any previous editor
|
||||||
let existingEditor = document.getElementById("editorContainer");
|
let existingEditor = document.getElementById("editorContainer");
|
||||||
if (existingEditor) existingEditor.remove();
|
if (existingEditor) existingEditor.remove();
|
||||||
@@ -204,6 +527,11 @@ export function editFile(fileName, folder) {
|
|||||||
const folderUsed = folder || window.currentFolder || "root";
|
const folderUsed = folder || window.currentFolder || "root";
|
||||||
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||||
|
|
||||||
|
if (await shouldUseOnlyOffice(fileName)) {
|
||||||
|
await openOnlyOffice(fileName, folderUsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||||
async function probeSize(url) {
|
async function probeSize(url) {
|
||||||
try {
|
try {
|
||||||
@@ -316,38 +644,36 @@ export function editFile(fileName, folder) {
|
|||||||
const normName = normalizeModeName(desiredMode) || "text/plain";
|
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||||
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||||
|
|
||||||
const cmOptions = {
|
const cm = window.CodeMirror.fromTextArea(
|
||||||
lineNumbers: !forcePlainText,
|
|
||||||
mode: initialMode,
|
|
||||||
theme,
|
|
||||||
viewportMargin: forcePlainText ? 20 : Infinity,
|
|
||||||
lineWrapping: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const editor = window.CodeMirror.fromTextArea(
|
|
||||||
document.getElementById("fileEditor"),
|
document.getElementById("fileEditor"),
|
||||||
cmOptions
|
{
|
||||||
|
lineNumbers: !forcePlainText,
|
||||||
|
mode: initialMode,
|
||||||
|
theme,
|
||||||
|
viewportMargin: forcePlainText ? 20 : Infinity,
|
||||||
|
lineWrapping: false
|
||||||
|
}
|
||||||
);
|
);
|
||||||
window.currentEditor = editor;
|
window.currentEditor = cm;
|
||||||
|
|
||||||
setTimeout(adjustEditorSize, 50);
|
setTimeout(adjustEditorSize, 50);
|
||||||
observeModalResize(modal);
|
observeModalResize(modal);
|
||||||
|
|
||||||
// Font controls (now that editor exists)
|
// Font controls (now that editor exists)
|
||||||
let currentFontSize = 14;
|
let currentFontSize = 14;
|
||||||
const wrapper = editor.getWrapperElement();
|
const wrapper = cm.getWrapperElement();
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
|
|
||||||
decBtn.addEventListener("click", function () {
|
decBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.max(8, currentFontSize - 2);
|
currentFontSize = Math.max(8, currentFontSize - 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
incBtn.addEventListener("click", function () {
|
incBtn.addEventListener("click", function () {
|
||||||
currentFontSize = Math.min(32, currentFontSize + 2);
|
currentFontSize = Math.min(32, currentFontSize + 2);
|
||||||
wrapper.style.fontSize = currentFontSize + "px";
|
wrapper.style.fontSize = currentFontSize + "px";
|
||||||
editor.refresh();
|
cm.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save
|
// Save
|
||||||
@@ -360,7 +686,7 @@ export function editFile(fileName, folder) {
|
|||||||
// Theme switch
|
// Theme switch
|
||||||
function updateEditorTheme() {
|
function updateEditorTheme() {
|
||||||
const isDark = document.body.classList.contains("dark-mode");
|
const isDark = document.body.classList.contains("dark-mode");
|
||||||
editor.setOption("theme", isDark ? "material-darker" : "default");
|
cm.setOption("theme", isDark ? "material-darker" : "default");
|
||||||
}
|
}
|
||||||
const toggle = document.getElementById("darkModeToggle");
|
const toggle = document.getElementById("darkModeToggle");
|
||||||
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
if (toggle) toggle.addEventListener("click", updateEditorTheme);
|
||||||
@@ -370,12 +696,10 @@ export function editFile(fileName, folder) {
|
|||||||
if (!canceled && !forcePlainText) {
|
if (!canceled && !forcePlainText) {
|
||||||
const nn = normalizeModeName(desiredMode);
|
const nn = normalizeModeName(desiredMode);
|
||||||
if (nn && isModeRegistered(nn)) {
|
if (nn && isModeRegistered(nn)) {
|
||||||
editor.setOption("mode", desiredMode);
|
cm.setOption("mode", desiredMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => { /* stay in plain text */ });
|
||||||
// If the mode truly fails to load, we just stay in plain text
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -1,154 +1,253 @@
|
|||||||
// fileMenu.js
|
// fileMenu.js
|
||||||
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
|
import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
import {
|
||||||
|
handleDeleteSelected, handleCopySelected, handleMoveSelected,
|
||||||
|
handleDownloadZipSelected, handleExtractZipSelected,
|
||||||
|
renameFile, openCreateFileModal
|
||||||
|
} 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 { t } from './i18n.js?v={{APP_QVER}}';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function showFileContextMenu(x, y, menuItems) {
|
|
||||||
let menu = document.getElementById("fileContextMenu");
|
const MENU_ID = 'fileContextMenu';
|
||||||
if (!menu) {
|
|
||||||
menu = document.createElement("div");
|
function qMenu() { return document.getElementById(MENU_ID); }
|
||||||
menu.id = "fileContextMenu";
|
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
|
||||||
menu.style.position = "fixed";
|
|
||||||
menu.style.backgroundColor = "#fff";
|
// One-time: localize labels
|
||||||
menu.style.border = "1px solid #ccc";
|
function localizeMenu() {
|
||||||
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
|
const m = qMenu(); if (!m) return;
|
||||||
menu.style.zIndex = "9999";
|
const map = {
|
||||||
menu.style.padding = "5px 0";
|
'create_file': 'create_file',
|
||||||
menu.style.minWidth = "150px";
|
'delete_selected': 'delete_selected',
|
||||||
document.body.appendChild(menu);
|
'copy_selected': 'copy_selected',
|
||||||
}
|
'move_selected': 'move_selected',
|
||||||
menu.innerHTML = "";
|
'download_zip': 'download_zip',
|
||||||
menuItems.forEach(item => {
|
'extract_zip': 'extract_zip',
|
||||||
let menuItem = document.createElement("div");
|
'tag_selected': 'tag_selected',
|
||||||
menuItem.textContent = item.label;
|
'preview': 'preview',
|
||||||
menuItem.style.padding = "5px 15px";
|
'edit': 'edit',
|
||||||
menuItem.style.cursor = "pointer";
|
'rename': 'rename',
|
||||||
menuItem.addEventListener("mouseover", () => {
|
'tag_file': 'tag_file',
|
||||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
// NEW:
|
||||||
});
|
'download_plain': 'download_plain'
|
||||||
menuItem.addEventListener("mouseout", () => {
|
};
|
||||||
menuItem.style.backgroundColor = "";
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
});
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
menuItem.addEventListener("click", () => {
|
if (el) setText(el, key);
|
||||||
item.action();
|
|
||||||
hideFileContextMenu();
|
|
||||||
});
|
|
||||||
menu.appendChild(menuItem);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
menu.style.left = x + "px";
|
// Show/hide items based on selection state
|
||||||
menu.style.top = y + "px";
|
function configureVisibility({ any, one, many, anyZip, canEdit }) {
|
||||||
menu.style.display = "block";
|
const m = qMenu(); if (!m) return;
|
||||||
|
|
||||||
const menuRect = menu.getBoundingClientRect();
|
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
if (menuRect.bottom > viewportHeight) {
|
show(m.querySelectorAll('[data-when="always"]'), true);
|
||||||
let newTop = viewportHeight - menuRect.height;
|
show(m.querySelectorAll('[data-when="any"]'), any);
|
||||||
if (newTop < 0) newTop = 0;
|
show(m.querySelectorAll('[data-when="one"]'), one);
|
||||||
menu.style.top = newTop + "px";
|
show(m.querySelectorAll('[data-when="many"]'), many);
|
||||||
|
show(m.querySelectorAll('[data-when="zip"]'), anyZip);
|
||||||
|
show(m.querySelectorAll('[data-when="can-edit"]'), canEdit);
|
||||||
|
|
||||||
|
// Hide separators at edges or duplicates
|
||||||
|
cleanupSeparators(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSeparators(menu) {
|
||||||
|
const kids = Array.from(menu.children);
|
||||||
|
let lastWasSep = true; // leading seps hidden
|
||||||
|
kids.forEach((el, i) => {
|
||||||
|
if (el.classList.contains('sep')) {
|
||||||
|
const hide = lastWasSep || (i === kids.length - 1);
|
||||||
|
el.hidden = hide || el.hidden; // keep hidden if already hidden by state
|
||||||
|
lastWasSep = !el.hidden;
|
||||||
|
} else if (!el.hidden) {
|
||||||
|
lastWasSep = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position menu within viewport
|
||||||
|
function placeMenu(x, y) {
|
||||||
|
const m = qMenu(); if (!m) return;
|
||||||
|
|
||||||
|
// make visible to measure
|
||||||
|
m.hidden = false;
|
||||||
|
m.style.left = '0px';
|
||||||
|
m.style.top = '0px';
|
||||||
|
|
||||||
|
// force a max-height via CSS fallback if styles didn't load yet
|
||||||
|
const pad = 8;
|
||||||
|
const vh = window.innerHeight, vw = window.innerWidth;
|
||||||
|
const mh = Math.min(vh - pad*2, 600); // JS fallback limit
|
||||||
|
m.style.maxHeight = mh + 'px';
|
||||||
|
|
||||||
|
// measure now that it's flow-visible
|
||||||
|
const r0 = m.getBoundingClientRect();
|
||||||
|
let nx = x, ny = y;
|
||||||
|
|
||||||
|
// If it would overflow right, shift left
|
||||||
|
if (nx + r0.width > vw - pad) nx = Math.max(pad, vw - r0.width - pad);
|
||||||
|
// If it would overflow bottom, try placing it above the cursor
|
||||||
|
if (ny + r0.height > vh - pad) {
|
||||||
|
const above = y - r0.height - 4;
|
||||||
|
ny = (above >= pad) ? above : Math.max(pad, vh - r0.height - pad);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard top/left minimums
|
||||||
|
nx = Math.max(pad, nx);
|
||||||
|
ny = Math.max(pad, ny);
|
||||||
|
|
||||||
|
m.style.left = `${nx}px`;
|
||||||
|
m.style.top = `${ny}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hideFileContextMenu() {
|
export function hideFileContextMenu() {
|
||||||
const menu = document.getElementById("fileContextMenu");
|
const m = qMenu();
|
||||||
if (menu) {
|
if (m) m.hidden = true;
|
||||||
menu.style.display = "none";
|
}
|
||||||
}
|
|
||||||
|
function currentSelection() {
|
||||||
|
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
|
||||||
|
// checkbox values are ESCAPED names (because buildFileTableRow used safeFileName)
|
||||||
|
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
|
||||||
|
const escSet = new Set(selectedEsc);
|
||||||
|
|
||||||
|
// map back to real file objects by comparing escaped(f.name)
|
||||||
|
const files = fileData.filter(f => escSet.has(escapeHTML(f.name)));
|
||||||
|
|
||||||
|
const any = files.length > 0;
|
||||||
|
const one = files.length === 1;
|
||||||
|
const many = files.length > 1;
|
||||||
|
const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
|
||||||
|
const file = one ? files[0] : null;
|
||||||
|
const canEditFlag = !!(file && canEditFile(file.name));
|
||||||
|
|
||||||
|
// also return the raw names if any caller needs them
|
||||||
|
return {
|
||||||
|
files, // <— real file objects for modals
|
||||||
|
all: files.map(f => f.name),
|
||||||
|
any, one, many, anyZip,
|
||||||
|
file,
|
||||||
|
canEdit: canEditFlag
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileListContextMenuHandler(e) {
|
export function fileListContextMenuHandler(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
let row = e.target.closest("tr");
|
// Check row if needed
|
||||||
|
const row = e.target.closest('tr');
|
||||||
if (row) {
|
if (row) {
|
||||||
const checkbox = row.querySelector(".file-checkbox");
|
const cb = row.querySelector('.file-checkbox');
|
||||||
if (checkbox && !checkbox.checked) {
|
if (cb && !cb.checked) {
|
||||||
checkbox.checked = true;
|
cb.checked = true;
|
||||||
updateRowHighlight(checkbox);
|
updateRowHighlight(cb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
|
const state = currentSelection();
|
||||||
|
configureVisibility(state);
|
||||||
|
placeMenu(e.clientX, e.clientY);
|
||||||
|
|
||||||
let menuItems = [
|
// Stash for click handlers
|
||||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
window.__filr_ctx_state = state;
|
||||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
|
||||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
|
||||||
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
|
|
||||||
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
|
|
||||||
];
|
|
||||||
|
|
||||||
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
|
|
||||||
menuItems.push({
|
|
||||||
label: t("extract_zip"),
|
|
||||||
action: () => { handleExtractZipSelected(new Event("click")); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selected.length > 1) {
|
|
||||||
menuItems.push({
|
|
||||||
label: t("tag_selected"),
|
|
||||||
action: () => {
|
|
||||||
const files = fileData.filter(f => selected.includes(f.name));
|
|
||||||
openMultiTagModal(files);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else if (selected.length === 1) {
|
|
||||||
const file = fileData.find(f => f.name === selected[0]);
|
|
||||||
|
|
||||||
menuItems.push({
|
|
||||||
label: t("preview"),
|
|
||||||
action: () => {
|
|
||||||
const folder = window.currentFolder || "root";
|
|
||||||
previewFile(buildPreviewUrl(folder, file.name), file.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canEditFile(file.name)) {
|
|
||||||
menuItems.push({
|
|
||||||
label: t("edit"),
|
|
||||||
action: () => { editFile(selected[0], window.currentFolder); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
menuItems.push({
|
|
||||||
label: t("rename"),
|
|
||||||
action: () => { renameFile(selected[0], window.currentFolder); }
|
|
||||||
});
|
|
||||||
|
|
||||||
menuItems.push({
|
|
||||||
label: t("tag_file"),
|
|
||||||
action: () => { openTagModal(file); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showFileContextMenu(e.clientX, e.clientY, menuItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- add near top ---
|
||||||
|
let __ctxBoundOnce = false;
|
||||||
|
|
||||||
|
function docClickClose(ev) {
|
||||||
|
const m = qMenu(); if (!m || m.hidden) return;
|
||||||
|
if (!m.contains(ev.target)) hideFileContextMenu();
|
||||||
|
}
|
||||||
|
function docKeyClose(ev) {
|
||||||
|
if (ev.key === 'Escape') hideFileContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuClickDelegate(ev) {
|
||||||
|
const btn = ev.target.closest('.mi[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// CLOSE MENU FIRST so it can’t overlay the modal
|
||||||
|
hideFileContextMenu();
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const s = window.__filr_ctx_state || currentSelection();
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'create_file': openCreateFileModal(); break;
|
||||||
|
case 'delete_selected': handleDeleteSelected(new Event('click')); break;
|
||||||
|
case 'copy_selected': handleCopySelected(new Event('click')); break;
|
||||||
|
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
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'preview':
|
||||||
|
if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
if (s.file && s.canEdit) editFile(s.file.name, folder);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rename':
|
||||||
|
if (s.file) renameFile(s.file.name, folder);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tag_file':
|
||||||
|
if (s.file) openTagModal(s.file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep your renderFileTable wrapper as-is
|
||||||
|
|
||||||
export function bindFileListContextMenu() {
|
export function bindFileListContextMenu() {
|
||||||
const fileListContainer = document.getElementById("fileList");
|
const container = document.getElementById('fileList');
|
||||||
if (fileListContainer) {
|
const menu = qMenu();
|
||||||
fileListContainer.oncontextmenu = fileListContextMenuHandler;
|
if (!container || !menu) return;
|
||||||
|
|
||||||
|
localizeMenu();
|
||||||
|
|
||||||
|
// Open on right click in the table
|
||||||
|
container.oncontextmenu = fileListContextMenuHandler;
|
||||||
|
|
||||||
|
// Bind once
|
||||||
|
if (!__ctxBoundOnce) {
|
||||||
|
document.addEventListener('click', docClickClose);
|
||||||
|
document.addEventListener('keydown', docKeyClose);
|
||||||
|
menu.addEventListener('click', menuClickDelegate); // handles actions
|
||||||
|
__ctxBoundOnce = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("click", function (e) {
|
// Rebind after table render (keeps your original behavior)
|
||||||
const menu = document.getElementById("fileContextMenu");
|
|
||||||
if (menu && menu.style.display === "block") {
|
|
||||||
hideFileContextMenu();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rebind context menu after file table render.
|
|
||||||
(function () {
|
(function () {
|
||||||
const originalRenderFileTable = window.renderFileTable;
|
const orig = window.renderFileTable;
|
||||||
window.renderFileTable = function (folder) {
|
if (typeof orig === 'function') {
|
||||||
originalRenderFileTable(folder);
|
window.renderFileTable = function (folder) {
|
||||||
bindFileListContextMenu();
|
orig(folder);
|
||||||
};
|
bindFileListContextMenu();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// If not present yet, bind once DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -1,172 +1,214 @@
|
|||||||
// fileTags.js
|
// fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
|
||||||
// This module provides functions for opening the tag modal,
|
|
||||||
// adding tags to files (with a global tag store for reuse),
|
|
||||||
// updating the file row display with tag badges,
|
|
||||||
// filtering the file list by tag, and persisting tag data.
|
|
||||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
export function openTagModal(file) {
|
// -------------------- state --------------------
|
||||||
// Create the modal element.
|
let __singleInit = false;
|
||||||
let modal = document.createElement('div');
|
let __multiInit = false;
|
||||||
modal.id = 'tagModal';
|
let currentFile = null;
|
||||||
modal.className = 'modal';
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-content" style="width: 450px; max-width:90vw;">
|
|
||||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
|
||||||
<h3 style="
|
|
||||||
margin:0;
|
|
||||||
display:inline-block;
|
|
||||||
max-width: calc(100% - 40px);
|
|
||||||
overflow:hidden;
|
|
||||||
text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;
|
|
||||||
">
|
|
||||||
${t("tag_file")}: ${escapeHTML(file.name)}
|
|
||||||
</h3>
|
|
||||||
<span id="closeTagModal" class="editor-close-btn">×</span>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="margin-top:10px;">
|
|
||||||
<label for="tagNameInput">${t("tag_name")}</label>
|
|
||||||
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
|
||||||
<br><br>
|
|
||||||
<label for="tagColorInput">${t("tag_name")}</label>
|
|
||||||
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
|
||||||
<br><br>
|
|
||||||
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
|
||||||
<!-- Custom tag options will be populated here -->
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<div style="text-align:right;">
|
|
||||||
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
|
|
||||||
</div>
|
|
||||||
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
|
|
||||||
<!-- Existing tags will be listed here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
updateCustomTagDropdown();
|
// Global store (preserve existing behavior)
|
||||||
|
window.globalTags = window.globalTags || [];
|
||||||
document.getElementById('closeTagModal').addEventListener('click', () => {
|
if (localStorage.getItem('globalTags')) {
|
||||||
modal.remove();
|
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
|
||||||
});
|
|
||||||
|
|
||||||
updateTagModalDisplay(file);
|
|
||||||
|
|
||||||
document.getElementById('tagNameInput').addEventListener('input', (e) => {
|
|
||||||
updateCustomTagDropdown(e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('saveTagBtn').addEventListener('click', () => {
|
|
||||||
const tagName = document.getElementById('tagNameInput').value.trim();
|
|
||||||
const tagColor = document.getElementById('tagColorInput').value;
|
|
||||||
if (!tagName) {
|
|
||||||
alert('Please enter a tag name.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addTagToFile(file, { name: tagName, color: tagColor });
|
|
||||||
updateTagModalDisplay(file);
|
|
||||||
updateFileRowTagDisplay(file);
|
|
||||||
saveFileTags(file);
|
|
||||||
if (window.viewMode === 'gallery') {
|
|
||||||
renderGalleryView(window.currentFolder);
|
|
||||||
} else {
|
|
||||||
renderFileTable(window.currentFolder);
|
|
||||||
}
|
|
||||||
document.getElementById('tagNameInput').value = '';
|
|
||||||
updateCustomTagDropdown();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------- ensure DOM (create-once-if-missing) --------------------
|
||||||
* Open a modal to tag multiple files.
|
function ensureSingleTagModal() {
|
||||||
* @param {Array} files - Array of file objects to tag.
|
// de-dupe if something already injected multiples
|
||||||
*/
|
const all = document.querySelectorAll('#tagModal');
|
||||||
export function openMultiTagModal(files) {
|
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
|
||||||
let modal = document.createElement('div');
|
|
||||||
modal.id = 'multiTagModal';
|
let modal = document.getElementById('tagModal');
|
||||||
modal.className = 'modal';
|
if (!modal) {
|
||||||
modal.innerHTML = `
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
<div class="modal-content" style="width: 450px; max-width:90vw;">
|
<div id="tagModal" class="modal" style="display:none">
|
||||||
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
<div class="modal-content" style="width:450px; max-width:90vw;">
|
||||||
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
<h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
</div>
|
${t('tag_file')}
|
||||||
<div class="modal-body" style="margin-top:10px;">
|
</h3>
|
||||||
<label for="multiTagNameInput">Tag Name:</label>
|
<span id="closeTagModal" class="editor-close-btn">×</span>
|
||||||
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
|
</div>
|
||||||
<br><br>
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
<label for="multiTagColorInput">Tag Color:</label>
|
<label for="tagNameInput">${t('tag_name')}</label>
|
||||||
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
<input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
|
||||||
<br><br>
|
<br><br>
|
||||||
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
|
<label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
|
||||||
<!-- Custom tag options will be populated here -->
|
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
</div>
|
<br><br>
|
||||||
<br>
|
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
|
||||||
<div style="text-align:right;">
|
<br>
|
||||||
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
|
<div style="text-align:right;">
|
||||||
|
<button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
|
||||||
|
</div>
|
||||||
|
<div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`);
|
||||||
`;
|
modal = document.getElementById('tagModal');
|
||||||
document.body.appendChild(modal);
|
}
|
||||||
modal.style.display = 'block';
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
updateMultiCustomTagDropdown();
|
function ensureMultiTagModal() {
|
||||||
|
const all = document.querySelectorAll('#multiTagModal');
|
||||||
|
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
|
||||||
|
|
||||||
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
|
let modal = document.getElementById('multiTagModal');
|
||||||
modal.remove();
|
if (!modal) {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div id="multiTagModal" class="modal" style="display:none">
|
||||||
|
<div class="modal-content" style="width:450px; max-width:90vw;">
|
||||||
|
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h3 id="multiTagTitle" style="margin:0;"></h3>
|
||||||
|
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="margin-top:10px;">
|
||||||
|
<label for="multiTagNameInput">${t('tag_name')}</label>
|
||||||
|
<input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
|
||||||
|
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
|
||||||
|
<br><br>
|
||||||
|
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
|
||||||
|
<br>
|
||||||
|
<div style="text-align:right;">
|
||||||
|
<button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
modal = document.getElementById('multiTagModal');
|
||||||
|
}
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- init (bind once) --------------------
|
||||||
|
function initSingleModalOnce() {
|
||||||
|
if (__singleInit) return;
|
||||||
|
const modal = ensureSingleTagModal();
|
||||||
|
const closeBtn = document.getElementById('closeTagModal');
|
||||||
|
const saveBtn = document.getElementById('saveTagBtn');
|
||||||
|
const nameInp = document.getElementById('tagNameInput');
|
||||||
|
|
||||||
|
// Close handlers
|
||||||
|
closeBtn?.addEventListener('click', hideTagModal);
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTagModal(); });
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) hideTagModal(); // click backdrop
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
|
// Input filter for dropdown
|
||||||
updateMultiCustomTagDropdown(e.target.value);
|
nameInp?.addEventListener('input', (e) => updateCustomTagDropdown(e.target.value));
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
saveBtn?.addEventListener('click', () => {
|
||||||
|
const tagName = (document.getElementById('tagNameInput')?.value || '').trim();
|
||||||
|
const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000';
|
||||||
|
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
|
||||||
|
if (!currentFile) return;
|
||||||
|
|
||||||
|
addTagToFile(currentFile, { name: tagName, color: tagColor });
|
||||||
|
updateTagModalDisplay(currentFile);
|
||||||
|
updateFileRowTagDisplay(currentFile);
|
||||||
|
saveFileTags(currentFile);
|
||||||
|
|
||||||
|
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
|
||||||
|
else renderFileTable(window.currentFolder);
|
||||||
|
|
||||||
|
const inp = document.getElementById('tagNameInput');
|
||||||
|
if (inp) inp.value = '';
|
||||||
|
updateCustomTagDropdown('');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
|
__singleInit = true;
|
||||||
const tagName = document.getElementById('multiTagNameInput').value.trim();
|
}
|
||||||
const tagColor = document.getElementById('multiTagColorInput').value;
|
|
||||||
if (!tagName) {
|
function initMultiModalOnce() {
|
||||||
alert('Please enter a tag name.');
|
if (__multiInit) return;
|
||||||
return;
|
const modal = ensureMultiTagModal();
|
||||||
}
|
const closeBtn = document.getElementById('closeMultiTagModal');
|
||||||
|
const saveBtn = document.getElementById('saveMultiTagBtn');
|
||||||
|
const nameInp = document.getElementById('multiTagNameInput');
|
||||||
|
|
||||||
|
closeBtn?.addEventListener('click', hideMultiTagModal);
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMultiTagModal(); });
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) hideMultiTagModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
nameInp?.addEventListener('input', (e) => updateMultiCustomTagDropdown(e.target.value));
|
||||||
|
|
||||||
|
saveBtn?.addEventListener('click', () => {
|
||||||
|
const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim();
|
||||||
|
const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000';
|
||||||
|
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
|
||||||
|
|
||||||
|
const files = (window.__multiTagFiles || []);
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
addTagToFile(file, { name: tagName, color: tagColor });
|
addTagToFile(file, { name: tagName, color: tagColor });
|
||||||
updateFileRowTagDisplay(file);
|
updateFileRowTagDisplay(file);
|
||||||
saveFileTags(file);
|
saveFileTags(file);
|
||||||
});
|
});
|
||||||
modal.remove();
|
|
||||||
if (window.viewMode === 'gallery') {
|
hideMultiTagModal();
|
||||||
renderGalleryView(window.currentFolder);
|
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
|
||||||
} else {
|
else renderFileTable(window.currentFolder);
|
||||||
renderFileTable(window.currentFolder);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
__multiInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// -------------------- open/close APIs --------------------
|
||||||
* Update the custom dropdown for multi-tag modal.
|
export function openTagModal(file) {
|
||||||
* Similar to updateCustomTagDropdown but includes a remove icon.
|
initSingleModalOnce();
|
||||||
*/
|
const modal = document.getElementById('tagModal');
|
||||||
|
const title = document.getElementById('tagModalTitle');
|
||||||
|
|
||||||
|
currentFile = file || null;
|
||||||
|
if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`;
|
||||||
|
updateCustomTagDropdown('');
|
||||||
|
updateTagModalDisplay(file);
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideTagModal() {
|
||||||
|
const modal = document.getElementById('tagModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openMultiTagModal(files) {
|
||||||
|
initMultiModalOnce();
|
||||||
|
const modal = document.getElementById('multiTagModal');
|
||||||
|
const title = document.getElementById('multiTagTitle');
|
||||||
|
window.__multiTagFiles = Array.isArray(files) ? files : [];
|
||||||
|
if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`;
|
||||||
|
updateMultiCustomTagDropdown('');
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideMultiTagModal() {
|
||||||
|
const modal = document.getElementById('multiTagModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- dropdown + UI helpers --------------------
|
||||||
function updateMultiCustomTagDropdown(filterText = "") {
|
function updateMultiCustomTagDropdown(filterText = "") {
|
||||||
const dropdown = document.getElementById("multiCustomTagDropdown");
|
const dropdown = document.getElementById("multiCustomTagDropdown");
|
||||||
if (!dropdown) return;
|
if (!dropdown) return;
|
||||||
dropdown.innerHTML = "";
|
dropdown.innerHTML = "";
|
||||||
let tags = window.globalTags || [];
|
let tags = window.globalTags || [];
|
||||||
if (filterText) {
|
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
item.style.cursor = "pointer";
|
item.style.cursor = "pointer";
|
||||||
item.style.padding = "5px";
|
item.style.padding = "5px";
|
||||||
item.style.borderBottom = "1px solid #eee";
|
item.style.borderBottom = "1px solid #eee";
|
||||||
// Display colored square and tag name with remove icon.
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
|
||||||
${escapeHTML(tag.name)}
|
${escapeHTML(tag.name)}
|
||||||
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
|
|||||||
`;
|
`;
|
||||||
item.addEventListener("click", function(e) {
|
item.addEventListener("click", function(e) {
|
||||||
if (e.target.classList.contains("global-remove")) return;
|
if (e.target.classList.contains("global-remove")) return;
|
||||||
document.getElementById("multiTagNameInput").value = tag.name;
|
const n = document.getElementById("multiTagNameInput");
|
||||||
document.getElementById("multiTagColorInput").value = tag.color;
|
const c = document.getElementById("multiTagColorInput");
|
||||||
|
if (n) n.value = tag.name;
|
||||||
|
if (c) c.value = tag.color;
|
||||||
});
|
});
|
||||||
item.querySelector('.global-remove').addEventListener("click", function(e){
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
|
|||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
|
|||||||
if (!dropdown) return;
|
if (!dropdown) return;
|
||||||
dropdown.innerHTML = "";
|
dropdown.innerHTML = "";
|
||||||
let tags = window.globalTags || [];
|
let tags = window.globalTags || [];
|
||||||
if (filterText) {
|
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
||||||
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
|
|
||||||
}
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
tags.forEach(tag => {
|
tags.forEach(tag => {
|
||||||
const item = document.createElement("div");
|
const item = document.createElement("div");
|
||||||
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
|
|||||||
`;
|
`;
|
||||||
item.addEventListener("click", function(e){
|
item.addEventListener("click", function(e){
|
||||||
if (e.target.classList.contains('global-remove')) return;
|
if (e.target.classList.contains('global-remove')) return;
|
||||||
document.getElementById("tagNameInput").value = tag.name;
|
const n = document.getElementById("tagNameInput");
|
||||||
document.getElementById("tagColorInput").value = tag.color;
|
const c = document.getElementById("tagColorInput");
|
||||||
|
if (n) n.value = tag.name;
|
||||||
|
if (c) c.value = tag.color;
|
||||||
});
|
});
|
||||||
item.querySelector('.global-remove').addEventListener("click", function(e){
|
item.querySelector('.global-remove').addEventListener("click", function(e){
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
|
|||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
|
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the modal display to show current tags on the file.
|
// Update the modal display to show current tags on the file.
|
||||||
function updateTagModalDisplay(file) {
|
function updateTagModalDisplay(file) {
|
||||||
const container = document.getElementById('currentTags');
|
const container = document.getElementById('currentTags');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<strong>Current Tags:</strong> ';
|
container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
|
||||||
if (file.tags && file.tags.length > 0) {
|
if (file?.tags?.length) {
|
||||||
file.tags.forEach(tag => {
|
file.tags.forEach(tag => {
|
||||||
const tagElem = document.createElement('span');
|
const tagElem = document.createElement('span');
|
||||||
tagElem.textContent = tag.name;
|
tagElem.textContent = tag.name;
|
||||||
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
|
|||||||
tagElem.style.borderRadius = '3px';
|
tagElem.style.borderRadius = '3px';
|
||||||
tagElem.style.display = 'inline-block';
|
tagElem.style.display = 'inline-block';
|
||||||
tagElem.style.position = 'relative';
|
tagElem.style.position = 'relative';
|
||||||
|
|
||||||
const removeIcon = document.createElement('span');
|
const removeIcon = document.createElement('span');
|
||||||
removeIcon.textContent = ' ✕';
|
removeIcon.textContent = ' ✕';
|
||||||
removeIcon.style.fontWeight = 'bold';
|
removeIcon.style.fontWeight = 'bold';
|
||||||
removeIcon.style.marginLeft = '3px';
|
removeIcon.style.marginLeft = '3px';
|
||||||
removeIcon.style.cursor = 'pointer';
|
removeIcon.style.cursor = 'pointer';
|
||||||
|
|
||||||
removeIcon.addEventListener('click', (e) => {
|
removeIcon.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeTagFromFile(file, tag.name);
|
removeTagFromFile(file, tag.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
tagElem.appendChild(removeIcon);
|
tagElem.appendChild(removeIcon);
|
||||||
container.appendChild(tagElem);
|
container.appendChild(tagElem);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML += 'None';
|
container.innerHTML += (t('none') || 'None');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTagFromFile(file, tagName) {
|
function removeTagFromFile(file, tagName) {
|
||||||
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
file.tags = (file.tags || []).filter(tg => tg.name.toLowerCase() !== tagName.toLowerCase());
|
||||||
updateTagModalDisplay(file);
|
updateTagModalDisplay(file);
|
||||||
updateFileRowTagDisplay(file);
|
updateFileRowTagDisplay(file);
|
||||||
saveFileTags(file);
|
saveFileTags(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a tag from the global tag store.
|
|
||||||
* This function updates window.globalTags and calls the backend endpoint
|
|
||||||
* to remove the tag from the persistent store.
|
|
||||||
*/
|
|
||||||
function removeGlobalTag(tagName) {
|
function removeGlobalTag(tagName) {
|
||||||
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
saveGlobalTagRemoval(tagName);
|
saveGlobalTagRemoval(tagName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Save global tag removal to the server.
|
|
||||||
function saveGlobalTagRemoval(tagName) {
|
function saveGlobalTagRemoval(tagName) {
|
||||||
fetch("/api/file/saveFileTag.php", {
|
fetch("/api/file/saveFileTag.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
folder: "root",
|
|
||||||
file: "global",
|
|
||||||
deleteGlobal: true,
|
|
||||||
tagToDelete: tagName,
|
|
||||||
tags: []
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success && data.globalTags) {
|
||||||
console.log("Global tag removed:", tagName);
|
window.globalTags = data.globalTags;
|
||||||
if (data.globalTags) {
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
window.globalTags = data.globalTags;
|
updateCustomTagDropdown();
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
updateMultiCustomTagDropdown();
|
||||||
updateCustomTagDropdown();
|
} else if (!data.success) {
|
||||||
updateMultiCustomTagDropdown();
|
console.error("Error removing global tag:", data.error);
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
console.error("Error removing global tag:", data.error);
|
.catch(err => console.error("Error removing global tag:", err));
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error("Error removing global tag:", err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global store for reusable tags.
|
// -------------------- exports kept from your original --------------------
|
||||||
window.globalTags = window.globalTags || [];
|
|
||||||
if (localStorage.getItem('globalTags')) {
|
|
||||||
try {
|
|
||||||
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// New function to load global tags from the server's persistent JSON.
|
|
||||||
export function loadGlobalTags() {
|
export function loadGlobalTags() {
|
||||||
fetch("/api/file/getFileTag.php", { credentials: "include" })
|
fetch("/api/file/getFileTag.php", { credentials: "include" })
|
||||||
.then(response => {
|
.then(r => r.ok ? r.json() : [])
|
||||||
if (!response.ok) {
|
|
||||||
// If the file doesn't exist, assume there are no global tags.
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then(data => {
|
.then(data => {
|
||||||
window.globalTags = data;
|
window.globalTags = data || [];
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
|
|||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadGlobalTags();
|
loadGlobalTags();
|
||||||
|
|
||||||
// Add (or update) a tag in the file object.
|
|
||||||
export function addTagToFile(file, tag) {
|
export function addTagToFile(file, tag) {
|
||||||
if (!file.tags) {
|
if (!file.tags) file.tags = [];
|
||||||
file.tags = [];
|
const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
}
|
if (exists) exists.color = tag.color; else file.tags.push(tag);
|
||||||
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
|
||||||
if (exists) {
|
const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
|
||||||
exists.color = tag.color;
|
|
||||||
} else {
|
|
||||||
file.tags.push(tag);
|
|
||||||
}
|
|
||||||
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
|
|
||||||
if (!globalExists) {
|
if (!globalExists) {
|
||||||
window.globalTags.push(tag);
|
window.globalTags.push(tag);
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the file row (in table view) to show tag badges.
|
|
||||||
export function updateFileRowTagDisplay(file) {
|
export function updateFileRowTagDisplay(file) {
|
||||||
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
|
||||||
console.log('Updating tags for rows:', rows);
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
let cell = row.querySelector('.file-name-cell');
|
let cell = row.querySelector('.file-name-cell');
|
||||||
if (cell) {
|
if (!cell) return;
|
||||||
let badgeContainer = cell.querySelector('.tag-badges');
|
let badgeContainer = cell.querySelector('.tag-badges');
|
||||||
if (!badgeContainer) {
|
if (!badgeContainer) {
|
||||||
badgeContainer = document.createElement('div');
|
badgeContainer = document.createElement('div');
|
||||||
badgeContainer.className = 'tag-badges';
|
badgeContainer.className = 'tag-badges';
|
||||||
badgeContainer.style.display = 'inline-block';
|
badgeContainer.style.display = 'inline-block';
|
||||||
badgeContainer.style.marginLeft = '5px';
|
badgeContainer.style.marginLeft = '5px';
|
||||||
cell.appendChild(badgeContainer);
|
cell.appendChild(badgeContainer);
|
||||||
}
|
|
||||||
badgeContainer.innerHTML = '';
|
|
||||||
if (file.tags && file.tags.length > 0) {
|
|
||||||
file.tags.forEach(tag => {
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.textContent = tag.name;
|
|
||||||
badge.style.backgroundColor = tag.color;
|
|
||||||
badge.style.color = '#fff';
|
|
||||||
badge.style.padding = '2px 4px';
|
|
||||||
badge.style.marginRight = '2px';
|
|
||||||
badge.style.borderRadius = '3px';
|
|
||||||
badge.style.fontSize = '0.8em';
|
|
||||||
badge.style.verticalAlign = 'middle';
|
|
||||||
badgeContainer.appendChild(badge);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
badgeContainer.innerHTML = '';
|
||||||
|
(file.tags || []).forEach(tag => {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.textContent = tag.name;
|
||||||
|
badge.style.backgroundColor = tag.color;
|
||||||
|
badge.style.color = '#fff';
|
||||||
|
badge.style.padding = '2px 4px';
|
||||||
|
badge.style.marginRight = '2px';
|
||||||
|
badge.style.borderRadius = '3px';
|
||||||
|
badge.style.fontSize = '0.8em';
|
||||||
|
badge.style.verticalAlign = 'middle';
|
||||||
|
badgeContainer.appendChild(badge);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initTagSearch() {
|
export function initTagSearch() {
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
if (searchInput) {
|
if (!searchInput) return;
|
||||||
let tagSearchInput = document.getElementById('tagSearchInput');
|
let tagSearchInput = document.getElementById('tagSearchInput');
|
||||||
if (!tagSearchInput) {
|
if (!tagSearchInput) {
|
||||||
tagSearchInput = document.createElement('input');
|
tagSearchInput = document.createElement('input');
|
||||||
tagSearchInput.id = 'tagSearchInput';
|
tagSearchInput.id = 'tagSearchInput';
|
||||||
tagSearchInput.placeholder = 'Filter by tag';
|
tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
|
||||||
tagSearchInput.style.marginLeft = '10px';
|
tagSearchInput.style.marginLeft = '10px';
|
||||||
tagSearchInput.style.padding = '5px';
|
tagSearchInput.style.padding = '5px';
|
||||||
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
|
||||||
tagSearchInput.addEventListener('input', () => {
|
tagSearchInput.addEventListener('input', () => {
|
||||||
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
|
||||||
if (window.currentFolder) {
|
if (window.currentFolder) renderFileTable(window.currentFolder);
|
||||||
renderFileTable(window.currentFolder);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterFilesByTag(files) {
|
|
||||||
if (window.currentTagFilter && window.currentTagFilter !== '') {
|
|
||||||
return files.filter(file => {
|
|
||||||
if (file.tags && file.tags.length > 0) {
|
|
||||||
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return files;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterFilesByTag(files) {
|
||||||
|
const q = (window.currentTagFilter || '').trim().toLowerCase();
|
||||||
|
if (!q) return files;
|
||||||
|
return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
|
||||||
|
}
|
||||||
|
|
||||||
function updateGlobalTagList() {
|
function updateGlobalTagList() {
|
||||||
const dataList = document.getElementById("globalTagList");
|
const dataList = document.getElementById("globalTagList");
|
||||||
if (dataList) {
|
if (!dataList) return;
|
||||||
dataList.innerHTML = "";
|
dataList.innerHTML = "";
|
||||||
window.globalTags.forEach(tag => {
|
(window.globalTags || []).forEach(tag => {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = tag.name;
|
option.value = tag.name;
|
||||||
dataList.appendChild(option);
|
dataList.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
|
||||||
const folder = file.folder || "root";
|
const folder = file.folder || "root";
|
||||||
const payload = {
|
const payload = deleteGlobal && tagToDelete ? {
|
||||||
folder: folder,
|
folder: "root",
|
||||||
file: file.name,
|
file: "global",
|
||||||
tags: file.tags
|
deleteGlobal: true,
|
||||||
};
|
tagToDelete,
|
||||||
if (deleteGlobal && tagToDelete) {
|
tags: []
|
||||||
payload.file = "global";
|
} : { folder, file: file.name, tags: file.tags };
|
||||||
payload.deleteGlobal = true;
|
|
||||||
payload.tagToDelete = tagToDelete;
|
|
||||||
}
|
|
||||||
fetch("/api/file/saveFileTag.php", {
|
fetch("/api/file/saveFileTag.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log("Tags saved:", data);
|
|
||||||
if (data.globalTags) {
|
if (data.globalTags) {
|
||||||
window.globalTags = data.globalTags;
|
window.globalTags = data.globalTags;
|
||||||
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
|
||||||
updateCustomTagDropdown();
|
updateCustomTagDropdown();
|
||||||
updateMultiCustomTagDropdown();
|
updateMultiCustomTagDropdown();
|
||||||
}
|
}
|
||||||
|
updateGlobalTagList();
|
||||||
} else {
|
} else {
|
||||||
console.error("Error saving tags:", data.error);
|
console.error("Error saving tags:", data.error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => console.error("Error saving tags:", err));
|
||||||
console.error("Error saving tags:", err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,8 @@ const translations = {
|
|||||||
"tag_name": "Tag Name:",
|
"tag_name": "Tag Name:",
|
||||||
"tag_color": "Tag Color:",
|
"tag_color": "Tag Color:",
|
||||||
"save_tag": "Save Tag",
|
"save_tag": "Save Tag",
|
||||||
|
"no_tags_available": "No tags available",
|
||||||
|
"current_tags": "Current Tags",
|
||||||
"light_mode": "Light Mode",
|
"light_mode": "Light Mode",
|
||||||
"dark_mode": "Dark Mode",
|
"dark_mode": "Dark Mode",
|
||||||
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
"upload_instruction": "Drop files/folders here or click 'Choose files'",
|
||||||
@@ -185,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",
|
||||||
@@ -233,7 +236,7 @@ const translations = {
|
|||||||
"error_generating_recovery_code": "Error generating recovery code",
|
"error_generating_recovery_code": "Error generating recovery code",
|
||||||
"error_loading_qr_code": "Error loading QR code.",
|
"error_loading_qr_code": "Error loading QR code.",
|
||||||
"error_disabling_totp_setting": "Error disabling TOTP setting",
|
"error_disabling_totp_setting": "Error disabling TOTP setting",
|
||||||
"user_management": "User Management",
|
"user_management": "Users, Groups & Access",
|
||||||
"add_user": "Add User",
|
"add_user": "Add User",
|
||||||
"remove_user": "Remove User",
|
"remove_user": "Remove User",
|
||||||
"user_permissions": "User Permissions",
|
"user_permissions": "User Permissions",
|
||||||
@@ -268,7 +271,7 @@ const translations = {
|
|||||||
"columns": "Columns",
|
"columns": "Columns",
|
||||||
"row_height": "Row Height",
|
"row_height": "Row Height",
|
||||||
"api_docs": "API Docs",
|
"api_docs": "API Docs",
|
||||||
"show_folders_above_files": "Show folders above files",
|
"show_folders_above_files": "Show folder strip above list",
|
||||||
"display": "Display",
|
"display": "Display",
|
||||||
"create_file": "Create File",
|
"create_file": "Create File",
|
||||||
"create_new_file": "Create New File",
|
"create_new_file": "Create New File",
|
||||||
@@ -302,7 +305,68 @@ const translations = {
|
|||||||
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||||
"context_move_folder": "Move Folder...",
|
"context_move_folder": "Move Folder...",
|
||||||
"context_move_here": "Move Here",
|
"context_move_here": "Move Here",
|
||||||
"context_move_cancel": "Cancel Move"
|
"context_move_cancel": "Cancel Move",
|
||||||
|
"mark_as_viewed": "Mark as viewed",
|
||||||
|
"viewed": "Viewed",
|
||||||
|
"resumed_from": "Resumed from",
|
||||||
|
"clear_progress": "Clear progress",
|
||||||
|
"marked_viewed": "Marked as viewed",
|
||||||
|
"progress_cleared": "Progress cleared",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"watched": "Watched",
|
||||||
|
"reset_progress": "Reset Progress",
|
||||||
|
"color_folder": "Color folder",
|
||||||
|
"choose_color": "Choose a color",
|
||||||
|
"reset_default": "Reset",
|
||||||
|
"save_color": "Save",
|
||||||
|
"folder_color_saved": "Folder color saved.",
|
||||||
|
"folder_color_cleared": "Folder color reset.",
|
||||||
|
"load_more": "Load more",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"no_access": "You do not have access to this resource.",
|
||||||
|
"please_select_valid_folder": "Please select a valid folder.",
|
||||||
|
"folder_help_click_view": "Click a folder in the tree to view its files.",
|
||||||
|
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.",
|
||||||
|
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
|
||||||
|
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
|
||||||
|
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||||
|
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
|
||||||
|
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
|
||||||
|
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
|
||||||
|
"load_more_folders": "Load More Folders",
|
||||||
|
"show_inline_folders": "Show folders as rows above files",
|
||||||
|
"name": "Name",
|
||||||
|
"size": "Size",
|
||||||
|
"modified": "Modified",
|
||||||
|
"created": "Created",
|
||||||
|
"owner": "Owner",
|
||||||
|
"hide_header_zoom_controls": "Hide header zoom controls",
|
||||||
|
"preview_not_available": "Preview is not available for this file type.",
|
||||||
|
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
|
||||||
|
"svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
|
||||||
|
"no_files_or_folders": "No files or folders to display.",
|
||||||
|
"no_preview_available": "No preview available.",
|
||||||
|
"more_actions": "More Actions",
|
||||||
|
"folder_actions": "Folder Actions",
|
||||||
|
"disable_hover_preview": "Disable hover preview in file list",
|
||||||
|
"zoom_in": "Zoom In",
|
||||||
|
"zoom_out": "Zoom Out",
|
||||||
|
"rotate_left": "Rotate Left",
|
||||||
|
"rotate_right": "Rotate Right",
|
||||||
|
"download_plain": "Download (no ZIP)",
|
||||||
|
"download_next": "Download next",
|
||||||
|
"nonzip_queue_title": "Files queued for download",
|
||||||
|
"nonzip_queue_subtitle": "{count} files queued. Click \"Download next\" for each file.",
|
||||||
|
"nonzip_queue_cleared": "Download queue cleared.",
|
||||||
|
"your_access": "Your access",
|
||||||
|
|
||||||
|
"perm_upload": "Upload",
|
||||||
|
"perm_move": "Move",
|
||||||
|
"perm_rename": "Rename",
|
||||||
|
"perm_share": "Share",
|
||||||
|
"perm_delete": "Delete"
|
||||||
|
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -62,23 +62,43 @@ async function ensureToastReady() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isDemoHost() {
|
function isDemoHost() {
|
||||||
// Handles optional "www." just in case
|
try {
|
||||||
|
const cfg = window.__FR_SITE_CFG__ || {};
|
||||||
|
if (typeof cfg.demoMode !== 'undefined') {
|
||||||
|
return !!cfg.demoMode;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Fallback for older configs / direct demo host:
|
||||||
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
return location.hostname.replace(/^www\./, '') === 'demo.filerise.net';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginTip(message) {
|
function showLoginTip(message) {
|
||||||
const tip = document.getElementById('fr-login-tip');
|
const tip = document.getElementById('fr-login-tip');
|
||||||
if (!tip) return;
|
if (!tip) return;
|
||||||
tip.innerHTML = ''; // clear
|
tip.innerHTML = ''; // clear
|
||||||
if (message) tip.append(document.createTextNode(message));
|
|
||||||
if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') {
|
if (message) {
|
||||||
const line = document.createElement('div'); line.style.marginTop = '6px';
|
tip.append(document.createTextNode(message));
|
||||||
const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; };
|
}
|
||||||
line.append(document.createTextNode('Demo login — user: '), mk('demo'),
|
|
||||||
document.createTextNode(' · pass: '), mk('demo'));
|
if (isDemoHost()) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.style.marginTop = '6px';
|
||||||
|
const mk = t => {
|
||||||
|
const k = document.createElement('code');
|
||||||
|
k.textContent = t;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
line.append(
|
||||||
|
document.createTextNode('Demo login — user: '), mk('demo'),
|
||||||
|
document.createTextNode(' · pass: '), mk('demo')
|
||||||
|
);
|
||||||
tip.append(line);
|
tip.append(line);
|
||||||
}
|
}
|
||||||
tip.style.display = 'block'; // reveal without shifting layout
|
|
||||||
|
tip.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hideOverlaySmoothly(overlay) {
|
async function hideOverlaySmoothly(overlay) {
|
||||||
@@ -225,6 +245,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
|
|||||||
return p.then(r => r.clone());
|
return p.then(r => r.clone());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Safe redirect helper (prevents open redirects) ----
|
||||||
|
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (!str) return fallback;
|
||||||
|
|
||||||
|
const candidate = new URL(str, window.location.origin);
|
||||||
|
|
||||||
|
// Enforce same-origin
|
||||||
|
if (candidate.origin !== window.location.origin) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to http/https
|
||||||
|
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return relative URL
|
||||||
|
return candidate.pathname + candidate.search + candidate.hash;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gentle toast normalizer (compatible with showToast(message, duration))
|
// Gentle toast normalizer (compatible with showToast(message, duration))
|
||||||
const origToast = window.showToast;
|
const origToast = window.showToast;
|
||||||
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
if (typeof origToast === 'function' && !origToast.__frWrapped) {
|
||||||
@@ -399,55 +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 {
|
try {
|
||||||
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
|
||||||
|
|
||||||
// Always keep <title> correct early (no visual flicker)
|
// Always keep <title> correct early (no visual flicker)
|
||||||
document.title = title;
|
document.title = title;
|
||||||
|
|
||||||
// --- Login options (apply in BOTH phases so login page is correct) ---
|
// --- Header logo (branding) in BOTH phases ---
|
||||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
try {
|
||||||
const disableForm = !!lo.disableFormLogin;
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
const disableOIDC = !!lo.disableOIDCLogin;
|
const customLogoUrl = branding.customLogoUrl || "";
|
||||||
const disableBasic = !!lo.disableBasicAuth;
|
const logoImg = document.querySelector('.header-logo img');
|
||||||
|
if (logoImg) {
|
||||||
const row = $('#loginForm');
|
if (customLogoUrl) {
|
||||||
if (row) {
|
logoImg.setAttribute('src', customLogoUrl);
|
||||||
if (disableForm) {
|
logoImg.setAttribute('alt', 'Site logo');
|
||||||
row.setAttribute('hidden', '');
|
} else {
|
||||||
row.style.display = ''; // don't leave display:none lying around
|
// fall back to default FileRise logo
|
||||||
} else {
|
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||||
row.removeAttribute('hidden');
|
logoImg.setAttribute('alt', 'FileRise');
|
||||||
row.style.display = '';
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// non-fatal; ignore branding issues
|
||||||
}
|
}
|
||||||
}
|
|
||||||
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) ---
|
// --- Header colors (branding) in BOTH phases ---
|
||||||
if (phase === 'final') {
|
try {
|
||||||
const h1 = document.querySelector('.header-title h1');
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
if (h1) {
|
const root = document.documentElement;
|
||||||
// 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;
|
const light = branding.headerBgLight || '';
|
||||||
|
const dark = branding.headerBgDark || '';
|
||||||
|
|
||||||
// lock it so late code can't stomp it
|
if (light) root.style.setProperty('--header-bg-light', light);
|
||||||
if (!h1.__titleLock) {
|
else root.style.removeProperty('--header-bg-light');
|
||||||
const mo = new MutationObserver(() => {
|
|
||||||
if (h1.textContent !== title) h1.textContent = title;
|
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||||
});
|
else root.style.removeProperty('--header-bg-dark');
|
||||||
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
} catch (e) {
|
||||||
h1.__titleLock = mo;
|
// 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 {
|
||||||
|
loginWrap.setAttribute('hidden', '');
|
||||||
|
loginWrap.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch { }
|
// 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 { }
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -474,11 +592,13 @@ function bindDarkMode() {
|
|||||||
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
||||||
const j = await r.json().catch(() => ({}));
|
const j = await r.json().catch(() => ({}));
|
||||||
window.__FR_SITE_CFG__ = j || {};
|
window.__FR_SITE_CFG__ = j || {};
|
||||||
|
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
|
||||||
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
||||||
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
||||||
return window.__FR_SITE_CFG__;
|
return window.__FR_SITE_CFG__;
|
||||||
} catch {
|
} catch {
|
||||||
window.__FR_SITE_CFG__ = {};
|
window.__FR_SITE_CFG__ = {};
|
||||||
|
window.__FR_DEMO__ = false;
|
||||||
applySiteConfig({}, { phase: 'early' });
|
applySiteConfig({}, { phase: 'early' });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -831,6 +951,19 @@ function bindDarkMode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function afterLogin() {
|
function afterLogin() {
|
||||||
|
// If index.html was opened with ?redirect=<url>, honor that first
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const raw = url.searchParams.get('redirect');
|
||||||
|
const safe = sanitizeRedirect(raw, { fallback: null });
|
||||||
|
if (safe) {
|
||||||
|
window.location.href = safe;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore URL/param issues and fall back to normal behavior
|
||||||
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
(function poll() {
|
(function poll() {
|
||||||
checkAuth().then(({ authed }) => {
|
checkAuth().then(({ authed }) => {
|
||||||
@@ -1037,6 +1170,21 @@ function bindDarkMode() {
|
|||||||
if (login) login.style.display = '';
|
if (login) login.style.display = '';
|
||||||
// …wire stuff…
|
// …wire stuff…
|
||||||
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
|
||||||
|
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
|
||||||
|
(() => {
|
||||||
|
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
|
||||||
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
|
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||||
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
|
|
||||||
|
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
|
||||||
|
const qp = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
if (onlyOIDC && qp.get('noauto') !== '1') {
|
||||||
|
const btn = document.getElementById('oidcLoginBtn');
|
||||||
|
if (btn) setTimeout(() => btn.click(), 250);
|
||||||
|
}
|
||||||
|
})();
|
||||||
await revealAppAndHideOverlay();
|
await revealAppAndHideOverlay();
|
||||||
const hb = document.querySelector('.header-buttons');
|
const hb = document.querySelector('.header-buttons');
|
||||||
if (hb) hb.style.visibility = 'hidden';
|
if (hb) hb.style.visibility = 'hidden';
|
||||||
@@ -1057,4 +1205,52 @@ function bindDarkMode() {
|
|||||||
|
|
||||||
if (overlay) overlay.style.display = 'none';
|
if (overlay) overlay.style.display = 'none';
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// --- Mobile switcher + PWA SW (mobile-only) ---
|
||||||
|
(() => {
|
||||||
|
// keep it simple + robust
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
const hasFrAppHint = qs.get('frapp') === '1';
|
||||||
|
|
||||||
|
const isStandalone =
|
||||||
|
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
|
||||||
|
(typeof navigator.standalone === 'boolean' && navigator.standalone);
|
||||||
|
|
||||||
|
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
|
||||||
|
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
|
||||||
|
|
||||||
|
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
|
||||||
|
const isMobileish =
|
||||||
|
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||||
|
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
|
||||||
|
|
||||||
|
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
|
||||||
|
const shouldLoadSwitcher =
|
||||||
|
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
|
||||||
|
|
||||||
|
// expose a flag to inspect later
|
||||||
|
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
|
||||||
|
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
|
||||||
|
if (shouldLoadSwitcher) {
|
||||||
|
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
|
||||||
|
.then(() => {
|
||||||
|
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
|
||||||
|
sessionStorage.setItem('frx_opened_once', '1');
|
||||||
|
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.info('[FileRise] switcher import failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SW only for web (https or localhost), never in Capacitor
|
||||||
|
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
||||||
|
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
365
public/js/mobile/switcher.js
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
(function(){
|
||||||
|
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
|
||||||
|
if (!isCap) return;
|
||||||
|
// NOTE: allow running inside Capacitor (origin "capacitor://localhost")
|
||||||
|
|
||||||
|
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
|
||||||
|
const Pref = Plugins.Preferences ? {
|
||||||
|
get: ({key}) => Plugins.Preferences.get({key}),
|
||||||
|
set: ({key,value}) => Plugins.Preferences.set({key,value}),
|
||||||
|
remove:({key}) => Plugins.Preferences.remove({key})
|
||||||
|
} : {
|
||||||
|
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
|
||||||
|
set: async ({key,value}) => localStorage.setItem(key, value),
|
||||||
|
remove: async ({key}) => localStorage.removeItem(key)
|
||||||
|
};
|
||||||
|
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
|
||||||
|
|
||||||
|
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
|
||||||
|
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
|
||||||
|
// Safe element builder: attributes only, children as nodes/strings (no innerHTML)
|
||||||
|
const el = (tag, attrs = {}, children = []) => {
|
||||||
|
const n = document.createElement(tag);
|
||||||
|
for (const k in attrs) n.setAttribute(k, attrs[k]);
|
||||||
|
(Array.isArray(children) ? children : [children]).forEach(c => {
|
||||||
|
if (c == null) return;
|
||||||
|
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
});
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize to http(s), strip creds, collapse trailing slashes
|
||||||
|
const normalize = (u) => {
|
||||||
|
if (!u) return '';
|
||||||
|
let v = u.trim();
|
||||||
|
if (!/^https?:\/\//i.test(v)) v = 'https://' + v;
|
||||||
|
try {
|
||||||
|
const url = new URL(v);
|
||||||
|
if (!/^https?:$/.test(url.protocol)) return '';
|
||||||
|
url.username = '';
|
||||||
|
url.password = '';
|
||||||
|
url.pathname = url.pathname.replace(/\/+$/,'');
|
||||||
|
return url.toString();
|
||||||
|
} catch { return ''; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append/overwrite a query param safely on a normalized URL
|
||||||
|
const withParam = (base, k, v) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(normalize(base));
|
||||||
|
u.searchParams.set(k, v);
|
||||||
|
return u.toString();
|
||||||
|
} catch { return ''; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const host = u => {
|
||||||
|
try { return new URL(normalize(u)).hostname; } catch { return ''; }
|
||||||
|
};
|
||||||
|
const originOf = u => {
|
||||||
|
try { return new URL(normalize(u)).origin; } catch { return ''; }
|
||||||
|
};
|
||||||
|
const faviconUrl = u => {
|
||||||
|
try { const x = new URL(normalize(u)); return x.origin + '/favicon.ico'; } catch { return ''; }
|
||||||
|
};
|
||||||
|
const initialsIcon = (hn='FR') => {
|
||||||
|
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
|
||||||
|
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
|
||||||
|
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
|
||||||
|
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
|
||||||
|
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
|
||||||
|
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getStatusCache(){
|
||||||
|
const raw=(await Pref.get({key:K_STATUS})).value;
|
||||||
|
try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
|
||||||
|
}
|
||||||
|
async function writeStatus(origin, ok){
|
||||||
|
const cache=await getStatusCache();
|
||||||
|
cache[origin]={ ok, ts: Date.now() };
|
||||||
|
await Pref.set({key:K_STATUS, value:JSON.stringify(cache)});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyFileRise(u, timeout=5000){
|
||||||
|
if (!u || !Http) return {ok:false};
|
||||||
|
const base = normalize(u), org = originOf(base);
|
||||||
|
const tryJson = async (url, validate) => {
|
||||||
|
try{
|
||||||
|
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
|
||||||
|
if (r && r.data) {
|
||||||
|
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
|
||||||
|
return !!validate(j);
|
||||||
|
}
|
||||||
|
}catch(_){}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (await tryJson(org + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin:org};
|
||||||
|
if (await tryJson(org + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||||
|
if (await tryJson(org + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin:org};
|
||||||
|
try{
|
||||||
|
const r = await Http.get({ url: org+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin:org};
|
||||||
|
}catch(_){}
|
||||||
|
return {ok:false, origin:org};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeReachable(u, timeout=3000){
|
||||||
|
try{
|
||||||
|
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
|
||||||
|
if (Http){
|
||||||
|
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
|
||||||
|
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await new Promise(res=>{
|
||||||
|
const img=new Image(), t=setTimeout(()=>done(false), timeout);
|
||||||
|
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
|
||||||
|
img.onload=()=>done(true); img.onerror=()=>done(false);
|
||||||
|
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
|
||||||
|
});
|
||||||
|
}catch{ return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstances(){
|
||||||
|
const raw=(await Pref.get({key:K_INST})).value;
|
||||||
|
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
|
||||||
|
}
|
||||||
|
async function saveInstances(list){
|
||||||
|
await Pref.set({key:K_INST, value:JSON.stringify(list)});
|
||||||
|
}
|
||||||
|
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
|
||||||
|
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
|
||||||
|
|
||||||
|
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
|
||||||
|
if (!$('#frx-mobile-style')) {
|
||||||
|
const css = `
|
||||||
|
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
|
||||||
|
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
|
||||||
|
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
|
||||||
|
.frx-fab:active { transform: translateY(1px) scale(.98); }
|
||||||
|
.frx-fab svg { width:26px; height:26px; fill:white }
|
||||||
|
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
|
||||||
|
.frx-scrim.show{opacity:1;visibility:visible}
|
||||||
|
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
|
||||||
|
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
|
||||||
|
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
|
||||||
|
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
|
||||||
|
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
|
||||||
|
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
|
||||||
|
.frx-title img{width:22px;height:22px}
|
||||||
|
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
|
||||||
|
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
|
||||||
|
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
|
||||||
|
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||||
|
.frx-left{display:flex;gap:10px;align-items:center}
|
||||||
|
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
|
||||||
|
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.frx-name{font-weight:800}
|
||||||
|
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
|
||||||
|
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
|
||||||
|
.frx-dot{width:10px;height:10px;border-radius:50%;}
|
||||||
|
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
|
||||||
|
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
|
||||||
|
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
|
||||||
|
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
|
||||||
|
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
|
||||||
|
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
|
||||||
|
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
|
||||||
|
.frx-row{display:flex;gap:8px;align-items:center}
|
||||||
|
.frx-field{display:grid;gap:6px;margin:8px 4px}
|
||||||
|
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
|
||||||
|
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
|
||||||
|
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DOM skeleton (no innerHTML) ----
|
||||||
|
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
|
||||||
|
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'});
|
||||||
|
const hdr = el('div',{class:'hdr'});
|
||||||
|
const title = el('div',{class:'frx-title'});
|
||||||
|
const logo = el('img',{src:'/assets/logo.svg', alt:'FileRise'});
|
||||||
|
// inline handler via property, not attribute
|
||||||
|
logo.onerror = function(){ this.style.display='none'; };
|
||||||
|
title.append(logo, el('span',{},'FileRise Switcher'));
|
||||||
|
const hdrBtns = el('div',{class:'frx-row'},[
|
||||||
|
el('button',{class:'frx-btn frx-ghost', id:'frx-home'},'Home'),
|
||||||
|
el('button',{class:'frx-btn frx-ghost', id:'frx-close'},'Close')
|
||||||
|
]);
|
||||||
|
hdr.append(title, hdrBtns);
|
||||||
|
|
||||||
|
const list = el('div',{class:'frx-list', id:'frx-list'});
|
||||||
|
const formWrap = el('div',{style:'padding:10px 12px'},[
|
||||||
|
el('div',{class:'frx-field'},[
|
||||||
|
el('input',{class:'frx-input', id:'frx-name', placeholder:'Display name (optional)'}),
|
||||||
|
el('input',{class:'frx-input', id:'frx-url', placeholder:'https://files.example.com'})
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
const footer = el('div',{class:'frx-footer'},[
|
||||||
|
el('button',{class:'frx-btn frx-ghost', id:'frx-add-cancel'},'Close'),
|
||||||
|
el('button',{class:'frx-btn frx-primary', id:'frx-add-save'},'+ Add server')
|
||||||
|
]);
|
||||||
|
sheet.append(hdr, list, formWrap, footer);
|
||||||
|
|
||||||
|
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'},[
|
||||||
|
el('svg',{viewBox:'0 0 24 24'},[ el('path',{d:'M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z'}) ])
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.body.appendChild(scrim);
|
||||||
|
document.body.appendChild(sheet);
|
||||||
|
document.body.appendChild(fab);
|
||||||
|
|
||||||
|
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
|
||||||
|
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
|
||||||
|
$('#frx-close').addEventListener('click', hide);
|
||||||
|
$('#frx-add-cancel').addEventListener('click', hide);
|
||||||
|
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
|
||||||
|
scrim.addEventListener('click', hide);
|
||||||
|
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
|
||||||
|
|
||||||
|
function chipNode(item, isActive){
|
||||||
|
const hv = host(item.url);
|
||||||
|
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
|
||||||
|
|
||||||
|
const top = el('div',{class:'frx-top'});
|
||||||
|
const left = el('div',{class:'frx-left'});
|
||||||
|
|
||||||
|
const ico = el('div',{class:'frx-ico'});
|
||||||
|
const img = new Image();
|
||||||
|
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
|
||||||
|
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
|
||||||
|
ico.appendChild(img);
|
||||||
|
|
||||||
|
const txt = el('div',{},[
|
||||||
|
el('div',{class:'frx-name'}, (item.name || hv)),
|
||||||
|
el('div',{class:'frx-host'}, hv)
|
||||||
|
]);
|
||||||
|
|
||||||
|
left.appendChild(ico);
|
||||||
|
left.appendChild(txt);
|
||||||
|
|
||||||
|
const dot = el('span',{class:'frx-dot', id:`frx-dot-${item.id}`});
|
||||||
|
const lbl = el('span',{id:`frx-lbl-${item.id}`}, 'Checking…');
|
||||||
|
const status = el('div',{class:'frx-status'}, [dot, lbl]);
|
||||||
|
|
||||||
|
top.appendChild(left);
|
||||||
|
top.appendChild(status);
|
||||||
|
|
||||||
|
const actions = el('div',{class:'frx-actions'});
|
||||||
|
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
|
||||||
|
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
|
||||||
|
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
|
||||||
|
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
|
||||||
|
|
||||||
|
node.appendChild(top);
|
||||||
|
node.appendChild(actions);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderList(){
|
||||||
|
const listEl=$('#frx-list'); listEl.textContent='';
|
||||||
|
const list=await loadInstances(); const active=await getActive();
|
||||||
|
const cache=await getStatusCache();
|
||||||
|
|
||||||
|
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
|
||||||
|
const chip = chipNode(item, item.id===active);
|
||||||
|
const o = originOf(item.url), cached = cache[o];
|
||||||
|
const dot = chip.querySelector(`#frx-dot-${item.id}`);
|
||||||
|
const lbl = chip.querySelector(`#frx-lbl-${item.id}`);
|
||||||
|
const openBtn = chip.querySelector('[data-act="open"]');
|
||||||
|
|
||||||
|
if (cached){
|
||||||
|
dot.classList.add(cached.ok ? 'on':'off');
|
||||||
|
lbl.textContent = cached.ok ? 'Online' : 'Offline';
|
||||||
|
openBtn.disabled = !cached.ok;
|
||||||
|
} else {
|
||||||
|
lbl.textContent = 'Unknown';
|
||||||
|
openBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.addEventListener('click', async (e)=>{
|
||||||
|
const act = e.target?.dataset?.act;
|
||||||
|
if (!act) return;
|
||||||
|
|
||||||
|
if (act==='open'){
|
||||||
|
if (openBtn.disabled) return;
|
||||||
|
await setActive(item.id);
|
||||||
|
const dest = withParam(item.url, 'frapp', '1');
|
||||||
|
if (dest) window.location.replace(dest);
|
||||||
|
} else if (act==='rename'){
|
||||||
|
const nn=prompt('New display name:', item.name || host(item.url));
|
||||||
|
if (nn!=null){
|
||||||
|
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
|
||||||
|
if (it){ it.name=nn.trim().slice(0,120); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
|
||||||
|
}
|
||||||
|
} else if (act==='remove'){
|
||||||
|
if (!confirm('Remove this server?')) return;
|
||||||
|
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
|
||||||
|
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.appendChild(chip);
|
||||||
|
|
||||||
|
// Live refresh (best effort)
|
||||||
|
(async ()=>{
|
||||||
|
const ok = await probeReachable(item.url, 2500);
|
||||||
|
const d = document.getElementById(`frx-dot-${item.id}`);
|
||||||
|
const l = document.getElementById(`frx-lbl-${item.id}`);
|
||||||
|
const b = chip.querySelector('[data-act="open"]');
|
||||||
|
if (d && l && b){
|
||||||
|
d.classList.remove('on','off');
|
||||||
|
d.classList.add(ok?'on':'off');
|
||||||
|
l.textContent = ok ? 'Online' : 'Offline';
|
||||||
|
b.disabled = !ok;
|
||||||
|
}
|
||||||
|
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#frx-add-save').addEventListener('click', async ()=>{
|
||||||
|
const name = $('#frx-name').value.trim();
|
||||||
|
const url = $('#frx-url').value.trim();
|
||||||
|
if (!url) { alert('Enter a valid URL'); return; }
|
||||||
|
|
||||||
|
// Verify: must be FileRise
|
||||||
|
const vf = await verifyFileRise(url);
|
||||||
|
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
|
||||||
|
|
||||||
|
let L = await loadInstances();
|
||||||
|
const h = host(url);
|
||||||
|
const dupe = L.find(i => host(i.url)===h);
|
||||||
|
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
|
||||||
|
inst.name = name || inst.name || h;
|
||||||
|
inst.url = normalize(url);
|
||||||
|
inst.favicon = faviconUrl(url);
|
||||||
|
inst.lastUsed = Date.now();
|
||||||
|
if (!dupe) L.push(inst);
|
||||||
|
await saveInstances(L);
|
||||||
|
await setActive(inst.id);
|
||||||
|
|
||||||
|
if (vf.origin) await writeStatus(vf.origin, true);
|
||||||
|
|
||||||
|
const dest = withParam(inst.url, 'frapp', '1');
|
||||||
|
if (dest) window.location.replace(dest);
|
||||||
|
});
|
||||||
|
|
||||||
|
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
|
||||||
|
|
||||||
|
// Ensure zoom gestures work if the host page tried to disable them
|
||||||
|
(function ensureZoomable(){
|
||||||
|
let m = document.querySelector('meta[name=viewport]');
|
||||||
|
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
|
||||||
|
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
|
||||||
|
const c = m.getAttribute('content') || '';
|
||||||
|
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
401
public/js/portal-login.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
// public/js/portal-login.js
|
||||||
|
|
||||||
|
// -------- URL helpers --------
|
||||||
|
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
|
||||||
|
if (!raw) return fallback;
|
||||||
|
try {
|
||||||
|
const str = String(raw).trim();
|
||||||
|
if (!str) return fallback;
|
||||||
|
|
||||||
|
// Resolve against current origin so relative URLs work
|
||||||
|
const candidate = new URL(str, window.location.origin);
|
||||||
|
|
||||||
|
// 1) Must stay on the same origin
|
||||||
|
if (candidate.origin !== window.location.origin) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Only allow http/https
|
||||||
|
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a relative URL (prevents host changes)
|
||||||
|
return candidate.pathname + candidate.search + candidate.hash;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectTarget() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const raw = url.searchParams.get('redirect');
|
||||||
|
|
||||||
|
// Default fallback: root
|
||||||
|
let target = sanitizeRedirect(raw, { fallback: '/' });
|
||||||
|
|
||||||
|
// If there was no *usable* redirect but we have a portal slug,
|
||||||
|
// send them back to that portal by default.
|
||||||
|
if (!target || target === '/') {
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
if (slug) {
|
||||||
|
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target || '/';
|
||||||
|
} catch {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPortalSlugFromUrl() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
|
||||||
|
let slug = url.searchParams.get('slug');
|
||||||
|
if (slug && slug.trim()) {
|
||||||
|
console.log('portal-login: slug from top-level param =', slug.trim());
|
||||||
|
return slug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
console.log('portal-login: raw redirect param =', redirect);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
|
||||||
|
// 2a) ?slug=... in redirect
|
||||||
|
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||||
|
if (innerSlug && innerSlug.trim()) {
|
||||||
|
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
|
||||||
|
return innerSlug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2b) Pretty path /portal/<slug> in redirect
|
||||||
|
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||||
|
if (pathMatch && pathMatch[1]) {
|
||||||
|
const fromPath = pathMatch[1].trim();
|
||||||
|
console.log('portal-login: slug from redirect path =', fromPath);
|
||||||
|
return fromPath;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('portal-login: failed to parse redirect URL', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c) Fallback regex on redirect string
|
||||||
|
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
const decoded = decodeURIComponent(m[1]).trim();
|
||||||
|
console.log('portal-login: slug from redirect regex =', decoded);
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Legacy fallback on current query string
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m2 && m2[1]) {
|
||||||
|
const decoded2 = decodeURIComponent(m2[1]).trim();
|
||||||
|
console.log('portal-login: slug from own query regex =', decoded2);
|
||||||
|
return decoded2;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('portal-login: no slug found');
|
||||||
|
return '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('portal-login: getPortalSlugFromUrl error', err);
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CSRF helpers (same pattern as portal.js) ---
|
||||||
|
function setCsrfToken(token) {
|
||||||
|
if (!token) return;
|
||||||
|
window.csrfToken = token;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('csrf', token);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
let meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta');
|
||||||
|
meta.name = 'csrf-token';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
meta.content = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken() {
|
||||||
|
return (
|
||||||
|
window.csrfToken ||
|
||||||
|
(document.querySelector('meta[name="csrf-token"]')?.content) ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCsrfToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/token.php', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const hdr = res.headers.get('X-CSRF-Token');
|
||||||
|
if (hdr) setCsrfToken(hdr);
|
||||||
|
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = body.csrf_token || getCsrfToken();
|
||||||
|
setCsrfToken(token);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('portal-login: failed to load CSRF token', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI helpers ---
|
||||||
|
function showError(msg) {
|
||||||
|
const box = document.getElementById('portalLoginError');
|
||||||
|
if (!box) return;
|
||||||
|
box.textContent = msg || 'Login failed.';
|
||||||
|
box.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
const box = document.getElementById('portalLoginError');
|
||||||
|
if (!box) return;
|
||||||
|
box.textContent = '';
|
||||||
|
box.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Portal meta (title + accent) --------
|
||||||
|
async function fetchPortalMeta(slug) {
|
||||||
|
if (!slug) return null;
|
||||||
|
console.log('portal-login: calling publicMeta.php for slug', slug);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
|
||||||
|
{ method: 'GET', credentials: 'include' }
|
||||||
|
);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!res.ok || !data || !data.success || !data.portal) {
|
||||||
|
console.warn('portal-login: publicMeta not ok', res.status, data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.portal;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('portal-login: failed to load portal meta', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPortalBranding(portal) {
|
||||||
|
if (!portal) return;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
(portal.title && portal.title.trim()) ||
|
||||||
|
portal.label ||
|
||||||
|
portal.slug ||
|
||||||
|
'Client portal';
|
||||||
|
|
||||||
|
const headingEl = document.getElementById('portalLoginTitle');
|
||||||
|
const subtitleEl = document.getElementById('portalLoginSubtitle');
|
||||||
|
const footerEl = document.getElementById('portalLoginFooter');
|
||||||
|
const logoEl = document.getElementById('portalLoginLogo');
|
||||||
|
|
||||||
|
if (headingEl) {
|
||||||
|
headingEl.textContent = 'Sign in to ' + title;
|
||||||
|
}
|
||||||
|
if (subtitleEl) {
|
||||||
|
subtitleEl.textContent = 'to access this client portal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer text from portal metadata, if provided
|
||||||
|
if (footerEl) {
|
||||||
|
const ft = (portal.footerText && portal.footerText.trim()) || '';
|
||||||
|
if (ft) {
|
||||||
|
footerEl.textContent = ft;
|
||||||
|
footerEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
footerEl.textContent = '';
|
||||||
|
footerEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Portal logo: use logoFile from metadata if present
|
||||||
|
if (logoEl) {
|
||||||
|
let logoSrc = null;
|
||||||
|
|
||||||
|
// If you ever decide to store a direct URL:
|
||||||
|
if (portal.logoUrl && portal.logoUrl.trim()) {
|
||||||
|
logoSrc = portal.logoUrl.trim();
|
||||||
|
} else if (portal.logoFile && portal.logoFile.trim()) {
|
||||||
|
// Same convention as portal.html: files live in uploads/profile_pics
|
||||||
|
logoSrc = '/uploads/profile_pics/' + portal.logoFile.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoSrc) {
|
||||||
|
logoEl.src = logoSrc;
|
||||||
|
logoEl.alt = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document title
|
||||||
|
try {
|
||||||
|
document.title = 'Sign in – ' + title;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Accent: portal brandColor -> CSS var
|
||||||
|
const brand = portal.brandColor && portal.brandColor.trim();
|
||||||
|
if (brand) {
|
||||||
|
document.documentElement.style.setProperty('--portal-accent', brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply card/button accent after we know portal color
|
||||||
|
applyAccentFromTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accent (card + button) ---
|
||||||
|
function applyAccentFromTheme() {
|
||||||
|
const card = document.querySelector('.portal-login-card');
|
||||||
|
const btn = document.getElementById('portalLoginSubmit');
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
|
// Prefer per-portal accent if present
|
||||||
|
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
|
||||||
|
if (!accent) {
|
||||||
|
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
card.style.borderTop = `3px solid ${accent}`;
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.style.backgroundColor = accent;
|
||||||
|
btn.style.borderColor = accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (metaTheme) {
|
||||||
|
metaTheme.setAttribute('content', accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Login call (JSON -> auth.php) ---
|
||||||
|
async function doLogin(username, password) {
|
||||||
|
const csrf = getCsrfToken() || '';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
};
|
||||||
|
if (csrf) {
|
||||||
|
payload.csrf_token = csrf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/auth/auth.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrf,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = body.error || body.message || text || 'Login failed.';
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.success === false || body.error || body.logged_in === false) {
|
||||||
|
throw new Error(body.error || 'Invalid username or password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const form = document.getElementById('portalLoginForm');
|
||||||
|
const userEl = document.getElementById('portalLoginUser');
|
||||||
|
const passEl = document.getElementById('portalLoginPass');
|
||||||
|
const btn = document.getElementById('portalLoginSubmit');
|
||||||
|
|
||||||
|
// Accent first (fallback to global accent)
|
||||||
|
applyAccentFromTheme();
|
||||||
|
|
||||||
|
// Try to load portal meta (title + brand color) using slug
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
console.log('portal-login: computed slug =', slug);
|
||||||
|
if (slug) {
|
||||||
|
fetchPortalMeta(slug).then(portal => {
|
||||||
|
if (portal) {
|
||||||
|
console.log('portal-login: got portal meta for', slug, portal);
|
||||||
|
applyPortalBranding(portal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-load CSRF (for auth.php)
|
||||||
|
loadCsrfToken().catch(() => {});
|
||||||
|
|
||||||
|
if (!form || !userEl || !passEl || !btn) return;
|
||||||
|
|
||||||
|
// Focus username
|
||||||
|
userEl.focus();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
const username = userEl.value.trim();
|
||||||
|
const password = passEl.value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
showError('Username and password are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await doLogin(username, password);
|
||||||
|
const target = getRedirectTarget();
|
||||||
|
window.location.href = target;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('portal-login: auth failed', err);
|
||||||
|
showError(err.message || 'Login failed. Please try again.');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign in';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1111
public/js/portal.js
Normal file
5
public/js/pwa/register-sw.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
9
public/js/pwa/sw.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// public/js/pwa/sw.js
|
||||||
|
const SW_VERSION = '{{APP_QVER}}';
|
||||||
|
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/', '/index.html',
|
||||||
|
'/css/styles.css?v={{APP_QVER}}',
|
||||||
|
'/js/main.js?v={{APP_QVER}}',
|
||||||
|
'/assets/logo.svg?v={{APP_QVER}}'
|
||||||
|
];
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||||
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
function showConfirm(message, onConfirm) {
|
function showConfirm(message, onConfirm) {
|
||||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
|||||||
toggleVisibility("restoreFilesModal", false);
|
toggleVisibility("restoreFilesModal", false);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error restoring files:", err);
|
console.error("Error restoring files:", err);
|
||||||
|
|||||||
@@ -3,8 +3,251 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
|||||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
// --- Lightweight tracking of in-progress resumable uploads (per user) ---
|
||||||
|
const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
|
||||||
|
|
||||||
|
function getCurrentUserKey() {
|
||||||
|
// Try a few globals; fall back to browser profile
|
||||||
|
const u =
|
||||||
|
(window.currentUser && String(window.currentUser)) ||
|
||||||
|
(window.appUser && String(window.appUser)) ||
|
||||||
|
(window.username && String(window.username)) ||
|
||||||
|
'';
|
||||||
|
return u || 'anon';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadResumableDraftsAll() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RESUMABLE_DRAFTS_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return (parsed && typeof parsed === 'object') ? parsed : {};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read resumable drafts from localStorage', e);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveResumableDraftsAll(all) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RESUMABLE_DRAFTS_KEY, JSON.stringify(all));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to persist resumable drafts to localStorage', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Single file-picker trigger guard (prevents multiple OS dialogs) ---
|
||||||
|
let _lastFilePickerOpen = 0;
|
||||||
|
|
||||||
|
function triggerFilePickerOnce() {
|
||||||
|
const now = Date.now();
|
||||||
|
// ignore any extra calls within 400ms of the last open
|
||||||
|
if (now - _lastFilePickerOpen < 400) return;
|
||||||
|
_lastFilePickerOpen = now;
|
||||||
|
|
||||||
|
const fi = document.getElementById('file');
|
||||||
|
if (fi) {
|
||||||
|
fi.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the "Choose files" button so it always uses the guarded trigger
|
||||||
|
function wireChooseButton() {
|
||||||
|
const btn = document.getElementById('customChooseBtn');
|
||||||
|
if (!btn || btn.__uploadBound) return;
|
||||||
|
btn.__uploadBound = true;
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // don't let it bubble to the drop-area click handler
|
||||||
|
triggerFilePickerOnce();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireFileInputChange(fileInput) {
|
||||||
|
if (!fileInput || fileInput.__uploadChangeBound) return;
|
||||||
|
fileInput.__uploadChangeBound = true;
|
||||||
|
|
||||||
|
// For file picker, remove directory attributes so only files can be chosen.
|
||||||
|
fileInput.removeAttribute("webkitdirectory");
|
||||||
|
fileInput.removeAttribute("mozdirectory");
|
||||||
|
fileInput.removeAttribute("directory");
|
||||||
|
fileInput.setAttribute("multiple", "");
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", async function () {
|
||||||
|
const files = Array.from(fileInput.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
if (useResumable) {
|
||||||
|
// New resumable batch: reset selectedFiles so the count is correct
|
||||||
|
window.selectedFiles = [];
|
||||||
|
_currentResumableIds.clear(); // <--- add this
|
||||||
|
|
||||||
|
// Ensure the lib/instance exists
|
||||||
|
if (!_resumableReady) await initResumableUpload();
|
||||||
|
if (resumableInstance) {
|
||||||
|
for (const f of files) {
|
||||||
|
resumableInstance.addFile(f);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If Resumable failed to load, fall back to XHR
|
||||||
|
processFiles(files);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-resumable: normal XHR path, drag-and-drop etc.
|
||||||
|
processFiles(files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUploadButtonVisible(visible) {
|
||||||
|
const btn = document.getElementById('uploadBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.style.display = visible ? 'block' : 'none';
|
||||||
|
btn.disabled = !visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserDraftContext() {
|
||||||
|
const all = loadResumableDraftsAll();
|
||||||
|
const userKey = getCurrentUserKey();
|
||||||
|
if (!all[userKey] || typeof all[userKey] !== 'object') {
|
||||||
|
all[userKey] = {};
|
||||||
|
}
|
||||||
|
const drafts = all[userKey];
|
||||||
|
return { all, userKey, drafts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert / update a record for this resumable file
|
||||||
|
function upsertResumableDraft(file, percent) {
|
||||||
|
if (!file || !file.uniqueIdentifier) return;
|
||||||
|
|
||||||
|
const { all, userKey, drafts } = getUserDraftContext();
|
||||||
|
const id = file.uniqueIdentifier;
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
const name = file.fileName || file.name || 'Unnamed file';
|
||||||
|
const size = file.size || 0;
|
||||||
|
|
||||||
|
const prev = drafts[id] || {};
|
||||||
|
const p = Math.max(0, Math.min(100, Math.floor(percent || 0)));
|
||||||
|
|
||||||
|
// Avoid hammering localStorage if nothing substantially changed
|
||||||
|
if (prev.lastPercent !== undefined && Math.abs(p - prev.lastPercent) < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drafts[id] = {
|
||||||
|
identifier: id,
|
||||||
|
fileName: name,
|
||||||
|
size,
|
||||||
|
folder,
|
||||||
|
lastPercent: p,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
all[userKey] = drafts;
|
||||||
|
saveResumableDraftsAll(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a single draft by identifier
|
||||||
|
function clearResumableDraft(identifier) {
|
||||||
|
if (!identifier) return;
|
||||||
|
const { all, userKey, drafts } = getUserDraftContext();
|
||||||
|
if (drafts[identifier]) {
|
||||||
|
delete drafts[identifier];
|
||||||
|
all[userKey] = drafts;
|
||||||
|
saveResumableDraftsAll(all);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally clear all drafts for the current folder (used on full success)
|
||||||
|
function clearResumableDraftsForFolder(folder) {
|
||||||
|
const { all, userKey, drafts } = getUserDraftContext();
|
||||||
|
const f = folder || 'root';
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, rec] of Object.entries(drafts)) {
|
||||||
|
if (!rec || typeof rec !== 'object') continue;
|
||||||
|
if (rec.folder === f) {
|
||||||
|
delete drafts[id];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
all[userKey] = drafts;
|
||||||
|
saveResumableDraftsAll(all);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a small banner if there is any in-progress resumable upload for this folder
|
||||||
|
function showResumableDraftBanner() {
|
||||||
|
const uploadCard = document.getElementById('uploadCard');
|
||||||
|
if (!uploadCard) return;
|
||||||
|
|
||||||
|
// Remove any existing banner first
|
||||||
|
const existing = document.getElementById('resumableDraftBanner');
|
||||||
|
if (existing && existing.parentNode) {
|
||||||
|
existing.parentNode.removeChild(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { drafts } = getUserDraftContext();
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
|
||||||
|
const candidates = Object.values(drafts)
|
||||||
|
.filter(d =>
|
||||||
|
d &&
|
||||||
|
d.folder === folder &&
|
||||||
|
typeof d.lastPercent === 'number' &&
|
||||||
|
d.lastPercent > 0 &&
|
||||||
|
d.lastPercent < 100
|
||||||
|
)
|
||||||
|
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
|
||||||
|
if (!candidates.length) {
|
||||||
|
return; // nothing to show
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = candidates[0];
|
||||||
|
const count = candidates.length;
|
||||||
|
|
||||||
|
const countText =
|
||||||
|
count === 1
|
||||||
|
? 'You have a partially uploaded file'
|
||||||
|
: `You have ${count} partially uploaded files. Latest:`;
|
||||||
|
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'resumableDraftBanner';
|
||||||
|
banner.className = 'upload-resume-banner';
|
||||||
|
banner.innerHTML = `
|
||||||
|
<div class="upload-resume-banner-inner">
|
||||||
|
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
|
||||||
|
<span class="upload-resume-text">
|
||||||
|
${countText}
|
||||||
|
<strong>${escapeHTML(latest.fileName)}</strong>
|
||||||
|
(~${latest.lastPercent}%).
|
||||||
|
Choose it again from your device to resume.
|
||||||
|
</span>
|
||||||
|
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dismissBtn = banner.querySelector('.upload-resume-dismiss-btn');
|
||||||
|
if (dismissBtn) {
|
||||||
|
dismissBtn.addEventListener('click', () => {
|
||||||
|
// Clear all resumable hints for this folder when the user dismisses.
|
||||||
|
clearResumableDraftsForFolder(folder);
|
||||||
|
if (banner.parentNode) {
|
||||||
|
banner.parentNode.removeChild(banner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at top of uploadCard
|
||||||
|
uploadCard.insertBefore(banner, uploadCard.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
Helpers for Drag–and–Drop Folder Uploads (Original Code)
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
@@ -82,23 +325,37 @@ function getFilesFromDataTransferItems(items) {
|
|||||||
|
|
||||||
function setDropAreaDefault() {
|
function setDropAreaDefault() {
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) {
|
if (!dropArea) return;
|
||||||
dropArea.innerHTML = `
|
|
||||||
<div id="uploadInstruction" class="upload-instruction">
|
dropArea.innerHTML = `
|
||||||
${t("upload_instruction")}
|
<div id="uploadInstruction" class="upload-instruction">
|
||||||
|
${t("upload_instruction")}
|
||||||
|
</div>
|
||||||
|
<div id="uploadFileRow" class="upload-file-row">
|
||||||
|
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
||||||
|
</div>
|
||||||
|
<div id="fileInfoWrapper" class="file-info-wrapper">
|
||||||
|
<div id="fileInfoContainer" class="file-info-container">
|
||||||
|
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="uploadFileRow" class="upload-file-row">
|
</div>
|
||||||
<button id="customChooseBtn" type="button">${t("choose_files")}</button>
|
<!-- File input for file picker (files only) -->
|
||||||
</div>
|
<input
|
||||||
<div id="fileInfoWrapper" class="file-info-wrapper">
|
type="file"
|
||||||
<div id="fileInfoContainer" class="file-info-container">
|
id="file"
|
||||||
<span id="fileInfoDefault"> ${t("no_files_selected_default")}</span>
|
name="file[]"
|
||||||
</div>
|
class="form-control-file"
|
||||||
</div>
|
multiple
|
||||||
<!-- File input for file picker (files only) -->
|
style="opacity:0; position:absolute; width:1px; height:1px;"
|
||||||
<input type="file" id="file" name="file[]" class="form-control-file" multiple style="opacity:0; position:absolute; width:1px; height:1px;" />
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
|
||||||
|
// After rebuilding markup, re-wire controls:
|
||||||
|
const fileInput = dropArea.querySelector('#file');
|
||||||
|
wireFileInputChange(fileInput);
|
||||||
|
wireChooseButton();
|
||||||
|
|
||||||
|
setUploadButtonVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustFolderHelpExpansion() {
|
function adjustFolderHelpExpansion() {
|
||||||
@@ -217,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);
|
||||||
@@ -427,6 +686,7 @@ function processFiles(filesInput) {
|
|||||||
|
|
||||||
window.selectedFiles = files;
|
window.selectedFiles = files;
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
|
setUploadButtonVisible(files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
@@ -437,6 +697,7 @@ const useResumable = true;
|
|||||||
let resumableInstance = null;
|
let resumableInstance = null;
|
||||||
let _pendingPickedFiles = []; // files picked before library/instance ready
|
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||||
let _resumableReady = false;
|
let _resumableReady = false;
|
||||||
|
let _currentResumableIds = new Set();
|
||||||
|
|
||||||
// Make init async-safe; it resolves when Resumable is constructed
|
// Make init async-safe; it resolves when Resumable is constructed
|
||||||
async function initResumableUpload() {
|
async function initResumableUpload() {
|
||||||
@@ -455,7 +716,7 @@ async function initResumableUpload() {
|
|||||||
chunkSize: 1.5 * 1024 * 1024,
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
simultaneousUploads: 3,
|
simultaneousUploads: 3,
|
||||||
forceChunkSize: true,
|
forceChunkSize: true,
|
||||||
testChunks: false,
|
testChunks: true,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
query: () => ({
|
query: () => ({
|
||||||
@@ -473,18 +734,20 @@ async function initResumableUpload() {
|
|||||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = document.getElementById("file");
|
|
||||||
if (fileInput) {
|
|
||||||
|
|
||||||
fileInput.addEventListener("change", function () {
|
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
|
||||||
resumableInstance.addFile(fileInput.files[i]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resumableInstance.on("fileAdded", function (file) {
|
resumableInstance.on("fileAdded", function (file) {
|
||||||
|
// Build a stable per-file key
|
||||||
|
const id =
|
||||||
|
file.uniqueIdentifier ||
|
||||||
|
((file.fileName || file.name || '') + ':' + (file.size || 0));
|
||||||
|
|
||||||
|
// If we've already seen this id in the current batch, skip wiring it again
|
||||||
|
if (_currentResumableIds.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_currentResumableIds.add(id);
|
||||||
|
|
||||||
// Initialize custom paused flag
|
// Initialize custom paused flag
|
||||||
file.paused = false;
|
file.paused = false;
|
||||||
file.uploadIndex = file.uniqueIdentifier;
|
file.uploadIndex = file.uniqueIdentifier;
|
||||||
@@ -492,8 +755,13 @@ async function initResumableUpload() {
|
|||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
}
|
}
|
||||||
window.selectedFiles.push(file);
|
window.selectedFiles.push(file);
|
||||||
|
|
||||||
|
// Track as in-progress draft at 0%
|
||||||
|
upsertResumableDraft(file, 0);
|
||||||
|
showResumableDraftBanner();
|
||||||
|
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
|
|
||||||
// Check if a wrapper already exists; if not, create one with a UL inside.
|
// Check if a wrapper already exists; if not, create one with a UL inside.
|
||||||
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
let listWrapper = progressContainer.querySelector(".upload-progress-wrapper");
|
||||||
let list;
|
let list;
|
||||||
@@ -509,18 +777,51 @@ async function initResumableUpload() {
|
|||||||
} else {
|
} else {
|
||||||
list = listWrapper.querySelector("ul.upload-progress-list");
|
list = listWrapper.querySelector("ul.upload-progress-list");
|
||||||
}
|
}
|
||||||
|
|
||||||
const li = createFileEntry(file);
|
const li = createFileEntry(file);
|
||||||
li.dataset.uploadIndex = file.uniqueIdentifier;
|
li.dataset.uploadIndex = file.uniqueIdentifier;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
updateResumableQuery();
|
updateResumableQuery();
|
||||||
|
setUploadButtonVisible(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileProgress", function (file) {
|
resumableInstance.on("fileProgress", function (file) {
|
||||||
const progress = file.progress(); // value between 0 and 1
|
const progress = file.progress(); // value between 0 and 1
|
||||||
const percent = Math.floor(progress * 100);
|
let percent = Math.floor(progress * 100);
|
||||||
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
|
|
||||||
|
// Never persist a full 100% from progress alone.
|
||||||
|
// If the tab dies here, we still want it to look resumable.
|
||||||
|
if (percent >= 100) percent = 99;
|
||||||
|
|
||||||
|
const li = document.querySelector(
|
||||||
|
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
|
||||||
|
);
|
||||||
|
if (li && li.progressBar) {
|
||||||
|
if (percent < 99) {
|
||||||
|
li.progressBar.style.width = percent + "%";
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
|
let speed = "";
|
||||||
|
if (elapsed > 0) {
|
||||||
|
const bytesUploaded = progress * file.size;
|
||||||
|
const spd = bytesUploaded / elapsed;
|
||||||
|
if (spd < 1024) speed = spd.toFixed(0) + " B/s";
|
||||||
|
else if (spd < 1048576) speed = (spd / 1024).toFixed(1) + " KB/s";
|
||||||
|
else speed = (spd / 1048576).toFixed(1) + " MB/s";
|
||||||
|
}
|
||||||
|
li.progressBar.innerText = percent + "% (" + speed + ")";
|
||||||
|
} else {
|
||||||
|
li.progressBar.style.width = "100%";
|
||||||
|
li.progressBar.innerHTML =
|
||||||
|
'<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
|
||||||
|
if (pauseResumeBtn) {
|
||||||
|
pauseResumeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (li && li.progressBar) {
|
if (li && li.progressBar) {
|
||||||
if (percent < 99) {
|
if (percent < 99) {
|
||||||
li.progressBar.style.width = percent + "%";
|
li.progressBar.style.width = percent + "%";
|
||||||
@@ -552,6 +853,7 @@ async function initResumableUpload() {
|
|||||||
pauseResumeBtn.disabled = false;
|
pauseResumeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
upsertResumableDraft(file, percent);
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("fileSuccess", function (file, message) {
|
resumableInstance.on("fileSuccess", function (file, message) {
|
||||||
@@ -588,8 +890,11 @@ async function initResumableUpload() {
|
|||||||
if (removeBtn) removeBtn.style.display = "none";
|
if (removeBtn) removeBtn.style.display = "none";
|
||||||
setTimeout(() => li.remove(), 5000);
|
setTimeout(() => li.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
refreshFolderIcon(window.currentFolder);
|
||||||
loadFileList(window.currentFolder);
|
loadFileList(window.currentFolder);
|
||||||
|
// This file finished successfully, remove its draft record
|
||||||
|
clearResumableDraft(file.uniqueIdentifier);
|
||||||
|
showResumableDraftBanner();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -607,18 +912,22 @@ async function initResumableUpload() {
|
|||||||
pauseResumeBtn.disabled = false;
|
pauseResumeBtn.disabled = false;
|
||||||
}
|
}
|
||||||
showToast("Error uploading file: " + file.fileName);
|
showToast("Error uploading file: " + file.fileName);
|
||||||
|
// Treat errored file as no longer resumable (for now) and clear its hint
|
||||||
|
showResumableDraftBanner();
|
||||||
});
|
});
|
||||||
|
|
||||||
resumableInstance.on("complete", function () {
|
resumableInstance.on("complete", function () {
|
||||||
// If any file is marked with an error, leave the list intact.
|
// If any file is marked with an error, leave the list intact.
|
||||||
const hasError = window.selectedFiles.some(f => f.isError);
|
const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
|
||||||
if (!hasError) {
|
if (!hasError) {
|
||||||
// All files succeeded—clear the file input and progress container after 5 seconds.
|
// All files succeeded—clear the file input and progress container after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) fileInput.value = "";
|
if (fileInput) fileInput.value = "";
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
progressContainer.innerHTML = "";
|
if (progressContainer) {
|
||||||
|
progressContainer.innerHTML = "";
|
||||||
|
}
|
||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
adjustFolderHelpExpansionClosed();
|
adjustFolderHelpExpansionClosed();
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
||||||
@@ -627,6 +936,16 @@ async function initResumableUpload() {
|
|||||||
}
|
}
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) setDropAreaDefault();
|
if (dropArea) setDropAreaDefault();
|
||||||
|
|
||||||
|
// IMPORTANT: clear Resumable's internal file list so the next upload
|
||||||
|
// doesn't think there are still resumable files queued.
|
||||||
|
if (resumableInstance) {
|
||||||
|
// cancel() after completion just resets internal state; no chunks are deleted server-side.
|
||||||
|
resumableInstance.cancel();
|
||||||
|
}
|
||||||
|
clearResumableDraftsForFolder(window.currentFolder || 'root');
|
||||||
|
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.");
|
||||||
@@ -650,11 +969,34 @@ function submitFiles(allFiles) {
|
|||||||
const f = window.currentFolder || "root";
|
const f = window.currentFolder || "root";
|
||||||
try { return decodeURIComponent(f); } catch { return f; }
|
try { return decodeURIComponent(f); } catch { return f; }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
|
if (!progressContainer) {
|
||||||
|
console.warn("submitFiles called but #uploadProgressContainer not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ensure there are progress list items for these files ---
|
||||||
|
let listItems = progressContainer.querySelectorAll("li.upload-progress-item");
|
||||||
|
|
||||||
|
if (!listItems.length) {
|
||||||
|
// Guarantee each file has a stable uploadIndex
|
||||||
|
allFiles.forEach((file, index) => {
|
||||||
|
if (file.uploadIndex === undefined || file.uploadIndex === null) {
|
||||||
|
file.uploadIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the UI rows for these files
|
||||||
|
// This will also set window.selectedFiles and fileInfoContainer, etc.
|
||||||
|
processFiles(allFiles);
|
||||||
|
|
||||||
|
// Re-query now that processFiles has populated the DOM
|
||||||
|
listItems = progressContainer.querySelectorAll("li.upload-progress-item");
|
||||||
|
}
|
||||||
|
|
||||||
const progressElements = {};
|
const progressElements = {};
|
||||||
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
|
|
||||||
listItems.forEach(item => {
|
listItems.forEach(item => {
|
||||||
progressElements[item.dataset.uploadIndex] = item;
|
progressElements[item.dataset.uploadIndex] = item;
|
||||||
});
|
});
|
||||||
@@ -680,7 +1022,7 @@ function submitFiles(allFiles) {
|
|||||||
if (e.lengthComputable) {
|
if (e.lengthComputable) {
|
||||||
currentPercent = Math.round((e.loaded / e.total) * 100);
|
currentPercent = Math.round((e.loaded / e.total) * 100);
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
const elapsed = (Date.now() - li.startTime) / 1000;
|
const elapsed = (Date.now() - li.startTime) / 1000;
|
||||||
let speed = "";
|
let speed = "";
|
||||||
if (elapsed > 0) {
|
if (elapsed > 0) {
|
||||||
@@ -716,12 +1058,12 @@ function submitFiles(allFiles) {
|
|||||||
return; // skip the "finishedCount++" and error/success logic for now
|
return; // skip the "finishedCount++" and error/success logic for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Normal success/error handling ────────────────────────────
|
// ─── Normal success/error handling ────────────────────────────
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
|
||||||
// real success
|
// real success
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.style.width = "100%";
|
li.progressBar.style.width = "100%";
|
||||||
li.progressBar.innerText = "Done";
|
li.progressBar.innerText = "Done";
|
||||||
if (li.removeBtn) li.removeBtn.style.display = "none";
|
if (li.removeBtn) li.removeBtn.style.display = "none";
|
||||||
@@ -730,39 +1072,40 @@ function submitFiles(allFiles) {
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// real failure
|
// real failure
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
allSucceeded = false;
|
allSucceeded = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.isClipboard) {
|
if (file.isClipboard) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.selectedFiles = [];
|
window.selectedFiles = [];
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const pc = document.getElementById("uploadProgressContainer");
|
||||||
if (progressContainer) progressContainer.innerHTML = "";
|
if (pc) pc.innerHTML = "";
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fic = document.getElementById("fileInfoContainer");
|
||||||
if (fileInfoContainer) {
|
if (fic) {
|
||||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Only now count this chunk as finished ───────────────────
|
// ─── Only now count this upload as finished ───────────────────
|
||||||
finishedCount++;
|
finishedCount++;
|
||||||
if (finishedCount === allFiles.length) {
|
if (finishedCount === allFiles.length) {
|
||||||
const succeededCount = uploadResults.filter(Boolean).length;
|
const succeededCount = uploadResults.filter(Boolean).length;
|
||||||
const failedCount = allFiles.length - succeededCount;
|
const failedCount = allFiles.length - succeededCount;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshFileList(allFiles, uploadResults, progressElements);
|
refreshFileList(allFiles, uploadResults, progressElements);
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener("error", function () {
|
xhr.addEventListener("error", function () {
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = false;
|
uploadResults[file.uploadIndex] = false;
|
||||||
@@ -778,7 +1121,7 @@ if (finishedCount === allFiles.length) {
|
|||||||
|
|
||||||
xhr.addEventListener("abort", function () {
|
xhr.addEventListener("abort", function () {
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
if (li) {
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Aborted";
|
li.progressBar.innerText = "Aborted";
|
||||||
}
|
}
|
||||||
uploadResults[file.uploadIndex] = false;
|
uploadResults[file.uploadIndex] = false;
|
||||||
@@ -808,38 +1151,42 @@ if (finishedCount === allFiles.length) {
|
|||||||
})
|
})
|
||||||
.map(s => s.trim().toLowerCase())
|
.map(s => s.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
let overallSuccess = true;
|
let overallSuccess = true;
|
||||||
let succeeded = 0;
|
let succeeded = 0;
|
||||||
|
|
||||||
allFiles.forEach(file => {
|
allFiles.forEach(file => {
|
||||||
const clientFileName = file.name.trim().toLowerCase();
|
const clientFileName = file.name.trim().toLowerCase();
|
||||||
const li = progressElements[file.uploadIndex];
|
const li = progressElements[file.uploadIndex];
|
||||||
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
|
||||||
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
|
|
||||||
if (li) {
|
if (!uploadResults[file.uploadIndex] ||
|
||||||
|
(!hadRelative && !serverFiles.includes(clientFileName))) {
|
||||||
|
if (li && li.progressBar) {
|
||||||
li.progressBar.innerText = "Error";
|
li.progressBar.innerText = "Error";
|
||||||
}
|
}
|
||||||
overallSuccess = false;
|
overallSuccess = false;
|
||||||
|
|
||||||
} else if (li) {
|
} else if (li) {
|
||||||
succeeded++;
|
succeeded++;
|
||||||
|
|
||||||
// Schedule removal of successful file entry after 5 seconds.
|
// Schedule removal of successful file entry after 5 seconds.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
li.remove();
|
li.remove();
|
||||||
delete progressElements[file.uploadIndex];
|
delete progressElements[file.uploadIndex];
|
||||||
updateFileInfoCount();
|
updateFileInfoCount();
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const pc = document.getElementById("uploadProgressContainer");
|
||||||
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
|
if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
|
||||||
const fileInput = document.getElementById("file");
|
const fi = document.getElementById("file");
|
||||||
if (fileInput) fileInput.value = "";
|
if (fi) fi.value = "";
|
||||||
progressContainer.innerHTML = "";
|
pc.innerHTML = "";
|
||||||
adjustFolderHelpExpansionClosed();
|
adjustFolderHelpExpansionClosed();
|
||||||
const fileInfoContainer = document.getElementById("fileInfoContainer");
|
const fic = document.getElementById("fileInfoContainer");
|
||||||
if (fileInfoContainer) {
|
if (fic) {
|
||||||
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
|
||||||
}
|
}
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
if (dropArea) setDropAreaDefault();
|
if (dropArea) setDropAreaDefault();
|
||||||
|
window.selectedFiles = [];
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
@@ -849,8 +1196,10 @@ if (finishedCount === allFiles.length) {
|
|||||||
const failed = allFiles.length - succeeded;
|
const failed = allFiles.length - succeeded;
|
||||||
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
|
||||||
} else {
|
} else {
|
||||||
showToast(`${succeeded} file 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);
|
||||||
@@ -858,7 +1207,6 @@ if (finishedCount === allFiles.length) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,9 +1215,17 @@ if (finishedCount === allFiles.length) {
|
|||||||
Main initUpload: Sets up file input, drop area, and form submission.
|
Main initUpload: Sets up file input, drop area, and form submission.
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
function initUpload() {
|
function initUpload() {
|
||||||
const fileInput = document.getElementById("file");
|
window.__FR_FLAGS = window.__FR_FLAGS || { wired: {} };
|
||||||
const dropArea = document.getElementById("uploadDropArea");
|
window.__FR_FLAGS.wired = window.__FR_FLAGS.wired || {};
|
||||||
|
|
||||||
const uploadForm = document.getElementById("uploadFileForm");
|
const uploadForm = document.getElementById("uploadFileForm");
|
||||||
|
const dropArea = document.getElementById("uploadDropArea");
|
||||||
|
|
||||||
|
// Always (re)build the inner markup and wire the Choose button
|
||||||
|
setDropAreaDefault();
|
||||||
|
wireChooseButton();
|
||||||
|
|
||||||
|
const fileInput = document.getElementById("file");
|
||||||
|
|
||||||
// For file picker, remove directory attributes so only files can be chosen.
|
// For file picker, remove directory attributes so only files can be chosen.
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
@@ -879,80 +1235,81 @@ function initUpload() {
|
|||||||
fileInput.setAttribute("multiple", "");
|
fileInput.setAttribute("multiple", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropAreaDefault();
|
|
||||||
|
|
||||||
// Drag–and–drop events (for folder uploads) use original processing.
|
// Drag–and–drop events (for folder uploads) use original processing.
|
||||||
if (dropArea) {
|
if (dropArea && !dropArea.__uploadBound) {
|
||||||
|
dropArea.__uploadBound = true;
|
||||||
dropArea.classList.add("upload-drop-area");
|
dropArea.classList.add("upload-drop-area");
|
||||||
|
|
||||||
dropArea.addEventListener("dragover", function (e) {
|
dropArea.addEventListener("dragover", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
|
dropArea.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#333" : "#f8f8f8";
|
||||||
});
|
});
|
||||||
|
|
||||||
dropArea.addEventListener("dragleave", function (e) {
|
dropArea.addEventListener("dragleave", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
dropArea.addEventListener("drop", function (e) {
|
dropArea.addEventListener("drop", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
const dt = e.dataTransfer;
|
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||||
if (dt.items && dt.items.length > 0) {
|
window.__pendingDropData = null;
|
||||||
|
if (dt && dt.items && dt.items.length > 0) {
|
||||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
processFiles(files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (dt.files && dt.files.length > 0) {
|
} else if (dt && dt.files && dt.files.length > 0) {
|
||||||
processFiles(dt.files);
|
processFiles(dt.files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Clicking drop area triggers file input.
|
|
||||||
dropArea.addEventListener("click", function () {
|
|
||||||
if (fileInput) fileInput.click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileInput) {
|
// Only trigger file picker when clicking the *bare* drop area, not controls inside it
|
||||||
fileInput.addEventListener("change", async function () {
|
dropArea.addEventListener("click", function (e) {
|
||||||
const files = Array.from(fileInput.files || []);
|
// If the click originated from the "Choose files" button or the file input itself,
|
||||||
if (!files.length) return;
|
// let their handlers deal with it.
|
||||||
|
if (e.target.closest('#customChooseBtn') || e.target.closest('#file')) {
|
||||||
if (useResumable) {
|
return;
|
||||||
// Ensure the lib/instance exists
|
|
||||||
if (!_resumableReady) await initResumableUpload();
|
|
||||||
if (resumableInstance) {
|
|
||||||
for (const f of files) resumableInstance.addFile(f);
|
|
||||||
} else {
|
|
||||||
// If still not ready (load error), fall back to your XHR path
|
|
||||||
processFiles(files);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
processFiles(files);
|
|
||||||
}
|
}
|
||||||
|
triggerFilePickerOnce();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadForm) {
|
if (uploadForm && !uploadForm.__uploadSubmitBound) {
|
||||||
|
uploadForm.__uploadSubmitBound = true;
|
||||||
uploadForm.addEventListener("submit", async function (e) {
|
uploadForm.addEventListener("submit", async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
|
|
||||||
|
const files =
|
||||||
|
(Array.isArray(window.selectedFiles) && window.selectedFiles.length)
|
||||||
|
? window.selectedFiles
|
||||||
|
: (fileInput ? Array.from(fileInput.files || []) : []);
|
||||||
|
|
||||||
if (!files || !files.length) {
|
if (!files || !files.length) {
|
||||||
showToast("No files selected.");
|
showToast("No files selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resumable path (only for picked files, not folder uploads)
|
setUploadButtonVisible(false);
|
||||||
const first = files[0];
|
|
||||||
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
|
const hasResumableFiles =
|
||||||
if (useResumable && !isFolderish) {
|
useResumable &&
|
||||||
|
resumableInstance &&
|
||||||
|
Array.isArray(resumableInstance.files) &&
|
||||||
|
resumableInstance.files.length > 0;
|
||||||
|
|
||||||
|
if (hasResumableFiles) {
|
||||||
if (!_resumableReady) await initResumableUpload();
|
if (!_resumableReady) await initResumableUpload();
|
||||||
if (resumableInstance) {
|
if (resumableInstance) {
|
||||||
// ensure folder/token fresh
|
|
||||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||||
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
|
|
||||||
resumableInstance.upload();
|
resumableInstance.upload();
|
||||||
showToast("Resumable upload started...");
|
showToast("Resumable upload started...");
|
||||||
} else {
|
} else {
|
||||||
// fallback
|
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -964,6 +1321,7 @@ function initUpload() {
|
|||||||
if (useResumable) {
|
if (useResumable) {
|
||||||
initResumableUpload();
|
initResumableUpload();
|
||||||
}
|
}
|
||||||
|
showResumableDraftBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
export { initUpload };
|
export { initUpload };
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// generated by CI
|
// generated by CI
|
||||||
window.APP_VERSION = 'v1.7.5';
|
window.APP_VERSION = 'v2.3.6';
|
||||||
|
|||||||
92
public/js/zoom.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// /js/zoom.js
|
||||||
|
(function () {
|
||||||
|
const MIN_PERCENT = 60; // 60%
|
||||||
|
const MAX_PERCENT = 140; // 140%
|
||||||
|
const STEP_PERCENT = 5; // 5%
|
||||||
|
const STORAGE_KEY = 'filerise.appZoomPercent';
|
||||||
|
|
||||||
|
function clampPercent(p) {
|
||||||
|
return Math.max(MIN_PERCENT, Math.min(MAX_PERCENT, p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplay(p) {
|
||||||
|
const el = document.getElementById('zoomDisplay');
|
||||||
|
if (el) el.textContent = `${p}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoomPercent(p) {
|
||||||
|
const clamped = clampPercent(p);
|
||||||
|
const scale = clamped / 100;
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--app-zoom', String(scale));
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, String(clamped)); } catch {}
|
||||||
|
|
||||||
|
updateDisplay(clamped);
|
||||||
|
return clamped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentPercent() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) return clampPercent(n);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const v = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--app-zoom')
|
||||||
|
.trim();
|
||||||
|
const n = parseFloat(v);
|
||||||
|
if (Number.isFinite(n) && n > 0) {
|
||||||
|
return clampPercent(Math.round(n * 100));
|
||||||
|
}
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public-ish API (percent-based)
|
||||||
|
window.fileriseZoom = {
|
||||||
|
in() {
|
||||||
|
const next = getCurrentPercent() + STEP_PERCENT;
|
||||||
|
return applyZoomPercent(next);
|
||||||
|
},
|
||||||
|
out() {
|
||||||
|
const next = getCurrentPercent() - STEP_PERCENT;
|
||||||
|
return applyZoomPercent(next);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
return applyZoomPercent(100);
|
||||||
|
},
|
||||||
|
setPercent(p) {
|
||||||
|
return applyZoomPercent(p);
|
||||||
|
},
|
||||||
|
currentPercent: getCurrentPercent
|
||||||
|
};
|
||||||
|
|
||||||
|
function initZoomUI() {
|
||||||
|
// bind buttons
|
||||||
|
const btns = document.querySelectorAll('.zoom-btn[data-zoom]');
|
||||||
|
btns.forEach(btn => {
|
||||||
|
if (btn.__zoomBound) return;
|
||||||
|
btn.__zoomBound = true;
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const mode = btn.dataset.zoom;
|
||||||
|
if (mode === 'in') window.fileriseZoom.in();
|
||||||
|
else if (mode === 'out') window.fileriseZoom.out();
|
||||||
|
else if (mode === 'reset') window.fileriseZoom.reset();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// apply initial zoom + update display
|
||||||
|
const initial = getCurrentPercent();
|
||||||
|
applyZoomPercent(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run immediately if DOM is ready, otherwise wait
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initZoomUI, { once: true });
|
||||||
|
} else {
|
||||||
|
initZoomUI();
|
||||||
|
}
|
||||||
|
})();
|
||||||
14
public/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "FileRise",
|
||||||
|
"short_name": "FileRise",
|
||||||
|
"start_url": "/?pwa=1",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111111",
|
||||||
|
"theme_color": "#0b5ed7",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
148
public/portal-login.html
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Sign in – Client Portal</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
|
||||||
|
<!-- Favicons / assets -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- CSS (reuse main app look) -->
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Version stamp -->
|
||||||
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- Portal login JS -->
|
||||||
|
<script type="module" src="/js/portal-login.js?v={{APP_QVER}}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--pre-bg, #f4f4f7);
|
||||||
|
}
|
||||||
|
.portal-login-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-login-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .portal-login-card {
|
||||||
|
background: #1f2933;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.portal-login-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-login-header img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-login-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.portal-login-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .portal-login-subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
#portalLoginError {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#portalLoginError.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.portal-login-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 22px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 3px solid var(--filr-accent-500, #0b5ed7);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body data-theme="light">
|
||||||
|
<div class="portal-login-wrapper">
|
||||||
|
<div class="portal-login-card">
|
||||||
|
<div class="portal-login-header">
|
||||||
|
<img id="portalLoginLogo"
|
||||||
|
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||||
|
alt="FileRise">
|
||||||
|
<div>
|
||||||
|
<div id="portalLoginTitle" class="portal-login-title">
|
||||||
|
Sign in to Client Portal
|
||||||
|
</div>
|
||||||
|
<div id="portalLoginSubtitle" class="portal-login-subtitle">
|
||||||
|
to access this client portal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalLoginError" class="alert alert-danger"></div>
|
||||||
|
|
||||||
|
<form id="portalLoginForm" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="portalLoginUser">Username or email</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="portalLoginUser"
|
||||||
|
autocomplete="username"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="portalLoginPass">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control form-control-sm"
|
||||||
|
id="portalLoginPass"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
id="portalLoginSubmit"
|
||||||
|
class="btn btn-primary btn-sm btn-block">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<small id="portalLoginHint"
|
||||||
|
class="text-muted d-block mt-2"
|
||||||
|
style="font-size:0.75rem;">
|
||||||
|
You’ll be sent back to the portal automatically after signing in.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<small id="portalLoginFooter"
|
||||||
|
class="text-muted d-block mt-1"
|
||||||
|
style="font-size:0.7rem; display:none;">
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
381
public/portal.html
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<style id="pretheme-css">
|
||||||
|
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--portal-accent: #0b5ed7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-card {
|
||||||
|
max-width: 640px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
.portal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.portal-logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-dropzone {
|
||||||
|
border: 2px dashed rgba(0,0,0,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.portal-dropzone.dragover {
|
||||||
|
border-color: var(--portal-accent);
|
||||||
|
background: rgba(11,94,215,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Files list container (scrollable) */
|
||||||
|
.portal-files-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NEW: grid-style gallery inside the list */
|
||||||
|
.portal-files-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
grid-auto-rows: minmax(48px, auto);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
background: rgba(0,0,0,0.01);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.portal-file-card:hover {
|
||||||
|
background: rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--portal-accent, #0b5ed7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.portal-file-card-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.portal-file-card-meta {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-file-card-actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.portal-file-card-download {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.16);
|
||||||
|
background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.portal-file-card-download:hover {
|
||||||
|
background: var(--portal-accent, #0b5ed7);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--portal-accent, #0b5ed7);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#customToast {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 4000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#customToast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* (Optional) keep old row style around if anything else uses it */
|
||||||
|
.portal-file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.portal-file-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.portal-required-star {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.portal-dropzone.portal-dropzone-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-style: solid;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Client Portal – FileRise</title>
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
|
|
||||||
|
<style id="pretheme-css">
|
||||||
|
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Favicons / assets -->
|
||||||
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
|
||||||
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
|
||||||
|
<!-- CSS (reuse main app CSS for look) -->
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Version stamp -->
|
||||||
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- Portal entry -->
|
||||||
|
<script type="module" src="/js/portal.js?v={{APP_QVER}}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.portal-wrapper {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.portal-card {
|
||||||
|
max-width: min(960px, 100%);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
.portal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.portal-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.portal-logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
.portal-dropzone {
|
||||||
|
border: 2px dashed rgba(0,0,0,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.portal-dropzone.dragover {
|
||||||
|
border-color: #0b5ed7;
|
||||||
|
background: rgba(11,94,215,0.06);
|
||||||
|
}
|
||||||
|
.portal-files-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.portal-file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.portal-file-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.portal-status {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
#customToast {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 4000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#customToast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="portalRoot" class="portal-wrapper">
|
||||||
|
<div class="portal-card">
|
||||||
|
<div class="portal-header">
|
||||||
|
<div class="portal-logo">
|
||||||
|
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
|
||||||
|
<div>
|
||||||
|
<div id="portalBrandHeading" style="font-weight:600; font-size:1rem;">Client Portal</div>
|
||||||
|
<div id="portalSubtitle" class="text-muted" style="font-size:0.8rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small id="portalUserLabel" class="text-muted"></small>
|
||||||
|
</div>
|
||||||
|
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
|
||||||
|
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
|
||||||
|
|
||||||
|
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
|
||||||
|
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
|
||||||
|
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
|
||||||
|
Please fill in your information before uploading files.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="portalFormGroupName" class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label id="portalFormLabelName" for="portalFormName">Name</label>
|
||||||
|
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalFormGroupEmail" class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label id="portalFormLabelEmail" for="portalFormEmail">Email</label>
|
||||||
|
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalFormGroupReference" class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label id="portalFormLabelReference" for="portalFormReference">Reference / Case / Order #</label>
|
||||||
|
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalFormGroupNotes" class="form-group" style="margin-bottom:8px;">
|
||||||
|
<label id="portalFormLabelNotes" for="portalFormNotes">Notes</label>
|
||||||
|
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="portalUploadSection">
|
||||||
|
<div id="portalDropzone" class="portal-dropzone">
|
||||||
|
<div><strong>Drop files here</strong> or click to browse.</div>
|
||||||
|
<div style="font-size:0.8rem;" class="text-muted">
|
||||||
|
Files will be uploaded to this portal only.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="portalFileInput" multiple style="display:none;">
|
||||||
|
<div id="portalStatus" class="portal-status text-muted"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalFilesSection" style="margin-top:12px; display:none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong style="font-size:0.95rem;">Files in this portal</strong>
|
||||||
|
<button type="button" id="portalRefreshBtn" class="btn btn-sm btn-outline-secondary">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="portalFilesList" class="portal-files-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalThankYouSection"
|
||||||
|
style="margin-top:12px; display:none;">
|
||||||
|
<div class="alert alert-success" style="font-size:0.9rem; margin-bottom:8px;">
|
||||||
|
<strong>Thank you!</strong>
|
||||||
|
<span id="portalThankYouMessage">
|
||||||
|
Your files have been uploaded.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="portalFooter" class="text-muted"
|
||||||
|
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="customToast"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
public/sw.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Root-scoped stub. Keeps the worker’s scope at “/” level
|
||||||
|
try {
|
||||||
|
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
|
||||||
|
} catch (_) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
BIN
resources/StorageDiskUsage.png
Normal file
|
After Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
resources/dark-client-portal3.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
BIN
resources/dark-client-portal4.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 470 KiB After Width: | Height: | Size: 871 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |