Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
77a94ecd85 | ||
|
|
699873848e | ||
|
|
9cb12c11a6 | ||
|
|
c08876380b | ||
|
|
5b824888cb | ||
|
|
b7d7f7c3ce | ||
|
|
e509b7ac9c | ||
|
|
947255d94c | ||
|
|
55d44ef880 |
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
|
||||||
|
|||||||
31
.github/workflows/sync-changelog.yml
vendored
@@ -5,18 +5,25 @@ on:
|
|||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- "CHANGELOG.md"
|
- "CHANGELOG.md"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: bump-and-sync-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump_and_sync:
|
bump_and_sync:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout FileRise
|
||||||
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
- name: Extract version from commit message
|
- name: Extract version from commit message
|
||||||
id: ver
|
id: ver
|
||||||
@@ -32,6 +39,23 @@ jobs:
|
|||||||
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
|
echo "No release(vX.Y.Z) tag in commit message; skipping bump."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure we're on the branch and up to date BEFORE modifying files
|
||||||
|
- name: Ensure clean branch (no local mods), update from remote
|
||||||
|
if: steps.ver.outputs.version != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Be on a named branch that tracks the remote
|
||||||
|
git checkout -B "${{ github.ref_name }}" --track "origin/${{ github.ref_name }}" || git checkout -B "${{ github.ref_name }}"
|
||||||
|
# Make sure the worktree is clean
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "::error::Working tree not clean before update. Aborting."
|
||||||
|
git status --porcelain
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Update branch
|
||||||
|
git pull --rebase origin "${{ github.ref_name }}"
|
||||||
|
|
||||||
- name: Update public/js/version.js (source of truth)
|
- name: Update public/js/version.js (source of truth)
|
||||||
if: steps.ver.outputs.version != ''
|
if: steps.ver.outputs.version != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -42,8 +66,6 @@ jobs:
|
|||||||
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
window.APP_VERSION = '${{ steps.ver.outputs.version }}';
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# ✂️ REMOVED: repo stamping of HTML/CSS/JS
|
|
||||||
|
|
||||||
- name: Commit version.js only
|
- name: Commit version.js only
|
||||||
if: steps.ver.outputs.version != ''
|
if: steps.ver.outputs.version != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -56,7 +78,7 @@ jobs:
|
|||||||
echo "No changes to commit"
|
echo "No changes to commit"
|
||||||
else
|
else
|
||||||
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
|
git commit -m "chore(release): set APP_VERSION to ${{ steps.ver.outputs.version }} [skip ci]"
|
||||||
git push
|
git push origin "${{ github.ref_name }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Checkout filerise-docker
|
- name: Checkout filerise-docker
|
||||||
@@ -66,6 +88,7 @@ jobs:
|
|||||||
repository: error311/filerise-docker
|
repository: error311/filerise-docker
|
||||||
token: ${{ secrets.PAT_TOKEN }}
|
token: ${{ secrets.PAT_TOKEN }}
|
||||||
path: docker-repo
|
path: docker-repo
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Copy CHANGELOG.md and write VERSION
|
- name: Copy CHANGELOG.md and write VERSION
|
||||||
if: steps.ver.outputs.version != ''
|
if: steps.ver.outputs.version != ''
|
||||||
|
|||||||
938
CHANGELOG.md
@@ -1,5 +1,943 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/26/2025 (v2.0.4)
|
||||||
|
|
||||||
|
release(v2.0.4): harden sessions and align Pro paths with USERS_DIR
|
||||||
|
|
||||||
|
- Enable strict_types in config.php and AdminController
|
||||||
|
- Decouple PHP session lifetime from "remember me" window
|
||||||
|
- Regenerate session ID on persistent token auto-login
|
||||||
|
- Point Pro license / bundle paths at USERS_DIR instead of hardcoded /users
|
||||||
|
- Tweak folder management card drag offset for better alignment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/26/2025 (v2.0.3)
|
||||||
|
|
||||||
|
release(v2.0.3): polish uploads, header dock, and panel fly animations
|
||||||
|
|
||||||
|
- Rework upload drop area markup to be rebuild-safe and wire a guarded "Choose files" button
|
||||||
|
so only one OS file-picker dialog can open at a time.
|
||||||
|
- Centralize file input change handling and reset selectedFiles/_currentResumableIds per batch
|
||||||
|
to avoid duplicate resumable entries and keep the progress list/drafts in sync.
|
||||||
|
- Ensure drag-and-drop uploads still support folder drops while file-picker is files-only.
|
||||||
|
- Add ghost-based animations when collapsing panels into the header dock and expanding them back
|
||||||
|
to sidebar/top zones, inheriting card background/border/shadow for smooth visuals.
|
||||||
|
- Offset sidebar ghosts so upload and folder cards don't stack directly on top of each other.
|
||||||
|
- Respect header-pinned cards: cards saved to HEADER stay as icons and no longer fly out on expand.
|
||||||
|
- Slightly tighten file summary margin in the file list header for better alignment with actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/23/2025 (v2.0.2)
|
||||||
|
|
||||||
|
release(v2.0.2): add config-driven demo mode and lock demo account changes
|
||||||
|
|
||||||
|
- Wire FR_DEMO_MODE through AdminModel/siteConfig and admin getConfig (demoMode flag)
|
||||||
|
- Drive demo detection in JS from __FR_SITE_CFG__.demoMode instead of hostname
|
||||||
|
- Show consistent login tip + toasts for demo using shared __FR_DEMO__ flag
|
||||||
|
- Block password changes for the demo user and profile picture uploads when in demo mode
|
||||||
|
- Keep normal user dropdown/admin UI visible even on the demo, while still protecting the demo account
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/23/2025 (v2.0.0)
|
||||||
|
|
||||||
|
### FileRise Core v2.0.0 & FileRise Pro v1.1.0
|
||||||
|
|
||||||
|
```text
|
||||||
|
release(v2.0.0): feat(pro): client portals + portal login flow
|
||||||
|
release(v2.0.1): fix: harden portal + core login redirects for codeql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core v2.0.0
|
||||||
|
|
||||||
|
- **Portal plumbing in core**
|
||||||
|
- New public pages: `portal.html` and `portal-login.html` for client-facing views.
|
||||||
|
- New portal controller + API endpoints that read portal definitions from the Pro bundle, enforce expiry, and expose safe public metadata.
|
||||||
|
- Login flow now respects a `?redirect=` parameter so portals can bounce through login cleanly and land back on the right slug.
|
||||||
|
|
||||||
|
- **Admin UX + styling**
|
||||||
|
- Admin panel CSS pulled into a dedicated `adminPanelStyles.js` helper instead of inline styles.
|
||||||
|
- User Groups and Client Portals modals use the new shared styling and dark-mode tweaks so they match the rest of the UI.
|
||||||
|
|
||||||
|
- **Breadcrumb root fix**
|
||||||
|
- Breadcrumbs now always show **root** explicitly and behave correctly when you’re at top level vs nested folders.
|
||||||
|
|
||||||
|
- **Routing**
|
||||||
|
- Apache rewrite added for pretty portal URLs:
|
||||||
|
`https://host/portal/<slug>` → `portal.html?slug=<slug>` without affecting other routes.
|
||||||
|
|
||||||
|
### Pro v1.1.0 – Client Portals
|
||||||
|
|
||||||
|
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
|
||||||
|
- Create multiple portals, each with:
|
||||||
|
- Slug + display name
|
||||||
|
- Target folder
|
||||||
|
- Optional client email
|
||||||
|
- Upload-only / allow-download flags
|
||||||
|
- Per-portal expiry date
|
||||||
|
- Portal-level copy and branding:
|
||||||
|
- Optional title + instructions
|
||||||
|
- Accent color used throughout the portal UI
|
||||||
|
- Footer text at bottom of the portal page
|
||||||
|
|
||||||
|
- **Optional intake form before uploads**
|
||||||
|
- Enable a form per portal with fields: name, email, reference, notes.
|
||||||
|
- Per-field “default value” and “required” toggles.
|
||||||
|
- Form must be completed before uploads when enabled.
|
||||||
|
|
||||||
|
- **Submissions log**
|
||||||
|
- Each portal keeps a submissions list showing:
|
||||||
|
- Date/time, folder, submitting user, IP address
|
||||||
|
- The intake form values (name, email, reference, notes).
|
||||||
|
|
||||||
|
- **Client-facing experience**
|
||||||
|
- New portal UI with:
|
||||||
|
- Branded header (title + accent color)
|
||||||
|
- Optional intake form
|
||||||
|
- Drag-and-drop upload dropzone
|
||||||
|
- If downloads are enabled, a clean list/grid of files already in that portal’s folder with download buttons.
|
||||||
|
|
||||||
|
- **Portal login page**
|
||||||
|
- Minimal login screen that pulls title/accent/footer from portal metadata.
|
||||||
|
- After successful login, user is redirected back to the original portal URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/21/2025 (v1.9.14)
|
||||||
|
|
||||||
|
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
|
||||||
|
|
||||||
|
- Add ACL-aware folder stats and byte counts in FolderModel::countVisible()
|
||||||
|
- Show subfolders inline as rows above files in table view (Explorer-style)
|
||||||
|
- Page folders + files together and wire folder rows into existing DnD and context menu flows
|
||||||
|
- Add folder action buttons (move/rename/color/share) with capability checks from /api/folder/capabilities.php
|
||||||
|
- Cache folder capabilities and owners to avoid repeat calls per row
|
||||||
|
- Add user settings to toggle folder strip and inline folder rows (stored in localStorage)
|
||||||
|
- Default itemsPerPage to 50 and remember current page across renders
|
||||||
|
- Sync inline folder icon size to file row height and tweak vertical alignment for different row heights
|
||||||
|
- Update table headers + i18n keys to use Name / Size / Modified / Created / Owner labels
|
||||||
|
- Compact and consolidate light/dark theme CSS, search pill, pagination, and font-size controls
|
||||||
|
- Tighten file action button hit areas and add specific styles for folder move/rename buttons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/20/2025 (v1.9.13)
|
||||||
|
|
||||||
|
release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals
|
||||||
|
|
||||||
|
- Added compact, unified light/dark theme for core surfaces (file list, upload, folder manager, admin panel).
|
||||||
|
- Updated modals, dropdown menus, and editor header to use the same modern panel styling in both themes.
|
||||||
|
- Restyled search bar into a pill-shaped control with a dedicated icon chip and better hover states.
|
||||||
|
- Refined pagination (Prev/Next) and font size (A-/A+) buttons to be smaller, rounded, and more consistent.
|
||||||
|
- Normalized input fields so borders render cleanly and focus states are consistent across the app.
|
||||||
|
- Tweaked button shadows so primary actions (Create/Upload) pop without feeling heavy in light mode.
|
||||||
|
- Polished dark-mode colors for tables, rows, toasts, and meta text for a more “app-like” feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/19/2025 (v1.9.12)
|
||||||
|
|
||||||
|
release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL
|
||||||
|
|
||||||
|
- Add Pro user groups as a first-class ACL source:
|
||||||
|
- Load group grants from FR_PRO_BUNDLE_DIR/groups.json in ACL::hasGrant().
|
||||||
|
- Treat group grants as additive only; they can never remove access.
|
||||||
|
|
||||||
|
- Introduce AclAdminController:
|
||||||
|
- Move getGrants/saveGrants logic into a dedicated controller.
|
||||||
|
- Keep existing ACL normalization and business rules (shareFolder ⇒ view, shareFile ⇒ at least viewOwn).
|
||||||
|
- Refactor public/api/admin/acl/getGrants.php and saveGrants.php to use the controller.
|
||||||
|
|
||||||
|
- Implement Pro user group storage and APIs:
|
||||||
|
- Add ProGroups store class under FR_PRO_BUNDLE_DIR (groups.json with {name,label,members,grants}).
|
||||||
|
- Add /api/pro/groups/list.php and /api/pro/groups/save.php, guarded by AdminController::requireAuth/requireAdmin/requireCsrf().
|
||||||
|
- Keep groups and bundle code behind FR_PRO_ACTIVE/FR_PRO_BUNDLE_DIR checks.
|
||||||
|
|
||||||
|
- Ship Pro-only endpoints from core instead of the bundle:
|
||||||
|
- Move public/api/pro/uploadBrandLogo.php into core and gate it on FR_PRO_ACTIVE.
|
||||||
|
- Remove start.sh logic that copied public/api/pro from the Pro bundle into the container image.
|
||||||
|
|
||||||
|
- Extend admin UI for user groups:
|
||||||
|
- Turn “User groups” into a real Pro-only modal with add/delete groups, multi-select members, and member chips.
|
||||||
|
- Add “Edit folder access” for each group, reusing the existing folder grants grid.
|
||||||
|
- Overlay group grants when editing a user’s ACL:
|
||||||
|
- Show which caps are coming from groups, lock those checkboxes, and update tooltips.
|
||||||
|
- Show group membership badges in the user permissions list.
|
||||||
|
- Add a collapsed “Groups” section at the top of the permissions screen to preview group ACLs (read-only).
|
||||||
|
|
||||||
|
- Misc:
|
||||||
|
- Bump PRO_LATEST_BUNDLE_VERSION hint in adminPanel.js to v1.0.1.
|
||||||
|
- Tweak modal border-radius styling to include the new userGroups and groupAcl modals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/18/2025 (v1.9.11)
|
||||||
|
|
||||||
|
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||||
|
|
||||||
|
- media: add proper HTTP Range support to /api/file/download.php so HTML5
|
||||||
|
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
|
||||||
|
- media: avoid buffering the entire file in memory; stream from disk with
|
||||||
|
200/206 responses and Accept-Ranges for smoother playback and faster start times.
|
||||||
|
- media: keep video progress tracking, watched badges, and status chip behavior
|
||||||
|
unchanged but now compatible with the new streaming endpoint.
|
||||||
|
|
||||||
|
- ui: update the folder strip to be responsive:
|
||||||
|
- desktop: keep the existing "chip" layout with icon above name.
|
||||||
|
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
|
||||||
|
- ui: add simple lazy-loading for the folder strip so only the first batch of
|
||||||
|
folders is rendered initially, with a "Load more…" button to append chunks for
|
||||||
|
very large folder sets (stays friendly with 100k+ folders).
|
||||||
|
|
||||||
|
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
|
||||||
|
and keep mobile/desktop behavior clearly separated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/18/2025 (v1.9.10)
|
||||||
|
|
||||||
|
release(v1.9.10): add Pro bundle installer and admin panel polish
|
||||||
|
|
||||||
|
- Add FileRise Pro section in admin panel with license management and bundle upload
|
||||||
|
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
|
||||||
|
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
|
||||||
|
- Update Pro badge/version UI with “update available” hint and link to filerise.net
|
||||||
|
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/16/2025 (v1.9.9)
|
||||||
|
|
||||||
|
release(v1.9.9): fix(branding): sanitize custom logo URL preview
|
||||||
|
|
||||||
|
- Sanitize branding.customLogoUrl on the server before writing siteConfig.json
|
||||||
|
- Allow only http/https or site-relative paths; strip invalid/sneaky values
|
||||||
|
- Update adminPanel.js live logo preview to set img src/alt safely
|
||||||
|
- Addresses CodeQL XSS warning while keeping Pro branding logo overrides working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/16/2025 (v1.9.8)
|
||||||
|
|
||||||
|
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
|
||||||
|
|
||||||
|
- Add Pro feature flags + bootstrap wiring
|
||||||
|
- Define FR_PRO_ACTIVE/FR_PRO_TYPE/FR_PRO_EMAIL/FR_PRO_VERSION/FR_PRO_LICENSE_FILE
|
||||||
|
in config.php and optionally require src/pro/bootstrap_pro.php.
|
||||||
|
- Expose a `pro` block from AdminController::getConfig() so the UI can show
|
||||||
|
license status, type, email, and bundle version without leaking the raw key.
|
||||||
|
|
||||||
|
- Implement license save endpoint
|
||||||
|
- Add AdminController::setLicense() and /api/admin/setLicense.php to accept a
|
||||||
|
FRP1 license string via JSON, validate basic shape, and persist it to
|
||||||
|
FR_PRO_LICENSE_FILE with strict 0600 permissions.
|
||||||
|
- Return structured JSON success/error responses for the admin UI.
|
||||||
|
|
||||||
|
- Extend admin config model with branding + safer validation
|
||||||
|
- Add `branding.customLogoUrl`, `branding.headerBgLight`, and
|
||||||
|
`branding.headerBgDark` fields to AdminModel defaults and updateConfig().
|
||||||
|
- Introduce AdminModel::sanitizeLogoUrl() to allow only site-relative /uploads
|
||||||
|
paths or http(s) URLs; reject absolute filesystem paths, data: URLs, and
|
||||||
|
javascript: URLs.
|
||||||
|
- Continue to validate ONLYOFFICE docsOrigin as http(s) only, keeping core
|
||||||
|
config hardening intact.
|
||||||
|
|
||||||
|
- New Pro-aware Admin Panel UI
|
||||||
|
- Rework User Management section to group:
|
||||||
|
- Add user / Remove user
|
||||||
|
- Folder Access (per-folder ACL)
|
||||||
|
- User Permissions (account-level flags)
|
||||||
|
- Add Pro-only actions with clear gating:
|
||||||
|
- “User groups” button (Pro)
|
||||||
|
- “Client upload portal” button with “Pro · Coming soon” pill
|
||||||
|
- Add “FileRise Pro” section:
|
||||||
|
- Show current Pro status (Free vs Active) + license metadata.
|
||||||
|
- Textarea for pasting license key, file upload helper, and “Save license”
|
||||||
|
action wired to /api/admin/setLicense.php.
|
||||||
|
- Optional “Copy current license” button when a license is present.
|
||||||
|
- Add “Sponsor / Donations” section with fixed GitHub Sponsors and Ko-fi URLs
|
||||||
|
and one-click copy/open buttons.
|
||||||
|
|
||||||
|
- Header branding controls (Pro)
|
||||||
|
- Add Header Logo + Header Colors controls under Header Settings, gated by
|
||||||
|
`config.pro.active`.
|
||||||
|
- Allow uploading a logo via /api/pro/uploadBrandLogo.php and auto-filling the
|
||||||
|
normalized /uploads path.
|
||||||
|
- Add live-preview helpers to update the header logo and header background
|
||||||
|
colors in the running UI after saving.
|
||||||
|
|
||||||
|
- Apply branding on app boot
|
||||||
|
- Update main.js to read branding config on load and apply:
|
||||||
|
- Custom header logo (or fallback to /assets/logo.svg).
|
||||||
|
- Light/dark header background colors via CSS variables.
|
||||||
|
- Keeps header consistent with saved branding across reloads and before
|
||||||
|
opening the admin panel.
|
||||||
|
|
||||||
|
- Styling + UX polish
|
||||||
|
- Add styles for new admin sections: collapsible headers, dark-mode aware
|
||||||
|
modal content, and refined folder access grid.
|
||||||
|
- Introduce .btn-pro-admin and .btn-pro-pill classes to render “Pro” and
|
||||||
|
“Pro · Coming soon” pills overlayed on buttons, matching the existing
|
||||||
|
header “Core/Pro” badge treatment.
|
||||||
|
- Minor spacing/typography tweaks in admin panel and ACL UI.
|
||||||
|
|
||||||
|
Note: Core code remains MIT-licensed; Pro functionality is enabled via optional
|
||||||
|
runtime hooks and separate closed-source bundle, without changing the core
|
||||||
|
license text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/14/2025 (v1.9.7)
|
||||||
|
|
||||||
|
release(v1.9.7): harden client path guard and refine header/folder strip CSS
|
||||||
|
|
||||||
|
- Tighten isSafeFolderPath() to reject dot-prefixed/invalid segments (client-side defense-in-depth on folder paths).
|
||||||
|
- Rework header layout: consistent logo sizing, centered title, cleaner button alignment, and better small-screen stacking.
|
||||||
|
- Polish user dropdown and icon buttons: improved hover/focus states, dark-mode colors, and rounded menu corners.
|
||||||
|
- Update folder strip tiles: cap tile width, allow long folder names to wrap neatly, and fine-tune text/icon alignment.
|
||||||
|
- Tweak folder tree rows: better label wrapping, vertical alignment, and consistent SVG folder icon rendering.
|
||||||
|
- Small CSS cleanup and normalization (body, main wrapper, media modal/progress styles) without changing behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/14/2025 (v1.9.6)
|
||||||
|
|
||||||
|
release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
|
||||||
|
|
||||||
|
- Resumable uploads
|
||||||
|
- Normalize resumable GET “test chunk” handling in `UploadModel` using `resumableChunkNumber` + `resumableIdentifier`, returning explicit `status: "found"|"not found"`.
|
||||||
|
- Skip CSRF checks for resumable GET tests in `UploadController`, but keep strict CSRF validation for real POST uploads with soft-fail `csrf_expired` responses.
|
||||||
|
- Refactor `UploadModel::handleUpload()` for chunked uploads: strict filename validation, safe folder normalization, reliable temp chunk directory creation, and robust merge with clear errors if any chunk is missing.
|
||||||
|
- Add `UploadModel::removeChunks()` + internal `rrmdir()` to safely clean up `resumable_…` temp folders via a dedicated controller endpoint.
|
||||||
|
|
||||||
|
- Frontend resumable UX & persistence
|
||||||
|
- Enable `testChunks: true` for Resumable.js and wire GET checks to the new backend status logic.
|
||||||
|
- Track in-progress resumable files per user in `localStorage` (identifier, filename, folder, size, lastPercent, updatedAt) and show a resumable hint banner inside the Upload card with a dismiss button that clears the hints for that folder.
|
||||||
|
- Clamp client-side progress to max `99%` until the server confirms success, so aborted tabs still show resumable state instead of “100% done”.
|
||||||
|
- Improve progress UI: show upload speed, spinner while finalizing, and ensure progress elements exist even for non-standard flows (e.g., submit without prior list build).
|
||||||
|
- On complete success, clear the progress UI, reset the file input, cancel Resumable’s internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
|
||||||
|
|
||||||
|
- Hiding resumable temp folders
|
||||||
|
- Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
|
||||||
|
- Folder tree BFS traversal (child discovery / recursion).
|
||||||
|
- `listChildren.php` results and child-cache hydration.
|
||||||
|
- The inline folder strip above the file list (also filtered in `fileListView.js`).
|
||||||
|
|
||||||
|
- Folder manager context menu upgrade
|
||||||
|
- Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
|
||||||
|
- Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
|
||||||
|
- Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
|
||||||
|
|
||||||
|
- File context menu & selection logic
|
||||||
|
- Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
|
||||||
|
- Rebuild `fileMenu.js` to:
|
||||||
|
- Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
|
||||||
|
- Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
|
||||||
|
- Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
|
||||||
|
|
||||||
|
- Tagging system robustness
|
||||||
|
- Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
|
||||||
|
- Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
|
||||||
|
- Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
|
||||||
|
- Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
|
||||||
|
|
||||||
|
- Visual polish & theming
|
||||||
|
- Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
|
||||||
|
- Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
|
||||||
|
- Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
|
||||||
|
- Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
|
||||||
|
- Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/13/2025 (v1.9.5)
|
||||||
|
|
||||||
|
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
|
||||||
|
|
||||||
|
- Replace innerHTML-based row construction in folderManager.js with safe DOM APIs
|
||||||
|
(createElement, textContent, dataset). All user-derived strings now use
|
||||||
|
textContent; only locally-generated SVG remains via innerHTML.
|
||||||
|
- Add isSafeFolderPath() client-side guard; fail closed on suspicious paths
|
||||||
|
before rendering clickable nodes.
|
||||||
|
- “Load more” button rebuilt with proper a11y:
|
||||||
|
- aria-label, optional aria-controls to the UL
|
||||||
|
- aria-busy + disabled during fetch; restore state only if the node is still
|
||||||
|
present (Node.isConnected).
|
||||||
|
- Keep lazy tree + cursor pagination behavior intact; chevrons/icons continue to
|
||||||
|
hydrate from server hints (hasSubfolders/nonEmpty) once available.
|
||||||
|
- Addresses CodeQL XSS findings by removing unsafe HTML interpolation and
|
||||||
|
avoiding HTML interpretation of extracted text.
|
||||||
|
|
||||||
|
No breaking changes; security + UX polish on top of v1.9.4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/13/2025 (v1.9.4)
|
||||||
|
|
||||||
|
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
|
||||||
|
|
||||||
|
**Big focus on folder management performance & UX for large libraries.**
|
||||||
|
|
||||||
|
feat(folder-tree):
|
||||||
|
|
||||||
|
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
|
||||||
|
- BFS-based initial selection: if user can’t view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
|
||||||
|
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
|
||||||
|
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
|
||||||
|
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
|
||||||
|
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
|
||||||
|
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
|
||||||
|
- Root DnD honored only when viewable; structural locks disable dragging.
|
||||||
|
|
||||||
|
perf(core):
|
||||||
|
|
||||||
|
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
|
||||||
|
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
|
||||||
|
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
|
||||||
|
|
||||||
|
api/model:
|
||||||
|
|
||||||
|
- `FolderModel::listChildren(...)` now returns items shaped like:
|
||||||
|
`{ name, locked, hasSubfolders, nonEmpty? }`
|
||||||
|
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
|
||||||
|
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
|
||||||
|
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
|
||||||
|
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
|
||||||
|
|
||||||
|
ui/ux:
|
||||||
|
|
||||||
|
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
|
||||||
|
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
|
||||||
|
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
|
||||||
|
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
|
||||||
|
|
||||||
|
security:
|
||||||
|
|
||||||
|
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
|
||||||
|
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
|
||||||
|
|
||||||
|
fixes:
|
||||||
|
|
||||||
|
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
|
||||||
|
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
|
||||||
|
|
||||||
|
chore:
|
||||||
|
|
||||||
|
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
|
||||||
|
- Minor code cleanups and comments across controller/model and JS tree logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/11/2025 (v1.9.3)
|
||||||
|
|
||||||
|
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
|
||||||
|
|
||||||
|
- UI / Icons
|
||||||
|
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
|
||||||
|
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
|
||||||
|
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
|
||||||
|
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
|
||||||
|
|
||||||
|
- Folder color & non-empty detection
|
||||||
|
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
|
||||||
|
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
|
||||||
|
|
||||||
|
- FileList strip
|
||||||
|
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
|
||||||
|
|
||||||
|
- Exports & helpers
|
||||||
|
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
|
||||||
|
|
||||||
|
- AppCore
|
||||||
|
- Update file upload DnD relay hook to `#fileList` (id rename).
|
||||||
|
|
||||||
|
- CSS tweaks
|
||||||
|
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
|
||||||
|
|
||||||
|
- CI/CD (release)
|
||||||
|
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
|
||||||
|
|
||||||
|
- Changelog highlights
|
||||||
|
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
|
||||||
|
- Real-time folder color propagation between views.
|
||||||
|
- Folder strip switched to SVG tiles with better layout + context actions.
|
||||||
|
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/10/2025 (v1.9.2)
|
||||||
|
|
||||||
|
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||||
|
|
||||||
|
- New “Upload file(s)” action in Create menu:
|
||||||
|
- Adds `<li id="uploadOption">` to the dropdown.
|
||||||
|
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||||
|
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||||
|
|
||||||
|
- Drag & Drop from file list → Upload:
|
||||||
|
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||||
|
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||||
|
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||||
|
|
||||||
|
- Synthetic drop fallback:
|
||||||
|
- Introduces window.__pendingDropData (cleared after use).
|
||||||
|
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||||
|
|
||||||
|
- Implementation details:
|
||||||
|
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||||
|
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||||
|
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||||
|
|
||||||
|
- UX/Safety:
|
||||||
|
- Defensive checks if modal/card isn’t present.
|
||||||
|
- No backend/API changes; CSRF/auth unchanged.
|
||||||
|
|
||||||
|
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.1)
|
||||||
|
|
||||||
|
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||||
|
|
||||||
|
### Highlights v1.9.1
|
||||||
|
|
||||||
|
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
|
||||||
|
- 📄 Folder icons auto-refresh when contents change (no full page reload).
|
||||||
|
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
|
||||||
|
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
|
||||||
|
|
||||||
|
- feat(colors): add per-folder color customization
|
||||||
|
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
|
||||||
|
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
|
||||||
|
- Read endpoint filters map by ACL::canRead before returning to the user.
|
||||||
|
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
|
||||||
|
|
||||||
|
- feat(ui): color-picker modal with live SVG folder preview
|
||||||
|
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
|
||||||
|
|
||||||
|
- feat(controls): “Color folder” button in Folder Management card
|
||||||
|
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
|
||||||
|
|
||||||
|
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
|
||||||
|
|
||||||
|
- ux(tree): make expansion state more predictable across refreshes
|
||||||
|
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
|
||||||
|
|
||||||
|
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
|
||||||
|
|
||||||
|
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/9/2025 (v1.9.0)
|
||||||
|
|
||||||
|
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
|
||||||
|
|
||||||
|
feat(ui): modern folder tree
|
||||||
|
|
||||||
|
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
|
||||||
|
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
|
||||||
|
- Breadcrumb tweaks (› separators), hover/selected polish
|
||||||
|
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
|
||||||
|
|
||||||
|
feat(api): add /api/folder/isEmpty.php via controller/model
|
||||||
|
|
||||||
|
- public/api/folder/isEmpty.php delegates to FolderController::stats()
|
||||||
|
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
|
||||||
|
- Releases PHP session lock early to avoid parallel-request pileups
|
||||||
|
|
||||||
|
perf: cap concurrent “isEmpty” requests + timeouts
|
||||||
|
|
||||||
|
- Small concurrency limiter + fetch timeouts
|
||||||
|
- In-memory result & inflight caches for fewer network hits
|
||||||
|
|
||||||
|
fix(state): preserve user expand/collapse choices
|
||||||
|
|
||||||
|
- Respect saved folderTreeState; don’t auto-expand unopened nodes
|
||||||
|
- Only show ancestors for visibility when navigating (no unwanted persists)
|
||||||
|
|
||||||
|
security: tighten .htaccess while enabling WebDAV
|
||||||
|
|
||||||
|
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
|
||||||
|
- AcceptPathInfo On; keep path-aware dotfile denial
|
||||||
|
|
||||||
|
refactor: move count logic to model; thin controller action
|
||||||
|
|
||||||
|
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
|
||||||
|
|
||||||
|
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.13)
|
||||||
|
|
||||||
|
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
|
||||||
|
|
||||||
|
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
|
||||||
|
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
|
||||||
|
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
|
||||||
|
- header dock: show dock whenever icons exist (and on collapse); hide when empty
|
||||||
|
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
|
||||||
|
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
|
||||||
|
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
|
||||||
|
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.12)
|
||||||
|
|
||||||
|
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
|
||||||
|
|
||||||
|
- auth (public/js/main.js)
|
||||||
|
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
|
||||||
|
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
|
||||||
|
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
|
||||||
|
- Minor cleanup (SW register catch spacing).
|
||||||
|
|
||||||
|
- drag & drop (public/js/dragAndDrop.js)
|
||||||
|
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
|
||||||
|
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
|
||||||
|
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
|
||||||
|
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
|
||||||
|
- Cleaner header icon modal plumbing; remove legacy/dead code.
|
||||||
|
|
||||||
|
- styles (public/css/styles.css)
|
||||||
|
- Header drop zone fills remaining space and right-aligns its icons.
|
||||||
|
|
||||||
|
UX:
|
||||||
|
|
||||||
|
- OIDC button reliably appears when form/basic are disabled.
|
||||||
|
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
|
||||||
|
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.11)
|
||||||
|
|
||||||
|
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
|
||||||
|
|
||||||
|
- Force PKCE via setCodeChallengeMethod('S256') so Authelia’s public-client policy is satisfied.
|
||||||
|
- Convert empty OIDC client secret to null to correctly signal a public client.
|
||||||
|
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
|
||||||
|
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/8/2025 (v1.8.10)
|
||||||
|
|
||||||
|
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
|
||||||
|
|
||||||
|
UI/UX — Media modal
|
||||||
|
|
||||||
|
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
|
||||||
|
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
|
||||||
|
- Restore “X” behavior and make hover theme-aware (red pill + white ‘X’ in light, red pill + black ‘X’ in dark).
|
||||||
|
|
||||||
|
Video/Image controls
|
||||||
|
|
||||||
|
- Top-right action icons use theme-aware styles and align with the filename row.
|
||||||
|
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
|
||||||
|
|
||||||
|
Progress badges (list & modal)
|
||||||
|
|
||||||
|
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
|
||||||
|
|
||||||
|
Drag & drop
|
||||||
|
|
||||||
|
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
|
||||||
|
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
|
||||||
|
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
|
||||||
|
|
||||||
|
Editor & ONLYOFFICE
|
||||||
|
|
||||||
|
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
|
||||||
|
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
|
||||||
|
|
||||||
|
Assets & polish
|
||||||
|
|
||||||
|
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
|
||||||
|
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
|
||||||
|
|
||||||
|
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.9)
|
||||||
|
|
||||||
|
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
|
||||||
|
|
||||||
|
- adminPanel.js:
|
||||||
|
- Masked inputs without a saved value now start with data-replace="1".
|
||||||
|
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/7/2025 (v1.8.8)
|
||||||
|
|
||||||
|
release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60
|
||||||
|
|
||||||
|
**Summary**
|
||||||
|
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||||
|
|
||||||
|
**Backend** changes:
|
||||||
|
|
||||||
|
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot downloads.
|
||||||
|
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
|
||||||
|
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
|
||||||
|
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
|
||||||
|
|
||||||
|
New **Worker**:
|
||||||
|
|
||||||
|
- New `src/cli/zip_worker.php` builds the archive in the background.
|
||||||
|
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the per‑token JSON.
|
||||||
|
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
|
||||||
|
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
|
||||||
|
|
||||||
|
New **API/Status Payload**:
|
||||||
|
|
||||||
|
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
|
||||||
|
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
|
||||||
|
|
||||||
|
**Frontend (UX)** changes:
|
||||||
|
|
||||||
|
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
|
||||||
|
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
|
||||||
|
- Shows determinate **0–98%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
|
||||||
|
- Modal closes just before download; UI resets for the next operation.
|
||||||
|
|
||||||
|
Added **CSS**:
|
||||||
|
|
||||||
|
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
|
||||||
|
|
||||||
|
**Why this closes #60**?
|
||||||
|
|
||||||
|
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
|
||||||
|
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
|
||||||
|
- Users get continuous, truthful feedback for large multi‑GB archives.
|
||||||
|
|
||||||
|
Additional **Notes**
|
||||||
|
|
||||||
|
- Download tokens are **one‑shot** and are deleted after the GET completes.
|
||||||
|
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/5/2025 (v1.8.7)
|
||||||
|
|
||||||
|
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
|
||||||
|
|
||||||
|
- FileController::downloadZip
|
||||||
|
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
|
||||||
|
- Close session locks, disable gzip/output buffering, set Content-Length when known
|
||||||
|
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
|
||||||
|
- Unlink the temp ZIP after successful send
|
||||||
|
- Preserves all auth/ACL/ownership checks
|
||||||
|
|
||||||
|
- FileModel::createZipArchive
|
||||||
|
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
|
||||||
|
|
||||||
|
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.6)
|
||||||
|
|
||||||
|
release(v1.8.6): fix large ZIP downloads + safer extract; close #60
|
||||||
|
|
||||||
|
- Zip creation
|
||||||
|
- Write archives to META_DIR/ziptmp (on large/writable disk) instead of system tmp.
|
||||||
|
- Auto-create ziptmp (0775) and verify writability.
|
||||||
|
- Free-space sanity check (~files total +5% +20MB); clearer error on low space.
|
||||||
|
- Normalize/validate folder segments; include only regular files.
|
||||||
|
- set_time_limit(0); use CREATE|OVERWRITE; improved error handling.
|
||||||
|
|
||||||
|
- Zip extraction
|
||||||
|
- New: stamp metadata for files in nested subfolders (per-folder metadata.json).
|
||||||
|
- Skip hidden “dot” paths (files/dirs with any segment starting with “.”) by default
|
||||||
|
via SKIP_DOTFILES_ON_EXTRACT=true; only extract allow-listed entries.
|
||||||
|
- Hardenings: zip-slip guard, reject symlinks (external_attributes), zip-bomb limits
|
||||||
|
(MAX_UNZIP_BYTES default 200GiB, MAX_UNZIP_FILES default 20k).
|
||||||
|
- Persist metadata for all touched folders; keep extractedFiles list for top-level names.
|
||||||
|
|
||||||
|
Ops note: ensure /var/www/metadata/ziptmp exists & is writable (or mount META_DIR to a large volume).
|
||||||
|
|
||||||
|
Closes #60.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.5)
|
||||||
|
|
||||||
|
release(v1.8.5): ci: reduce pre-run delay to 2-min and add missing `needs: delay`, final test
|
||||||
|
|
||||||
|
- No change release just testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.4)
|
||||||
|
|
||||||
|
release(v1.8.4): ci: add 3-min pre-run delay to avoid workflow_run races
|
||||||
|
|
||||||
|
- No change release just testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.3)
|
||||||
|
|
||||||
|
release(v1.8.3): feat(mobile+ci): harden Capacitor switcher & make release-on-version robust
|
||||||
|
|
||||||
|
- switcher.js: allow running inside Capacitor; remove innerHTML usage; build nodes safely; normalize/strip creds from URLs; add withParam() for ?frapp=1; drop inline handlers; clamp rename length; minor UX polish.
|
||||||
|
- CI: cancel superseded runs per ref; checkout triggering commit (workflow_run head_sha); improve APP_VERSION parsing; point tag to checked-out commit; add recent-tag debug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.2)
|
||||||
|
|
||||||
|
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
|
||||||
|
|
||||||
|
- **Highlights**
|
||||||
|
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
|
||||||
|
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
- API (new):
|
||||||
|
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
|
||||||
|
- GET /api/media/getProgress.php — fetch per-file progress.
|
||||||
|
- GET /api/media/getViewedMap.php — folder map for badges.
|
||||||
|
|
||||||
|
- **Frontend (media):**
|
||||||
|
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
|
||||||
|
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
|
||||||
|
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
|
||||||
|
|
||||||
|
- **Mobile & PWA:**
|
||||||
|
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
|
||||||
|
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
|
||||||
|
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
|
||||||
|
|
||||||
|
- **Notes**
|
||||||
|
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
|
||||||
|
- No breaking changes expected; endpoints are additive.
|
||||||
|
|
||||||
|
Closes #37.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/3/2025 (V1.8.1)
|
||||||
|
|
||||||
|
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
|
||||||
|
|
||||||
|
- Add ONLYOFFICE URL sanitizers:
|
||||||
|
- getTrustedDocsOrigin(): enforce http/https, strip creds, normalize to origin
|
||||||
|
- buildOnlyOfficeApiUrl(): construct fixed /web-apps/.../api.js via URL()
|
||||||
|
- Probe hardening (addresses CodeQL js/xss-through-dom):
|
||||||
|
- ooProbeScript/ooProbeFrame now use sanitized origins and fixed paths
|
||||||
|
- optional CSP nonce support for injected script
|
||||||
|
- optional iframe sandbox; robust cleanup/timeout handling
|
||||||
|
- CSP helper now renders lines based on validated origin (fallback to raw for visibility)
|
||||||
|
- Admin UI UX: placeholder switched to HTTPS example (`https://docs.example.com`)
|
||||||
|
- Comments added to justify safety to static analyzers
|
||||||
|
|
||||||
|
Files: public/js/adminPanel.js
|
||||||
|
|
||||||
|
Refs: #37
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/3/2025 (v1.8.0)
|
||||||
|
|
||||||
|
release(v1.8.0): feat(onlyoffice): first-class ONLYOFFICE integration (view/edit), admin UI, API, CSP helpers
|
||||||
|
|
||||||
|
Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
|
||||||
|
|
||||||
|
Adds secure, ACL-aware ONLYOFFICE support throughout FileRise:
|
||||||
|
|
||||||
|
- **Backend / API**
|
||||||
|
- New OnlyOfficeController with supported extensions (doc/xls/ppt/pdf etc.), status/config endpoints, and signed download flow.
|
||||||
|
- New endpoints:
|
||||||
|
- GET /api/onlyoffice/status.php — reports availability + supported exts.
|
||||||
|
- GET /api/onlyoffice/config.php — returns DocEditor config (signed URLs, callback).
|
||||||
|
- GET /api/onlyoffice/signed-download.php — serves signed blobs to DS.
|
||||||
|
- Effective config/overrides: env/constant wins; supports docsOrigin, publicOrigin, and jwtSecret; status gated on presence of origin+secret.
|
||||||
|
- Public origin resolution (BASE_URL/proxy aware) for absolute URLs.
|
||||||
|
|
||||||
|
- **Admin config / UI**
|
||||||
|
- AdminPanel gets a new “ONLYOFFICE” section with Enable toggle, Document Server Origin, masked JWT Secret, and “Replace” control.
|
||||||
|
- Built-in connection tester (status, secret presence, callback ping, api.js load, iframe embed) + CSP helper (Apache & Nginx snippets)
|
||||||
|
|
||||||
|
- **Frontend integration**
|
||||||
|
- fileEditor detects OO capability via /api/onlyoffice/status and routes supported types to the DocEditor; loads DocsAPI dynamically.
|
||||||
|
- editFile() short-circuits to openOnlyOffice when applicable; includes live dark/light theme sync where supported.
|
||||||
|
- fileListView pulls status once on load to drive UI decisions (e.g., editing affordances).
|
||||||
|
|
||||||
|
- **AdminModel / config**
|
||||||
|
- Adds onlyoffice {enabled, docsOrigin, publicOrigin} defaults and update path, with jwtSecret persisted (kept unless explicitly replaced).
|
||||||
|
- Optional constants in config.php to override and debug.
|
||||||
|
|
||||||
|
- **Security & UX notes**
|
||||||
|
- Editor access remains ACL-checked (read/edit) and uses absolute, signed URLs surfaced via controller.
|
||||||
|
- Admin UI never echoes secrets; “Replace” toggles explicit updates only.
|
||||||
|
- CSP helper makes it straightforward to permit api.js + iframe + XHR to your DS.
|
||||||
|
|
||||||
|
- **Docs/Styling**
|
||||||
|
- Minor CSS touch-ups around hover states and modal layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 11/2/2025 (v1.7.5)
|
||||||
|
|
||||||
|
release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50)
|
||||||
|
release(v1.7.5): retrigger CI bump (no code changes)
|
||||||
|
release(v1.7.5): retrigger CI bump ensure up to date
|
||||||
|
|
||||||
|
### Security/headers
|
||||||
|
|
||||||
|
- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'.
|
||||||
|
- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts.
|
||||||
|
|
||||||
|
### Previews & editor
|
||||||
|
|
||||||
|
- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL).
|
||||||
|
- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots.
|
||||||
|
- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials.
|
||||||
|
|
||||||
|
### Login, theming & UX polish
|
||||||
|
|
||||||
|
- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`.
|
||||||
|
- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips.
|
||||||
|
- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard).
|
||||||
|
|
||||||
|
### HTML/CSS & perf
|
||||||
|
|
||||||
|
- Make Bootstrap/Styles/Roboto critical (plain `<link rel="stylesheet">`); keep fonts as true preloads; modulepreload app entry.
|
||||||
|
- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads.
|
||||||
|
- Header logo marked `fetchpriority="high"` for faster first paint.
|
||||||
|
- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.).
|
||||||
|
|
||||||
|
### Manual Deploy script
|
||||||
|
|
||||||
|
- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- If you change the inline pre-theme snippet, update the CSP hash accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes 10/31/2025 (v1.7.4)
|
||||||
|
|
||||||
|
release(v1.7.4): login hint replace toast + fix unauth boot
|
||||||
|
|
||||||
|
main.js
|
||||||
|
|
||||||
|
- Added isDemoHost() and showLoginTip(message).
|
||||||
|
- In the unauth branch, call showLoginTip('Please log in to continue').
|
||||||
|
- Removed ensureToastReady() + showToast('please_log_in_to_continue') in the unauth path to avoid loading toast/DOM utils before auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 10/31/2025 (v1.7.3)
|
## Changes 10/31/2025 (v1.7.3)
|
||||||
|
|
||||||
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
|
release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
|
||||||
|
|||||||
484
README.md
@@ -10,424 +10,192 @@
|
|||||||
[](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.
|
||||||
|
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in-browser previews & code editor.
|
||||||
|
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||||
|
- 👥 **User groups & client portals (Pro)** – Group-based ACLs and brandable client upload portals.
|
||||||
|
|
||||||
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):**
|
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||||
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
|
||||||
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.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
docker pull error311/filerise-docker:latest
|
docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run a container
|
Then visit:
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
docker run -d \
|
http://your-server-ip:8080
|
||||||
--name filerise \
|
|
||||||
-p 8080:80 \
|
|
||||||
-e TIMEZONE="America/New_York" \
|
|
||||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
|
||||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
|
||||||
-e SECURE="false" \
|
|
||||||
-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 SHARE_URL="" \
|
|
||||||
-v ~/filerise/uploads:/var/www/uploads \
|
|
||||||
-v ~/filerise/users:/var/www/users \
|
|
||||||
-v ~/filerise/metadata:/var/www/metadata \
|
|
||||||
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.
|
On first launch you’ll be guided through creating the **initial admin user**.
|
||||||
|
|
||||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||||
|
[Install & Setup](https://github.com/error311/FileRise/wiki/Installation-Setup)
|
||||||
**Notes**
|
[nginx](https://github.com/error311/FileRise/wiki/Nginx-Setup)
|
||||||
|
[FAQ](https://github.com/error311/FileRise/wiki/FAQ)
|
||||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||||
- `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
|
|
||||||
|
|
||||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
filerise:
|
|
||||||
image: error311/filerise-docker:latest
|
|
||||||
container_name: filerise
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
environment:
|
|
||||||
TIMEZONE: "UTC"
|
|
||||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
|
||||||
TOTAL_UPLOAD_SIZE: "10G"
|
|
||||||
SECURE: "false"
|
|
||||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
|
||||||
# Ownership & indexing
|
|
||||||
PUID: "1000" # Unraid users often use 99
|
|
||||||
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:
|
|
||||||
- ./uploads:/var/www/uploads
|
|
||||||
- ./users:/var/www/users
|
|
||||||
- ./metadata:/var/www/metadata
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
Access at `http://localhost:8080` (or your server’s IP).
|
|
||||||
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.”
|
|
||||||
|
|
||||||
**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)
|
## 3. Manual install (PHP web server)
|
||||||
|
|
||||||
If you prefer a traditional web server (LAMP stack or similar):
|
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.
|
||||||
|
|
||||||
- `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,35 +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
|
||||||
|
|
||||||
# --- HTTPS redirect ---
|
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
|
||||||
# Use ONE of these blocks.
|
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
|
||||||
|
RewriteRule - - [L]
|
||||||
|
|
||||||
# A) Direct TLS on this server (enable this if Apache terminates HTTPS here)
|
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
|
||||||
#RewriteCond %{HTTPS} off
|
# 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]
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# 4) HTTPS redirect (enable ONE of these, comment the other)
|
||||||
|
|
||||||
|
# A) Direct TLS on this server
|
||||||
|
#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
|
||||||
@@ -37,7 +66,7 @@ RewriteEngine On
|
|||||||
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"
|
||||||
@@ -48,54 +77,53 @@ RewriteEngine On
|
|||||||
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 Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; 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 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'"
|
||||||
</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
|
||||||
</FilesMatch>
|
Header set Cache-Control "public, max-age=604800" env=!IS_VER
|
||||||
|
|
||||||
# Versioned assets (?v=...): 1 year + immutable
|
|
||||||
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
|
||||||
Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
</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}]}']);
|
|
||||||
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();
|
||||||
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);
|
||||||
|
}
|
||||||
108
public/api/pro/portals/publicMeta.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?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'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
91
public/api/pro/portals/submitForm.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?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'] ?? '');
|
||||||
|
$payload = [
|
||||||
|
'slug' => $slug,
|
||||||
|
'portalLabel' => $portal['label'] ?? '',
|
||||||
|
'folder' => $portal['folder'] ?? '',
|
||||||
|
'form' => [
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
'reference' => $reference,
|
||||||
|
'notes' => $notes,
|
||||||
|
],
|
||||||
|
'submittedBy' => $submittedBy,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||||
|
'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);
|
||||||
|
}
|
||||||
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 |
@@ -2,65 +2,45 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>FileRise</title>
|
|
||||||
|
|
||||||
<!-- Icons -->
|
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
|
||||||
|
|
||||||
<!-- App meta -->
|
|
||||||
<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="theme-color" content="#0b5ed7">
|
<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>
|
||||||
<!-- Minimal critical CSS only (keeps CSP clean, no inline JS) -->
|
<style id="pretheme-css">
|
||||||
<style>
|
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
|
||||||
.main-wrapper{display:none}
|
|
||||||
#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}
|
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
|
||||||
<!-- CSS: preload, then promote via tiny external JS (no inline onload) -->
|
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
|
||||||
<link rel="preload" as="style" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
|
||||||
<link rel="preload" as="style" href="/css/styles.css?v={{APP_QVER}}">
|
<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">
|
||||||
<!-- Fonts: preload only those used above the fold -->
|
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
|
||||||
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
|
||||||
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
|
||||||
<!-- Do NOT preload material icons unless needed above the fold -->
|
|
||||||
|
|
||||||
<!-- Non-blocking stylesheet promotion (external to satisfy CSP) -->
|
|
||||||
<script src="/js/defer-css.js?v={{APP_QVER}}" defer></script>
|
|
||||||
|
|
||||||
|
<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.">
|
||||||
<!-- Base CSS as a fallback if JS is disabled -->
|
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
|
||||||
<noscript>
|
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
|
||||||
</noscript>
|
<!-- Critical CSS -->
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
<!-- Preload font CSS (non-blocking) -->
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
<link rel="preload" as="style" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
<link rel="preload" as="style" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
|
|
||||||
|
<!-- Fonts (ok to keep as real preloads) -->
|
||||||
<!-- Vendor JS (keep defer; they’re not modules) -->
|
<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>
|
||||||
|
|
||||||
|
<!-- Vendor & version (deferred) -->
|
||||||
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
<!-- IMPORTANT: Remove CodeMirror here; lazy-load it inside your editor route/module. -->
|
|
||||||
|
|
||||||
<!-- Version marker (non-blocking) -->
|
|
||||||
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- App entry -->
|
||||||
<!-- App entry: start fetching early, execute after parse -->
|
<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">
|
||||||
<header class="header-container">
|
<header class="header-container">
|
||||||
|
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a href="index.html">
|
<a href="index.html">
|
||||||
<div class="header-logo">
|
<div class="header-logo">
|
||||||
@@ -68,19 +48,21 @@
|
|||||||
src="/assets/logo.svg?v={{APP_QVER}}"
|
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||||
alt="FileRise"
|
alt="FileRise"
|
||||||
class="logo"
|
class="logo"
|
||||||
width="50" height="50"
|
width="50"
|
||||||
|
height="50"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="high"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-title">
|
<div class="header-title">
|
||||||
<h1 data-i18n-key="header_title">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;">
|
<div class="header-buttons-wrapper" style="display: flex; align-items: center; gap: 10px;">
|
||||||
<!-- Your header drop zone -->
|
|
||||||
<div id="headerDropArea" class="header-drop-zone"></div>
|
<div id="headerDropArea" class="header-drop-zone"></div>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
|
||||||
@@ -115,7 +97,7 @@
|
|||||||
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
|
||||||
<i class="material-icons">person_remove</i>
|
<i class="material-icons">person_remove</i>
|
||||||
</button>
|
</button>
|
||||||
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
|
<button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode" hidden>
|
||||||
<span class="material-icons" id="darkModeIcon">
|
<span class="material-icons" id="darkModeIcon">
|
||||||
dark_mode
|
dark_mode
|
||||||
</span>
|
</span>
|
||||||
@@ -124,15 +106,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="loadingOverlay"></div>
|
<div id="loadingOverlay"></div>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
<main id="main">
|
<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">
|
||||||
|
<div id="loginBox" class="login-box">
|
||||||
|
<div id="fr-login-tip" class="alert alert-info login-hint" role="status" aria-live="polite" style="display:none;"></div>
|
||||||
|
|
||||||
<form id="authForm" method="post">
|
<form id="authForm" method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="loginUsername" data-i18n-key="user">User:</label>
|
<label for="loginUsername" data-i18n-key="user">User:</label>
|
||||||
@@ -158,13 +143,14 @@
|
|||||||
HTTP
|
HTTP
|
||||||
Login</a>
|
Login</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper" hidden>
|
||||||
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
|
||||||
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
<div id="sidebarDropArea" class="drop-target-sidebar"></div>
|
||||||
<!-- Main Column -->
|
<!-- Main Column -->
|
||||||
@@ -266,6 +252,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>
|
||||||
@@ -288,14 +277,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
<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);">
|
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);border-radius:8px;max-width:320px;line-height:1.35;">
|
||||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
<style>
|
||||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
/* Dark mode polish */
|
||||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
body.dark-mode #folderHelpTooltip {
|
||||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
|
||||||
subfolder.</li>
|
}
|
||||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
|
||||||
the appropriate button.</li>
|
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
|
||||||
|
</style>
|
||||||
|
<ul class="folder-help-list">
|
||||||
|
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
|
||||||
|
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.</li>
|
||||||
|
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
|
||||||
|
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
|
||||||
|
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
|
||||||
|
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
|
||||||
|
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
|
||||||
|
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,6 +365,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 -->
|
||||||
@@ -474,6 +477,26 @@
|
|||||||
</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>
|
||||||
|
<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>
|
||||||
@@ -505,7 +528,19 @@
|
|||||||
</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>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
302
public/js/adminPanelStyles.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
// Admin panel inline CSS moved out of adminPanel.js
|
||||||
|
// This file is imported for its side effects only.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
if (document.getElementById('adminPanelStyles')) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'adminPanelStyles';
|
||||||
|
style.textContent = `
|
||||||
|
/* Modal sizing */
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
max-width: 1100px;
|
||||||
|
width: 50%;
|
||||||
|
background: #fff !important;
|
||||||
|
color: #000 !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelModal .modal-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal header */
|
||||||
|
#adminPanelModal .modal-header {
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.15);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#adminPanelModal .modal-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
#adminPanelModal .modal-title .admin-title-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal body layout */
|
||||||
|
#adminPanelModal .modal-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelModal .modal-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar nav */
|
||||||
|
#adminPanelSidebar {
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
border-right: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#adminPanelSidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link .material-icons {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link.active {
|
||||||
|
background: rgba(0, 123, 255, 0.08);
|
||||||
|
border-color: rgba(0, 123, 255, 0.3);
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
#adminPanelSidebar .nav-link:hover {
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
#adminPanelContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.admin-section-title .material-icons {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.admin-section-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-field-group {
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
.admin-field-group label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.admin-field-group small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-inline-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(0,0,0,0.03);
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.admin-badge .material-icons {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.admin-table-sm {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.admin-table-sm th,
|
||||||
|
.admin-table-sm td {
|
||||||
|
padding: 0.35rem 0.4rem !important;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch alignment */
|
||||||
|
.form-check.form-switch .form-check-input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pro license textarea */
|
||||||
|
#proLicenseInput {
|
||||||
|
font-family: var(--filr-font-mono, monospace);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pro info alert */
|
||||||
|
#proLicenseStatus {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Client portals */
|
||||||
|
#clientPortalsBody .portal-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submissions list */
|
||||||
|
#clientPortalsBody .portal-submissions {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-empty {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-item {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
#clientPortalsBody .portal-submissions-meta {
|
||||||
|
opacity: 0.75;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
.dark-mode #adminPanelModal .modal-content {
|
||||||
|
background: #121212 !important;
|
||||||
|
color: #f5f5f5 !important;
|
||||||
|
border-color: rgba(255,255,255,0.15) !important;
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelModal .modal-header {
|
||||||
|
border-bottom-color: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar {
|
||||||
|
border-right-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link {
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.dark-mode #adminPanelSidebar .nav-link.active {
|
||||||
|
background: rgba(13,110,253,0.3);
|
||||||
|
border-color: rgba(13,110,253,0.7);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.dark-mode .admin-section-subtitle {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-field-group small {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-badge {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode .admin-table-sm tbody tr:hover td {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-row {
|
||||||
|
border-bottom-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-meta {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-submissions {
|
||||||
|
border-top-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.dark-mode #clientPortalsBody .portal-submissions-empty {
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -76,7 +90,8 @@ export function initializeApp() {
|
|||||||
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';
|
||||||
|
|
||||||
// Load public site config early (safe subset)
|
// Load public site config early (safe subset)
|
||||||
loadAdminConfigFunc();
|
loadAdminConfigFunc();
|
||||||
@@ -84,27 +99,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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ 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: 500px;
|
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;
|
||||||
@@ -352,30 +351,73 @@ 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');
|
const dispLegend = document.createElement('legend');
|
||||||
dispLegend.textContent = t('display');
|
dispLegend.textContent = t('display');
|
||||||
dispFs.appendChild(dispLegend);
|
dispFs.appendChild(dispLegend);
|
||||||
const dispLabel = document.createElement('label');
|
|
||||||
dispLabel.style.cursor = 'pointer';
|
// 1) Show folder strip above list
|
||||||
const dispCb = document.createElement('input');
|
const stripLabel = document.createElement('label');
|
||||||
dispCb.type = 'checkbox';
|
stripLabel.style.cursor = 'pointer';
|
||||||
dispCb.id = 'showFoldersInList';
|
stripLabel.style.display = 'block';
|
||||||
dispCb.style.verticalAlign = 'middle';
|
stripLabel.style.marginBottom = '4px';
|
||||||
const stored = localStorage.getItem('showFoldersInList');
|
|
||||||
dispCb.checked = stored === null ? true : stored === 'true';
|
const stripCb = document.createElement('input');
|
||||||
dispLabel.appendChild(dispCb);
|
stripCb.type = 'checkbox';
|
||||||
dispLabel.append(` ${t('show_folders_above_files')}`);
|
stripCb.id = 'showFoldersInList';
|
||||||
dispFs.appendChild(dispLabel);
|
stripCb.style.verticalAlign = 'middle';
|
||||||
|
|
||||||
|
{
|
||||||
|
const storedStrip = localStorage.getItem('showFoldersInList');
|
||||||
|
// default: unchecked
|
||||||
|
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);
|
||||||
|
// you’ll want a string like this in i18n:
|
||||||
|
// "show_inline_folders": "Show folders inline (above files)"
|
||||||
|
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
|
||||||
|
dispFs.appendChild(inlineLabel);
|
||||||
|
|
||||||
content.appendChild(dispFs);
|
content.appendChild(dispFs);
|
||||||
|
|
||||||
dispCb.addEventListener('change', () => {
|
// Handlers: toggle + refresh list
|
||||||
window.showFoldersInList = dispCb.checked;
|
stripCb.addEventListener('change', () => {
|
||||||
localStorage.setItem('showFoldersInList', dispCb.checked);
|
window.showFoldersInList = stripCb.checked;
|
||||||
// re‐load the entire file list (and strip) in one go:
|
localStorage.setItem('showFoldersInList', stripCb.checked);
|
||||||
loadFileList(window.currentFolder);
|
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');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// wire up image‐input change
|
// wire up image‐input change
|
||||||
@@ -426,6 +468,18 @@ 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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// show
|
// show
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe)
|
// /public/js/defer-css.js
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise.
|
||||||
// Promote any preloaded core CSS
|
(function () {
|
||||||
document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => {
|
if (window.__CSS_PROMISE__) return;
|
||||||
const href = link.getAttribute('href');
|
|
||||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')]
|
|
||||||
.some(s => s.getAttribute('href') === href)) return;
|
|
||||||
const sheet = document.createElement('link');
|
|
||||||
sheet.rel = 'stylesheet';
|
|
||||||
sheet.href = href;
|
|
||||||
document.head.appendChild(sheet);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
var loads = [];
|
||||||
|
|
||||||
// Optionally load non-critical icon/extra font CSS after first paint:
|
// Promote <link rel="preload" as="style"> IN-PLACE
|
||||||
const extra = document.createElement('link');
|
var preloads = document.querySelectorAll('link[rel="preload"][as="style"]');
|
||||||
extra.rel = 'stylesheet';
|
for (var i = 0; i < preloads.length; i++) {
|
||||||
extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
|
var l = preloads[i];
|
||||||
document.head.appendChild(extra);
|
// resolve when it finishes loading as a stylesheet
|
||||||
});
|
loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); }));
|
||||||
|
l.rel = 'stylesheet';
|
||||||
|
if (!l.media || l.media === 'print') l.media = 'all'; // be explicit
|
||||||
|
l.removeAttribute('as'); // keep some engines happy about "used" preload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also wait for any existing <link rel="stylesheet"> that haven't finished yet
|
||||||
|
var styles = document.querySelectorAll('link[rel="stylesheet"]');
|
||||||
|
for (var j = 0; j < styles.length; j++) {
|
||||||
|
var s = styles[j];
|
||||||
|
if (s.sheet) continue; // already applied
|
||||||
|
loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari quirk: nudge layout so promoted sheets apply immediately
|
||||||
|
void document.documentElement.offsetHeight;
|
||||||
|
|
||||||
|
window.__CSS_PROMISE__ = Promise.all(loads);
|
||||||
|
})();
|
||||||
@@ -156,15 +156,15 @@ 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="hide-small 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>${t("actions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -283,9 +283,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,52 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 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 +93,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 +166,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 +176,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 +187,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 +313,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 +348,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 +370,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 +682,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 +735,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"));
|
||||||
}
|
}
|
||||||
@@ -694,10 +870,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 +899,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;
|
||||||
@@ -2,124 +2,163 @@
|
|||||||
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 } 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) {
|
||||||
|
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).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// thresholds for editor behavior
|
// thresholds for editor behavior
|
||||||
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||||
@@ -14,7 +15,7 @@ const CM_BASE = "/vendor/codemirror/5.65.5/";
|
|||||||
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`;
|
||||||
|
|
||||||
const CORE = {
|
const CORE = {
|
||||||
js: coreUrl("codemirror.min.js"),
|
js: coreUrl("codemirror.min.js"),
|
||||||
css: coreUrl("codemirror.min.css"),
|
css: coreUrl("codemirror.min.css"),
|
||||||
themeCss: coreUrl("theme/material-darker.min.css"),
|
themeCss: coreUrl("theme/material-darker.min.css"),
|
||||||
};
|
};
|
||||||
@@ -22,30 +23,30 @@ const CORE = {
|
|||||||
// Which mode file to load for a given name/mime
|
// Which mode file to load for a given name/mime
|
||||||
const MODE_URL = {
|
const MODE_URL = {
|
||||||
// core/common
|
// core/common
|
||||||
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
"xml": "mode/xml/xml.min.js?v={{APP_QVER}}",
|
||||||
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
"css": "mode/css/css.min.js?v={{APP_QVER}}",
|
||||||
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
"javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// meta / combos
|
// meta / combos
|
||||||
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}",
|
||||||
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
"application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// docs / data
|
// docs / data
|
||||||
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
"markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}",
|
||||||
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
"yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}",
|
||||||
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
"properties": "mode/properties/properties.min.js?v={{APP_QVER}}",
|
||||||
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
"sql": "mode/sql/sql.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// shells
|
// shells
|
||||||
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
"shell": "mode/shell/shell.min.js?v={{APP_QVER}}",
|
||||||
|
|
||||||
// languages
|
// languages
|
||||||
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
"python": "mode/python/python.min.js?v={{APP_QVER}}",
|
||||||
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
"text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
"text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
"text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
"text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}",
|
||||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mode dependency graph
|
// Mode dependency graph
|
||||||
@@ -64,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,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);
|
||||||
}
|
}
|
||||||
@@ -133,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() : "";
|
||||||
@@ -195,29 +519,48 @@ 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();
|
||||||
|
|
||||||
const folderUsed = folder || window.currentFolder || "root";
|
const folderUsed = folder || window.currentFolder || "root";
|
||||||
const folderPath = folderUsed === "root"
|
const fileUrl = buildPreviewUrl(folderUsed, fileName);
|
||||||
? "uploads/"
|
|
||||||
: "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/";
|
|
||||||
const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime();
|
|
||||||
|
|
||||||
fetch(fileUrl, { method: "HEAD" })
|
if (await shouldUseOnlyOffice(fileName)) {
|
||||||
.then(response => {
|
await openOnlyOffice(fileName, folderUsed);
|
||||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
return;
|
||||||
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
|
}
|
||||||
|
|
||||||
|
// Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET.
|
||||||
|
async function probeSize(url) {
|
||||||
|
try {
|
||||||
|
const h = await fetch(url, { method: "HEAD", credentials: "include" });
|
||||||
|
const len = h.headers.get("content-length") ?? h.headers.get("Content-Length");
|
||||||
|
if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10);
|
||||||
|
} catch { }
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Range: "bytes=0-0" },
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
// Content-Range: bytes 0-0/12345
|
||||||
|
const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range");
|
||||||
|
const m = cr && cr.match(/\/(\d+)\s*$/);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
} catch { }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
probeSize(fileUrl)
|
||||||
|
.then(sizeBytes => {
|
||||||
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
|
||||||
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
|
||||||
throw new Error("File too large.");
|
throw new Error("File too large.");
|
||||||
}
|
}
|
||||||
return response;
|
return fetch(fileUrl, { credentials: "include" });
|
||||||
})
|
})
|
||||||
.then(() => fetch(fileUrl))
|
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
if (!response.ok) throw new Error("HTTP error! Status: " + response.status);
|
||||||
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length");
|
||||||
@@ -269,8 +612,8 @@ export function editFile(fileName, folder) {
|
|||||||
// Keep buttons responsive even before editor exists
|
// Keep buttons responsive even before editor exists
|
||||||
const decBtn = document.getElementById("decreaseFont");
|
const decBtn = document.getElementById("decreaseFont");
|
||||||
const incBtn = document.getElementById("increaseFont");
|
const incBtn = document.getElementById("increaseFont");
|
||||||
decBtn.addEventListener("click", () => {});
|
decBtn.addEventListener("click", () => { });
|
||||||
incBtn.addEventListener("click", () => {});
|
incBtn.addEventListener("click", () => { });
|
||||||
|
|
||||||
// Theme + mode selection
|
// Theme + mode selection
|
||||||
const isDarkMode = document.body.classList.contains("dark-mode");
|
const isDarkMode = document.body.classList.contains("dark-mode");
|
||||||
@@ -301,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
|
||||||
@@ -345,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);
|
||||||
@@ -355,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,157 +1,246 @@
|
|||||||
// 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 { previewFile } from './filePreview.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 { 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 } 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) {
|
const MENU_ID = 'fileContextMenu';
|
||||||
let menu = document.getElementById("fileContextMenu");
|
|
||||||
if (!menu) {
|
function qMenu() { return document.getElementById(MENU_ID); }
|
||||||
menu = document.createElement("div");
|
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
|
||||||
menu.id = "fileContextMenu";
|
|
||||||
menu.style.position = "fixed";
|
// One-time: localize labels
|
||||||
menu.style.backgroundColor = "#fff";
|
function localizeMenu() {
|
||||||
menu.style.border = "1px solid #ccc";
|
const m = qMenu(); if (!m) return;
|
||||||
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
|
const map = {
|
||||||
menu.style.zIndex = "9999";
|
'create_file': 'create_file',
|
||||||
menu.style.padding = "5px 0";
|
'delete_selected': 'delete_selected',
|
||||||
menu.style.minWidth = "150px";
|
'copy_selected': 'copy_selected',
|
||||||
document.body.appendChild(menu);
|
'move_selected': 'move_selected',
|
||||||
}
|
'download_zip': 'download_zip',
|
||||||
menu.innerHTML = "";
|
'extract_zip': 'extract_zip',
|
||||||
menuItems.forEach(item => {
|
'tag_selected': 'tag_selected',
|
||||||
let menuItem = document.createElement("div");
|
'preview': 'preview',
|
||||||
menuItem.textContent = item.label;
|
'edit': 'edit',
|
||||||
menuItem.style.padding = "5px 15px";
|
'rename': 'rename',
|
||||||
menuItem.style.cursor = "pointer";
|
'tag_file': 'tag_file'
|
||||||
menuItem.addEventListener("mouseover", () => {
|
};
|
||||||
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
|
Object.entries(map).forEach(([action, key]) => {
|
||||||
});
|
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||||
menuItem.addEventListener("mouseout", () => {
|
if (el) setText(el, key);
|
||||||
menuItem.style.backgroundColor = "";
|
|
||||||
});
|
|
||||||
menuItem.addEventListener("click", () => {
|
|
||||||
item.action();
|
|
||||||
hideFileContextMenu();
|
|
||||||
});
|
|
||||||
menu.appendChild(menuItem);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
menu.style.left = x + "px";
|
|
||||||
menu.style.top = y + "px";
|
// Show/hide items based on selection state
|
||||||
menu.style.display = "block";
|
function configureVisibility({ any, one, many, anyZip, canEdit }) {
|
||||||
|
const m = qMenu(); if (!m) return;
|
||||||
const menuRect = menu.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
|
||||||
if (menuRect.bottom > viewportHeight) {
|
|
||||||
let newTop = viewportHeight - menuRect.height;
|
show(m.querySelectorAll('[data-when="always"]'), true);
|
||||||
if (newTop < 0) newTop = 0;
|
show(m.querySelectorAll('[data-when="any"]'), any);
|
||||||
menu.style.top = newTop + "px";
|
show(m.querySelectorAll('[data-when="one"]'), one);
|
||||||
|
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);
|
||||||
let menuItems = [
|
placeMenu(e.clientX, e.clientY);
|
||||||
{ label: t("create_file"), action: () => openCreateFileModal() },
|
|
||||||
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
|
// Stash for click handlers
|
||||||
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
|
window.__filr_ctx_state = state;
|
||||||
{ 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";
|
|
||||||
const folderPath = folder === "root"
|
|
||||||
? "uploads/"
|
|
||||||
: "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/";
|
|
||||||
previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), 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 '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");
|
(function () {
|
||||||
if (menu && menu.style.display === "block") {
|
const orig = window.renderFileTable;
|
||||||
hideFileContextMenu();
|
if (typeof orig === 'function') {
|
||||||
|
window.renderFileTable = function (folder) {
|
||||||
|
orig(folder);
|
||||||
|
bindFileListContextMenu();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// If not present yet, bind once DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Rebind context menu after file table render.
|
|
||||||
(function() {
|
|
||||||
const originalRenderFileTable = window.renderFileTable;
|
|
||||||
window.renderFileTable = function(folder) {
|
|
||||||
originalRenderFileTable(folder);
|
|
||||||
bindFileListContextMenu();
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,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 +268,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 +302,42 @@ 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"
|
||||||
},
|
},
|
||||||
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.",
|
||||||
|
|||||||
@@ -61,6 +61,53 @@ async function ensureToastReady() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDemoHost() {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoginTip(message) {
|
||||||
|
const tip = document.getElementById('fr-login-tip');
|
||||||
|
if (!tip) return;
|
||||||
|
tip.innerHTML = ''; // clear
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
tip.append(document.createTextNode(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideOverlaySmoothly(overlay) {
|
||||||
|
if (!overlay) return;
|
||||||
|
try { await document.fonts?.ready; } catch { }
|
||||||
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function wireModalEnterDefault() {
|
function wireModalEnterDefault() {
|
||||||
if (window.__FR_FLAGS.wired.enterDefault) return;
|
if (window.__FR_FLAGS.wired.enterDefault) return;
|
||||||
window.__FR_FLAGS.wired.enterDefault = true;
|
window.__FR_FLAGS.wired.enterDefault = true;
|
||||||
@@ -198,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) {
|
||||||
@@ -288,7 +361,6 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
|||||||
let stored = null;
|
let stored = null;
|
||||||
try { stored = localStorage.getItem('darkMode'); } catch { }
|
try { stored = localStorage.getItem('darkMode'); } catch { }
|
||||||
|
|
||||||
// If no stored pref, fall back to system
|
|
||||||
let isDark = (stored === null)
|
let isDark = (stored === null)
|
||||||
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
: (stored === '1' || stored === 'true');
|
: (stored === '1' || stored === 'true');
|
||||||
@@ -302,15 +374,26 @@ function applyDarkMode({ fromSystemChange = false } = {}) {
|
|||||||
el.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
el.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// keep UA chrome & bg consistent post-toggle
|
||||||
|
const bg = isDark ? '#121212' : '#ffffff';
|
||||||
|
root.style.backgroundColor = bg;
|
||||||
|
root.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
|
if (body) {
|
||||||
|
body.style.backgroundColor = bg;
|
||||||
|
body.style.colorScheme = isDark ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
const mt = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (mt) mt.content = bg;
|
||||||
|
const mcs = document.querySelector('meta[name="color-scheme"]');
|
||||||
|
if (mcs) mcs.content = isDark ? 'dark light' : 'light dark';
|
||||||
|
|
||||||
const btn = document.getElementById('darkModeToggle');
|
const btn = document.getElementById('darkModeToggle');
|
||||||
const icon = document.getElementById('darkModeIcon');
|
const icon = document.getElementById('darkModeIcon');
|
||||||
if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode';
|
if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode';
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode');
|
const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode');
|
||||||
const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode');
|
const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode');
|
||||||
const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode'));
|
const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode'));
|
||||||
|
|
||||||
btn.classList.toggle('active', isDark);
|
btn.classList.toggle('active', isDark);
|
||||||
btn.setAttribute('aria-label', aria);
|
btn.setAttribute('aria-label', aria);
|
||||||
btn.setAttribute('title', isDark ? ttOff : ttOn);
|
btn.setAttribute('title', isDark ? ttOff : ttOn);
|
||||||
@@ -347,6 +430,9 @@ function bindDarkMode() {
|
|||||||
// ---------- tiny utils ----------
|
// ---------- tiny utils ----------
|
||||||
const $ = (s, root = document) => root.querySelector(s);
|
const $ = (s, root = document) => root.querySelector(s);
|
||||||
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
|
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
|
||||||
|
// Safe show/hide that work with both CSS and [hidden]
|
||||||
|
const unhide = (el) => { if (!el) return; el.removeAttribute('hidden'); el.style.display = ''; };
|
||||||
|
const hideEl = (el) => { if (!el) return; el.setAttribute('hidden', ''); el.style.display = 'none'; };
|
||||||
const show = (el) => {
|
const show = (el) => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.hidden = false; el.classList?.remove('d-none', 'hidden');
|
el.hidden = false; el.classList?.remove('d-none', 'hidden');
|
||||||
@@ -360,28 +446,142 @@ function bindDarkMode() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------- site config / auth ----------
|
// ---------- site config / auth ----------
|
||||||
function applySiteConfig(cfg) {
|
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)
|
||||||
document.title = title;
|
document.title = title;
|
||||||
const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = title;
|
// --- Header logo (branding) in BOTH phases ---
|
||||||
|
try {
|
||||||
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
|
const customLogoUrl = branding.customLogoUrl || "";
|
||||||
|
const logoImg = document.querySelector('.header-logo img');
|
||||||
|
if (logoImg) {
|
||||||
|
if (customLogoUrl) {
|
||||||
|
logoImg.setAttribute('src', customLogoUrl);
|
||||||
|
logoImg.setAttribute('alt', 'Site logo');
|
||||||
|
} else {
|
||||||
|
// fall back to default FileRise logo
|
||||||
|
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||||
|
logoImg.setAttribute('alt', 'FileRise');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// non-fatal; ignore branding issues
|
||||||
|
}
|
||||||
|
// --- Header colors (branding) in BOTH phases ---
|
||||||
|
try {
|
||||||
|
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
const light = branding.headerBgLight || '';
|
||||||
|
const dark = branding.headerBgDark || '';
|
||||||
|
|
||||||
|
if (light) root.style.setProperty('--header-bg-light', light);
|
||||||
|
else root.style.removeProperty('--header-bg-light');
|
||||||
|
|
||||||
|
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||||
|
else root.style.removeProperty('--header-bg-dark');
|
||||||
|
} catch (e) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Login options (apply in BOTH phases so login page is correct) ---
|
||||||
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
|
||||||
const disableForm = !!lo.disableFormLogin;
|
|
||||||
const disableOIDC = !!lo.disableOIDCLogin;
|
|
||||||
const disableBasic = !!lo.disableBasicAuth;
|
|
||||||
|
|
||||||
const row = $('#loginForm'); if (row) row.style.display = disableForm ? 'none' : '';
|
|
||||||
|
// be tolerant to key variants just in case
|
||||||
|
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
|
||||||
|
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
|
||||||
|
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
|
||||||
|
|
||||||
|
const showForm = !disableForm;
|
||||||
|
const showOIDC = !disableOIDC;
|
||||||
|
const showBasic = !disableBasic;
|
||||||
|
|
||||||
|
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
|
||||||
|
const authForm = $('#authForm'); // inner username/password form
|
||||||
|
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
|
||||||
|
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
|
|
||||||
|
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
|
||||||
|
if (loginWrap) {
|
||||||
|
const anyMethod = showForm || showOIDC || showBasic;
|
||||||
|
if (anyMethod) {
|
||||||
|
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
|
||||||
|
loginWrap.style.display = ''; // let CSS decide
|
||||||
|
} else {
|
||||||
|
loginWrap.setAttribute('hidden', '');
|
||||||
|
loginWrap.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Toggle the pieces inside the wrapper
|
||||||
|
if (authForm) authForm.style.display = showForm ? '' : 'none';
|
||||||
|
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
|
||||||
|
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
|
||||||
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
|
||||||
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
|
||||||
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
if (basic) basic.style.display = disableBasic ? 'none' : '';
|
||||||
|
|
||||||
|
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
|
||||||
|
if (phase === 'final') {
|
||||||
|
const h1 = document.querySelector('.header-title h1');
|
||||||
|
if (h1) {
|
||||||
|
// prevent i18n or legacy from overwriting it
|
||||||
|
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
|
||||||
|
|
||||||
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
|
|
||||||
|
// lock it so late code can't stomp it
|
||||||
|
if (!h1.__titleLock) {
|
||||||
|
const mo = new MutationObserver(() => {
|
||||||
|
if (h1.textContent !== title) h1.textContent = title;
|
||||||
|
});
|
||||||
|
mo.observe(h1, { childList: true, characterData: true, subtree: true });
|
||||||
|
h1.__titleLock = mo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readyToReveal() {
|
||||||
|
// Wait for CSS + fonts so the first revealed frame is fully styled
|
||||||
|
try { await (window.__CSS_PROMISE__ || Promise.resolve()); } catch { }
|
||||||
|
try { await document.fonts?.ready; } catch { }
|
||||||
|
// Give layout one paint to settle
|
||||||
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealAppAndHideOverlay() {
|
||||||
|
const appRoot = document.getElementById('appRoot');
|
||||||
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
await readyToReveal();
|
||||||
|
if (appRoot) appRoot.style.visibility = 'visible';
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.transition = 'opacity .18s ease-out';
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => { overlay.style.display = 'none'; }, 220);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSiteConfig() {
|
async function loadSiteConfig() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
const r = await fetch('/api/siteConfig.php', { credentials: 'include' });
|
||||||
const j = await r.json().catch(() => ({})); applySiteConfig(j);
|
const j = await r.json().catch(() => ({}));
|
||||||
} catch { applySiteConfig({}); }
|
window.__FR_SITE_CFG__ = j || {};
|
||||||
|
window.__FR_DEMO__ = !!(window.__FR_SITE_CFG__.demoMode);
|
||||||
|
// Early pass: title + login options (skip touching <h1> to avoid flicker)
|
||||||
|
applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' });
|
||||||
|
return window.__FR_SITE_CFG__;
|
||||||
|
} catch {
|
||||||
|
window.__FR_SITE_CFG__ = {};
|
||||||
|
window.__FR_DEMO__ = false;
|
||||||
|
applySiteConfig({}, { phase: 'early' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function primeCsrf() {
|
async function primeCsrf() {
|
||||||
try {
|
try {
|
||||||
@@ -631,7 +831,6 @@ function bindDarkMode() {
|
|||||||
function forceLoginVisible() {
|
function forceLoginVisible() {
|
||||||
show($('#main'));
|
show($('#main'));
|
||||||
show($('#loginForm'));
|
show($('#loginForm'));
|
||||||
hide($('.main-wrapper'));
|
|
||||||
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
|
const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden';
|
||||||
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
|
const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -732,6 +931,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 }) => {
|
||||||
@@ -775,8 +987,7 @@ function bindDarkMode() {
|
|||||||
window.__FR_FLAGS.booted = true;
|
window.__FR_FLAGS.booted = true;
|
||||||
ensureToastReady();
|
ensureToastReady();
|
||||||
// show chrome
|
// show chrome
|
||||||
const wrap = document.querySelector('.main-wrapper'); if (wrap) { wrap.hidden = false; wrap.classList?.remove('d-none', 'hidden'); wrap.style.display = 'block'; }
|
|
||||||
const lf = document.getElementById('loginForm'); if (lf) lf.style.display = 'none';
|
|
||||||
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
|
const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible';
|
||||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
|
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex';
|
||||||
|
|
||||||
@@ -791,6 +1002,9 @@ function bindDarkMode() {
|
|||||||
window.__FR_AUTH_STATE = state;
|
window.__FR_AUTH_STATE = state;
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
|
// authed → heavy boot path
|
||||||
|
document.body.classList.add('authed');
|
||||||
|
|
||||||
// 1) i18n (safe)
|
// 1) i18n (safe)
|
||||||
// i18n: honor saved language first, then apply translations
|
// i18n: honor saved language first, then apply translations
|
||||||
try {
|
try {
|
||||||
@@ -806,10 +1020,20 @@ function bindDarkMode() {
|
|||||||
if (!window.__FR_FLAGS.initialized) {
|
if (!window.__FR_FLAGS.initialized) {
|
||||||
if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken();
|
if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken();
|
||||||
if (typeof app.initializeApp === 'function') app.initializeApp();
|
if (typeof app.initializeApp === 'function') app.initializeApp();
|
||||||
|
const darkBtn = document.getElementById('darkModeToggle');
|
||||||
|
if (darkBtn) {
|
||||||
|
darkBtn.removeAttribute('hidden');
|
||||||
|
darkBtn.style.setProperty('display', 'inline-flex', 'important'); // beats any CSS
|
||||||
|
darkBtn.style.visibility = ''; // just in case
|
||||||
|
}
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = '/css/vendor/material-icons.css?v={{APP_QVER}}';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
|
||||||
window.__FR_FLAGS.initialized = true;
|
window.__FR_FLAGS.initialized = true;
|
||||||
|
|
||||||
// Show "Welcome back, <username>!" only once per tab-session
|
|
||||||
try {
|
try {
|
||||||
if (!sessionStorage.getItem('__fr_welcomed')) {
|
if (!sessionStorage.getItem('__fr_welcomed')) {
|
||||||
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
|
const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || '';
|
||||||
@@ -830,7 +1054,7 @@ function bindDarkMode() {
|
|||||||
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
|
auth.applyProxyBypassUI && auth.applyProxyBypassUI();
|
||||||
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
|
auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state);
|
||||||
|
|
||||||
// ⬇️ bind ALL the admin / change-password buttons once
|
// bind ALL the admin / change-password buttons once
|
||||||
if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') {
|
if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') {
|
||||||
try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); }
|
try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); }
|
||||||
window.__FR_FLAGS.wired.authInit = true;
|
window.__FR_FLAGS.wired.authInit = true;
|
||||||
@@ -879,38 +1103,134 @@ function bindDarkMode() {
|
|||||||
|
|
||||||
// ---------- entry (no flicker: decide state BEFORE showing login) ----------
|
// ---------- entry (no flicker: decide state BEFORE showing login) ----------
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
|
|
||||||
if (window.__FR_FLAGS.entryStarted) return;
|
if (window.__FR_FLAGS.entryStarted) return;
|
||||||
window.__FR_FLAGS.entryStarted = true;
|
window.__FR_FLAGS.entryStarted = true;
|
||||||
|
|
||||||
|
// Always start clean
|
||||||
|
document.body.classList.remove('authed');
|
||||||
|
|
||||||
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
const wrap = document.querySelector('.main-wrapper'); // app shell
|
||||||
|
const mainEl = document.getElementById('main'); // contains loginForm
|
||||||
|
const login = document.getElementById('loginForm');
|
||||||
|
|
||||||
bindDarkMode();
|
bindDarkMode();
|
||||||
await loadSiteConfig();
|
await loadSiteConfig();
|
||||||
|
|
||||||
const { authed, setup } = await checkAuth();
|
const { authed, setup } = await checkAuth();
|
||||||
|
|
||||||
if (setup) { await bootSetupWizard(); return; }
|
if (setup) {
|
||||||
if (authed) { await bootHeavy(); return; }
|
// Setup wizard runs inside app shell
|
||||||
|
unhide(wrap);
|
||||||
|
hideEl(login);
|
||||||
|
await bootSetupWizard();
|
||||||
|
await revealAppAndHideOverlay();
|
||||||
|
|
||||||
// login view
|
return;
|
||||||
show(document.querySelector('#main'));
|
}
|
||||||
show(document.querySelector('#loginForm'));
|
|
||||||
(document.querySelector('.header-buttons') || {}).style && (document.querySelector('.header-buttons').style.visibility = 'hidden');
|
if (authed) {
|
||||||
const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none';
|
// Authenticated path: show app, hide login
|
||||||
|
document.body.classList.add('authed');
|
||||||
|
unhide(wrap); // works whether CSS or [hidden] was used
|
||||||
|
hideEl(login);
|
||||||
|
await bootHeavy();
|
||||||
|
await revealAppAndHideOverlay();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const pre = document.getElementById('pretheme-css');
|
||||||
|
if (pre) pre.remove();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- NOT AUTHED: show only the login view ----
|
||||||
|
hideEl(wrap); // ensure app shell stays hidden while logged out
|
||||||
|
unhide(mainEl);
|
||||||
|
unhide(login);
|
||||||
|
if (login) login.style.display = '';
|
||||||
|
// …wire stuff…
|
||||||
|
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();
|
||||||
|
const hb = document.querySelector('.header-buttons');
|
||||||
|
if (hb) hb.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
// keep app cards inert while logged out (no layout poke)
|
||||||
['uploadCard', 'folderManagementCard'].forEach(id => {
|
['uploadCard', 'folderManagementCard'].forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.display = 'none';
|
|
||||||
el.setAttribute('aria-hidden', 'true');
|
el.setAttribute('aria-hidden', 'true');
|
||||||
try { el.inert = true; } catch { }
|
try { el.inert = true; } catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
bindLogin();
|
bindLogin();
|
||||||
wireCreateDropdown();
|
wireCreateDropdown();
|
||||||
keepCreateDropdownWired();
|
keepCreateDropdownWired();
|
||||||
wireModalEnterDefault();
|
wireModalEnterDefault();
|
||||||
|
showLoginTip('Please log in to continue');
|
||||||
|
|
||||||
await ensureToastReady();
|
if (overlay) overlay.style.display = 'none';
|
||||||
window.showToast('please_log_in_to_continue', 6000);
|
}, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
}, { once: true }); // <— important
|
|
||||||
|
// --- 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);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
382
public/js/portal-login.js
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// 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');
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
716
public/js/portal.js
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
// public/js/portal.js
|
||||||
|
// Standalone client portal logic – no imports from main app JS to avoid DOM coupling.
|
||||||
|
|
||||||
|
let portal = null;
|
||||||
|
let portalFormDone = false;
|
||||||
|
|
||||||
|
// --- Portal helpers: folder + download flag -----------------
|
||||||
|
function portalFolder() {
|
||||||
|
if (!portal) return 'root';
|
||||||
|
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
function portalCanDownload() {
|
||||||
|
if (!portal) return false;
|
||||||
|
|
||||||
|
// Prefer explicit flags if present
|
||||||
|
if (typeof portal.allowDownload !== 'undefined') {
|
||||||
|
return !!portal.allowDownload;
|
||||||
|
}
|
||||||
|
if (typeof portal.allowDownloads !== 'undefined') {
|
||||||
|
return !!portal.allowDownloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: uploadOnly = true => no downloads
|
||||||
|
if (typeof portal.uploadOnly !== 'undefined') {
|
||||||
|
return !portal.uploadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: allow downloads
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- DOM helpers / status -----------------
|
||||||
|
function qs(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
const el = qs('portalStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || '';
|
||||||
|
el.classList.toggle('text-danger', !!isError);
|
||||||
|
if (!isError) {
|
||||||
|
el.classList.add('text-muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Form submit -----------------
|
||||||
|
async function submitPortalForm(slug, formData) {
|
||||||
|
const payload = {
|
||||||
|
slug,
|
||||||
|
form: formData
|
||||||
|
};
|
||||||
|
const headers = { 'X-CSRF-Token': getCsrfToken() || '' };
|
||||||
|
const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers);
|
||||||
|
if (!res || !res.success) {
|
||||||
|
throw new Error((res && res.error) || 'Error saving form.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Toast -----------------
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.getElementById('customToast');
|
||||||
|
if (!toast) {
|
||||||
|
console.warn('Toast:', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.style.display = 'block';
|
||||||
|
// Force reflow
|
||||||
|
void toast.offsetWidth;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.display = 'none';
|
||||||
|
}, 200);
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Fetch wrapper -----------------
|
||||||
|
async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { ...customHeaders }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data && !(data instanceof FormData)) {
|
||||||
|
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
} else if (data instanceof FormData) {
|
||||||
|
options.body = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
const text = await res.text();
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
payload = text;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw payload;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Portal form wiring -----------------
|
||||||
|
function setupPortalForm(slug) {
|
||||||
|
const formSection = qs('portalFormSection');
|
||||||
|
const uploadSection = qs('portalUploadSection');
|
||||||
|
|
||||||
|
if (!portal || !portal.requireForm) {
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = 'portalFormDone:' + slug;
|
||||||
|
if (sessionStorage.getItem(key) === '1') {
|
||||||
|
portalFormDone = true;
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
portalFormDone = false;
|
||||||
|
if (formSection) formSection.style.display = 'block';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '0.5';
|
||||||
|
|
||||||
|
const nameEl = qs('portalFormName');
|
||||||
|
const emailEl = qs('portalFormEmail');
|
||||||
|
const refEl = qs('portalFormReference');
|
||||||
|
const notesEl = qs('portalFormNotes');
|
||||||
|
const submitBtn = qs('portalFormSubmit');
|
||||||
|
|
||||||
|
const fd = portal.formDefaults || {};
|
||||||
|
|
||||||
|
if (nameEl && fd.name && !nameEl.value) {
|
||||||
|
nameEl.value = fd.name;
|
||||||
|
}
|
||||||
|
if (emailEl && fd.email && !emailEl.value) {
|
||||||
|
emailEl.value = fd.email;
|
||||||
|
} else if (emailEl && portal.clientEmail && !emailEl.value) {
|
||||||
|
// fallback to clientEmail
|
||||||
|
emailEl.value = portal.clientEmail;
|
||||||
|
}
|
||||||
|
if (refEl && fd.reference && !refEl.value) {
|
||||||
|
refEl.value = fd.reference;
|
||||||
|
}
|
||||||
|
if (notesEl && fd.notes && !notesEl.value) {
|
||||||
|
notesEl.value = fd.notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!submitBtn) return;
|
||||||
|
|
||||||
|
submitBtn.onclick = async () => {
|
||||||
|
const name = nameEl ? nameEl.value.trim() : '';
|
||||||
|
const email = emailEl ? emailEl.value.trim() : '';
|
||||||
|
const reference = refEl ? refEl.value.trim() : '';
|
||||||
|
const notes = notesEl ? notesEl.value.trim() : '';
|
||||||
|
|
||||||
|
const req = portal.formRequired || {};
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
if (req.name && !name) missing.push('name');
|
||||||
|
if (req.email && !email) missing.push('email');
|
||||||
|
if (req.reference && !reference) missing.push('reference');
|
||||||
|
if (req.notes && !notes) missing.push('notes');
|
||||||
|
|
||||||
|
if (missing.length) {
|
||||||
|
showToast('Please fill in: ' + missing.join(', ') + '.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default behavior when no specific required flags:
|
||||||
|
if (!req.name && !req.email && !req.reference && !req.notes) {
|
||||||
|
if (!name && !email) {
|
||||||
|
showToast('Please provide at least a name or email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitPortalForm(slug, { name, email, reference, notes });
|
||||||
|
portalFormDone = true;
|
||||||
|
sessionStorage.setItem(key, '1');
|
||||||
|
if (formSection) formSection.style.display = 'none';
|
||||||
|
if (uploadSection) uploadSection.style.opacity = '1';
|
||||||
|
showToast('Thank you. You can now upload files.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast('Error saving your info. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- CSRF helpers -----------------
|
||||||
|
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() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Auth -----------------
|
||||||
|
async function ensureAuthenticated() {
|
||||||
|
try {
|
||||||
|
const data = await sendRequest('/api/auth/checkAuth.php', 'GET');
|
||||||
|
if (!data || !data.username) {
|
||||||
|
// redirect to main UI/login; after login, user can re-open portal link
|
||||||
|
const target = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = '/portal-login.html?redirect=' + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lbl = qs('portalUserLabel');
|
||||||
|
if (lbl) {
|
||||||
|
lbl.textContent = data.username || '';
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
const target = encodeURIComponent(window.location.href);
|
||||||
|
window.location.href = '/portal-login.html?redirect=' + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Portal fetch + render -----------------
|
||||||
|
async function fetchPortal(slug) {
|
||||||
|
setStatus('Loading portal details…');
|
||||||
|
try {
|
||||||
|
const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET');
|
||||||
|
if (!data || !data.success || !data.portal) {
|
||||||
|
throw new Error((data && data.error) || 'Portal not found.');
|
||||||
|
}
|
||||||
|
portal = data.portal;
|
||||||
|
return portal;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus('This portal could not be found or is no longer available.', true);
|
||||||
|
showToast('Portal not found or expired.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPortalInfo() {
|
||||||
|
if (!portal) return;
|
||||||
|
const titleEl = qs('portalTitle');
|
||||||
|
const descEl = qs('portalDescription');
|
||||||
|
const subtitleEl = qs('portalSubtitle');
|
||||||
|
const brandEl = document.getElementById('portalBrandHeading');
|
||||||
|
const footerEl = document.getElementById('portalFooter');
|
||||||
|
const drop = qs('portalDropzone');
|
||||||
|
const card = document.querySelector('.portal-card');
|
||||||
|
const formBtn = qs('portalFormSubmit');
|
||||||
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
const filesSection = qs('portalFilesSection');
|
||||||
|
|
||||||
|
const heading = portal.title && portal.title.trim()
|
||||||
|
? portal.title.trim()
|
||||||
|
: (portal.label || portal.slug || 'Client portal');
|
||||||
|
|
||||||
|
if (titleEl) titleEl.textContent = heading;
|
||||||
|
if (brandEl) brandEl.textContent = heading;
|
||||||
|
|
||||||
|
if (descEl) {
|
||||||
|
if (portal.introText && portal.introText.trim()) {
|
||||||
|
descEl.textContent = portal.introText.trim();
|
||||||
|
} else {
|
||||||
|
const folder = portalFolder();
|
||||||
|
descEl.textContent = 'Files you upload here go directly into: ' + folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleEl) {
|
||||||
|
const parts = [];
|
||||||
|
if (portal.uploadOnly) parts.push('upload only');
|
||||||
|
if (portalCanDownload()) parts.push('download allowed');
|
||||||
|
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footerEl) {
|
||||||
|
footerEl.textContent = portal.footerText && portal.footerText.trim()
|
||||||
|
? portal.footerText.trim()
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = portal.brandColor && portal.brandColor.trim();
|
||||||
|
if (color) {
|
||||||
|
// expose brand color as a CSS variable for gallery styling
|
||||||
|
document.documentElement.style.setProperty('--portal-accent', color);
|
||||||
|
|
||||||
|
if (drop) {
|
||||||
|
drop.style.borderColor = color;
|
||||||
|
}
|
||||||
|
if (card) {
|
||||||
|
card.style.borderTop = '3px solid ' + color;
|
||||||
|
}
|
||||||
|
if (formBtn) {
|
||||||
|
formBtn.style.backgroundColor = color;
|
||||||
|
formBtn.style.borderColor = color;
|
||||||
|
}
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.style.borderColor = color;
|
||||||
|
refreshBtn.style.color = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide files section based on download capability
|
||||||
|
if (filesSection) {
|
||||||
|
filesSection.style.display = portalCanDownload() ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- File helpers for gallery -----------------
|
||||||
|
function formatFileSizeLabel(f) {
|
||||||
|
// API currently returns f.size as a human-readable string, so prefer that
|
||||||
|
if (f && f.size) return f.size;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExtLabel(name) {
|
||||||
|
if (!name) return 'FILE';
|
||||||
|
const parts = name.split('.');
|
||||||
|
if (parts.length < 2) return 'FILE';
|
||||||
|
const ext = parts.pop().trim().toUpperCase();
|
||||||
|
if (!ext) return 'FILE';
|
||||||
|
return ext.length <= 4 ? ext : ext.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageName(name) {
|
||||||
|
if (!name) return false;
|
||||||
|
return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Load files for portal gallery -----------------
|
||||||
|
async function loadPortalFiles() {
|
||||||
|
if (!portal || !portalCanDownload()) return;
|
||||||
|
|
||||||
|
const listEl = qs('portalFilesList');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folder = portalFolder();
|
||||||
|
const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET');
|
||||||
|
if (!data || data.error) {
|
||||||
|
const msg = (data && data.error) ? data.error : 'Error loading files.';
|
||||||
|
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize files: handle both array and object-return shapes
|
||||||
|
let files = [];
|
||||||
|
if (Array.isArray(data.files)) {
|
||||||
|
files = data.files;
|
||||||
|
} else if (data.files && typeof data.files === 'object') {
|
||||||
|
files = Object.entries(data.files).map(([name, meta]) => {
|
||||||
|
const f = meta || {};
|
||||||
|
f.name = name;
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = portal.brandColor && portal.brandColor.trim();
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
listEl.classList.add('portal-files-grid'); // gallery layout
|
||||||
|
|
||||||
|
const MAX = 24;
|
||||||
|
const slice = files.slice(0, MAX);
|
||||||
|
|
||||||
|
slice.forEach(f => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'portal-file-card';
|
||||||
|
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'portal-file-card-icon';
|
||||||
|
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'portal-file-card-main';
|
||||||
|
|
||||||
|
const nameEl = document.createElement('div');
|
||||||
|
nameEl.className = 'portal-file-card-name';
|
||||||
|
nameEl.textContent = f.name || 'Unnamed file';
|
||||||
|
|
||||||
|
const metaEl = document.createElement('div');
|
||||||
|
metaEl.className = 'portal-file-card-meta text-muted';
|
||||||
|
metaEl.textContent = formatFileSizeLabel(f);
|
||||||
|
|
||||||
|
main.appendChild(nameEl);
|
||||||
|
main.appendChild(metaEl);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'portal-file-card-actions';
|
||||||
|
|
||||||
|
// Thumbnail vs extension badge
|
||||||
|
const fname = f.name || '';
|
||||||
|
const folder = portalFolder();
|
||||||
|
|
||||||
|
if (isImageName(fname)) {
|
||||||
|
const thumbUrl =
|
||||||
|
'/api/file/download.php?folder=' +
|
||||||
|
encodeURIComponent(folder) +
|
||||||
|
'&file=' + encodeURIComponent(fname) +
|
||||||
|
'&inline=1&t=' + Date.now();
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = thumbUrl;
|
||||||
|
img.alt = fname;
|
||||||
|
// 🔧 constrain image so it doesn't fill the whole list
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.maxHeight = '120px';
|
||||||
|
img.style.objectFit = 'cover';
|
||||||
|
img.style.display = 'block';
|
||||||
|
img.style.borderRadius = '6px';
|
||||||
|
|
||||||
|
icon.appendChild(img);
|
||||||
|
} else {
|
||||||
|
icon.textContent = fileExtLabel(fname);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accent) {
|
||||||
|
icon.style.borderColor = accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/file/download.php?folder=' +
|
||||||
|
encodeURIComponent(folder) +
|
||||||
|
'&file=' + encodeURIComponent(fname);
|
||||||
|
a.textContent = 'Download';
|
||||||
|
a.className = 'portal-file-card-download';
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener';
|
||||||
|
actions.appendChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(icon);
|
||||||
|
card.appendChild(main);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
listEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length > MAX) {
|
||||||
|
const more = document.createElement('div');
|
||||||
|
more.className = 'portal-files-more text-muted';
|
||||||
|
more.textContent = 'And ' + (files.length - MAX) + ' more…';
|
||||||
|
listEl.appendChild(more);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Upload -----------------
|
||||||
|
async function uploadFiles(fileList) {
|
||||||
|
if (!portal || !fileList || !fileList.length) return;
|
||||||
|
if (portal.requireForm && !portalFormDone) {
|
||||||
|
showToast('Please fill in your details before uploading.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(fileList);
|
||||||
|
const folder = portalFolder();
|
||||||
|
|
||||||
|
setStatus('Uploading ' + files.length + ' file(s)…');
|
||||||
|
let successCount = 0;
|
||||||
|
let failureCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const form = new FormData();
|
||||||
|
|
||||||
|
const csrf = getCsrfToken() || '';
|
||||||
|
|
||||||
|
// Match main upload.js
|
||||||
|
form.append('file[]', file);
|
||||||
|
form.append('folder', folder);
|
||||||
|
if (csrf) {
|
||||||
|
form.append('upload_token', csrf); // legacy alias, but your controller supports it
|
||||||
|
}
|
||||||
|
|
||||||
|
let retried = false;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/upload/upload.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-Token': csrf || ''
|
||||||
|
},
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await resp.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.csrf_expired && data.csrf_token) {
|
||||||
|
setCsrfToken(data.csrf_token);
|
||||||
|
if (!retried) {
|
||||||
|
retried = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok || (data && data.error)) {
|
||||||
|
failureCount++;
|
||||||
|
console.error('Upload error:', data || text);
|
||||||
|
} else {
|
||||||
|
successCount++;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Upload error:', e);
|
||||||
|
failureCount++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount && !failureCount) {
|
||||||
|
setStatus('Uploaded ' + successCount + ' file(s).');
|
||||||
|
showToast('Upload complete.');
|
||||||
|
} else if (successCount && failureCount) {
|
||||||
|
setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true);
|
||||||
|
showToast('Some files failed to upload.');
|
||||||
|
} else {
|
||||||
|
setStatus('Upload failed.', true);
|
||||||
|
showToast('Upload failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
loadPortalFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Upload UI wiring -----------------
|
||||||
|
function wireUploadUI() {
|
||||||
|
const drop = qs('portalDropzone');
|
||||||
|
const input = qs('portalFileInput');
|
||||||
|
const refreshBtn = qs('portalRefreshBtn');
|
||||||
|
|
||||||
|
if (drop && input) {
|
||||||
|
drop.addEventListener('click', () => input.click());
|
||||||
|
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
uploadFiles(files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(ev => {
|
||||||
|
drop.addEventListener(ev, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
drop.classList.add('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(ev => {
|
||||||
|
drop.addEventListener(ev, e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
drop.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
drop.addEventListener('drop', e => {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
if (!dt || !dt.files || !dt.files.length) return;
|
||||||
|
uploadFiles(dt.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => {
|
||||||
|
loadPortalFiles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- Slug + init -----------------
|
||||||
|
function getPortalSlugFromUrl() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
// 1) Normal case: slug is directly in query (?slug=portal-xxxxx)
|
||||||
|
let slug = url.searchParams.get('slug');
|
||||||
|
if (slug && slug.trim()) {
|
||||||
|
return slug.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Pretty URL: /portal/<slug>
|
||||||
|
// e.g. /portal/portal-h46ozd
|
||||||
|
const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i);
|
||||||
|
if (pathMatch && pathMatch[1]) {
|
||||||
|
return pathMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Fallback: slug inside redirect param
|
||||||
|
// e.g. ?redirect=/portal.html?slug=portal-h46ozd
|
||||||
|
const redirect = url.searchParams.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
const innerSlug = redirectUrl.searchParams.get('slug');
|
||||||
|
if (innerSlug && innerSlug.trim()) {
|
||||||
|
return innerSlug.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = redirect.match(/[?&]slug=([^&]+)/);
|
||||||
|
if (m && m[1]) {
|
||||||
|
return decodeURIComponent(m[1]).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Final fallback: old regex on our own query string
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m2 = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : '';
|
||||||
|
} catch {
|
||||||
|
const qs = window.location.search || '';
|
||||||
|
const m = qs.match(/[?&]slug=([^&]+)/);
|
||||||
|
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initPortal() {
|
||||||
|
const slug = getPortalSlugFromUrl();
|
||||||
|
if (!slug) {
|
||||||
|
setStatus('Missing portal slug.', true);
|
||||||
|
showToast('Portal slug missing in URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadCsrfToken();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('CSRF load failed (may be fine if unauthenticated yet).', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await ensureAuthenticated();
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
const p = await fetchPortal(slug);
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
renderPortalInfo();
|
||||||
|
setupPortalForm(slug);
|
||||||
|
wireUploadUI();
|
||||||
|
|
||||||
|
if (portalCanDownload()) {
|
||||||
|
loadPortalFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initPortal().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setStatus('Unexpected error initializing portal.', true);
|
||||||
|
showToast('Unexpected error loading portal.');
|
||||||
|
});
|
||||||
|
});
|
||||||
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,243 @@ 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 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 +317,35 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustFolderHelpExpansion() {
|
function adjustFolderHelpExpansion() {
|
||||||
@@ -437,6 +684,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 +703,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 +721,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 +742,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,7 +764,7 @@ 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);
|
||||||
@@ -519,8 +774,40 @@ async function initResumableUpload() {
|
|||||||
|
|
||||||
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 +839,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 +876,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 +898,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 +922,15 @@ 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();
|
||||||
}, 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 +954,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 +1007,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 +1043,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 +1057,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 +1106,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 +1136,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,7 +1181,7 @@ 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.`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
@@ -858,7 +1190,6 @@ if (finishedCount === allFiles.length) {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loadFolderTree(window.currentFolder);
|
loadFolderTree(window.currentFolder);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -867,9 +1198,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 +1218,79 @@ 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)
|
const hasResumableFiles =
|
||||||
const first = files[0];
|
useResumable &&
|
||||||
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
|
resumableInstance &&
|
||||||
if (useResumable && !isFolderish) {
|
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 +1302,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.3';
|
window.APP_VERSION = 'v2.0.4';
|
||||||
|
|||||||
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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
146
public/portal-login.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<!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 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>
|
||||||
362
public/portal.html
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<!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;
|
||||||
|
}
|
||||||
|
</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 class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormName">Name</label>
|
||||||
|
<input type="text" id="portalFormName" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormEmail">Email</label>
|
||||||
|
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:6px;">
|
||||||
|
<label for="portalFormReference">Reference / Case / Order #</label>
|
||||||
|
<input type="text" id="portalFormReference" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:8px;">
|
||||||
|
<label for="portalFormNotes">Notes</label>
|
||||||
|
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="portalUploadSection">
|
||||||
|
<div id="portalDropzone" class="portal-dropzone">
|
||||||
|
<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="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
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 535 KiB |
BIN
resources/dark-client-portal1.png
Normal file
|
After Width: | Height: | Size: 488 KiB |
BIN
resources/dark-client-portal2.png
Normal file
|
After Width: | Height: | Size: 387 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 |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 581 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 698 KiB |
BIN
resources/dark-user-groups.png
Normal file
|
After Width: | Height: | Size: 501 KiB |
BIN
resources/filerise-v2.0.0.png
Normal file
|
After Width: | Height: | Size: 737 KiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |