release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script

This commit is contained in:
Ryan
2025-11-09 19:55:07 -05:00
committed by GitHub
parent 6727cc66ac
commit bd7ff4d9cd
15 changed files with 1830 additions and 575 deletions

View File

@@ -1,5 +1,39 @@
# Changelog # Changelog
## 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) ## Changes 11/9/2025 (v1.9.0)
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening

View 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']);
}

View 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']);
}

View File

@@ -62,6 +62,51 @@ body {
@media (max-width: 600px) { @media (max-width: 600px) {
.zones-toggle { left: 85px !important; } .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 HEADER & NAVIGATION
=========================================================== */ =========================================================== */
@@ -801,14 +846,17 @@ body {
} }
#uploadForm { #uploadForm {
display: none; display: none;
}.folder-actions { }
display: flex; .folder-actions {
flex-wrap: nowrap; display: flex;
padding-left: 8px; justify-content: center;
align-items: center; align-items: center;
white-space: nowrap; gap: 2px;
padding-top: 10px; flex-wrap: wrap;
}@media (min-width: 600px) and (max-width: 992px) { white-space: normal;
margin: 0; /* no hacks needed */
}
@media (min-width: 600px) and (max-width: 992px) {
.folder-actions { .folder-actions {
white-space: nowrap; white-space: nowrap;
}} }}
@@ -821,10 +869,8 @@ body {
}.folder-actions .material-icons { }.folder-actions .material-icons {
font-size: 24px; font-size: 24px;
vertical-align: -2px; vertical-align: -2px;
}.folder-actions .btn + .btn {
margin-left: 6px;
}.folder-actions .btn { }.folder-actions .btn {
padding: 10px 12px;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.1; line-height: 1.1;
border-radius: 6px; border-radius: 6px;
@@ -834,7 +880,7 @@ body {
transition: transform 120ms ease, box-shadow 120ms ease; transition: transform 120ms ease, box-shadow 120ms ease;
will-change: transform; will-change: transform;
}.folder-actions .material-icons { }.folder-actions .material-icons {
font-size: 24px; font-size: 20px;
vertical-align: -2px; vertical-align: -2px;
transition: transform 120ms ease; transition: transform 120ms ease;
}.folder-actions .btn:hover, }.folder-actions .btn:hover,
@@ -1152,6 +1198,7 @@ body {
width: 5px; width: 5px;
}#folderTreeContainer { }#folderTreeContainer {
display: block; display: block;
margin-left: 10px;
}.folder-option { }.folder-option {
cursor: pointer; cursor: pointer;
}.folder-option:hover { }.folder-option:hover {
@@ -1641,6 +1688,7 @@ body {
}.custom-folder-card-body { }.custom-folder-card-body {
padding-top: 5px !important; padding-top: 5px !important;
padding-right: 0 !important; padding-right: 0 !important;
padding-left: 0 !important;
}#addUserModal, }#addUserModal,
#removeUserModal { #removeUserModal {
z-index: 5000 !important; z-index: 5000 !important;
@@ -2073,17 +2121,6 @@ body {
/* ---------- Crisp colors & strokes for the SVG parts ---------- */ /* ---------- Crisp colors & strokes for the SVG parts ---------- */
#folderTreeContainer .folder-icon .folder-front,
#folderTreeContainer .folder-icon .folder-back {
fill: currentColor;
stroke: var(--filr-folder-stroke);
stroke-width: 1.1;
vector-effect: non-scaling-stroke;
paint-order: stroke fill;
}
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }
#folderTreeContainer .folder-icon .paper { #folderTreeContainer .folder-icon .paper {
fill: var(--filr-paper-fill); fill: var(--filr-paper-fill);
@@ -2123,3 +2160,15 @@ body {
background: rgba(122,179,255,.24); background: rgba(122,179,255,.24);
box-shadow: inset 0 0 0 1px rgba(122,179,255,.45); box-shadow: inset 0 0 0 1px rgba(122,179,255,.45);
} }
/* variables will be set inline per .folder-option when user colors a folder */
#folderTreeContainer .folder-icon .folder-front,
#folderTreeContainer .folder-icon .folder-back {
fill: currentColor;
stroke: var(--filr-folder-stroke);
stroke-width: 1.1;
vector-effect: non-scaling-stroke;
paint-order: stroke fill;
}
#folderTreeContainer .folder-icon .folder-front { color: var(--filr-folder-front); }
#folderTreeContainer .folder-icon .folder-back { color: var(--filr-folder-back); }

View File

@@ -252,6 +252,9 @@
</div> </div>
</div> </div>
</div> </div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder"> <button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i> <i class="material-icons">share</i>

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}'; import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}'; import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) { export function handleDeleteSelected(e) {
@@ -47,6 +48,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files deleted successfully!"); showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not delete files")); showToast("Error: " + (data.error || "Could not delete files"));
} }
@@ -129,6 +131,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error); if (!js.success) throw new Error(js.error);
showToast(t('file_created')); showToast(t('file_created'));
loadFileList(folder); loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
} finally { } finally {
@@ -300,6 +303,7 @@ document.addEventListener("DOMContentLoaded", () => {
} }
showToast(t('file_created_successfully')); showToast(t('file_created_successfully'));
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast(err.message || t('error_creating_file')); showToast(err.message || t('error_creating_file'));
@@ -633,6 +637,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files copied successfully!", 5000); showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not copy files"), 5000); showToast("Error: " + (data.error || "Could not copy files"), 5000);
} }
@@ -685,6 +690,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) { if (data.success) {
showToast("Selected files moved successfully!"); showToast("Selected files moved successfully!");
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else { } else {
showToast("Error: " + (data.error || "Could not move files")); showToast("Error: " + (data.error || "Could not move files"));
} }

View File

@@ -74,6 +74,13 @@ function loadFolderTreeState() {
function saveFolderTreeState(state) { function saveFolderTreeState(state) {
localStorage.setItem("folderTreeState", JSON.stringify(state)); localStorage.setItem("folderTreeState", JSON.stringify(state));
} }
/* ----------------------
Transient UI guards (click suppression)
----------------------*/
let _suppressToggleUntil = 0;
function suppressNextToggle(ms = 300) {
_suppressToggleUntil = performance.now() + ms;
}
// Helper for getting the parent folder. // Helper for getting the parent folder.
export function getParentFolder(folder) { export function getParentFolder(folder) {
@@ -102,9 +109,11 @@ async function applyFolderCapabilities(folder) {
window.currentFolderCaps = caps; window.currentFolderCaps = caps;
const isRoot = (folder === 'root'); const isRoot = (folder === 'root');
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate); setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder); setControlEnabled(document.getElementById('moveFolderBtn'), !!caps.canMoveFolder);
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename); setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
setControlEnabled(document.getElementById('colorFolderBtn'), !isRoot && !!caps.canRename);
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete); setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder); setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
} }
@@ -174,56 +183,71 @@ function breadcrumbDropHandler(e) {
e.preventDefault(); e.preventDefault();
link.classList.remove("drop-hover"); link.classList.remove("drop-hover");
const dropFolder = link.getAttribute("data-folder"); const dropFolder = link.getAttribute("data-folder");
let dragData; let dragData;
try { try {
dragData = JSON.parse(e.dataTransfer.getData("application/json")); dragData = JSON.parse(e.dataTransfer.getData("application/json"));
} catch (err) { } catch (_) { /* noop */ }
console.error("Invalid drag data on breadcrumb:", err);
return; // FOLDER MOVE FALLBACK (folder->folder)
}
/* FOLDER MOVE FALLBACK */
if (!dragData) { if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || const plain = (e.dataTransfer && e.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; (e.dataTransfer && e.dataTransfer.getData("text/plain")) || "";
if (plain) { const sourceFolder = String(plain || "").trim();
const sourceFolder = String(plain).trim(); if (!sourceFolder || sourceFolder === "root") return;
if (sourceFolder && sourceFolder !== "root") {
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
showToast("Invalid destination.", 4000); showToast("Invalid destination.", 4000);
return; return;
}
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
}
} }
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
// Make icons reflect new emptiness without reload
refreshFolderIcon(dragData.sourceFolder);
refreshFolderIcon(dropFolder);
if (window.currentFolder &&
(window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
// carry color without await
const oldColor = window.folderColorMap[sourceFolder];
if (oldColor) {
saveFolderColor(newPath, oldColor)
.then(() => saveFolderColor(sourceFolder, ''))
.catch(() => { });
}
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
return; return;
} }
// File(s) drop path (unchanged)
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return; if (filesToMove.length === 0) return;
@@ -242,6 +266,8 @@ function breadcrumbDropHandler(e) {
if (data.success) { if (data.success) {
showToast(`File(s) moved successfully to ${dropFolder}!`); showToast(`File(s) moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder); loadFileList(dragData.sourceFolder);
refreshFolderIcon(dragData.sourceFolder);
refreshFolderIcon(dropFolder);
} else { } else {
showToast("Error moving files: " + (data.error || "Unknown error")); showToast("Error moving files: " + (data.error || "Unknown error"));
} }
@@ -252,6 +278,230 @@ function breadcrumbDropHandler(e) {
}); });
} }
// ---- Folder Colors (state + helpers) ----
window.folderColorMap = {}; // { "path": "#RRGGBB", ... }
async function loadFolderColors() {
try {
const r = await fetch('/api/folder/getFolderColors.php', { credentials: 'include' });
if (!r.ok) return (window.folderColorMap = {});
window.folderColorMap = await r.json() || {};
} catch { window.folderColorMap = {}; }
}
// tiny color utils
function hexToHsl(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
function hslToHex(h, s, l) {
h /= 360; s /= 100; l /= 100;
const f = n => {
const k = (n + h * 12) % 12, a = s * Math.min(l, 1 - l);
const c = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
return Math.round(255 * c).toString(16).padStart(2, '0');
};
return '#' + f(0) + f(8) + f(4);
}
function lighten(hex, amt = 12) {
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.min(100, l + amt));
}
function darken(hex, amt = 18) {
const { h, s, l } = hexToHsl(hex); return hslToHex(h, s, Math.max(0, l - amt));
}
function applyFolderColorToOption(folder, hex) {
// accepts folder like "root" or "A/B"
const sel = folder === 'root'
? '#rootRow .folder-option'
: `.folder-option[data-folder="${CSS.escape(folder)}"]`;
const el = document.querySelector(sel);
if (!el) return;
if (!hex) {
el.style.removeProperty('--filr-folder-front');
el.style.removeProperty('--filr-folder-back');
el.style.removeProperty('--filr-folder-stroke');
return;
}
const front = hex; // main
const back = lighten(hex, 14); // body (slightly lighter)
const stroke = darken(hex, 22); // outline
el.style.setProperty('--filr-folder-front', front);
el.style.setProperty('--filr-folder-back', back);
el.style.setProperty('--filr-folder-stroke', stroke);
}
function applyAllFolderColors(scope = document) {
Object.entries(window.folderColorMap || {}).forEach(([folder, hex]) => {
applyFolderColorToOption(folder, hex);
});
}
async function saveFolderColor(folder, colorHexOrEmpty) {
const res = await fetch('/api/folder/saveFolderColor.php', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken },
body: JSON.stringify({ folder, color: colorHexOrEmpty })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
// update local map & apply
if (data.color) window.folderColorMap[folder] = data.color;
else delete window.folderColorMap[folder];
applyFolderColorToOption(folder, data.color || '');
return data;
}
function openColorFolderModal(folder) {
const existing = window.folderColorMap[folder] || '';
const defaultHex = existing || '#f6b84e';
const modal = document.createElement('div');
modal.id = 'colorFolderModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width:460px;max-width:90vw;">
<style>
/* Scoped styles for the preview only */
#colorFolderModal .folder-preview {
display:flex; align-items:center; gap:12px;
margin-top:12px; padding:10px 12px; border-radius:12px;
border:1px solid var(--border-color, #ddd);
background: var(--bg, transparent);
}
body.dark-mode #colorFolderModal .folder-preview {
--border-color:#444; --bg: rgba(255,255,255,.02);
}
#colorFolderModal .folder-preview .folder-icon { width:56px; height:56px; display:inline-block }
#colorFolderModal .folder-preview svg { width:56px; height:56px; display:block }
/* Use the same variable names you already apply on folder rows */
#colorFolderModal .folder-preview .folder-back { fill:var(--filr-folder-back, #f0d084) }
#colorFolderModal .folder-preview .folder-front { fill:var(--filr-folder-front, #e2b158); stroke:var(--filr-folder-stroke, #996a1e); stroke-width:.6 }
#colorFolderModal .folder-preview .lip-highlight { stroke:rgba(255,255,255,.35); fill:none; stroke-width:.9 }
#colorFolderModal .folder-preview .paper { fill:#fff; stroke:#d0d0d0; stroke-width:.6 }
#colorFolderModal .folder-preview .paper-fold { fill:#ececec }
#colorFolderModal .folder-preview .paper-line { stroke:#c8c8c8; stroke-width:.8 }
#colorFolderModal .folder-preview .label { font-weight:600; user-select:none }
/* High-contrast ghost button just for this modal */
#colorFolderModal .btn-ghost {
background: transparent;
border: 1px solid var(--ghost-border, #cfcfcf);
color: var(--ghost-fg, #222);
padding: 6px 12px;
border-radius: 8px;
}
#colorFolderModal .btn-ghost:hover {
background: var(--ghost-hover-bg, #f5f5f5);
}
#colorFolderModal .btn-ghost:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
body.dark-mode #colorFolderModal .btn-ghost {
--ghost-border: #60636b;
--ghost-fg: #f0f0f0;
--ghost-hover-bg: rgba(255,255,255,.08);
}
</style>
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">${t('color_folder')}: ${escapeHTML(folder)}</h3>
<span id="closeColorFolderModal" class="editor-close-btn" role="button" aria-label="Close">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="folderColorInput" style="display:block;margin-bottom:6px;">${t('choose_color')}</label>
<input type="color" id="folderColorInput" style="width:100%;padding:6px;" value="${defaultHex}"/>
<!-- Live preview -->
<div class="folder-preview" id="folderColorPreview" aria-label="Preview">
<span class="folder-icon" aria-hidden="true">${folderSVG('paper')}</span>
<span class="label">${escapeHTML(folder)}</span>
</div>
<div style="margin-top:12px;display:flex;gap:8px;justify-content:flex-end;">
<button id="resetFolderColorBtn" class="btn btn-ghost">${t('reset_default')}</button>
<button id="saveFolderColorBtn" class="btn btn-primary">${t('save_color')}</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// --- live preview wiring
const previewEl = modal.querySelector('#folderColorPreview');
const inputEl = modal.querySelector('#folderColorInput');
function applyPreview(hex) {
if (!hex || typeof hex !== 'string') return;
const front = hex;
const back = lighten(hex, 14);
const stroke = darken(hex, 22);
previewEl.style.setProperty('--filr-folder-front', front);
previewEl.style.setProperty('--filr-folder-back', back);
previewEl.style.setProperty('--filr-folder-stroke', stroke);
}
applyPreview(defaultHex);
inputEl?.addEventListener('input', () => applyPreview(inputEl.value));
// --- buttons/close
document.getElementById('closeColorFolderModal')?.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
});
document.getElementById('resetFolderColorBtn')?.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
await saveFolderColor(folder, ''); // clear
showToast(t('folder_color_cleared'));
} catch (err) {
showToast(err.message || 'Error');
} finally {
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
}
});
document.getElementById('saveFolderColorBtn')?.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
try {
const hex = String(inputEl.value || '').trim();
await saveFolderColor(folder, hex);
showToast(t('folder_color_saved'));
} finally {
suppressNextToggle(300);
setTimeout(() => expandTreePath(folder, { force: true }), 0);
modal.remove();
}
});
}
/* ---------------------- /* ----------------------
Check Current User's Folder-Only Permission Check Current User's Folder-Only Permission
----------------------*/ ----------------------*/
@@ -287,6 +537,20 @@ async function checkUserFolderPermission() {
} }
} }
// Invalidate client-side folder "non-empty" caches
function invalidateFolderCaches(folder) {
if (!folder) return;
_folderCountCache.delete(folder);
_nonEmptyCache.delete(folder);
_inflightCounts.delete(folder);
}
// Public: force a fresh count + icon for a folder row
export function refreshFolderIcon(folder) {
invalidateFolderCaches(folder);
ensureFolderIcon(folder);
}
// ---------------- SVG icons + icon helpers ---------------- // ---------------- SVG icons + icon helpers ----------------
const _nonEmptyCache = new Map(); const _nonEmptyCache = new Map();
@@ -369,7 +633,9 @@ async function fetchFolderCounts(folder) {
if (_folderCountCache.has(folder)) return _folderCountCache.get(folder); if (_folderCountCache.has(folder)) return _folderCountCache.get(folder);
if (_inflightCounts.has(folder)) return _inflightCounts.get(folder); if (_inflightCounts.has(folder)) return _inflightCounts.get(folder);
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}`; // cache-bust query param to avoid any proxy/cdn caching
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`;
const p = _runCount(url).then(data => { const p = _runCount(url).then(data => {
const result = { const result = {
folders: Number(data?.folders || 0), folders: Number(data?.folders || 0),
@@ -473,15 +739,18 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
return html; return html;
} }
// replace your current expandTreePath with this version
function expandTreePath(path, opts = {}) { function expandTreePath(path, opts = {}) {
const { force = false } = opts; const { force = false, persist = false, includeLeaf = false } = opts;
const state = loadFolderTreeState(); const state = loadFolderTreeState();
const parts = (path || '').split('/').filter(Boolean); const parts = (path || '').split('/').filter(Boolean);
let cumulative = ''; let cumulative = '';
const lastIndex = includeLeaf ? parts.length - 1 : Math.max(0, parts.length - 2);
parts.forEach((part, i) => { parts.forEach((part, i) => {
cumulative = i === 0 ? part : `${cumulative}/${part}`; cumulative = i === 0 ? part : `${cumulative}/${part}`;
if (i > lastIndex) return; // skip leaf unless asked
const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`); const option = document.querySelector(`.folder-option[data-folder="${CSS.escape(cumulative)}"]`);
if (!option) return; if (!option) return;
@@ -489,12 +758,17 @@ function expandTreePath(path, opts = {}) {
const nestedUl = li ? li.querySelector(':scope > ul') : null; const nestedUl = li ? li.querySelector(':scope > ul') : null;
if (!nestedUl) return; if (!nestedUl) return;
// Only expand if caller forces it OR saved state says "block"
const shouldExpand = force || state[cumulative] === 'block'; const shouldExpand = force || state[cumulative] === 'block';
nestedUl.classList.toggle('expanded', shouldExpand); nestedUl.classList.toggle('expanded', shouldExpand);
nestedUl.classList.toggle('collapsed', !shouldExpand); nestedUl.classList.toggle('collapsed', !shouldExpand);
li.setAttribute('aria-expanded', String(!!shouldExpand)); li.setAttribute('aria-expanded', String(!!shouldExpand));
if (persist && shouldExpand) {
state[cumulative] = 'block';
}
}); });
if (persist) saveFolderTreeState(state);
} }
@@ -514,58 +788,72 @@ function folderDropHandler(event) {
event.preventDefault(); event.preventDefault();
event.currentTarget.classList.remove("drop-hover"); event.currentTarget.classList.remove("drop-hover");
const dropFolder = event.currentTarget.getAttribute("data-folder"); const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData = null; let dragData = null;
try { try {
const jsonStr = event.dataTransfer.getData("application/json") || ""; const jsonStr = event.dataTransfer.getData("application/json") || "";
if (jsonStr) dragData = JSON.parse(jsonStr); if (jsonStr) dragData = JSON.parse(jsonStr);
} } catch (e) {
catch (e) {
console.error("Invalid drag data", e); console.error("Invalid drag data", e);
return; return;
} }
/* FOLDER MOVE FALLBACK */
// FOLDER MOVE FALLBACK (folder->folder)
if (!dragData) { if (!dragData) {
const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) || const plain = (event.dataTransfer && event.dataTransfer.getData("application/x-filerise-folder")) ||
(event.dataTransfer && event.dataTransfer.getData("text/plain")) || ""; (event.dataTransfer && event.dataTransfer.getData("text/plain")) || "";
if (plain) { const sourceFolder = String(plain || "").trim();
const sourceFolder = String(plain).trim(); if (!sourceFolder || sourceFolder === "root") return;
if (sourceFolder && sourceFolder !== "root") {
if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) { if (dropFolder === sourceFolder || (dropFolder + "/").startsWith(sourceFolder + "/")) {
showToast("Invalid destination.", 4000); showToast("Invalid destination.", 4000);
return; return;
}
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
if (window.currentFolder && (window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
}
} }
fetchWithCsrf("/api/folder/moveFolder.php", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ source: sourceFolder, destination: dropFolder })
})
.then(safeJson)
.then(data => {
if (data && !data.error) {
showToast(`Folder moved to ${dropFolder}!`);
if (window.currentFolder &&
(window.currentFolder === sourceFolder || window.currentFolder.startsWith(sourceFolder + "/"))) {
const base = sourceFolder.split("/").pop();
const newPath = (dropFolder === "root" ? "" : dropFolder + "/") + base;
// carry color without await
const oldColor = window.folderColorMap[sourceFolder];
if (oldColor) {
saveFolderColor(newPath, oldColor)
.then(() => saveFolderColor(sourceFolder, ''))
.catch(() => { });
}
window.currentFolder = newPath;
}
return loadFolderTree().then(() => {
try { expandTreePath(window.currentFolder || "root", { persist: false, includeLeaf: false }); } catch (_) { }
loadFileList(window.currentFolder || "root");
});
} else {
showToast("Error: " + (data && data.error || "Could not move folder"), 5000);
}
})
.catch(err => {
console.error("Error moving folder:", err);
showToast("Error moving folder", 5000);
});
return; return;
} }
// File(s) drop path (unchanged)
const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []); const filesToMove = dragData.files ? dragData.files : (dragData.fileName ? [dragData.fileName] : []);
if (filesToMove.length === 0) return; if (filesToMove.length === 0) return;
@@ -584,6 +872,8 @@ function folderDropHandler(event) {
if (data.success) { if (data.success) {
showToast(`File(s) moved successfully to ${dropFolder}!`); showToast(`File(s) moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder); loadFileList(dragData.sourceFolder);
refreshFolderIcon(dragData.sourceFolder);
refreshFolderIcon(dropFolder);
} else { } else {
showToast("Error moving files: " + (data.error || "Unknown error")); showToast("Error moving files: " + (data.error || "Unknown error"));
} }
@@ -721,6 +1011,11 @@ export async function loadFolderTree(selectedFolder) {
} }
container.innerHTML = html; container.innerHTML = html;
await loadFolderColors();
try { applyAllFolderColors(container); } catch (e) {
console.warn('applyAllFolderColors failed:', e);
}
const st = loadFolderTreeState(); const st = loadFolderTreeState();
const rootUl = container.querySelector('#rootRow + ul'); const rootUl = container.querySelector('#rootRow + ul');
if (rootUl) { if (rootUl) {
@@ -777,7 +1072,7 @@ export async function loadFolderTree(selectedFolder) {
// Show ancestors so the current selection is visible, but don't persist // Show ancestors so the current selection is visible, but don't persist
if (window.currentFolder && window.currentFolder !== effectiveRoot) { if (window.currentFolder && window.currentFolder !== effectiveRoot) {
expandTreePath(window.currentFolder, { persist: false, includeLeaf: false }); expandTreePath(window.currentFolder, { force: true, persist: true, includeLeaf: false });
} }
const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`); const selectedEl = container.querySelector(`.folder-option[data-folder="${window.currentFolder}"]`);
@@ -811,45 +1106,48 @@ export async function loadFolderTree(selectedFolder) {
}); });
}); });
// Root toggle // --- One delegated toggle handler (robust) ---
const rootToggle = container.querySelector("#rootRow .folder-toggle"); (function bindToggleDelegation() {
if (rootToggle) { const container = document.getElementById('folderTreeContainer');
rootToggle.addEventListener("click", function (e) { if (!container || container._toggleBound) return;
container._toggleBound = true;
container.addEventListener('click', (e) => {
if (performance.now() < _suppressToggleUntil) {
e.stopPropagation();
e.preventDefault();
return;
}
const btn = e.target.closest('button.folder-toggle');
if (!btn || !container.contains(btn)) return;
e.stopPropagation(); e.stopPropagation();
const nestedUl = container.querySelector("#rootRow + ul");
if (!nestedUl) return;
const state = loadFolderTreeState(); const folderPath = btn.getAttribute('data-folder');
const expanded = !(nestedUl.classList.contains("expanded")); let siblingUl = null;
nestedUl.classList.toggle("expanded", expanded); let expandedTarget = null;
nestedUl.classList.toggle("collapsed", !expanded);
document.getElementById("rootRow").setAttribute("aria-expanded", String(expanded)); // Root toggle?
state[effectiveRoot] = expanded ? "block" : "none"; if (btn.closest('#rootRow')) {
saveFolderTreeState(state); siblingUl = container.querySelector('#rootRow + ul');
}); expandedTarget = document.getElementById('rootRow');
} } else {
const li = btn.closest('li[role="treeitem"]');
// Other toggles if (!li) return;
siblingUl = li.querySelector(':scope > ul');
container.querySelectorAll("button.folder-toggle").forEach(toggle => { expandedTarget = li;
toggle.addEventListener("click", function (e) { }
e.stopPropagation();
const li = this.closest('li[role="treeitem"]');
const siblingUl = li ? li.querySelector(':scope > ul') : null;
const folderPath = this.getAttribute("data-folder");
if (!siblingUl) return; if (!siblingUl) return;
const expanded = !siblingUl.classList.contains('expanded');
siblingUl.classList.toggle('expanded', expanded);
siblingUl.classList.toggle('collapsed', !expanded);
if (expandedTarget) expandedTarget.setAttribute('aria-expanded', String(expanded));
const state = loadFolderTreeState(); const state = loadFolderTreeState();
const expanded = !(siblingUl.classList.contains("expanded")); state[folderPath] = expanded ? 'block' : 'none';
siblingUl.classList.toggle("expanded", expanded);
siblingUl.classList.toggle("collapsed", !expanded);
li.setAttribute("aria-expanded", String(expanded));
state[folderPath] = expanded ? "block" : "none";
saveFolderTreeState(state); saveFolderTreeState(state);
ensureFolderIcon(folderPath); }, true);
}); })();
});
} catch (error) { } catch (error) {
console.error("Error loading folder tree:", error); console.error("Error loading folder tree:", error);
@@ -928,6 +1226,16 @@ if (submitRename) {
if (data.success) { if (data.success) {
showToast("Folder renamed successfully!"); showToast("Folder renamed successfully!");
window.currentFolder = newFolderFull; window.currentFolder = newFolderFull;
// carry color without await
const oldPath = selectedFolder;
const oldColor = window.folderColorMap[oldPath];
if (oldColor) {
saveFolderColor(newFolderFull, oldColor)
.then(() => saveFolderColor(oldPath, ''))
.catch(() => { });
}
localStorage.setItem("lastOpenedFolder", newFolderFull); localStorage.setItem("lastOpenedFolder", newFolderFull);
loadFolderList(newFolderFull); loadFolderList(newFolderFull);
} else { } else {
@@ -1020,6 +1328,8 @@ if (confirmDelete) {
if (data.success) { if (data.success) {
showToast("Folder deleted successfully!"); showToast("Folder deleted successfully!");
window.currentFolder = getParentFolder(selectedFolder); window.currentFolder = getParentFolder(selectedFolder);
const parentForIcon = getParentFolder(selectedFolder);
refreshFolderIcon(parentForIcon);
localStorage.setItem("lastOpenedFolder", window.currentFolder); localStorage.setItem("lastOpenedFolder", window.currentFolder);
loadFolderList(window.currentFolder); loadFolderList(window.currentFolder);
} else { } else {
@@ -1083,6 +1393,8 @@ if (submitCreate) {
.then(data => { .then(data => {
if (!data.success) throw new Error(data.error || "Server rejected the request"); if (!data.success) throw new Error(data.error || "Server rejected the request");
showToast("Folder created!"); showToast("Folder created!");
const parentForIcon = parent || 'root';
refreshFolderIcon(parentForIcon);
const full = parent ? `${parent}/${folderInput}` : folderInput; const full = parent ? `${parent}/${folderInput}` : folderInput;
window.currentFolder = full; window.currentFolder = full;
localStorage.setItem("lastOpenedFolder", full); localStorage.setItem("lastOpenedFolder", full);
@@ -1101,6 +1413,45 @@ if (submitCreate) {
} }
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ---------- // ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
async function folderManagerContextMenuHandler(e) {
const target = e.target.closest(".folder-option, .breadcrumb-link");
if (!target) return;
e.preventDefault();
e.stopPropagation();
const folder = target.getAttribute("data-folder");
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder); // <-- await ensures fresh caps
// Visual selection
document.querySelectorAll(".folder-option, .breadcrumb-link")
.forEach(el => el.classList.remove("selected"));
target.classList.add("selected");
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canRename);
const menuItems = [
{
label: t("create_folder"), action: () => {
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
if (input) input.focus();
}
},
{ label: t("move_folder"), action: () => openMoveFolderUI(folder) },
{ label: t("rename_folder"), action: () => openRenameFolderModal() },
...(canColor ? [{ label: t("color_folder"), action: () => openColorFolderModal(folder) }] : []),
{ label: t("folder_share"), action: () => openFolderShareModal(folder) },
{ label: t("delete_folder"), action: () => openDeleteFolderModal() }
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
}
export function showFolderManagerContextMenu(x, y, menuItems) { export function showFolderManagerContextMenu(x, y, menuItems) {
let menu = document.getElementById("folderManagerContextMenu"); let menu = document.getElementById("folderManagerContextMenu");
if (!menu) { if (!menu) {
@@ -1112,6 +1463,7 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.zIndex = "9999"; menu.style.zIndex = "9999";
document.body.appendChild(menu); document.body.appendChild(menu);
} }
if (document.body.classList.contains("dark-mode")) { if (document.body.classList.contains("dark-mode")) {
menu.style.backgroundColor = "#2c2c2c"; menu.style.backgroundColor = "#2c2c2c";
menu.style.border = "1px solid #555"; menu.style.border = "1px solid #555";
@@ -1121,18 +1473,16 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.border = "1px solid #ccc"; menu.style.border = "1px solid #ccc";
menu.style.color = "#000"; menu.style.color = "#000";
} }
menu.innerHTML = ""; menu.innerHTML = "";
menuItems.forEach(item => { menuItems.forEach(item => {
const menuItem = document.createElement("div"); const menuItem = document.createElement("div");
menuItem.textContent = item.label; menuItem.textContent = item.label;
menuItem.style.padding = "5px 15px"; menuItem.style.padding = "5px 15px";
menuItem.style.cursor = "pointer"; menuItem.style.cursor = "pointer";
menuItem.addEventListener("mouseover", () => { menuItem.addEventListener("mouseover", () => {
if (document.body.classList.contains("dark-mode")) { menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
menuItem.style.backgroundColor = "#444";
} else {
menuItem.style.backgroundColor = "#f0f0f0";
}
}); });
menuItem.addEventListener("mouseout", () => { menuItem.addEventListener("mouseout", () => {
menuItem.style.backgroundColor = ""; menuItem.style.backgroundColor = "";
@@ -1141,75 +1491,26 @@ export function showFolderManagerContextMenu(x, y, menuItems) {
item.action(); item.action();
hideFolderManagerContextMenu(); hideFolderManagerContextMenu();
}); });
menu.appendChild(menuItem); menu.appendChild(menuItem);
}); });
menu.style.left = x + "px";
menu.style.top = y + "px"; menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = "block"; menu.style.display = "block";
} }
export function hideFolderManagerContextMenu() { export function hideFolderManagerContextMenu() {
const menu = document.getElementById("folderManagerContextMenu"); const menu = document.getElementById("folderManagerContextMenu");
if (menu) { if (menu) menu.style.display = "none";
menu.style.display = "none";
}
}
function folderManagerContextMenuHandler(e) {
const target = e.target.closest(".folder-option, .breadcrumb-link");
if (!target) return;
e.preventDefault();
e.stopPropagation();
const folder = target.getAttribute("data-folder");
if (!folder) return;
window.currentFolder = folder;
applyFolderCapabilities(window.currentFolder);
// Visual selection
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));
target.classList.add("selected");
const menuItems = [
{
label: t("create_folder"),
action: () => {
const modal = document.getElementById("createFolderModal");
const input = document.getElementById("newFolderName");
if (modal) modal.style.display = "block";
if (input) input.focus();
}
},
{
label: t("move_folder"),
action: () => { openMoveFolderUI(folder); }
},
{
label: t("rename_folder"),
action: () => { openRenameFolderModal(); }
},
{
label: t("folder_share"),
action: () => { openFolderShareModal(folder); }
},
{
label: t("delete_folder"),
action: () => { openDeleteFolderModal(); }
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
} }
// Delegate contextmenu so it works with dynamically re-rendered breadcrumbs // Delegate contextmenu so it works with dynamically re-rendered breadcrumbs
function bindFolderManagerContextMenu() { function bindFolderManagerContextMenu() {
const tree = document.getElementById("folderTreeContainer"); const tree = document.getElementById("folderTreeContainer");
if (tree) { if (tree) {
// remove old bound handler if present if (tree._ctxHandler) tree.removeEventListener("contextmenu", tree._ctxHandler, false);
if (tree._ctxHandler) { tree._ctxHandler = (e) => {
tree.removeEventListener("contextmenu", tree._ctxHandler, false);
}
tree._ctxHandler = function (e) {
const onOption = e.target.closest(".folder-option"); const onOption = e.target.closest(".folder-option");
if (!onOption) return; if (!onOption) return;
folderManagerContextMenuHandler(e); folderManagerContextMenuHandler(e);
@@ -1219,10 +1520,8 @@ function bindFolderManagerContextMenu() {
const title = document.getElementById("fileListTitle"); const title = document.getElementById("fileListTitle");
if (title) { if (title) {
if (title._ctxHandler) { if (title._ctxHandler) title.removeEventListener("contextmenu", title._ctxHandler, false);
title.removeEventListener("contextmenu", title._ctxHandler, false); title._ctxHandler = (e) => {
}
title._ctxHandler = function (e) {
const onCrumb = e.target.closest(".breadcrumb-link"); const onCrumb = e.target.closest(".breadcrumb-link");
if (!onCrumb) return; if (!onCrumb) return;
folderManagerContextMenuHandler(e); folderManagerContextMenuHandler(e);
@@ -1266,6 +1565,22 @@ document.addEventListener("DOMContentLoaded", function () {
} }
}); });
document.addEventListener("DOMContentLoaded", function () {
const colorFolderBtn = document.getElementById("colorFolderBtn");
if (colorFolderBtn) {
colorFolderBtn.addEventListener("click", () => {
const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") {
showToast(t('please_select_valid_folder') || "Please select a valid folder.");
return;
}
openColorFolderModal(selectedFolder);
});
} else {
console.warn("colorFolderBtn element not found in the DOM.");
}
});
// Initial context menu delegation bind // Initial context menu delegation bind
bindFolderManagerContextMenu(); bindFolderManagerContextMenu();
@@ -1309,6 +1624,13 @@ document.addEventListener("DOMContentLoaded", () => {
await loadFolderTree(); await loadFolderTree();
const base = source.split('/').pop(); const base = source.split('/').pop();
const newPath = (destination === 'root' ? '' : destination + '/') + base; const newPath = (destination === 'root' ? '' : destination + '/') + base;
const oldColor = window.folderColorMap[source];
if (oldColor) {
try {
await saveFolderColor(newPath, oldColor);
await saveFolderColor(source, '');
} catch (_) { }
}
window.currentFolder = newPath; window.currentFolder = newPath;
loadFileList(window.currentFolder || 'root'); loadFileList(window.currentFolder || 'root');
} else { } else {

View File

@@ -312,7 +312,13 @@ const translations = {
"previous": "Previous", "previous": "Previous",
"next": "Next", "next": "Next",
"watched": "Watched", "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: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -2,7 +2,7 @@
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}'; import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}'; import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}'; import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) { function showConfirm(message, onConfirm) {
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
toggleVisibility("restoreFilesModal", false); toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
refreshFolderIcon(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);

View File

@@ -3,6 +3,7 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}'; import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
/* ----------------------------------------------------- /* -----------------------------------------------------
@@ -588,7 +589,7 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none"; if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000); setTimeout(() => li.remove(), 5000);
} }
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

50
scripts/manual-sync.sh Normal file
View File

@@ -0,0 +1,50 @@
# === Update FileRise to v1.9.1 (safe rsync) ===
set -euo 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 -L "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"
composer install --no-dev --optimize-autoloader
fi
# 6) Reload Apache
systemctl reload apache2
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."

File diff suppressed because it is too large Load Diff

View File

@@ -10,23 +10,38 @@ class ACL
private static $path = null; private static $path = null;
private const BUCKETS = [ private const BUCKETS = [
'owners','read','write','share','read_own', 'owners',
'create','upload','edit','rename','copy','move','delete','extract', 'read',
'share_file','share_folder' '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'; if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path; 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"); $f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root'; if ($f === '' || $f === 'root') return 'root';
return $f; return $f;
} }
public static function purgeUser(string $user): bool { public static function purgeUser(string $user): bool
{
$user = (string)$user; $user = (string)$user;
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$changed = false; $changed = false;
@@ -41,49 +56,107 @@ class ACL
return $changed ? self::save($acl) : true; return $changed ? self::save($acl) : true;
} }
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
{ {
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true; if (self::hasGrant($user, $folder, 'owners')) return true;
$folder = trim($folder, "/\\ "); $folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false; if ($folder === '' || $folder === 'root') return false;
$parts = explode('/', $folder); $parts = explode('/', $folder);
while (count($parts) > 1) { while (count($parts) > 1) {
array_pop($parts); array_pop($parts);
$parent = implode('/', $parts); $parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true; 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/... */ /** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void public static function renameTree(string $oldFolder, string $newFolder): void
{ {
$old = self::normalizeFolder($oldFolder); $old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder); $new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root if ($old === '' || $old === 'root') return; // nothing to re-key for root
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return; if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$rebased = []; $rebased = [];
foreach ($acl['folders'] as $k => $rec) { foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) { if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old)); $suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/'); $suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : ''); $newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec; $rebased[$newKey] = $rec;
} else { } else {
$rebased[$k] = $rec; $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(); $path = self::path();
if (!is_file($path)) { if (!is_file($path)) {
@mkdir(dirname($path), 0755, true); @mkdir(dirname($path), 0755, true);
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
'read' => ['admin'], 'read' => ['admin'],
'write' => ['admin'], 'write' => ['admin'],
'share' => ['admin'], 'share' => ['admin'],
'read_own'=> [], 'read_own' => [],
'create' => [], 'create' => [],
'upload' => [], 'upload' => [],
'edit' => [], 'edit' => [],
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$healed = false; $healed = false;
foreach ($data['folders'] as $folder => &$rec) { 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) { foreach (self::BUCKETS as $k) {
$v = $rec[$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))); $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); unset($rec);
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return $data; 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; $ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl; if ($ok) self::$cache = $acl;
return $ok; 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(); $acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null; $f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : []; 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); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) { 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($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true; if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true; if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true; if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username']) if (
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) { defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
) {
return true; return true;
} }
return false; 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); $folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap; $capKey = ($cap === 'owner') ? 'owners' : $cap;
$arr = self::listFor($folder, $capKey); $arr = self::listFor($folder, $capKey);
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return false; 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; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners'); 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); 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); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read'); || 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; if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own'); 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); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || 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); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') 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) // 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); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? []; $rec = $acl['folders'][$folder] ?? [];
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
} }
// New: full explicit including granular // New: full explicit including granular
public static function explicitAll(string $folder): array { public static function explicitAll(string $folder): array
{
$folder = self::normalizeFolder($folder); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? []; $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); $folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh(); $acl = self::$cache ?? self::loadFresh();
$existing = $acl['folders'][$folder] ?? ['read_own' => []]; $existing = $acl['folders'][$folder] ?? ['read_own' => []];
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return self::save($acl); 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; $user = (string)$user;
$path = self::path(); $path = self::path();
$fh = @fopen($path, 'c+'); $fh = @fopen($path, 'c+');
if (!$fh) throw new RuntimeException('Cannot open ACL storage'); 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 { try {
$raw = stream_get_contents($fh); $raw = stream_get_contents($fh);
if ($raw === false) $raw = ''; if ($raw === false) $raw = '';
$acl = json_decode($raw, true); $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['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = []; 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) { foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder); $ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = []; 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) { foreach (self::BUCKETS as $k) {
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$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']); $sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']); $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 ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = 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 ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $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 ($dl) $rec['delete'][] = $user;
if ($ex) $rec['extract'][] = $user; if ($ex) $rec['extract'][] = $user;
if ($sf) $rec['share_file'][] = $user; if ($sf) $rec['share_file'][] = $user;
if ($sfo)$rec['share_folder'][] = $user; if ($sfo) $rec['share_folder'][] = $user;
foreach (self::BUCKETS as $k) { foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$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 { public static function canCreate(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'create') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'create')
} || self::hasGrant($user, $folder, 'write');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool { public static function canCreateFolder(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
// Only owners/managers can create subfolders under $folder if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners'); // 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 { public static function canUpload(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'upload') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'upload')
} || self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool { public static function canEdit(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'edit') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'edit')
} || self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool { public static function canRename(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'rename') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'rename')
} || self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool { public static function canCopy(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'copy') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || self::hasGrant($user, $folder, 'copy')
} || self::hasGrant($user, $folder, 'write');
}
public static function canMove(string $user, array $perms, string $folder): bool { public static function canMove(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::ownsFolderOrAncestor($user, $perms, $folder); if (self::isAdmin($perms)) return true;
} return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool { public static function canMoveFolder(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::ownsFolderOrAncestor($user, $perms, $folder); if (self::isAdmin($perms)) return true;
} return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canDelete(string $user, array $perms, string $folder): bool { public static function canDelete(string $user, array $perms, string $folder): bool
$folder = self::normalizeFolder($folder); {
if (self::isAdmin($perms)) return true; $folder = self::normalizeFolder($folder);
return self::hasGrant($user, $folder, 'owners') if (self::isAdmin($perms)) return true;
|| self::hasGrant($user, $folder, 'delete') return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write'); || 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. */ /** 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); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); 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); $folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true; if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share'); $can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');

90
src/models/FolderMeta.php Normal file
View 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];
}
}