Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f |
215
.github/workflows/release-on-version.yml
vendored
@@ -6,160 +6,79 @@ on:
|
||||
branches: ["master"]
|
||||
paths:
|
||||
- public/js/version.js
|
||||
workflow_run:
|
||||
workflows: ["Bump version and sync Changelog to Docker Repo"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref (branch or SHA) to build from (default: origin/master)"
|
||||
description: "Ref (branch/sha) to build from (default: master)"
|
||||
required: false
|
||||
version:
|
||||
description: "Explicit version tag to release (e.g., v1.8.6). If empty, auto-detect."
|
||||
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
delay:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delay 10 minutes
|
||||
run: sleep 600
|
||||
|
||||
release:
|
||||
needs: delay
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Guard: Only run on trusted workflow_run events (pushes from this repo)
|
||||
if: >
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
github.event_name == 'workflow_dispatch'
|
||||
|
||||
# Use run_id for a stable, unique key
|
||||
concurrency:
|
||||
group: release-${{ github.run_id }}
|
||||
group: release-${{ github.event_name }}-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
steps:
|
||||
- name: Checkout (fetch all)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure tags + master available
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch --tags --force --prune --quiet
|
||||
git fetch origin master --quiet
|
||||
|
||||
- name: Resolve source ref + (maybe) version
|
||||
- name: Resolve source ref
|
||||
id: pickref
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
REF=""
|
||||
VER=""
|
||||
SRC=""
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
# manual run
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
VER_IN="${{ github.event.inputs.version }}"
|
||||
if [[ -n "$REF_IN" ]]; then
|
||||
# Try branch/sha; fetch branch if needed
|
||||
git fetch origin "$REF_IN" --quiet || true
|
||||
if REF_SHA="$(git rev-parse --verify --quiet "$REF_IN")"; then
|
||||
REF="$REF_SHA"
|
||||
else
|
||||
echo "Provided ref '$REF_IN' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
|
||||
REF_IN="${{ github.event.inputs.ref }}"
|
||||
else
|
||||
REF="$(git rev-parse origin/master)"
|
||||
REF_IN="master"
|
||||
fi
|
||||
if [[ -n "$VER_IN" ]]; then
|
||||
VER="$VER_IN"
|
||||
SRC="manual-version"
|
||||
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
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
REF="${{ github.event.workflow_run.head_sha }}"
|
||||
else
|
||||
REF="${{ github.sha }}"
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
echo "Using ref=$REF"
|
||||
|
||||
# If no explicit version, try to find the latest bot bump reachable from REF
|
||||
if [[ -z "$VER" ]]; then
|
||||
# Search recent history reachable from REF
|
||||
BOT_SHA="$(git log "$REF" -n 200 --author='github-actions[bot]' --grep='set APP_VERSION to v' --pretty=%H | head -n1 || true)"
|
||||
if [[ -n "$BOT_SHA" ]]; then
|
||||
SUBJ="$(git log -n1 --pretty=%s "$BOT_SHA")"
|
||||
BOT_VER="$(sed -n 's/.*set APP_VERSION to \(v[^ ]*\).*/\1/p' <<<"${SUBJ}")"
|
||||
if [[ -n "$BOT_VER" ]]; then
|
||||
VER="$BOT_VER"
|
||||
REF="$BOT_SHA" # build/tag from the bump commit
|
||||
SRC="bot-commit"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output
|
||||
REF_SHA="$(git rev-parse "$REF")"
|
||||
echo "ref=$REF_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "source=${SRC:-event-ref}" >> "$GITHUB_OUTPUT"
|
||||
echo "preversion=${VER}" >> "$GITHUB_OUTPUT"
|
||||
echo "Using source=${SRC:-event-ref} ref=$REF_SHA"
|
||||
if [[ -n "$VER" ]]; then echo "Pre-resolved version=$VER"; fi
|
||||
|
||||
- name: Checkout chosen ref
|
||||
- name: Checkout chosen ref (full history + tags, no persisted token)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.pickref.outputs.ref }}
|
||||
|
||||
- name: Assert ref is on master
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REF="${{ steps.pickref.outputs.ref }}"
|
||||
git fetch origin master --quiet
|
||||
if ! git merge-base --is-ancestor "$REF" origin/master; then
|
||||
echo "Ref $REF is not on master; refusing to release."
|
||||
exit 78
|
||||
fi
|
||||
|
||||
- name: Debug version.js provenance
|
||||
shell: bash
|
||||
run: |
|
||||
echo "version.js last-change commit: $(git log -n1 --pretty='%h %s' -- public/js/version.js || echo 'none')"
|
||||
sed -n '1,20p' public/js/version.js || true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Prefer pre-resolved version (manual input or bot commit)
|
||||
if [[ -n "${{ steps.pickref.outputs.preversion }}" ]]; then
|
||||
VER="${{ steps.pickref.outputs.preversion }}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (pre-resolved): $VER"
|
||||
exit 0
|
||||
fi
|
||||
# Fallback to version.js
|
||||
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 version.js" >&2
|
||||
exit 1
|
||||
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
|
||||
VER="${{ github.event.inputs.version }}"
|
||||
else
|
||||
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
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "Parsed version (file): $VER"
|
||||
echo "Detected version: $VER"
|
||||
|
||||
- name: Skip if tag already exists
|
||||
id: tagcheck
|
||||
@@ -173,7 +92,7 @@ jobs:
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prep stamper script
|
||||
- name: Prepare stamp script
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -181,7 +100,7 @@ jobs:
|
||||
sed -i 's/\r$//' scripts/stamp-assets.sh || true
|
||||
chmod +x scripts/stamp-assets.sh
|
||||
|
||||
- name: Build zip artifact (stamped)
|
||||
- name: Build stamped staging tree
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -195,27 +114,67 @@ jobs:
|
||||
./ 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'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ROOT="$(pwd)/staging"
|
||||
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
|
||||
echo "---- DEBUG (show 10 hits with context) ----"
|
||||
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
|
||||
echo "Unreplaced placeholders found in staging." >&2
|
||||
exit 1
|
||||
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'
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -223,7 +182,7 @@ jobs:
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
|
||||
|
||||
- name: Compute SHA-256 checksum
|
||||
- name: Compute SHA-256
|
||||
if: steps.tagcheck.outputs.exists == 'false'
|
||||
id: sum
|
||||
shell: bash
|
||||
@@ -268,9 +227,9 @@ jobs:
|
||||
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
|
||||
fi
|
||||
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'
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
157
CHANGELOG.md
@@ -1,5 +1,162 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
|
||||
32
README.md
@@ -21,15 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
|
||||
|
||||
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.
|
||||
|
||||
New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
|
||||
|
||||
**10/25/2025 Video demo:**
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -326,21 +324,6 @@ 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.
|
||||
|
||||
---
|
||||
@@ -404,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
## Security posture
|
||||
|
||||
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
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.
|
||||
@@ -445,18 +430,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
||||
|
||||
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
|
||||
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
|
||||
– Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
|
||||
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
|
||||
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
|
||||
|
||||
#### Security / CSP
|
||||
|
||||
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
|
||||
|
||||
### PHP Libraries
|
||||
|
||||
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
|
||||
@@ -478,7 +456,7 @@ If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
Options -Indexes -Multiviews
|
||||
DirectoryIndex index.html
|
||||
|
||||
# Allow PATH_INFO for routes like /webdav.php/foo/bar
|
||||
AcceptPathInfo On
|
||||
|
||||
# ---------------- Security: dotfiles ----------------
|
||||
<IfModule mod_authz_core.c>
|
||||
# Block direct access to dotfiles like .env, .gitignore, etc.
|
||||
@@ -24,10 +27,14 @@ RewriteRule - - [L]
|
||||
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
|
||||
RewriteRule "(^|/)\.(?!well-known/)" - [F]
|
||||
|
||||
# 2) Deny direct access to PHP outside /api/
|
||||
# This stops scanners from hitting /index.php, /admin.php, /wso.php, etc.
|
||||
RewriteCond %{REQUEST_URI} !^/api/
|
||||
RewriteRule \.php$ - [F]
|
||||
# 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]
|
||||
|
||||
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']);
|
||||
}
|
||||
30
public/api/folder/isEmpty.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// public/api/folder/isEmpty.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/FolderController.php';
|
||||
|
||||
// Snapshot then release session lock so parallel requests don’t block
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
// Input
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
||||
|
||||
// Delegate to controller (model handles ACL + path safety)
|
||||
$result = FolderController::stats($folder, $user, $perms);
|
||||
|
||||
// Always return a compact JSON object like before
|
||||
echo json_encode([
|
||||
'folders' => (int)($result['folders'] ?? 0),
|
||||
'files' => (int)($result['files'] ?? 0),
|
||||
]);
|
||||
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']);
|
||||
}
|
||||
@@ -62,6 +62,51 @@ body {
|
||||
@media (max-width: 600px) {
|
||||
.zones-toggle { left: 85px !important; }
|
||||
}
|
||||
|
||||
/* Optional tokens */
|
||||
:root{
|
||||
--filr-accent-500:#008CB4; /* base */
|
||||
--filr-accent-600:#00789A; /* hover */
|
||||
--filr-accent-700:#006882; /* active/border */
|
||||
--filr-accent-ring:rgba(0,140,180,.4);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn-color-folder{
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:var(--filr-accent-500);
|
||||
border:1px solid var(--filr-accent-700);
|
||||
color:#fff; /* ensure white text */
|
||||
}
|
||||
.btn-color-folder .material-icons{
|
||||
color:currentColor; /* makes icon white too */
|
||||
}
|
||||
|
||||
.btn-color-folder:hover,
|
||||
.btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:active{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
|
||||
.btn-color-folder:focus-visible{
|
||||
outline:2px solid var(--filr-accent-ring);
|
||||
outline-offset:2px;
|
||||
}
|
||||
|
||||
/* Dark mode: start slightly deeper so it doesn't glow */
|
||||
.dark-mode .btn-color-folder{
|
||||
background:var(--filr-accent-600);
|
||||
border-color:var(--filr-accent-700);
|
||||
color:#fff;
|
||||
}
|
||||
.dark-mode .btn-color-folder:hover,
|
||||
.dark-mode .btn-color-folder:focus-visible{
|
||||
background:var(--filr-accent-700);
|
||||
}
|
||||
/* ===========================================================
|
||||
HEADER & NAVIGATION
|
||||
=========================================================== */
|
||||
@@ -142,13 +187,13 @@ body {
|
||||
border-radius: 4px !important;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
/* make the drop zone fill leftover space and right-align its own icons */
|
||||
#headerDropArea.header-drop-zone{
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 100px;
|
||||
|
||||
}
|
||||
#headerDropArea.header-drop-zone{
|
||||
display: flex;
|
||||
justify-content: flex-end; /* buttons to the right */
|
||||
align-items: center;
|
||||
min-height: 40px; /* so the label has room */
|
||||
}
|
||||
.header-buttons button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
@@ -694,11 +739,167 @@ body {
|
||||
width: 100% !important;
|
||||
}#fileList table tr:nth-child(even) {
|
||||
background-color: transparent;
|
||||
}#fileList table tr:hover {
|
||||
background-color: #e0e0e0;
|
||||
}.dark-mode #fileList table tr:hover {
|
||||
background-color: #444;
|
||||
}#fileListTitle {
|
||||
}
|
||||
/* --- File list rows: match folder-tree hover/selected --- */
|
||||
:root {
|
||||
--filr-row-hover-bg: rgba(122,179,255,.14);
|
||||
--filr-row-selected-bg: rgba(122,179,255,.24);
|
||||
}
|
||||
|
||||
/* Let cell corners round like a pill */
|
||||
#fileList table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* ===== Reset any conflicting backgrounds (Bootstrap etc.) inside #fileList only ===== */
|
||||
#fileList table tbody tr,
|
||||
#fileList table tbody tr > td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Kill Bootstrap hover/zebra just for this table */
|
||||
#fileList table.table-hover tbody tr:hover > * { background-color: transparent !important; }
|
||||
#fileList table.table-striped > tbody > tr:nth-of-type(odd) > * { background-color: transparent !important; }
|
||||
|
||||
/* ===== Our look, scoped to the table we tagged in JS ===== */
|
||||
#fileList table.filr-table tbody tr,
|
||||
#fileList table.filr-table tbody td {
|
||||
transition: background-color .12s ease;
|
||||
}
|
||||
|
||||
/* Hover (when not selected) */
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
}
|
||||
|
||||
/* Selected (support a few legacy class names just in case) */
|
||||
#fileList table.filr-table tbody tr.selected > td,
|
||||
#fileList table.filr-table tbody tr.row-selected > td,
|
||||
#fileList table.filr-table tbody tr.selected-row > td,
|
||||
#fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
/* Rounded “pill” ends on hover/selected */
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Keyboard focus visibility */
|
||||
#fileList table.filr-table tbody tr:focus-within > td {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: -2px;
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.dark-mode #fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
}
|
||||
.dark-mode #fileList table.filr-table tbody tr.selected > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.row-selected > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.selected-row > td,
|
||||
.dark-mode #fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
}
|
||||
|
||||
#fileList table.filr-table {
|
||||
--bs-table-hover-color: inherit;
|
||||
--bs-table-striped-color: inherit;
|
||||
}
|
||||
|
||||
#fileList table.table-hover tbody tr:hover,
|
||||
#fileList table.table-hover tbody tr:hover > td {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.dark-mode #fileList table.filr-table tbody td a,
|
||||
.dark-mode #fileList table.filr-table tbody td a:hover {
|
||||
color: inherit !important;
|
||||
}
|
||||
:root{
|
||||
--filr-row-outline: rgba(122,179,255,.45);
|
||||
--filr-row-outline-hover: rgba(122,179,255,.35);
|
||||
}
|
||||
|
||||
#fileList table.filr-table > :not(caption) > * > * { border: 0 !important; }
|
||||
#fileList table.filr-table td,
|
||||
#fileList table.filr-table th { box-shadow: none !important; }
|
||||
|
||||
#fileList table.filr-table tbody tr.selected > td,
|
||||
#fileList table.filr-table tbody tr.row-selected > td,
|
||||
#fileList table.filr-table tbody tr.selected-row > td,
|
||||
#fileList table.filr-table tbody tr.is-selected > td {
|
||||
background: var(--filr-row-selected-bg) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
}
|
||||
#fileList table.filr-table tbody tr.selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:first-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:first-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:first-child {
|
||||
box-shadow:
|
||||
inset 1px 0 0 var(--filr-row-outline),
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr.selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.row-selected > td:last-child,
|
||||
#fileList table.filr-table tbody tr.selected-row > td:last-child,
|
||||
#fileList table.filr-table tbody tr.is-selected > td:last-child {
|
||||
box-shadow:
|
||||
inset -1px 0 0 var(--filr-row-outline),
|
||||
inset 0 1px 0 var(--filr-row-outline),
|
||||
inset 0 -1px 0 var(--filr-row-outline);
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
|
||||
}
|
||||
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td {
|
||||
background: var(--filr-row-hover-bg) !important;
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:first-child {
|
||||
box-shadow:
|
||||
inset 1px 0 0 var(--filr-row-outline-hover),
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
border-top-left-radius: 8px; border-bottom-left-radius: 8px;
|
||||
}
|
||||
#fileList table.filr-table tbody tr:hover:not(.selected, .row-selected, .selected-row, .is-selected) > td:last-child {
|
||||
box-shadow:
|
||||
inset -1px 0 0 var(--filr-row-outline-hover),
|
||||
inset 0 1px 0 var(--filr-row-outline-hover),
|
||||
inset 0 -1px 0 var(--filr-row-outline-hover);
|
||||
border-top-right-radius: 8px; border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
#fileList table.filr-table tbody tr:focus-within > td { outline: none; }
|
||||
#fileList table.filr-table tbody tr:focus-within > td:first-child,
|
||||
#fileList table.filr-table tbody tr:focus-within > td:last-child {
|
||||
outline: 2px solid #8ab4f8; outline-offset: -2px;
|
||||
}
|
||||
|
||||
#fileListTitle {
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
@@ -801,14 +1002,17 @@ body {
|
||||
}
|
||||
#uploadForm {
|
||||
display: none;
|
||||
}.folder-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.folder-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding-top: 10px;
|
||||
}@media (min-width: 600px) and (max-width: 992px) {
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
margin: 0; /* no hacks needed */
|
||||
}
|
||||
@media (min-width: 600px) and (max-width: 992px) {
|
||||
.folder-actions {
|
||||
white-space: nowrap;
|
||||
}}
|
||||
@@ -821,10 +1025,8 @@ body {
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
vertical-align: -2px;
|
||||
}.folder-actions .btn + .btn {
|
||||
margin-left: 6px;
|
||||
}.folder-actions .btn {
|
||||
padding: 10px 12px;
|
||||
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.1;
|
||||
border-radius: 6px;
|
||||
@@ -834,7 +1036,7 @@ body {
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
will-change: transform;
|
||||
}.folder-actions .material-icons {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
vertical-align: -2px;
|
||||
transition: transform 120ms ease;
|
||||
}.folder-actions .btn:hover,
|
||||
@@ -954,7 +1156,7 @@ body {
|
||||
#fileListTitle {
|
||||
font-size: 1.8em;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 10px;
|
||||
}.file-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1132,14 +1334,14 @@ body {
|
||||
border-radius: 4px;
|
||||
}.folder-tree {
|
||||
list-style-type: none;
|
||||
padding-left: 10px;
|
||||
padding-left: 5px;
|
||||
margin: 0;
|
||||
}.folder-tree.collapsed {
|
||||
display: none;
|
||||
}.folder-tree.expanded {
|
||||
display: block;
|
||||
}.folder-item {
|
||||
margin: 4px 0;
|
||||
margin: 2px 0;
|
||||
display: block;
|
||||
}.folder-toggle {
|
||||
cursor: pointer;
|
||||
@@ -1149,9 +1351,10 @@ body {
|
||||
text-align: right;
|
||||
}.folder-indent-placeholder {
|
||||
display: inline-block;
|
||||
width: 30px;
|
||||
width: 5px;
|
||||
}#folderTreeContainer {
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
}.folder-option {
|
||||
cursor: pointer;
|
||||
}.folder-option:hover {
|
||||
@@ -1385,8 +1588,6 @@ body {
|
||||
}.dark-mode table {
|
||||
background-color: #2c2c2c;
|
||||
color: #e0e0e0;
|
||||
}.dark-mode table tr:hover {
|
||||
background-color: #444;
|
||||
}.dark-mode #uploadProgressContainer .progress {
|
||||
background-color: #333;
|
||||
}.dark-mode #uploadProgressContainer .progress-bar {
|
||||
@@ -1532,7 +1733,16 @@ body {
|
||||
.drag-header.active {
|
||||
width: 350px;
|
||||
height: 750px;
|
||||
}.main-column {
|
||||
}
|
||||
/* Fixed-width sidebar (always 350px) */
|
||||
#sidebarDropArea{
|
||||
width: 350px;
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
flex: 0 0 350px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.main-column {
|
||||
flex: 1;
|
||||
transition: margin-left 0.3s ease;
|
||||
}#uploadFolderRow {
|
||||
@@ -1600,8 +1810,8 @@ body {
|
||||
}#sidebarDropArea,
|
||||
#uploadFolderRow {
|
||||
background-color: transparent;
|
||||
|
||||
}.dark-mode #sidebarDropArea,
|
||||
}
|
||||
.dark-mode #sidebarDropArea,
|
||||
.dark-mode #uploadFolderRow {
|
||||
background-color: transparent;
|
||||
}.dark-mode #sidebarDropArea.highlight,
|
||||
@@ -1615,8 +1825,6 @@ body {
|
||||
border: none !important;
|
||||
}.dragging:focus {
|
||||
outline: none;
|
||||
}#sidebarDropArea > .card {
|
||||
margin-bottom: 1rem;
|
||||
}.card {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
@@ -1634,6 +1842,7 @@ body {
|
||||
}.custom-folder-card-body {
|
||||
padding-top: 5px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}#addUserModal,
|
||||
#removeUserModal {
|
||||
z-index: 5000 !important;
|
||||
@@ -1713,8 +1922,9 @@ body {
|
||||
border: 2px dashed #555;
|
||||
color: #fff;
|
||||
}.header-drop-zone.drag-active:empty::before {
|
||||
content: "Drop";
|
||||
content: "Drop Zone";
|
||||
font-size: 10px;
|
||||
padding-right: 6px;
|
||||
color: #aaa;
|
||||
}/* Disable text selection on rows to prevent accidental copying when shift-clicking */
|
||||
#fileList tbody tr.clickable-row {
|
||||
@@ -1820,34 +2030,74 @@ body {
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}.folder-strip-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-strip-container {
|
||||
display: flex;
|
||||
padding-top: 0px !important;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
gap: 10px 14px;
|
||||
align-content: flex-start; /* multi-line wrap stays top-aligned */
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-item {
|
||||
display: flex;
|
||||
padding-top: 0px !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: 80px;
|
||||
align-items: center; /* horizontal (cross-axis) center */
|
||||
justify-content: center; /* vertical (main-axis) center */
|
||||
min-width: 0;
|
||||
gap: 2px !important;
|
||||
padding: 6px 8px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
font-size: 0.85em;
|
||||
}.folder-strip-container .folder-item i.material-icons {
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
}.folder-strip-container .folder-name {
|
||||
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease;
|
||||
}
|
||||
.folder-strip-container .folder-item .folder-svg {
|
||||
line-height: 0;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.folder-strip-container .folder-item .folder-svg svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: block;
|
||||
}
|
||||
.folder-strip-container .folder-name {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
max-width: 80px;
|
||||
margin-top: 4px;
|
||||
}.folder-strip-container .folder-item i.material-icons {
|
||||
color: currentColor;
|
||||
}.folder-strip-container .folder-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}:root {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-item:hover {
|
||||
transform: translateY(-1px) scale(1.04);
|
||||
background-color: rgba(0, 0, 0, 0.04); /* light mode */
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
/* Dark mode hover */
|
||||
body.dark-mode .folder-strip-container .folder-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
/* Optional: keyboard focus */
|
||||
.folder-strip-container .folder-item:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--perm-caret: #444;
|
||||
}/* light */
|
||||
.dark-mode {
|
||||
@@ -1947,4 +2197,219 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#downloadProgressBarOuter { height: 10px; }
|
||||
#downloadProgressBarOuter { height: 10px; }
|
||||
|
||||
/* ===== Folder Tree – theme + structure ===== */
|
||||
#folderTreeContainer {
|
||||
/* Theme vars (light mode defaults) */
|
||||
--filr-folder-front: #f6b84e;
|
||||
--filr-folder-back: #ffd36e;
|
||||
--filr-folder-stroke:#a87312;
|
||||
--filr-paper-fill: #ffffff;
|
||||
--filr-paper-stroke: #9fb3d6; /* slightly darker for sharper paper */
|
||||
|
||||
/* Sizes */
|
||||
--row-h: 28px;
|
||||
--twisty: 24px;
|
||||
--twisty-gap: -5px;
|
||||
--icon-size: 24px;
|
||||
--icon-gap: 6px;
|
||||
--indent: 10px;
|
||||
}
|
||||
|
||||
|
||||
#folderTreeContainer .folder-item { position: static; padding-left: 0; }
|
||||
#folderTreeContainer .folder-item > .folder-tree { margin-left: var(--indent); }
|
||||
|
||||
#folderTreeContainer .folder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--row-h);
|
||||
height: auto;
|
||||
padding-left: calc(var(--twisty) + var(--twisty-gap));
|
||||
}
|
||||
|
||||
/* Chevron + spacer (centered vertically) */
|
||||
#folderTreeContainer .folder-row > button.folder-toggle,
|
||||
#folderTreeContainer .folder-row > .folder-spacer {
|
||||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: var(--twisty); height: var(--twisty);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid transparent; border-radius: 6px;
|
||||
background: transparent; cursor: pointer;
|
||||
}
|
||||
#folderTreeContainer .folder-row > button.folder-toggle::before {
|
||||
content: "▸"; font-size: calc(var(--twisty) * 0.8); line-height: 1;
|
||||
}
|
||||
#folderTreeContainer li[role="treeitem"][aria-expanded="true"]
|
||||
> .folder-row > button.folder-toggle::before,
|
||||
#rootRow[aria-expanded="true"] > button.folder-toggle::before { content: "▾"; }
|
||||
#folderTreeContainer .folder-row > button.folder-toggle:hover {
|
||||
border-color: color-mix(in srgb, #7ab3ff 35%, transparent);
|
||||
}
|
||||
|
||||
/* Row "pill" that hugs content and wraps */
|
||||
#folderTreeContainer .folder-option {
|
||||
display: inline-flex;
|
||||
flex: 0 1 auto; /* don't stretch full width */
|
||||
align-items: center;
|
||||
gap: var(--icon-gap);
|
||||
height: auto;
|
||||
min-height: var(--row-h);
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.2;
|
||||
user-select: none;
|
||||
max-width: 100%;
|
||||
white-space: normal; /* allow wrapping */
|
||||
}
|
||||
#folderTreeContainer .folder-option:hover {
|
||||
background: rgba(122,179,255,.14);
|
||||
}
|
||||
#folderTreeContainer .folder-option.selected {
|
||||
background: rgba(122,179,255,.24);
|
||||
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
|
||||
}
|
||||
|
||||
/* Label must be shrinkable so wrapping works */
|
||||
#folderTreeContainer .folder-label {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Icon box */
|
||||
#folderTreeContainer .folder-icon {
|
||||
flex: 0 0 var(--icon-size);
|
||||
width: var(--icon-size); height: var(--icon-size);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
#folderTreeContainer .folder-icon svg {
|
||||
width: 100%; height: 100%; display: block;
|
||||
shape-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
|
||||
/* Make folder tree outline match folder strip */
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: .5;
|
||||
paint-order: fill stroke;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
|
||||
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
#folderTreeContainer .folder-icon .paper {
|
||||
fill: var(--filr-paper-fill);
|
||||
stroke: var(--filr-paper-stroke);
|
||||
stroke-width: 1.5;
|
||||
paint-order: stroke fill;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .paper-fold { fill: var(--filr-paper-stroke); }
|
||||
#folderTreeContainer .folder-icon .paper-line {
|
||||
stroke: var(--filr-paper-stroke); stroke-width: 1.5;
|
||||
stroke-linecap: round; fill: none; opacity: .95;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lip-highlight {
|
||||
stroke: #ffffff; stroke-opacity: .35; stroke-width: .9;
|
||||
fill: none; vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon,
|
||||
#folderTreeContainer .folder-label { transform: none !important; }
|
||||
|
||||
/* ===== File List Strip – color the shared folderSVG() ===== */
|
||||
.folder-strip-container .folder-svg svg {
|
||||
display: block;
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
.folder-strip-container .folder-item {
|
||||
/* defaults — overridden per-tile via inline CSS vars set in JS */
|
||||
--filr-folder-front: #f6b84e;
|
||||
--filr-folder-back: #ffd36e;
|
||||
--filr-folder-stroke: #a87312;
|
||||
}
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back {
|
||||
fill: currentColor;
|
||||
stroke: var(--filr-folder-stroke);
|
||||
stroke-width: .5;
|
||||
paint-order: fill stroke;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.folder-strip-container .folder-svg .folder-front { color: var(--filr-folder-front); }
|
||||
.folder-strip-container .folder-svg .folder-back { color: var(--filr-folder-back); }
|
||||
|
||||
.folder-strip-container .folder-svg .paper {
|
||||
fill: #fff;
|
||||
stroke: #b2c2db; /* light mode */
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.folder-strip-container .folder-svg .paper-fold { fill: #b2c2db; }
|
||||
.folder-strip-container .folder-svg .paper-line {
|
||||
stroke: #b2c2db; stroke-width: 1; stroke-linecap: round; fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.folder-strip-container .folder-svg .lip-highlight {
|
||||
stroke: rgba(255,255,255,.45); stroke-width: .8; fill: none;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back,
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back,
|
||||
#folderTreeContainer .folder-icon .lip-highlight,
|
||||
.folder-strip-container .folder-svg .lip-highlight {
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Make sure we’re not forcing crispEdges anywhere */
|
||||
.folder-strip-container .folder-svg svg,
|
||||
#folderTreeContainer .folder-icon svg { shape-rendering: geometricPrecision !important; }
|
||||
|
||||
@media (max-resolution: 1.5dppx) {
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back { stroke-width: .6; }
|
||||
}
|
||||
|
||||
|
||||
/* Scribble (the handwriting line) */
|
||||
#folderTreeContainer .folder-icon .paper-ink,
|
||||
.folder-strip-container .folder-svg .paper-ink {
|
||||
stroke: #4da3ff;
|
||||
stroke-width: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
opacity: .85;
|
||||
paint-order: normal;
|
||||
|
||||
}
|
||||
|
||||
/* tree @ 24px icon */
|
||||
#folderTreeContainer .folder-icon .folder-front,
|
||||
#folderTreeContainer .folder-icon .folder-back,
|
||||
#folderTreeContainer .folder-icon .paper-line,
|
||||
#folderTreeContainer .folder-icon .paper-ink,
|
||||
#folderTreeContainer .folder-icon .lip-highlight { stroke-width: .6px; }
|
||||
|
||||
/* strip @ 48px icon (2× bigger), halve stroke width to look the same */
|
||||
.folder-strip-container .folder-svg .folder-front,
|
||||
.folder-strip-container .folder-svg .folder-back,
|
||||
.folder-strip-container .folder-svg .paper-line,
|
||||
.folder-strip-container .folder-svg .paper-ink,
|
||||
.folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; }
|
||||
|
||||
@@ -252,6 +252,9 @@
|
||||
</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">
|
||||
<i class="material-icons">share</i>
|
||||
@@ -352,6 +355,10 @@
|
||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="create_folder">Create folder</span>
|
||||
</li>
|
||||
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||
<span data-i18n-key="upload">Upload file(s)</span>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Create File Modal -->
|
||||
@@ -491,6 +498,19 @@
|
||||
</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>
|
||||
</html>
|
||||
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.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 { 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
|
||||
const _nativeFetch = window.fetch.bind(window);
|
||||
|
||||
@@ -84,25 +98,53 @@ export function initializeApp() {
|
||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||
initTagSearch();
|
||||
|
||||
|
||||
// Hook DnD relay from fileList area into upload area
|
||||
const fileListArea = document.getElementById('fileListContainer');
|
||||
const uploadArea = document.getElementById('uploadDropArea');
|
||||
if (fileListArea && uploadArea) {
|
||||
const fileListArea = document.getElementById('fileList');
|
||||
|
||||
if (fileListArea) {
|
||||
let hoverTimer = null;
|
||||
|
||||
fileListArea.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
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.classList.remove('drop-hover');
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
});
|
||||
fileListArea.addEventListener('drop', e => {
|
||||
|
||||
fileListArea.addEventListener('drop', async e => {
|
||||
e.preventDefault();
|
||||
fileListArea.classList.remove('drop-hover');
|
||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
||||
dataTransfer: e.dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||
|
||||
// 1) open the same modal that the Create menu uses
|
||||
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 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
|
||||
|
||||
export function buildFileTableHeader(sortOrder) {
|
||||
return `
|
||||
<table class="table">
|
||||
<table class="table filr-table table-hover table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
|
||||
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
|
||||
const row = checkbox.closest('tr');
|
||||
if (!row) return;
|
||||
if (checkbox.checked) {
|
||||
row.classList.add('row-selected');
|
||||
row.classList.add('row-selected', 'selected');
|
||||
} else {
|
||||
row.classList.remove('row-selected');
|
||||
row.classList.remove('row-selected', 'selected');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ function readLayout() {
|
||||
function writeLayout(layout) {
|
||||
localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout || {}));
|
||||
}
|
||||
|
||||
function setLayoutFor(cardId, zoneId) {
|
||||
const layout = readLayout();
|
||||
layout[cardId] = zoneId;
|
||||
@@ -93,6 +92,7 @@ function removeHeaderIconForCard(card) {
|
||||
function insertCardInHeader(card) {
|
||||
const host = getHeaderDropArea();
|
||||
if (!host) return;
|
||||
|
||||
// Ensure hidden container exists to park real cards while icon-visible.
|
||||
let hidden = $('hiddenCardsContainer');
|
||||
if (!hidden) {
|
||||
@@ -110,10 +110,10 @@ function insertCardInHeader(card) {
|
||||
iconButton.style.border = 'none';
|
||||
iconButton.style.background = 'none';
|
||||
iconButton.style.cursor = 'pointer';
|
||||
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${card.id === 'uploadCard' ? 'cloud_upload'
|
||||
: card.id === 'folderManagementCard' ? 'folder'
|
||||
: 'insert_drive_file'
|
||||
}</i>`;
|
||||
iconButton.innerHTML = `<i class="material-icons" style="font-size:24px;">${
|
||||
card.id === 'uploadCard' ? 'cloud_upload' :
|
||||
card.id === 'folderManagementCard' ? 'folder' : 'insert_drive_file'
|
||||
}</i>`;
|
||||
|
||||
iconButton.cardElement = card;
|
||||
card.headerIconButton = iconButton;
|
||||
@@ -150,8 +150,8 @@ function insertCardInHeader(card) {
|
||||
function showModal() {
|
||||
ensureModal();
|
||||
if (!modal.contains(card)) {
|
||||
let hidden = $('hiddenCardsContainer');
|
||||
if (hidden && hidden.contains(card)) hidden.removeChild(card);
|
||||
const hiddenNow = $('hiddenCardsContainer');
|
||||
if (hiddenNow && hiddenNow.contains(card)) hiddenNow.removeChild(card);
|
||||
card.style.width = '';
|
||||
card.style.minWidth = '';
|
||||
modal.appendChild(card);
|
||||
@@ -163,8 +163,8 @@ function insertCardInHeader(card) {
|
||||
if (!modal) return;
|
||||
modal.style.visibility = 'hidden';
|
||||
modal.style.opacity = '0';
|
||||
const hidden = $('hiddenCardsContainer');
|
||||
if (hidden && modal.contains(card)) hidden.appendChild(card);
|
||||
const hiddenNow = $('hiddenCardsContainer');
|
||||
if (hiddenNow && modal.contains(card)) hiddenNow.appendChild(card);
|
||||
}
|
||||
function maybeHide() {
|
||||
setTimeout(() => {
|
||||
@@ -181,6 +181,8 @@ function insertCardInHeader(card) {
|
||||
});
|
||||
|
||||
host.appendChild(iconButton);
|
||||
// make sure the dock is visible when icons exist
|
||||
showHeaderDockPersistent();
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
@@ -227,6 +229,10 @@ function placeCardInZone(card, zoneId, { animate = true } = {}) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
updateZonesToggleUI(); // live update when zones change
|
||||
}
|
||||
|
||||
function currentZoneForCard(card) {
|
||||
@@ -234,7 +240,6 @@ function currentZoneForCard(card) {
|
||||
const pid = card.parentNode.id || '';
|
||||
if (pid === 'hiddenCardsContainer' && card.headerIconButton) return ZONES.HEADER;
|
||||
if ([ZONES.SIDEBAR, ZONES.TOP_LEFT, ZONES.TOP_RIGHT, ZONES.HEADER].includes(pid)) return pid;
|
||||
// If card is temporarily in modal (header), treat as header
|
||||
if (card.headerIconButton && card.headerIconButton.modalInstance?.contains(card)) return ZONES.HEADER;
|
||||
return pid || null;
|
||||
}
|
||||
@@ -248,44 +253,6 @@ function saveCurrentLayout() {
|
||||
writeLayout(layout);
|
||||
}
|
||||
|
||||
function applyUserLayoutOrDefault() {
|
||||
const layout = readLayout();
|
||||
const hasAny = Object.keys(layout).length > 0;
|
||||
|
||||
// If we have saved user layout, honor it
|
||||
if (hasAny) {
|
||||
getCards().forEach(card => {
|
||||
const targetZone = layout[card.id];
|
||||
if (!targetZone) return;
|
||||
// On small screens: if saved zone is the sidebar, temporarily place in top cols
|
||||
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
|
||||
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
placeCardInZone(card, target, { animate: false });
|
||||
} else {
|
||||
placeCardInZone(card, targetZone, { animate: false });
|
||||
}
|
||||
});
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// No saved layout yet: apply defaults
|
||||
if (!isSmallScreen()) {
|
||||
// Wide: default both to sidebar (if not already)
|
||||
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
|
||||
} else {
|
||||
// Small: deterministic mapping
|
||||
getCards().forEach(c => {
|
||||
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
placeCardInZone(c, zone, { animate: false });
|
||||
});
|
||||
}
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
saveCurrentLayout(); // initialize baseline so future moves persist
|
||||
}
|
||||
|
||||
// -------------------- responsive stash --------------------
|
||||
function stashSidebarCardsBeforeSmall() {
|
||||
const sb = getSidebar();
|
||||
@@ -339,21 +306,62 @@ function enforceResponsiveZones() {
|
||||
__wasSmall = nowSmall;
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
updateZonesToggleUI(); // keep icon in sync when responsive flips
|
||||
}
|
||||
|
||||
// -------------------- header dock visibility helpers --------------------
|
||||
function showHeaderDockPersistent() {
|
||||
const h = getHeaderDropArea();
|
||||
if (h) {
|
||||
h.style.display = 'inline-flex';
|
||||
h.classList.add('dock-visible');
|
||||
}
|
||||
}
|
||||
function hideHeaderDockPersistent() {
|
||||
const h = getHeaderDropArea();
|
||||
if (h) {
|
||||
h.classList.remove('dock-visible');
|
||||
if (h.children.length === 0) h.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- zones toggle (collapse to header) --------------------
|
||||
function isZonesCollapsed() { return localStorage.getItem('zonesCollapsed') === '1'; }
|
||||
|
||||
function applyCollapsedBodyClass() {
|
||||
// helps grid/containers expand the file list area when sidebar is hidden
|
||||
document.body.classList.toggle('sidebar-hidden', isZonesCollapsed());
|
||||
const main = document.querySelector('.main-wrapper') || document.querySelector('#main') || document.querySelector('main');
|
||||
if (main) {
|
||||
main.style.contain = 'size';
|
||||
void main.offsetHeight;
|
||||
setTimeout(() => { main.style.removeProperty('contain'); }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function setZonesCollapsed(collapsed) {
|
||||
localStorage.setItem('zonesCollapsed', collapsed ? '1' : '0');
|
||||
|
||||
if (collapsed) {
|
||||
// Move ALL cards to header icons (transient). Do not overwrite saved layout.
|
||||
// Move ALL cards to header icons (transient) regardless of where they were.
|
||||
getCards().forEach(insertCardInHeader);
|
||||
showHeaderDockPersistent();
|
||||
const sb = getSidebar();
|
||||
if (sb) sb.style.display = 'none';
|
||||
} else {
|
||||
// Restore the saved user layout.
|
||||
// Restore saved layout + rebuild header icons only for HEADER-assigned cards
|
||||
applyUserLayoutOrDefault();
|
||||
loadHeaderOrder();
|
||||
hideHeaderDockPersistent();
|
||||
}
|
||||
|
||||
updateSidebarVisibility();
|
||||
updateTopZoneLayout();
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
applyCollapsedBodyClass();
|
||||
|
||||
document.dispatchEvent(new CustomEvent('zones:collapsed-changed', { detail: { collapsed: isZonesCollapsed() } }));
|
||||
}
|
||||
|
||||
function getHeaderHost() {
|
||||
@@ -379,7 +387,6 @@ function ensureZonesToggle() {
|
||||
position: 'absolute',
|
||||
top: `${TOGGLE_TOP_PX}px`,
|
||||
left: `${TOGGLE_LEFT_PX}px`,
|
||||
zIndex: '10010',
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
borderRadius: '19px',
|
||||
@@ -424,7 +431,7 @@ function updateZonesToggleUI() {
|
||||
iconEl.style.transition = 'transform 0.2s ease';
|
||||
iconEl.style.display = 'inline-flex';
|
||||
iconEl.style.alignItems = 'center';
|
||||
// fun rotate if both cards are in top zone
|
||||
// rotate if both cards are in top zone (only when not collapsed)
|
||||
const tz = getTopZone();
|
||||
const allTop = !!tz?.querySelector('#uploadCard') && !!tz?.querySelector('#folderManagementCard');
|
||||
iconEl.style.transform = (!collapsed && allTop) ? 'rotate(90deg)' : 'rotate(0deg)';
|
||||
@@ -486,7 +493,31 @@ function updateTopZoneLayout() {
|
||||
if (top) top.style.display = (hasUpload || hasFolder) ? '' : 'none';
|
||||
}
|
||||
|
||||
// drag visual helpers
|
||||
// --- sidebar placeholder while dragging (only when empty) ---
|
||||
function ensureSidebarPlaceholder() {
|
||||
const sb = getSidebar();
|
||||
if (!sb) return;
|
||||
if (hasSidebarCards()) return; // only when empty
|
||||
let ph = sb.querySelector('.sb-dnd-placeholder');
|
||||
if (!ph) {
|
||||
ph = document.createElement('div');
|
||||
ph.className = 'sb-dnd-placeholder';
|
||||
Object.assign(ph.style, {
|
||||
height: '340px',
|
||||
width: '100%',
|
||||
visibility: 'hidden'
|
||||
});
|
||||
sb.appendChild(ph);
|
||||
}
|
||||
}
|
||||
function removeSidebarPlaceholder() {
|
||||
const sb = getSidebar();
|
||||
if (!sb) return;
|
||||
const ph = sb.querySelector('.sb-dnd-placeholder');
|
||||
if (ph) ph.remove();
|
||||
}
|
||||
|
||||
// -------------------- DnD core --------------------
|
||||
function addTopZoneHighlight() {
|
||||
const top = getTopZone();
|
||||
if (!top) return;
|
||||
@@ -525,6 +556,7 @@ function cleanupTopZoneAfterDrop() {
|
||||
if (ph) ph.remove();
|
||||
top.classList.remove('highlight');
|
||||
top.style.minHeight = '';
|
||||
// ✅ fixed selector string here
|
||||
const hasAny = top.querySelectorAll('#uploadCard, #folderManagementCard').length > 0;
|
||||
top.style.display = hasAny ? '' : 'none';
|
||||
}
|
||||
@@ -539,11 +571,10 @@ function hideHeaderDropZone() {
|
||||
const h = getHeaderDropArea();
|
||||
if (h) {
|
||||
h.classList.remove('drag-active');
|
||||
if (h.children.length === 0) h.style.display = 'none';
|
||||
if (h.children.length === 0 && !isZonesCollapsed()) h.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- DnD core --------------------
|
||||
function makeCardDraggable(card) {
|
||||
if (!card) return;
|
||||
const header = card.querySelector('.card-header');
|
||||
@@ -573,11 +604,9 @@ function makeCardDraggable(card) {
|
||||
|
||||
const sb = getSidebar();
|
||||
if (sb) {
|
||||
sb.classList.add('active');
|
||||
sb.classList.add('highlight');
|
||||
sb.classList.add('active', 'highlight');
|
||||
if (!isZonesCollapsed()) sb.style.display = 'block';
|
||||
sb.style.removeProperty('height');
|
||||
sb.style.minWidth = '280px';
|
||||
ensureSidebarPlaceholder(); // make empty sidebar easy to drop into
|
||||
}
|
||||
|
||||
showHeaderDropZone();
|
||||
@@ -597,9 +626,8 @@ function makeCardDraggable(card) {
|
||||
top: initialTop + 'px',
|
||||
width: rect.width + 'px',
|
||||
height: rect.height + 'px',
|
||||
minWidth: rect.width + 'px',
|
||||
flexShrink: '0',
|
||||
zIndex: '10000'
|
||||
zIndex: '10000',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
}, 450);
|
||||
});
|
||||
@@ -623,8 +651,7 @@ function makeCardDraggable(card) {
|
||||
const sb = getSidebar();
|
||||
if (sb) {
|
||||
sb.classList.remove('highlight');
|
||||
sb.style.height = '';
|
||||
sb.style.minWidth = '';
|
||||
removeSidebarPlaceholder();
|
||||
}
|
||||
|
||||
let dropped = null;
|
||||
@@ -663,22 +690,20 @@ function makeCardDraggable(card) {
|
||||
}
|
||||
}
|
||||
|
||||
// If not dropped anywhere, return to original container
|
||||
if (!dropped) {
|
||||
// return to original container
|
||||
const orig = $(card.dataset.originalContainerId);
|
||||
if (orig) {
|
||||
orig.appendChild(card);
|
||||
card.style.removeProperty('width');
|
||||
animateVerticalSlide(card);
|
||||
// keep previous zone in layout (no change)
|
||||
}
|
||||
} else {
|
||||
// Persist user layout on manual move (including header)
|
||||
setLayoutFor(card.id, dropped);
|
||||
}
|
||||
|
||||
// Clear inline drag styles
|
||||
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width']
|
||||
['position', 'left', 'top', 'z-index', 'height', 'min-width', 'flex-shrink', 'transition', 'transform', 'opacity', 'width', 'pointer-events']
|
||||
.forEach(prop => card.style.removeProperty(prop));
|
||||
|
||||
removeTopZoneHighlight();
|
||||
@@ -686,15 +711,52 @@ function makeCardDraggable(card) {
|
||||
cleanupTopZoneAfterDrop();
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
updateZonesToggleUI();
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------- defaults + layout --------------------
|
||||
function applyUserLayoutOrDefault() {
|
||||
const layout = readLayout();
|
||||
const hasAny = Object.keys(layout).length > 0;
|
||||
|
||||
if (hasAny) {
|
||||
getCards().forEach(card => {
|
||||
const targetZone = layout[card.id];
|
||||
if (!targetZone) return;
|
||||
// On small screens: if saved zone is the sidebar, temporarily place in top cols
|
||||
if (isSmallScreen() && targetZone === ZONES.SIDEBAR) {
|
||||
const target = (card.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
placeCardInZone(card, target, { animate: false });
|
||||
} else {
|
||||
placeCardInZone(card, targetZone, { animate: false });
|
||||
}
|
||||
});
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// No saved layout yet: apply defaults
|
||||
if (!isSmallScreen()) {
|
||||
getCards().forEach(c => placeCardInZone(c, ZONES.SIDEBAR, { animate: false }));
|
||||
} else {
|
||||
getCards().forEach(c => {
|
||||
const zone = (c.id === 'uploadCard') ? ZONES.TOP_LEFT : ZONES.TOP_RIGHT;
|
||||
placeCardInZone(c, zone, { animate: false });
|
||||
});
|
||||
}
|
||||
updateTopZoneLayout();
|
||||
updateSidebarVisibility();
|
||||
saveCurrentLayout(); // initialize baseline so future moves persist
|
||||
}
|
||||
|
||||
// -------------------- public API --------------------
|
||||
export function loadSidebarOrder() {
|
||||
// Backward compat: act as "apply layout"
|
||||
applyUserLayoutOrDefault();
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
applyCollapsedBodyClass();
|
||||
}
|
||||
|
||||
export function loadHeaderOrder() {
|
||||
@@ -704,9 +766,9 @@ export function loadHeaderOrder() {
|
||||
|
||||
const layout = readLayout();
|
||||
|
||||
// If collapsed: all cards appear as header icons
|
||||
if (isZonesCollapsed()) {
|
||||
getCards().forEach(insertCardInHeader);
|
||||
showHeaderDockPersistent();
|
||||
saveHeaderOrder();
|
||||
return;
|
||||
}
|
||||
@@ -715,6 +777,7 @@ export function loadHeaderOrder() {
|
||||
getCards().forEach(card => {
|
||||
if (layout[card.id] === ZONES.HEADER) insertCardInHeader(card);
|
||||
});
|
||||
if (header.children.length === 0) header.style.display = 'none';
|
||||
saveHeaderOrder();
|
||||
}
|
||||
|
||||
@@ -727,6 +790,7 @@ export function initDragAndDrop() {
|
||||
// 2) Paint controls/UI
|
||||
ensureZonesToggle();
|
||||
updateZonesToggleUI();
|
||||
applyCollapsedBodyClass();
|
||||
|
||||
// 3) Make cards draggable
|
||||
getCards().forEach(makeCardDraggable);
|
||||
@@ -735,9 +799,7 @@ export function initDragAndDrop() {
|
||||
let raf = null;
|
||||
const onResize = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(() => {
|
||||
enforceResponsiveZones();
|
||||
});
|
||||
raf = requestAnimationFrame(() => enforceResponsiveZones());
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
enforceResponsiveZones();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFileList } 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}}';
|
||||
|
||||
export function handleDeleteSelected(e) {
|
||||
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
|
||||
showToast("no_files_selected");
|
||||
return;
|
||||
}
|
||||
|
||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||
const count = window.filesToDelete.length;
|
||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
|
||||
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 () {
|
||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||
if (cancelDelete) {
|
||||
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files deleted successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not delete files"));
|
||||
}
|
||||
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
|
||||
if (!js.success) throw new Error(js.error);
|
||||
showToast(t('file_created'));
|
||||
loadFileList(folder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
} finally {
|
||||
@@ -300,6 +348,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
showToast(t('file_created_successfully'));
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(folder);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast(err.message || t('error_creating_file'));
|
||||
@@ -633,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files copied successfully!", 5000);
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not copy files"), 5000);
|
||||
}
|
||||
@@ -685,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (data.success) {
|
||||
showToast("Selected files moved successfully!");
|
||||
loadFileList(window.currentFolder);
|
||||
refreshFolderIcon(targetFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
} else {
|
||||
showToast("Error: " + (data.error || "Could not move files"));
|
||||
}
|
||||
@@ -822,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const menu = document.getElementById('createMenu');
|
||||
const fileOpt = document.getElementById('createFileOption');
|
||||
const folderOpt = document.getElementById('createFolderOption');
|
||||
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||
|
||||
// Toggle dropdown on click
|
||||
btn.addEventListener('click', (e) => {
|
||||
@@ -846,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('click', () => {
|
||||
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;
|
||||
@@ -312,7 +312,13 @@ const translations = {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"watched": "Watched",
|
||||
"reset_progress": "Reset Progress"
|
||||
"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."
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
import { toggleVisibility, showToast } from './domUtils.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}}';
|
||||
|
||||
function showConfirm(message, onConfirm) {
|
||||
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
|
||||
toggleVisibility("restoreFilesModal", false);
|
||||
loadFileList(window.currentFolder);
|
||||
loadFolderTree(window.currentFolder);
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Error restoring files:", err);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { loadFolderTree } from './folderManager.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}}';
|
||||
|
||||
/* -----------------------------------------------------
|
||||
@@ -588,7 +589,7 @@ async function initResumableUpload() {
|
||||
if (removeBtn) removeBtn.style.display = "none";
|
||||
setTimeout(() => li.remove(), 5000);
|
||||
}
|
||||
|
||||
refreshFolderIcon(window.currentFolder);
|
||||
loadFileList(window.currentFolder);
|
||||
});
|
||||
|
||||
@@ -895,7 +896,8 @@ function initUpload() {
|
||||
dropArea.addEventListener("drop", function (e) {
|
||||
e.preventDefault();
|
||||
dropArea.style.backgroundColor = "";
|
||||
const dt = e.dataTransfer;
|
||||
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||
window.__pendingDropData = null;
|
||||
if (dt.items && dt.items.length > 0) {
|
||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.8.12';
|
||||
window.APP_VERSION = 'v1.9.3';
|
||||
|
||||
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 788 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 541 KiB |
54
scripts/manual-sync.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# === Update FileRise to v1.9.1 (safe rsync) ===
|
||||
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
VER="v1.9.1"
|
||||
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
|
||||
WEBROOT="/var/www"
|
||||
TMP="/tmp/filerise-update"
|
||||
|
||||
# 0) (optional) quick backup of critical bits
|
||||
stamp="$(date +%F-%H%M)"
|
||||
mkdir -p /root/backups
|
||||
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
|
||||
public/.htaccess config users uploads metadata || true
|
||||
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
|
||||
|
||||
# 1) Fetch the release zip
|
||||
rm -rf "$TMP"
|
||||
mkdir -p "$TMP"
|
||||
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
|
||||
|
||||
# 2) Unzip to a staging dir
|
||||
unzip -q "$TMP/$ASSET" -d "$TMP"
|
||||
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
|
||||
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
|
||||
|
||||
# 3) Sync code into /var/www
|
||||
# - keep public/.htaccess
|
||||
# - keep data dirs and current config.php
|
||||
rsync -a --delete \
|
||||
--exclude='public/.htaccess' \
|
||||
--exclude='uploads/***' \
|
||||
--exclude='users/***' \
|
||||
--exclude='metadata/***' \
|
||||
--exclude='config/config.php' \
|
||||
--exclude='.github/***' \
|
||||
--exclude='docker-compose.yml' \
|
||||
"$STAGE_DIR"/ "$WEBROOT"/
|
||||
|
||||
# 4) Ownership (Ubuntu/Debian w/ Apache)
|
||||
chown -R www-data:www-data "$WEBROOT"
|
||||
|
||||
# 5) (optional) Composer autoload optimization if composer is available
|
||||
if command -v composer >/dev/null 2>&1; then
|
||||
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
|
||||
composer install --no-dev --optimize-autoloader
|
||||
fi
|
||||
|
||||
# 6) Reload Apache (don’t fail the whole script if reload isn’t available)
|
||||
systemctl reload apache2 2>/dev/null || true
|
||||
|
||||
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."
|
||||
390
src/lib/ACL.php
@@ -10,23 +10,38 @@ class ACL
|
||||
private static $path = null;
|
||||
|
||||
private const BUCKETS = [
|
||||
'owners','read','write','share','read_own',
|
||||
'create','upload','edit','rename','copy','move','delete','extract',
|
||||
'share_file','share_folder'
|
||||
'owners',
|
||||
'read',
|
||||
'write',
|
||||
'share',
|
||||
'read_own',
|
||||
'create',
|
||||
'upload',
|
||||
'edit',
|
||||
'rename',
|
||||
'copy',
|
||||
'move',
|
||||
'delete',
|
||||
'extract',
|
||||
'share_file',
|
||||
'share_folder'
|
||||
];
|
||||
|
||||
private static function path(): string {
|
||||
private static function path(): string
|
||||
{
|
||||
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
|
||||
return self::$path;
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $f): string {
|
||||
public static function normalizeFolder(string $f): string
|
||||
{
|
||||
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
|
||||
if ($f === '' || $f === 'root') return 'root';
|
||||
return $f;
|
||||
}
|
||||
|
||||
public static function purgeUser(string $user): bool {
|
||||
public static function purgeUser(string $user): bool
|
||||
{
|
||||
$user = (string)$user;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$changed = false;
|
||||
@@ -41,49 +56,107 @@ class ACL
|
||||
return $changed ? self::save($acl) : true;
|
||||
}
|
||||
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
if (self::hasGrant($user, $folder, 'owners')) return true;
|
||||
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
$folder = trim($folder, "/\\ ");
|
||||
if ($folder === '' || $folder === 'root') return false;
|
||||
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
$parts = explode('/', $folder);
|
||||
while (count($parts) > 1) {
|
||||
array_pop($parts);
|
||||
$parent = implode('/', $parts);
|
||||
if (self::hasGrant($user, $parent, 'owners')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function migrateSubtree(string $source, string $target): array
|
||||
{
|
||||
// PHP <8 polyfill
|
||||
if (!function_exists('str_starts_with')) {
|
||||
function str_starts_with(string $h, string $n): bool
|
||||
{
|
||||
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
|
||||
}
|
||||
}
|
||||
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed' => false, 'moved' => 0];
|
||||
|
||||
$file = self::path(); // e.g. META_DIR/folder_acl.json
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
if (!is_array($map)) $map = [];
|
||||
|
||||
$prefix = $src;
|
||||
$needle = $src . '/';
|
||||
|
||||
$new = $map;
|
||||
$changed = false;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $entry) {
|
||||
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
|
||||
if (!$isMatch) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
|
||||
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
|
||||
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
|
||||
|
||||
// keep only known buckets (defensive)
|
||||
if (is_array($entry)) {
|
||||
$clean = [];
|
||||
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
|
||||
$entry = $clean ?: $entry;
|
||||
}
|
||||
|
||||
// overwrite any existing entry at destination path (safer than union)
|
||||
$new[$newKey] = $entry;
|
||||
$changed = true;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@chmod($file, 0664);
|
||||
self::$cache = $new; // keep in-process cache fresh if you use it
|
||||
}
|
||||
|
||||
return ['changed' => $changed, 'moved' => $moved];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
{
|
||||
$old = self::normalizeFolder($oldFolder);
|
||||
$new = self::normalizeFolder($newFolder);
|
||||
if ($old === '' || $old === 'root') return; // nothing to re-key for root
|
||||
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
|
||||
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
$rebased = [];
|
||||
foreach ($acl['folders'] as $k => $rec) {
|
||||
if ($k === $old || strpos($k, $old . '/') === 0) {
|
||||
$suffix = substr($k, strlen($old));
|
||||
$suffix = ltrim((string)$suffix, '/');
|
||||
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
|
||||
$rebased[$newKey] = $rec;
|
||||
} else {
|
||||
$rebased[$k] = $rec;
|
||||
}
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
$acl['folders'] = $rebased;
|
||||
self::save($acl);
|
||||
}
|
||||
|
||||
private static function loadFresh(): array {
|
||||
private static function loadFresh(): array
|
||||
{
|
||||
$path = self::path();
|
||||
if (!is_file($path)) {
|
||||
@mkdir(dirname($path), 0755, true);
|
||||
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
'read' => ['admin'],
|
||||
'write' => ['admin'],
|
||||
'share' => ['admin'],
|
||||
'read_own'=> [],
|
||||
'read_own' => [],
|
||||
'create' => [],
|
||||
'upload' => [],
|
||||
'edit' => [],
|
||||
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
|
||||
$healed = false;
|
||||
foreach ($data['folders'] as $folder => &$rec) {
|
||||
if (!is_array($rec)) { $rec = []; $healed = true; }
|
||||
if (!is_array($rec)) {
|
||||
$rec = [];
|
||||
$healed = true;
|
||||
}
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$v = $rec[$k] ?? [];
|
||||
if (!is_array($v)) { $v = []; $healed = true; }
|
||||
if (!is_array($v)) {
|
||||
$v = [];
|
||||
$healed = true;
|
||||
}
|
||||
$v = array_values(array_unique(array_map('strval', $v)));
|
||||
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
|
||||
if (($rec[$k] ?? null) !== $v) {
|
||||
$rec[$k] = $v;
|
||||
$healed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($rec);
|
||||
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return $data;
|
||||
}
|
||||
|
||||
private static function save(array $acl): bool {
|
||||
private static function save(array $acl): bool
|
||||
{
|
||||
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
|
||||
if ($ok) self::$cache = $acl;
|
||||
return $ok;
|
||||
}
|
||||
|
||||
private static function listFor(string $folder, string $key): array {
|
||||
private static function listFor(string $folder, string $key): array
|
||||
{
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$f = $acl['folders'][$folder] ?? null;
|
||||
return is_array($f[$key] ?? null) ? $f[$key] : [];
|
||||
}
|
||||
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
|
||||
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
if (!isset($acl['folders'][$folder])) {
|
||||
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
public static function isAdmin(array $perms = []): bool {
|
||||
public static function isAdmin(array $perms = []): bool
|
||||
{
|
||||
if (!empty($_SESSION['isAdmin'])) return true;
|
||||
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
|
||||
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
|
||||
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
|
||||
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
|
||||
if (
|
||||
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
|
||||
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool {
|
||||
public static function hasGrant(string $user, string $folder, string $cap): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$capKey = ($cap === 'owner') ? 'owners' : $cap;
|
||||
$arr = self::listFor($folder, $capKey);
|
||||
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool {
|
||||
public static function isOwner(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canManage(string $user, array $perms, string $folder): bool {
|
||||
public static function canManage(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
return self::isOwner($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canRead(string $user, array $perms, string $folder): bool {
|
||||
public static function canRead(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'read');
|
||||
}
|
||||
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool {
|
||||
public static function canReadOwn(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
if (self::canRead($user, $perms, $folder)) return true;
|
||||
return self::hasGrant($user, $folder, 'read_own');
|
||||
}
|
||||
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool {
|
||||
public static function canWrite(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canShare(string $user, array $perms, string $folder): bool {
|
||||
public static function canShare(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// Legacy-only explicit (to avoid breaking existing callers)
|
||||
public static function explicit(string $folder): array {
|
||||
public static function explicit(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
|
||||
// New: full explicit including granular
|
||||
public static function explicitAll(string $folder): array {
|
||||
public static function explicitAll(string $folder): array
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$rec = $acl['folders'][$folder] ?? [];
|
||||
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
];
|
||||
}
|
||||
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
|
||||
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$acl = self::$cache ?? self::loadFresh();
|
||||
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
|
||||
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
return self::save($acl);
|
||||
}
|
||||
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array {
|
||||
public static function applyUserGrantsAtomic(string $user, array $grants): array
|
||||
{
|
||||
$user = (string)$user;
|
||||
$path = self::path();
|
||||
|
||||
$fh = @fopen($path, 'c+');
|
||||
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
|
||||
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
|
||||
if (!flock($fh, LOCK_EX)) {
|
||||
fclose($fh);
|
||||
throw new RuntimeException('Cannot lock ACL storage');
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = stream_get_contents($fh);
|
||||
if ($raw === false) $raw = '';
|
||||
$acl = json_decode($raw, true);
|
||||
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
|
||||
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
|
||||
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
|
||||
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
|
||||
|
||||
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
foreach ($grants as $folder => $caps) {
|
||||
$ff = self::normalizeFolder((string)$folder);
|
||||
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
|
||||
$rec =& $acl['folders'][$ff];
|
||||
$rec = &$acl['folders'][$ff];
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
|
||||
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
|
||||
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
|
||||
|
||||
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
|
||||
if ($m) {
|
||||
$v = true;
|
||||
$w = true;
|
||||
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
|
||||
}
|
||||
if ($u && !$v && !$vo) $vo = true;
|
||||
//if ($s && !$v) $v = true;
|
||||
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
|
||||
if ($w) {
|
||||
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
|
||||
}
|
||||
|
||||
if ($m) $rec['owners'][] = $user;
|
||||
if ($v) $rec['read'][] = $user;
|
||||
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
if ($dl) $rec['delete'][] = $user;
|
||||
if ($ex) $rec['extract'][] = $user;
|
||||
if ($sf) $rec['share_file'][] = $user;
|
||||
if ($sfo)$rec['share_folder'][] = $user;
|
||||
if ($sfo) $rec['share_folder'][] = $user;
|
||||
|
||||
foreach (self::BUCKETS as $k) {
|
||||
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
|
||||
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
|
||||
}
|
||||
}
|
||||
|
||||
// --- Granular write family -----------------------------------------------
|
||||
// --- Granular write family -----------------------------------------------
|
||||
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCreate(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'create')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
public static function canCreateFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
// Only owners/managers can create subfolders under $folder
|
||||
return self::hasGrant($user, $folder, 'owners');
|
||||
}
|
||||
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canUpload(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'upload')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canEdit(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'edit')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canRename(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canRename(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'rename')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canCopy(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'copy')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canMove(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMove(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
public static function canMoveFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::ownsFolderOrAncestor($user, $perms, $folder);
|
||||
}
|
||||
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
public static function canDelete(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'delete')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
public static function canExtract(string $user, array $perms, string $folder): bool {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners')
|
||||
|| self::hasGrant($user, $folder, 'extract')
|
||||
|| self::hasGrant($user, $folder, 'write');
|
||||
}
|
||||
|
||||
/** Sharing: files use share, folders require share + full-view. */
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFile(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
}
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool {
|
||||
public static function canShareFolder(string $user, array $perms, string $folder): bool
|
||||
{
|
||||
$folder = self::normalizeFolder($folder);
|
||||
if (self::isAdmin($perms)) return true;
|
||||
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
|
||||
|
||||
90
src/models/FolderMeta.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// src/models/FolderMeta.php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/config/config.php';
|
||||
require_once __DIR__ . '/../../src/lib/ACL.php';
|
||||
|
||||
class FolderMeta
|
||||
{
|
||||
private static function path(): string {
|
||||
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
|
||||
}
|
||||
|
||||
public static function normalizeFolder(string $folder): string {
|
||||
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
|
||||
return ($f === '' || $f === 'root') ? 'root' : $f;
|
||||
}
|
||||
|
||||
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
|
||||
public static function normalizeHex(?string $hex): ?string {
|
||||
if ($hex === null || $hex === '') return null;
|
||||
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
|
||||
throw new \InvalidArgumentException('Invalid color hex');
|
||||
}
|
||||
if (strlen($hex) === 4) {
|
||||
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
|
||||
}
|
||||
return strtoupper($hex);
|
||||
}
|
||||
|
||||
/** Read full map from disk */
|
||||
public static function getMap(): array {
|
||||
$file = self::path();
|
||||
$raw = @file_get_contents($file);
|
||||
$map = is_string($raw) ? json_decode($raw, true) : [];
|
||||
return is_array($map) ? $map : [];
|
||||
}
|
||||
|
||||
/** Write full map to disk (atomic-ish) */
|
||||
private static function writeMap(array $map): void {
|
||||
$file = self::path();
|
||||
$dir = dirname($file);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
$tmp = $file . '.tmp';
|
||||
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@rename($tmp, $file);
|
||||
@chmod($file, 0664);
|
||||
}
|
||||
|
||||
/** Set or clear a color for one folder */
|
||||
public static function setColor(string $folder, ?string $hex): array {
|
||||
$folder = self::normalizeFolder($folder);
|
||||
$hex = self::normalizeHex($hex);
|
||||
$map = self::getMap();
|
||||
|
||||
if ($hex === null) unset($map[$folder]);
|
||||
else $map[$folder] = $hex;
|
||||
|
||||
self::writeMap($map);
|
||||
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
|
||||
}
|
||||
|
||||
/** Migrate color entries for a whole subtree (used by move/rename) */
|
||||
public static function migrateSubtree(string $source, string $target): array {
|
||||
$src = self::normalizeFolder($source);
|
||||
$dst = self::normalizeFolder($target);
|
||||
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$map = self::getMap();
|
||||
if (!$map) return ['changed'=>false, 'moved'=>0];
|
||||
|
||||
$new = $map;
|
||||
$moved = 0;
|
||||
|
||||
foreach ($map as $key => $hex) {
|
||||
$isSelf = ($key === $src);
|
||||
$isSub = str_starts_with($key.'/', $src.'/');
|
||||
if (!$isSelf && !$isSub) continue;
|
||||
|
||||
unset($new[$key]);
|
||||
$suffix = substr($key, strlen($src)); // '' or '/child/...'
|
||||
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
|
||||
$new[$newKey] = $hex;
|
||||
$moved++;
|
||||
}
|
||||
|
||||
if ($moved) self::writeMap($new);
|
||||
return ['changed'=> (bool)$moved, 'moved'=> $moved];
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,45 @@ class FolderModel
|
||||
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
|
||||
* ============================================================ */
|
||||
|
||||
public static function countVisible(string $folder, string $user, array $perms): array
|
||||
{
|
||||
// Normalize
|
||||
$folder = ACL::normalizeFolder($folder);
|
||||
|
||||
// ACL gate: if you can’t read, report empty (no leaks)
|
||||
if (!$user || !ACL::canRead($user, $perms, $folder)) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Resolve paths under UPLOAD_DIR
|
||||
$root = rtrim((string)UPLOAD_DIR, '/\\');
|
||||
$path = ($folder === 'root') ? $root : ($root . '/' . $folder);
|
||||
|
||||
$realRoot = @realpath($root);
|
||||
$realPath = @realpath($path);
|
||||
if ($realRoot === false || $realPath === false || strpos($realPath, $realRoot) !== 0) {
|
||||
return ['folders' => 0, 'files' => 0];
|
||||
}
|
||||
|
||||
// Count quickly, skipping UI-internal dirs
|
||||
$folders = 0; $files = 0;
|
||||
try {
|
||||
foreach (new DirectoryIterator($realPath) as $f) {
|
||||
if ($f->isDot()) continue;
|
||||
$name = $f->getFilename();
|
||||
if ($name === 'trash' || $name === 'profile_pics') continue;
|
||||
|
||||
if ($f->isDir()) $folders++; else $files++;
|
||||
if ($folders > 0 || $files > 0) break; // short-circuit: we only care if empty vs not
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Stay quiet + safe
|
||||
$folders = 0; $files = 0;
|
||||
}
|
||||
|
||||
return ['folders' => $folders, 'files' => $files];
|
||||
}
|
||||
|
||||
/** Load the folder → owner map. */
|
||||
public static function getFolderOwners(): array
|
||||
{
|
||||
|
||||