Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405ed7f925 | ||
|
|
6491a7b1b3 | ||
|
|
3a5f5fcfd9 | ||
|
|
a4efa4ff45 | ||
|
|
acac4235ad |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,9 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 12/6/2025 (v2.3.6)
|
||||
|
||||
release(v2.3.6): add non-zip multi-download, richer hover preview/peak, modified sort default
|
||||
|
||||
- download: add "Download (no ZIP)" bulk action
|
||||
- New context-menu action to download multiple selected files individually without creating a ZIP.
|
||||
- Shows a centered stepper panel with "Download next" / "Cancel" while walking the queue.
|
||||
- Limits plain multi-downloads (default 20) and nudges user to ZIP for larger batches.
|
||||
- Uses existing /api/file/download.php URLs and respects current folder + selection.
|
||||
|
||||
- hover preview/peak: richer folder/file details and safer snippets
|
||||
- Folder hover now shows:
|
||||
- Icon + path
|
||||
- Owner (from folder caps, when available)
|
||||
- "Your access" summary (Upload / Move / Rename / Share / Delete) based on capabilities.
|
||||
- Created / Modified timestamps derived from folder stats.
|
||||
- Peek into child items (📁 / 📄) with trimmed labels and a clean "…" when truncated.
|
||||
- File hover now adds:
|
||||
- Tags/metadata line (tag names + MIME, duration, resolution when present).
|
||||
- Text snippets are now capped per-line and by total characters to avoid huge blocks and keep previews/peak tidy.
|
||||
|
||||
- sorting: modified-desc default and folder stats for created/modified
|
||||
- Default sort for the file list is now `Modified ↓` (newest first), matching typical Explorer-style views.
|
||||
- Folders respect Created/Uploaded and Modified sort using folder stats:
|
||||
- Created/Uploaded uses `earliest_uploaded`.
|
||||
- Modified uses `latest_mtime`.
|
||||
- Added a shared compareFilesForSort() so table view and gallery view use the same sort pipeline.
|
||||
- Inline folders still render A>Z by name, so tree/folder strip remain predictable.
|
||||
|
||||
- UX / plumbing
|
||||
- Added i18n strings for the new download queue labels and permission names ("Your access", Upload/Move/Rename/Share/Delete).
|
||||
- Reset hover snippet styling per-row so folder previews and file previews each get the right wrapping behavior.
|
||||
- Exported downloadSelectedFilesIndividually on window for file context menu integration and optional debugging helpers.
|
||||
- Changed default file list row height from 48px to 44px.
|
||||
|
||||
---
|
||||
|
||||
## Changese 12/6/2025 (v2.3.5)
|
||||
|
||||
release(v2.3.5): make client portals ACL-aware and improve admin UX
|
||||
|
||||
- Wire PortalController into ACL.php and expose canUpload/canDownload flags
|
||||
- Gate portal uploads/downloads on both portal flags and folder ACL for logged-in users
|
||||
- Normalize legacy portal JSON (uploadOnly) with new allowDownload checkbox semantics
|
||||
- Disable portal upload UI when uploads are turned off; hide refresh when downloads are disabled
|
||||
- Improve portal subtitles (“Upload & download”, “Upload only”, etc.) and status messaging
|
||||
- Add quick-access buttons in Client Portals modal for Add user, Folder access, and User groups
|
||||
- Enforce slug + folder as required on both frontend and backend, with inline hints and scroll-to-first-error
|
||||
- Auto-focus newly created portals’ folder input for faster setup
|
||||
- Raise user permissions modal z-index so it appears above the portals modal
|
||||
- Enhance portal form submission logging with better client IP detection (X-Forwarded-For / X-Real-IP aware)
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/5/2025 (v2.3.4)
|
||||
|
||||
release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL
|
||||
|
||||
---
|
||||
|
||||
## Changes 12/5/2025 (v2.3.3)
|
||||
|
||||
release(v2.3.3): footer branding, Pro bundle UX + file list polish
|
||||
|
||||
@@ -27,7 +27,7 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
|
||||
|
||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||

|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
@@ -58,6 +58,27 @@ try {
|
||||
require_once $subPath;
|
||||
|
||||
$submittedBy = (string)($_SESSION['username'] ?? '');
|
||||
|
||||
// ─────────────────────────────
|
||||
// Better client IP detection
|
||||
// ─────────────────────────────
|
||||
$ip = '';
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
// Can be a comma-separated list; use the first non-empty
|
||||
$parts = explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
foreach ($parts as $part) {
|
||||
$candidate = trim($part);
|
||||
if ($candidate !== '') {
|
||||
$ip = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} elseif (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
$ip = trim((string)$_SERVER['HTTP_X_REAL_IP']);
|
||||
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
|
||||
$ip = trim((string)$_SERVER['REMOTE_ADDR']);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'slug' => $slug,
|
||||
'portalLabel' => $portal['label'] ?? '',
|
||||
@@ -69,7 +90,7 @@ try {
|
||||
'notes' => $notes,
|
||||
],
|
||||
'submittedBy' => $submittedBy,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
|
||||
'ip' => $ip,
|
||||
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'createdAt' => gmdate('c'),
|
||||
];
|
||||
|
||||
@@ -474,25 +474,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
||||
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
|
||||
<div class="sep" data-when="always"></div>
|
||||
|
||||
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
|
||||
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
|
||||
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
|
||||
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
|
||||
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
|
||||
|
||||
<div class="sep" data-when="any"></div>
|
||||
|
||||
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
|
||||
|
||||
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
|
||||
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
|
||||
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
|
||||
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
|
||||
</div>
|
||||
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
|
||||
<button type="button" class="mi"
|
||||
data-action="create_file"
|
||||
data-when="always">
|
||||
<i class="material-icons">note_add</i>
|
||||
<span>Create file</span>
|
||||
</button>
|
||||
<div class="sep" data-when="always"></div>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="delete_selected"
|
||||
data-when="any">
|
||||
<i class="material-icons">delete</i>
|
||||
<span>Delete selected</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="copy_selected"
|
||||
data-when="any">
|
||||
<i class="material-icons">content_copy</i>
|
||||
<span>Copy selected</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="move_selected"
|
||||
data-when="any">
|
||||
<i class="material-icons">drive_file_move</i>
|
||||
<span>Move selected</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="download_zip"
|
||||
data-when="any">
|
||||
<i class="material-icons">archive</i>
|
||||
<span>Download as ZIP</span>
|
||||
</button>
|
||||
|
||||
<!-- NEW: multi-download without ZIP -->
|
||||
<button type="button" class="mi"
|
||||
data-action="download_plain"
|
||||
data-when="any">
|
||||
<i class="material-icons">file_download</i>
|
||||
<span>Download (no ZIP)</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="extract_zip"
|
||||
data-when="zip">
|
||||
<i class="material-icons">unarchive</i>
|
||||
<span>Extract ZIP</span>
|
||||
</button>
|
||||
|
||||
<div class="sep" data-when="any"></div>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="tag_selected"
|
||||
data-when="many">
|
||||
<i class="material-icons">sell</i>
|
||||
<span>Tag selected</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="preview"
|
||||
data-when="one">
|
||||
<i class="material-icons">visibility</i>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="edit"
|
||||
data-when="can-edit">
|
||||
<i class="material-icons">edit</i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="rename"
|
||||
data-when="one">
|
||||
<i class="material-icons">drive_file_rename_outline</i>
|
||||
<span>Rename</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mi"
|
||||
data-action="tag_file"
|
||||
data-when="one">
|
||||
<i class="material-icons">sell</i>
|
||||
<span>Tag file</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="removeUserModal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<h3 data-i18n-key="remove_user_title">Remove User</h3>
|
||||
|
||||
@@ -1968,7 +1968,7 @@ export function openUserPermissionsModal() {
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background-color: ${overlayBackground};
|
||||
display: flex; justify-content: center; align-items: center;
|
||||
z-index: 3500;
|
||||
z-index: 10000;
|
||||
`;
|
||||
userPermissionsModal.innerHTML = `
|
||||
<div class="modal-content" style="${modalContentStyles}">
|
||||
|
||||
@@ -217,6 +217,9 @@ let __portalsCache = {};
|
||||
let __portalFolderListLoaded = false;
|
||||
let __portalFolderOptions = [];
|
||||
|
||||
// Remember a newly-created portal to focus its folder field
|
||||
let __portalSlugToFocus = null;
|
||||
|
||||
// Cache portal submissions per slug for CSV export
|
||||
const __portalSubmissionsCache = {};
|
||||
|
||||
@@ -279,13 +282,38 @@ export async function openClientPortalsModal() {
|
||||
(and optionally download) files without seeing your full FileRise UI.
|
||||
</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
||||
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||
<span style="margin-left:4px;">Add portal</span>
|
||||
</button>
|
||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center" style="margin:8px 0 10px;">
|
||||
<div>
|
||||
<button type="button" id="addPortalBtn" class="btn btn-sm btn-success">
|
||||
<i class="material-icons" style="font-size:16px;">cloud_upload</i>
|
||||
<span style="margin-left:4px;">Add portal</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
id="clientPortalsQuickAddUser"
|
||||
class="btn btn-sm btn-outline-primary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">person_add</i>
|
||||
<span style="margin-left:4px;">Add user…</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="clientPortalsOpenUserPerms"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">folder_shared</i>
|
||||
<span style="margin-left:4px;">Folder access…</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="clientPortalsOpenUserGroups"
|
||||
class="btn btn-sm btn-outline-secondary ms-1">
|
||||
<i class="material-icons" style="font-size:16px; vertical-align:middle;">groups</i>
|
||||
<span style="margin-left:4px;">User groups…</span>
|
||||
</button>
|
||||
</div>
|
||||
<span id="clientPortalsStatus" class="small text-muted"></span>
|
||||
</div>
|
||||
|
||||
<div id="clientPortalsBody" style="max-height:60vh; overflow:auto; margin-bottom:12px;">
|
||||
${t('loading')}…
|
||||
@@ -303,6 +331,41 @@ export async function openClientPortalsModal() {
|
||||
document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none');
|
||||
document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI;
|
||||
document.getElementById('addPortalBtn').onclick = addEmptyPortalRow;
|
||||
const quickAddUserBtn = document.getElementById('clientPortalsQuickAddUser');
|
||||
if (quickAddUserBtn) {
|
||||
quickAddUserBtn.onclick = () => {
|
||||
// Reuse existing admin add-user button / modal
|
||||
const globalBtn = document.getElementById('adminOpenAddUser');
|
||||
if (globalBtn) {
|
||||
globalBtn.click();
|
||||
} else {
|
||||
showToast('Use the Users tab to add a new user.');
|
||||
}
|
||||
};
|
||||
}
|
||||
const openPermsBtn = document.getElementById('clientPortalsOpenUserPerms');
|
||||
if (openPermsBtn) {
|
||||
openPermsBtn.onclick = () => {
|
||||
const btn = document.getElementById('adminOpenUserPermissions');
|
||||
if (btn) {
|
||||
btn.click();
|
||||
} else {
|
||||
showToast('Use the Users tab to edit folder access.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const openGroupsBtn = document.getElementById('clientPortalsOpenUserGroups');
|
||||
if (openGroupsBtn) {
|
||||
openGroupsBtn.onclick = () => {
|
||||
const btn = document.getElementById('adminOpenUserGroups');
|
||||
if (btn) {
|
||||
btn.click();
|
||||
} else {
|
||||
showToast('Use the Users tab to manage user groups.');
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
modal.style.background = overlayBg;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
@@ -358,7 +421,22 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
const folder = p.folder || '';
|
||||
const clientEmail = p.clientEmail || '';
|
||||
const uploadOnly = !!p.uploadOnly;
|
||||
const allowDownload = p.allowDownload !== false; // default true
|
||||
|
||||
// Backwards compat:
|
||||
// - Old portals only had "uploadOnly":
|
||||
// uploadOnly = true => upload yes, download no
|
||||
// uploadOnly = false => upload yes, download yes
|
||||
// - New portals have explicit allowDownload.
|
||||
let allowDownload;
|
||||
if (Object.prototype.hasOwnProperty.call(p, 'allowDownload')) {
|
||||
allowDownload = p.allowDownload !== false;
|
||||
} else {
|
||||
// Legacy: "upload only" meant no download
|
||||
allowDownload = !uploadOnly;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : '';
|
||||
const brandColor = p.brandColor || '';
|
||||
const footerText = p.footerText || '';
|
||||
@@ -419,8 +497,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
|
||||
<div class="portal-card-body">
|
||||
<div class="portal-meta-row">
|
||||
<label style="font-weight:600;">
|
||||
Portal slug:
|
||||
<label style="font-weight:600;">
|
||||
Portal slug<span class="text-danger">*</span>:
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
data-portal-field="slug"
|
||||
@@ -439,8 +517,8 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
|
||||
<div class="portal-meta-row">
|
||||
<div class="portal-folder-row">
|
||||
<label>
|
||||
Folder:
|
||||
<label>
|
||||
Folder<span class="text-danger">*</span>:
|
||||
<input type="text"
|
||||
class="form-control form-control-sm portal-folder-input"
|
||||
data-portal-field="folder"
|
||||
@@ -482,11 +560,11 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
<input type="checkbox"
|
||||
data-portal-field="uploadOnly"
|
||||
${uploadOnly ? 'checked' : ''} />
|
||||
<span>Upload only</span>
|
||||
<span>Allow upload</span>
|
||||
</label>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:4px;">
|
||||
@@ -495,7 +573,6 @@ async function loadClientPortalsList(useCacheOnly) {
|
||||
${allowDownload ? 'checked' : ''} />
|
||||
<span>Allow download</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:8px;">
|
||||
<div class="form-group" style="margin-bottom:6px;">
|
||||
@@ -840,6 +917,32 @@ body.querySelectorAll('[data-portal-action="delete"]').forEach(btn => {
|
||||
card.remove();
|
||||
});
|
||||
});
|
||||
// After rendering, if we have a "new" portal to focus, expand it and focus Folder
|
||||
if (__portalSlugToFocus) {
|
||||
const focusSlug = __portalSlugToFocus;
|
||||
__portalSlugToFocus = null;
|
||||
|
||||
const focusCard = body.querySelector(`.portal-card[data-portal-slug="${focusSlug}"]`);
|
||||
if (focusCard) {
|
||||
const header = focusCard.querySelector('.portal-card-header');
|
||||
const bodyEl = focusCard.querySelector('.portal-card-body');
|
||||
const caret = focusCard.querySelector('.portal-card-caret');
|
||||
|
||||
if (header && bodyEl) {
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
bodyEl.style.display = 'block';
|
||||
if (caret) caret.textContent = '▾';
|
||||
}
|
||||
|
||||
const folderInput = focusCard.querySelector('[data-portal-field="folder"]');
|
||||
if (folderInput) {
|
||||
folderInput.focus();
|
||||
folderInput.select();
|
||||
}
|
||||
|
||||
focusCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
// Keep submissions viewer working
|
||||
attachPortalSubmissionsUI();
|
||||
// Intake presets dropdowns
|
||||
@@ -881,6 +984,8 @@ function addEmptyPortalRow() {
|
||||
expiresAt: ''
|
||||
};
|
||||
|
||||
// After re-render, auto-focus this portal's folder field
|
||||
__portalSlugToFocus = slug;
|
||||
loadClientPortalsList(true);
|
||||
}
|
||||
|
||||
@@ -1421,6 +1526,48 @@ async function saveClientPortalsFromUI() {
|
||||
|
||||
const cards = body.querySelectorAll('.card[data-portal-slug]');
|
||||
const portals = {};
|
||||
const invalid = [];
|
||||
let firstInvalidField = null;
|
||||
|
||||
// Clear previous visual errors
|
||||
cards.forEach(card => {
|
||||
card.style.boxShadow = '';
|
||||
card.style.borderColor = '';
|
||||
card.classList.remove('portal-card-has-error');
|
||||
|
||||
const hint = card.querySelector('.portal-card-error-hint');
|
||||
if (hint) hint.remove();
|
||||
});
|
||||
|
||||
const markCardMissingRequired = (card, message) => {
|
||||
// Mark visually
|
||||
card.classList.add('portal-card-has-error');
|
||||
card.style.borderColor = '#dc3545';
|
||||
card.style.boxShadow = '0 0 0 2px rgba(220,53,69,0.6)';
|
||||
|
||||
// Expand the card so the error is visible even if it was collapsed
|
||||
const header = card.querySelector('.portal-card-header');
|
||||
const bodyEl = card.querySelector('.portal-card-body') || card;
|
||||
const caret = card.querySelector('.portal-card-caret');
|
||||
|
||||
if (header && bodyEl) {
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
bodyEl.style.display = 'block';
|
||||
if (caret) caret.textContent = '▾';
|
||||
}
|
||||
|
||||
// Small inline hint at top of the card body
|
||||
let hint = bodyEl.querySelector('.portal-card-error-hint');
|
||||
if (!hint) {
|
||||
hint = document.createElement('div');
|
||||
hint.className = 'portal-card-error-hint text-danger small';
|
||||
hint.style.marginBottom = '6px';
|
||||
hint.textContent = message || 'Slug and folder are required. This portal will not be saved until both are filled.';
|
||||
bodyEl.insertBefore(hint, bodyEl.firstChild);
|
||||
} else {
|
||||
hint.textContent = message || hint.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
cards.forEach(card => {
|
||||
const origSlug = card.getAttribute('data-portal-slug') || '';
|
||||
@@ -1453,21 +1600,22 @@ async function saveClientPortalsFromUI() {
|
||||
const lblRef = getVal('[data-portal-field="lblRef"]').trim();
|
||||
const lblNotes = getVal('[data-portal-field="lblNotes"]').trim();
|
||||
|
||||
const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
|
||||
const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]');
|
||||
const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]');
|
||||
const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
|
||||
const requireFormEl = card.querySelector('[data-portal-field="requireForm"]');
|
||||
|
||||
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
||||
const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true;
|
||||
const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false;
|
||||
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
||||
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
||||
const requireForm = requireFormEl ? !!requireFormEl.checked : false;
|
||||
|
||||
const reqNameEl = card.querySelector('[data-portal-field="reqName"]');
|
||||
const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]');
|
||||
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
||||
const reqRefEl = card.querySelector('[data-portal-field="reqRef"]');
|
||||
const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]');
|
||||
|
||||
const reqName = reqNameEl ? !!reqNameEl.checked : false;
|
||||
const reqName = reqNameEl ? !!reqNameEl.checked : false;
|
||||
const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false;
|
||||
const reqRef = reqRefEl ? !!reqRefEl.checked : false;
|
||||
const reqRef = reqRefEl ? !!reqRefEl.checked : false;
|
||||
const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false;
|
||||
|
||||
const visNameEl = card.querySelector('[data-portal-field="visName"]');
|
||||
@@ -1487,63 +1635,106 @@ async function saveClientPortalsFromUI() {
|
||||
|
||||
const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]');
|
||||
const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false;
|
||||
|
||||
const folderInput = card.querySelector('[data-portal-field="folder"]');
|
||||
const slugInput = card.querySelector('[data-portal-field="slug"]');
|
||||
if (slugInput) {
|
||||
const rawSlug = slugInput.value.trim();
|
||||
if (rawSlug) slug = rawSlug;
|
||||
}
|
||||
|
||||
const labelForError = label || slug || origSlug || '(unnamed portal)';
|
||||
|
||||
// Validation: slug + folder required
|
||||
if (!slug || !folder) {
|
||||
invalid.push(labelForError);
|
||||
|
||||
// Remember the first problematic field so we can scroll exactly to it
|
||||
if (!firstInvalidField) {
|
||||
if (!folder && folderInput) {
|
||||
firstInvalidField = folderInput;
|
||||
} else if (!slug && slugInput) {
|
||||
firstInvalidField = slugInput;
|
||||
} else {
|
||||
firstInvalidField = card;
|
||||
}
|
||||
}
|
||||
|
||||
markCardMissingRequired(
|
||||
card,
|
||||
'Slug and folder are required. This portal will not be saved until both are filled.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
portals[slug] = {
|
||||
label,
|
||||
folder,
|
||||
clientEmail,
|
||||
uploadOnly,
|
||||
allowDownload,
|
||||
expiresAt,
|
||||
title,
|
||||
introText,
|
||||
requireForm,
|
||||
brandColor,
|
||||
footerText,
|
||||
logoFile,
|
||||
logoUrl,
|
||||
formDefaults: {
|
||||
name: defName,
|
||||
email: defEmail,
|
||||
reference: defRef,
|
||||
notes: defNotes
|
||||
},
|
||||
formRequired: {
|
||||
name: reqName,
|
||||
email: reqEmail,
|
||||
reference: reqRef,
|
||||
notes: reqNotes
|
||||
},
|
||||
formLabels: {
|
||||
name: lblName,
|
||||
email: lblEmail,
|
||||
reference: lblRef,
|
||||
notes: lblNotes
|
||||
},
|
||||
formVisible: {
|
||||
name: visName,
|
||||
email: visEmail,
|
||||
reference: visRef,
|
||||
notes: visNotes
|
||||
},
|
||||
uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
|
||||
uploadExtWhitelist,
|
||||
uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
|
||||
showThankYou,
|
||||
thankYouText,
|
||||
};
|
||||
label,
|
||||
folder,
|
||||
clientEmail,
|
||||
uploadOnly,
|
||||
allowDownload,
|
||||
expiresAt,
|
||||
title,
|
||||
introText,
|
||||
requireForm,
|
||||
brandColor,
|
||||
footerText,
|
||||
logoFile,
|
||||
logoUrl,
|
||||
formDefaults: {
|
||||
name: defName,
|
||||
email: defEmail,
|
||||
reference: defRef,
|
||||
notes: defNotes
|
||||
},
|
||||
formRequired: {
|
||||
name: reqName,
|
||||
email: reqEmail,
|
||||
reference: reqRef,
|
||||
notes: reqNotes
|
||||
},
|
||||
formLabels: {
|
||||
name: lblName,
|
||||
email: lblEmail,
|
||||
reference: lblRef,
|
||||
notes: lblNotes
|
||||
},
|
||||
formVisible: {
|
||||
name: visName,
|
||||
email: visEmail,
|
||||
reference: visRef,
|
||||
notes: visNotes
|
||||
},
|
||||
uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0,
|
||||
uploadExtWhitelist,
|
||||
uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0,
|
||||
showThankYou,
|
||||
thankYouText,
|
||||
};
|
||||
});
|
||||
|
||||
if (invalid.length) {
|
||||
if (status) {
|
||||
status.textContent = 'Please fill slug and folder for highlighted portals.';
|
||||
status.className = 'small text-danger';
|
||||
}
|
||||
|
||||
// Scroll the *first missing field* into view so the admin sees exactly where to fix
|
||||
const targetEl = firstInvalidField || body.querySelector('.portal-card-has-error');
|
||||
if (targetEl) {
|
||||
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// If it's an input, focus + select to make typing instant
|
||||
if (typeof targetEl.focus === 'function') {
|
||||
targetEl.focus();
|
||||
if (typeof targetEl.select === 'function') {
|
||||
targetEl.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Please set slug and folder for: ' + invalid.join(', '));
|
||||
return; // Don’t hit the API if local validation failed
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent = 'Saving…';
|
||||
status.className = 'small text-muted';
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function loadCsrfToken() {
|
||||
APP INIT (shared)
|
||||
========================= */
|
||||
export function initializeApp() {
|
||||
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
|
||||
const saved = parseInt(localStorage.getItem('rowHeight') || '44', 10);
|
||||
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
|
||||
|
||||
const last = localStorage.getItem('lastOpenedFolder');
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from './fileDragDrop.js?v={{APP_QVER}}';
|
||||
|
||||
export let fileData = [];
|
||||
export let sortOrder = { column: "uploaded", ascending: true };
|
||||
export let sortOrder = { column: "modified", ascending: false };
|
||||
|
||||
|
||||
const FOLDER_STRIP_PAGE_SIZE = 50;
|
||||
@@ -196,6 +196,13 @@ function renderFolderStripPaged(strip, subfolders) {
|
||||
drawPage(1);
|
||||
}
|
||||
|
||||
function _trimLabel(str, max = 40) {
|
||||
if (!str) return "";
|
||||
const s = String(str);
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max - 1) + "…";
|
||||
}
|
||||
|
||||
// helper to repaint one strip item quickly
|
||||
function repaintStripIcon(folder) {
|
||||
const el = document.querySelector(`#folderStripContainer .folder-item[data-folder="${CSS.escape(folder)}"]`);
|
||||
@@ -265,16 +272,29 @@ async function fillFileSnippet(file, snippetEl) {
|
||||
if (!res.ok) throw 0;
|
||||
const text = await res.text();
|
||||
|
||||
const MAX_LINES = 6;
|
||||
const MAX_CHARS = 600;
|
||||
const MAX_LINES = 6;
|
||||
const MAX_CHARS_TOTAL = 600;
|
||||
const MAX_LINE_CHARS = 20; // ← per-line cap (tweak to taste)
|
||||
|
||||
const allLines = text.split(/\r?\n/);
|
||||
let visibleLines = allLines.slice(0, MAX_LINES);
|
||||
let snippet = visibleLines.join("\n");
|
||||
let truncated = allLines.length > MAX_LINES;
|
||||
|
||||
if (snippet.length > MAX_CHARS) {
|
||||
snippet = snippet.slice(0, MAX_CHARS);
|
||||
// Take the first few lines and trim each so they don't wrap forever
|
||||
let visibleLines = allLines.slice(0, MAX_LINES).map(line =>
|
||||
_trimLabel(line, MAX_LINE_CHARS)
|
||||
);
|
||||
|
||||
let truncated =
|
||||
allLines.length > MAX_LINES ||
|
||||
visibleLines.some((line, idx) => {
|
||||
const orig = allLines[idx] || "";
|
||||
return orig.length > MAX_LINE_CHARS;
|
||||
});
|
||||
|
||||
let snippet = visibleLines.join("\n");
|
||||
|
||||
// Also enforce an overall character ceiling just in case
|
||||
if (snippet.length > MAX_CHARS_TOTAL) {
|
||||
snippet = snippet.slice(0, MAX_CHARS_TOTAL);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
@@ -286,6 +306,7 @@ async function fillFileSnippet(file, snippetEl) {
|
||||
|
||||
_fileSnippetCache.set(key, finalSnippet);
|
||||
snippetEl.textContent = finalSnippet;
|
||||
|
||||
} catch {
|
||||
snippetEl.textContent = "";
|
||||
snippetEl.style.display = "none";
|
||||
@@ -571,6 +592,13 @@ window.addEventListener('folderColorChanged', (e) => {
|
||||
// Hide "Edit" for files >10 MiB
|
||||
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Max number of files allowed for non-ZIP multi-download
|
||||
const MAX_NONZIP_MULTI_DOWNLOAD = 20;
|
||||
|
||||
// Global queue + panel ref for stepper-style downloads
|
||||
window.__nonZipDownloadQueue = window.__nonZipDownloadQueue || [];
|
||||
window.__nonZipDownloadPanel = window.__nonZipDownloadPanel || null;
|
||||
|
||||
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
|
||||
let __fileListReqSeq = 0;
|
||||
|
||||
@@ -812,18 +840,26 @@ function fillHoverPreviewForRow(row) {
|
||||
const propsEl = el.querySelector(".hover-preview-props");
|
||||
const snippetEl = el.querySelector(".hover-preview-snippet");
|
||||
|
||||
|
||||
|
||||
if (!titleEl || !metaEl || !thumbEl || !propsEl || !snippetEl) return;
|
||||
|
||||
// Reset content
|
||||
thumbEl.innerHTML = "";
|
||||
propsEl.innerHTML = "";
|
||||
snippetEl.textContent = "";
|
||||
snippetEl.style.display = "none";
|
||||
metaEl.textContent = "";
|
||||
titleEl.textContent = "";
|
||||
// Reset content
|
||||
thumbEl.innerHTML = "";
|
||||
propsEl.innerHTML = "";
|
||||
snippetEl.textContent = "";
|
||||
snippetEl.style.display = "none";
|
||||
metaEl.textContent = "";
|
||||
titleEl.textContent = "";
|
||||
|
||||
// Reset per-row sizing (we only make this tall for images)
|
||||
thumbEl.style.minHeight = "0";
|
||||
// reset snippet style defaults (for file previews)
|
||||
snippetEl.style.whiteSpace = "pre-wrap";
|
||||
snippetEl.style.overflowX = "auto";
|
||||
snippetEl.style.textOverflow = "clip";
|
||||
snippetEl.style.wordBreak = "break-word";
|
||||
|
||||
// Reset per-row sizing...
|
||||
thumbEl.style.minHeight = "0";
|
||||
|
||||
const isFolder = row.classList.contains("folder-row");
|
||||
|
||||
@@ -841,23 +877,61 @@ function fillHoverPreviewForRow(row) {
|
||||
folder: folderPath
|
||||
};
|
||||
|
||||
// Right column: icon + path
|
||||
const iconHtml = `
|
||||
// Right column: icon + path (start props array so we can append later)
|
||||
const props = [];
|
||||
|
||||
props.push(`
|
||||
<div class="hover-prop-line" style="display:flex;align-items:center;margin-bottom:4px;">
|
||||
<span class="hover-preview-icon material-icons" style="margin-right:6px;">folder</span>
|
||||
<strong>${t("folder") || "Folder"}</strong>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
|
||||
let propsHtml = iconHtml;
|
||||
propsHtml += `
|
||||
props.push(`
|
||||
<div class="hover-prop-line">
|
||||
<strong>${t("path") || "Path"}:</strong> ${escapeHTML(folderPath || "root")}
|
||||
</div>
|
||||
`;
|
||||
propsEl.innerHTML = propsHtml;
|
||||
`);
|
||||
|
||||
// Meta: counts + size
|
||||
propsEl.innerHTML = props.join("");
|
||||
|
||||
// --- Owner + "Your access" (from capabilities) --------------------
|
||||
fetchFolderCaps(folderPath).then(caps => {
|
||||
if (!caps || !document.body.contains(el)) return;
|
||||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||||
|
||||
const owner = caps.owner || caps.user || "";
|
||||
if (owner) {
|
||||
props.push(`
|
||||
<div class="hover-prop-line">
|
||||
<strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(owner)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Summarize what the current user can do in this folder
|
||||
const perms = [];
|
||||
if (caps.canUpload || caps.canCreate) perms.push(t("perm_upload") || "Upload");
|
||||
if (caps.canMoveFolder) perms.push(t("perm_move") || "Move");
|
||||
if (caps.canRename) perms.push(t("perm_rename") || "Rename");
|
||||
if (caps.canShareFolder) perms.push(t("perm_share") || "Share");
|
||||
if (caps.canDeleteFolder || caps.canDelete)
|
||||
perms.push(t("perm_delete") || "Delete");
|
||||
|
||||
if (perms.length) {
|
||||
const label = t("your_access") || "Your access";
|
||||
props.push(`
|
||||
<div class="hover-prop-line">
|
||||
<strong>${escapeHTML(label)}:</strong> ${escapeHTML(perms.join(", "))}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
propsEl.innerHTML = props.join("");
|
||||
}).catch(() => {});
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// --- Meta: counts + size + created/modified -----------------------
|
||||
fetchFolderStats(folderPath).then(stats => {
|
||||
if (!stats || !document.body.contains(el)) return;
|
||||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||||
@@ -884,22 +958,55 @@ function fillHoverPreviewForRow(row) {
|
||||
metaEl.textContent = sizeLabel
|
||||
? `${pieces.join(", ")} • ${sizeLabel}`
|
||||
: pieces.join(", ");
|
||||
}).catch(() => {});
|
||||
|
||||
// Left side: peek inside folder (first few children)
|
||||
// Optional: created / modified range under the path/owner/access
|
||||
const created = typeof stats.earliest_uploaded === "string" ? stats.earliest_uploaded : "";
|
||||
const modified = typeof stats.latest_mtime === "string" ? stats.latest_mtime : "";
|
||||
|
||||
if (modified) {
|
||||
props.push(`
|
||||
<div class="hover-prop-line">
|
||||
<strong>${t("modified") || "Modified"}:</strong> ${escapeHTML(modified)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
if (created) {
|
||||
props.push(`
|
||||
<div class="hover-prop-line">
|
||||
<strong>${t("created") || "Created"}:</strong> ${escapeHTML(created)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
propsEl.innerHTML = props.join("");
|
||||
}).catch(() => {});
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// Left side: peek inside folder (first few children)
|
||||
fetchFolderPeek(folderPath).then(result => {
|
||||
if (!document.body.contains(el)) return;
|
||||
if (!hoverPreviewContext || hoverPreviewContext.folder !== folderPath) return;
|
||||
|
||||
// Folder mode: force single-line-ish behavior and avoid wrapping
|
||||
snippetEl.style.whiteSpace = "pre";
|
||||
snippetEl.style.wordBreak = "normal";
|
||||
snippetEl.style.overflowX = "hidden";
|
||||
snippetEl.style.textOverflow = "ellipsis";
|
||||
|
||||
if (!result) {
|
||||
snippetEl.style.display = "none";
|
||||
const msg =
|
||||
t("no_files_or_folders") ||
|
||||
t("no_files_found") ||
|
||||
"No files or folders";
|
||||
|
||||
snippetEl.textContent = msg;
|
||||
snippetEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const { items, truncated } = result;
|
||||
|
||||
// If nothing inside, show a friendly message like files do
|
||||
if (!items || !items.length) {
|
||||
const msg =
|
||||
t("no_files_or_folders") ||
|
||||
@@ -911,12 +1018,15 @@ fetchFolderPeek(folderPath).then(result => {
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_LABEL_CHARS = 42; // tweak to taste
|
||||
|
||||
const lines = items.map(it => {
|
||||
const prefix = it.type === "folder" ? "📁 " : "📄 ";
|
||||
return prefix + it.name;
|
||||
const trimmed = _trimLabel(it.name, MAX_LABEL_CHARS);
|
||||
return prefix + trimmed;
|
||||
});
|
||||
|
||||
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, turn the LAST line into "…"
|
||||
// If we had to cut the list to FOLDER_PEEK_MAX_ITEMS, show a clean final "…"
|
||||
if (truncated && lines.length) {
|
||||
lines[lines.length - 1] = "…";
|
||||
}
|
||||
@@ -1024,6 +1134,56 @@ fetchFolderPeek(folderPath).then(result => {
|
||||
props.push(`<div class="hover-prop-line"><strong>${t("owner") || "Owner"}:</strong> ${escapeHTML(file.uploader)}</div>`);
|
||||
}
|
||||
|
||||
// --- NEW: Tags / Metadata line ------------------------------------
|
||||
(function addMetaLine() {
|
||||
// Tags from backend: file.tags = [{ name, color }, ...]
|
||||
const tagNames = Array.isArray(file.tags)
|
||||
? file.tags
|
||||
.map(t => t && t.name ? String(t.name).trim() : "")
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
// Optional extra metadata if you ever add it to fileData
|
||||
const mime =
|
||||
file.mime ||
|
||||
file.mimetype ||
|
||||
file.contentType ||
|
||||
"";
|
||||
|
||||
const extraPieces = [];
|
||||
if (mime) extraPieces.push(mime);
|
||||
|
||||
// Example future fields; safe even if undefined
|
||||
if (Number.isFinite(file.durationSeconds)) {
|
||||
extraPieces.push(`${file.durationSeconds}s`);
|
||||
}
|
||||
if (file.width && file.height) {
|
||||
extraPieces.push(`${file.width}×${file.height}`);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (tagNames.length) {
|
||||
parts.push(tagNames.join(", "));
|
||||
}
|
||||
if (extraPieces.length) {
|
||||
parts.push(extraPieces.join(" • "));
|
||||
}
|
||||
|
||||
if (!parts.length) return; // nothing to show
|
||||
|
||||
const useMetadataLabel = parts.length > 1 || extraPieces.length > 0;
|
||||
const labelKey = useMetadataLabel ? "metadata" : "tags";
|
||||
const label = t(labelKey) || (useMetadataLabel ? "MetaData" : "Tags");
|
||||
|
||||
props.push(
|
||||
`<div class="hover-prop-line"><strong>${escapeHTML(label)}:</strong> ${escapeHTML(parts.join(" • "))}</div>`
|
||||
);
|
||||
})();
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
propsEl.innerHTML = props.join("");
|
||||
|
||||
propsEl.innerHTML = props.join("");
|
||||
|
||||
// Text snippet (left) for smaller text/code files
|
||||
@@ -1372,6 +1532,165 @@ function formatSize(totalBytes) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function ensureNonZipDownloadPanel() {
|
||||
if (window.__nonZipDownloadPanel) return window.__nonZipDownloadPanel;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'nonZipDownloadPanel';
|
||||
panel.setAttribute('role', 'status');
|
||||
|
||||
// Simple bottom-right card using Bootstrap-ish styles + inline layout tweaks
|
||||
panel.style.position = 'fixed';
|
||||
panel.style.top = '50%';
|
||||
panel.style.left = '50%';
|
||||
panel.style.transform = 'translate(-50%, -50%)';
|
||||
panel.style.zIndex = '9999';
|
||||
panel.style.width = 'min(440px, 95vw)';
|
||||
panel.style.minWidth = '280px';
|
||||
panel.style.maxWidth = '440px';
|
||||
panel.style.padding = '14px 16px';
|
||||
panel.style.borderRadius = '12px';
|
||||
panel.style.boxShadow = '0 18px 40px rgba(0,0,0,0.35)';
|
||||
panel.style.backgroundColor = 'var(--filr-menu-bg, #222)';
|
||||
panel.style.color = 'var(--filr-menu-fg, #f9fafb)';
|
||||
panel.style.fontSize = '0.9rem';
|
||||
panel.style.display = 'none';
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="nonzip-title" style="margin-bottom:6px; font-weight:600;"></div>
|
||||
<div class="nonzip-sub" style="margin-bottom:8px; opacity:0.85;"></div>
|
||||
<div class="nonzip-actions" style="display:flex; justify-content:flex-end; gap:6px;">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-secondary nonzip-cancel-btn">
|
||||
${t('cancel') || 'Cancel'}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-primary nonzip-next-btn">
|
||||
${t('download_next') || 'Download next'}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(panel);
|
||||
|
||||
const nextBtn = panel.querySelector('.nonzip-next-btn');
|
||||
const cancelBtn = panel.querySelector('.nonzip-cancel-btn');
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
triggerNextNonZipDownload();
|
||||
});
|
||||
}
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
clearNonZipQueue(true);
|
||||
});
|
||||
}
|
||||
|
||||
window.__nonZipDownloadPanel = panel;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function updateNonZipPanelText() {
|
||||
const panel = ensureNonZipDownloadPanel();
|
||||
const q = window.__nonZipDownloadQueue || [];
|
||||
const count = q.length;
|
||||
|
||||
const titleEl = panel.querySelector('.nonzip-title');
|
||||
const subEl = panel.querySelector('.nonzip-sub');
|
||||
|
||||
if (!titleEl || !subEl) return;
|
||||
|
||||
if (!count) {
|
||||
titleEl.textContent = t('no_files_queued') || 'No files queued.';
|
||||
subEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const title =
|
||||
t('nonzip_queue_title') ||
|
||||
'Files queued for download';
|
||||
|
||||
const raw = t('nonzip_queue_subtitle') ||
|
||||
'{count} files queued. Click "Download next" for each file.';
|
||||
|
||||
const msg = raw.replace('{count}', String(count));
|
||||
|
||||
titleEl.textContent = title;
|
||||
subEl.textContent = msg;
|
||||
}
|
||||
|
||||
function showNonZipPanel() {
|
||||
const panel = ensureNonZipDownloadPanel();
|
||||
updateNonZipPanelText();
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideNonZipPanel() {
|
||||
const panel = ensureNonZipDownloadPanel();
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearNonZipQueue(showToastCancel = false) {
|
||||
window.__nonZipDownloadQueue = [];
|
||||
hideNonZipPanel();
|
||||
if (showToastCancel) {
|
||||
showToast(
|
||||
t('nonzip_queue_cleared') || 'Download queue cleared.',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNextNonZipDownload() {
|
||||
const q = window.__nonZipDownloadQueue || [];
|
||||
if (!q.length) {
|
||||
hideNonZipPanel();
|
||||
showToast(
|
||||
t('downloads_started') || 'All downloads started.',
|
||||
'success'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { folder, name } = q.shift();
|
||||
const url = apiFileUrl(folder || 'root', name, /* inline */ false);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
|
||||
try {
|
||||
a.click();
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
if (a && a.parentNode) {
|
||||
a.parentNode.removeChild(a);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Update queue + UI
|
||||
window.__nonZipDownloadQueue = q;
|
||||
if (q.length) {
|
||||
updateNonZipPanelText();
|
||||
} else {
|
||||
hideNonZipPanel();
|
||||
showToast(
|
||||
t('downloads_started') || 'All downloads started.',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional debug helpers if you want them globally:
|
||||
window.triggerNextNonZipDownload = triggerNextNonZipDownload;
|
||||
window.clearNonZipQueue = clearNonZipQueue;
|
||||
|
||||
|
||||
/**
|
||||
* Build the folder summary HTML using the filtered file list.
|
||||
*/
|
||||
@@ -1719,7 +2038,7 @@ export async function loadFileList(folderParam) {
|
||||
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
|
||||
};
|
||||
} else {
|
||||
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
|
||||
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "44", 10);
|
||||
sliderContainer.innerHTML = `
|
||||
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
|
||||
${t("row_height")}:
|
||||
@@ -2207,7 +2526,7 @@ if (iconSpan) {
|
||||
}
|
||||
function syncFolderIconSizeToRowHeight() {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const raw = cs.getPropertyValue('--file-row-height') || '48px';
|
||||
const raw = cs.getPropertyValue('--file-row-height') || '44px';
|
||||
const rowH = parseInt(raw, 10) || 60;
|
||||
|
||||
const FUDGE = 1;
|
||||
@@ -2262,7 +2581,7 @@ async function sortSubfoldersForCurrentOrder(subfolders) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Size sort – use folder stats (bytes); keep folders as a block above files
|
||||
// Size sort – use folder stats (bytes)
|
||||
if (col === "size" || col === "filesize") {
|
||||
const statsList = await Promise.all(
|
||||
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
|
||||
@@ -2306,6 +2625,72 @@ async function sortSubfoldersForCurrentOrder(subfolders) {
|
||||
return decorated.map(d => d.sf);
|
||||
}
|
||||
|
||||
// NEW: Created / Uploaded sort – use earliest_uploaded from stats
|
||||
if (col === "uploaded" || col === "created") {
|
||||
const statsList = await Promise.all(
|
||||
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
|
||||
);
|
||||
|
||||
const decorated = base.map((sf, idx) => {
|
||||
const stats = statsList[idx];
|
||||
let ts = 0;
|
||||
|
||||
if (stats && typeof stats.earliest_uploaded === "string") {
|
||||
ts = parseCustomDate(String(stats.earliest_uploaded));
|
||||
if (!Number.isFinite(ts)) ts = 0;
|
||||
}
|
||||
|
||||
return { sf, ts };
|
||||
});
|
||||
|
||||
decorated.sort((a, b) => {
|
||||
if (a.ts < b.ts) return -1 * dir;
|
||||
if (a.ts > b.ts) return 1 * dir;
|
||||
|
||||
// tie-break by name
|
||||
const n1 = (a.sf.name || "").toLowerCase();
|
||||
const n2 = (b.sf.name || "").toLowerCase();
|
||||
if (n1 < n2) return -1 * dir;
|
||||
if (n1 > n2) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return decorated.map(d => d.sf);
|
||||
}
|
||||
|
||||
// NEW: Modified sort – use latest_mtime from stats
|
||||
if (col === "modified") {
|
||||
const statsList = await Promise.all(
|
||||
base.map(sf => fetchFolderStats(sf.full).catch(() => null))
|
||||
);
|
||||
|
||||
const decorated = base.map((sf, idx) => {
|
||||
const stats = statsList[idx];
|
||||
let ts = 0;
|
||||
|
||||
if (stats && typeof stats.latest_mtime === "string") {
|
||||
ts = parseCustomDate(String(stats.latest_mtime));
|
||||
if (!Number.isFinite(ts)) ts = 0;
|
||||
}
|
||||
|
||||
return { sf, ts };
|
||||
});
|
||||
|
||||
decorated.sort((a, b) => {
|
||||
if (a.ts < b.ts) return -1 * dir;
|
||||
if (a.ts > b.ts) return 1 * dir;
|
||||
|
||||
// tie-break by name
|
||||
const n1 = (a.sf.name || "").toLowerCase();
|
||||
const n2 = (b.sf.name || "").toLowerCase();
|
||||
if (n1 < n2) return -1 * dir;
|
||||
if (n1 > n2) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return decorated.map(d => d.sf);
|
||||
}
|
||||
|
||||
// Default: keep folders A–Z by name regardless of other sorts
|
||||
base.sort((a, b) =>
|
||||
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
|
||||
@@ -2343,7 +2728,12 @@ export async function renderFileTable(folder, container, subfolders) {
|
||||
let currentPage = window.currentPage || 1;
|
||||
|
||||
// Files (filtered by search)
|
||||
const filteredFiles = searchFiles(searchTerm);
|
||||
let filteredFiles = searchFiles(searchTerm);
|
||||
|
||||
// Apply current sort (Modified desc by default for you)
|
||||
if (Array.isArray(filteredFiles) && filteredFiles.length) {
|
||||
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
|
||||
}
|
||||
|
||||
// Inline folders: sort once (Explorer-style A→Z)
|
||||
const allSubfolders = Array.isArray(window.currentSubfolders)
|
||||
@@ -2812,7 +3202,11 @@ function getMaxImageHeight() {
|
||||
export function renderGalleryView(folder, container) {
|
||||
const fileListContent = container || document.getElementById("fileList");
|
||||
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
|
||||
const filteredFiles = searchFiles(searchTerm);
|
||||
let filteredFiles = searchFiles(searchTerm);
|
||||
|
||||
if (Array.isArray(filteredFiles) && filteredFiles.length) {
|
||||
filteredFiles = [...filteredFiles].sort(compareFilesForSort);
|
||||
}
|
||||
|
||||
// API preview base (we’ll build per-file URLs)
|
||||
const apiBase = `/api/file/download.php?folder=${encodeURIComponent(folder)}&file=`;
|
||||
@@ -3166,6 +3560,97 @@ function updateSliderConstraints() {
|
||||
window.addEventListener('load', updateSliderConstraints);
|
||||
window.addEventListener('resize', updateSliderConstraints);
|
||||
|
||||
/**
|
||||
* Fallback: derive selected files from DOM checkboxes if no explicit list
|
||||
* of file objects is provided.
|
||||
*/
|
||||
function getSelectedFilesForDownload() {
|
||||
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
|
||||
if (!checks.length) return [];
|
||||
|
||||
// checkbox values are ESCAPED names
|
||||
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
|
||||
if (!selectedEsc.length) return [];
|
||||
|
||||
const escSet = new Set(selectedEsc);
|
||||
|
||||
const files = Array.isArray(fileData)
|
||||
? fileData.filter(f => escSet.has(escapeHTML(f.name)))
|
||||
: [];
|
||||
|
||||
return files.map(f => ({
|
||||
folder: f.folder || window.currentFolder || 'root',
|
||||
name: f.name
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Push selected files into a stepper queue and show the
|
||||
* bottom-right panel with "Download next / Cancel".
|
||||
*
|
||||
* Expects `fileObjs` to be an array of file objects from `fileData`
|
||||
* (e.g. currentSelection().files in fileMenu.js).
|
||||
*/
|
||||
export function downloadSelectedFilesIndividually(fileObjs) {
|
||||
const src = Array.isArray(fileObjs) ? fileObjs : [];
|
||||
|
||||
if (!src.length) {
|
||||
showToast(t('no_files_selected') || 'No files selected.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = src.map(f => ({
|
||||
folder: f.folder || window.currentFolder || 'root',
|
||||
name: f.name
|
||||
}));
|
||||
|
||||
const limit = window.maxNonZipDownloads || MAX_NONZIP_MULTI_DOWNLOAD;
|
||||
if (mapped.length > limit) {
|
||||
const msg =
|
||||
t('too_many_plain_downloads') ||
|
||||
`You selected ${mapped.length} files. For more than ${limit} files, please use "Download as ZIP".`;
|
||||
showToast(msg, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace any existing queue with the new one.
|
||||
window.__nonZipDownloadQueue = mapped.slice();
|
||||
|
||||
// Show the panel; user will click "Download next" for each file.
|
||||
showNonZipPanel();
|
||||
|
||||
// auto-fire the first file here:
|
||||
triggerNextNonZipDownload();
|
||||
}
|
||||
|
||||
function compareFilesForSort(a, b) {
|
||||
const column = sortOrder?.column || "uploaded";
|
||||
const ascending = sortOrder?.ascending !== false;
|
||||
|
||||
let valA = a[column] ?? "";
|
||||
let valB = b[column] ?? "";
|
||||
|
||||
if (column === "size" || column === "filesize") {
|
||||
// numeric size
|
||||
valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0;
|
||||
valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0;
|
||||
} else if (column === "modified" || column === "uploaded") {
|
||||
// date sort (newest/oldest)
|
||||
const parsedA = parseCustomDate(String(valA || ""));
|
||||
const parsedB = parseCustomDate(String(valB || ""));
|
||||
valA = parsedA;
|
||||
valB = parsedB;
|
||||
} else {
|
||||
if (typeof valA === "string") valA = valA.toLowerCase();
|
||||
if (typeof valB === "string") valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return ascending ? -1 : 1;
|
||||
if (valA > valB) return ascending ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
export function sortFiles(column, folder) {
|
||||
if (sortOrder.column === column) {
|
||||
sortOrder.ascending = !sortOrder.ascending;
|
||||
@@ -3174,28 +3659,8 @@ export function sortFiles(column, folder) {
|
||||
sortOrder.ascending = true;
|
||||
}
|
||||
|
||||
fileData.sort((a, b) => {
|
||||
let valA = a[column] || "";
|
||||
let valB = b[column] || "";
|
||||
|
||||
if (column === "size" || column === "filesize") {
|
||||
// numeric size
|
||||
valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0;
|
||||
valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0;
|
||||
} else if (column === "modified" || column === "uploaded") {
|
||||
const parsedA = parseCustomDate(valA);
|
||||
const parsedB = parseCustomDate(valB);
|
||||
valA = parsedA;
|
||||
valB = parsedB;
|
||||
} else if (typeof valA === "string") {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return sortOrder.ascending ? -1 : 1;
|
||||
if (valA > valB) return sortOrder.ascending ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
// Re-sort master fileData
|
||||
fileData.sort(compareFilesForSort);
|
||||
|
||||
if (window.viewMode === "gallery") {
|
||||
renderGalleryView(folder);
|
||||
@@ -3299,4 +3764,5 @@ window.loadFileList = loadFileList;
|
||||
window.renderFileTable = renderFileTable;
|
||||
window.renderGalleryView = renderGalleryView;
|
||||
window.sortFiles = sortFiles;
|
||||
window.toggleAdvancedSearch = toggleAdvancedSearch;
|
||||
window.toggleAdvancedSearch = toggleAdvancedSearch;
|
||||
window.downloadSelectedFilesIndividually = downloadSelectedFilesIndividually;
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
} from './fileActions.js?v={{APP_QVER}}';
|
||||
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
|
||||
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
|
||||
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { canEditFile, fileData, downloadSelectedFilesIndividually } from './fileListView.js?v={{APP_QVER}}';
|
||||
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
|
||||
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
|
||||
|
||||
|
||||
const MENU_ID = 'fileContextMenu';
|
||||
|
||||
function qMenu() { return document.getElementById(MENU_ID); }
|
||||
@@ -31,7 +32,9 @@ function localizeMenu() {
|
||||
'preview': 'preview',
|
||||
'edit': 'edit',
|
||||
'rename': 'rename',
|
||||
'tag_file': 'tag_file'
|
||||
'tag_file': 'tag_file',
|
||||
// NEW:
|
||||
'download_plain': 'download_plain'
|
||||
};
|
||||
Object.entries(map).forEach(([action, key]) => {
|
||||
const el = m.querySelector(`.mi[data-action="${action}"]`);
|
||||
@@ -187,6 +190,10 @@ function menuClickDelegate(ev) {
|
||||
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
||||
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
||||
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
||||
case 'download_plain':
|
||||
// Uses current checkbox selection; limit enforced in fileListView
|
||||
downloadSelectedFilesIndividually(s.files);
|
||||
break;
|
||||
|
||||
case 'tag_selected':
|
||||
openMultiTagModal(s.files); // s.files are the real file objects
|
||||
|
||||
@@ -353,7 +353,20 @@ const translations = {
|
||||
"zoom_in": "Zoom In",
|
||||
"zoom_out": "Zoom Out",
|
||||
"rotate_left": "Rotate Left",
|
||||
"rotate_right": "Rotate Right"
|
||||
"rotate_right": "Rotate Right",
|
||||
"download_plain": "Download (no ZIP)",
|
||||
"download_next": "Download next",
|
||||
"nonzip_queue_title": "Files queued for download",
|
||||
"nonzip_queue_subtitle": "{count} files queued. Click \"Download next\" for each file.",
|
||||
"nonzip_queue_cleared": "Download queue cleared.",
|
||||
"your_access": "Your access",
|
||||
|
||||
"perm_upload": "Upload",
|
||||
"perm_move": "Move",
|
||||
"perm_rename": "Rename",
|
||||
"perm_share": "Share",
|
||||
"perm_delete": "Delete"
|
||||
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -10,10 +10,33 @@ function portalFolder() {
|
||||
return portal.folder || portal.targetFolder || portal.path || 'root';
|
||||
}
|
||||
|
||||
function portalCanUpload() {
|
||||
if (!portal) return false;
|
||||
|
||||
// Prefer explicit flags from backend (PortalController)
|
||||
if (typeof portal.canUpload !== 'undefined') {
|
||||
return !!portal.canUpload;
|
||||
}
|
||||
|
||||
// Fallbacks for older bundles (if you ever add these)
|
||||
if (typeof portal.allowUpload !== 'undefined') {
|
||||
return !!portal.allowUpload;
|
||||
}
|
||||
|
||||
// Legacy behavior: portals were always upload-capable;
|
||||
// uploadOnly only controlled download visibility.
|
||||
return true;
|
||||
}
|
||||
|
||||
function portalCanDownload() {
|
||||
if (!portal) return false;
|
||||
|
||||
// Prefer explicit flags if present
|
||||
// Prefer explicit flag if present (PortalController)
|
||||
if (typeof portal.canDownload !== 'undefined') {
|
||||
return !!portal.canDownload;
|
||||
}
|
||||
|
||||
// Fallback to allowDownload / allowDownloads (older payloads)
|
||||
if (typeof portal.allowDownload !== 'undefined') {
|
||||
return !!portal.allowDownload;
|
||||
}
|
||||
@@ -21,7 +44,7 @@ function portalCanDownload() {
|
||||
return !!portal.allowDownloads;
|
||||
}
|
||||
|
||||
// Fallback: uploadOnly = true => no downloads
|
||||
// Legacy: uploadOnly = true => no downloads
|
||||
if (typeof portal.uploadOnly !== 'undefined') {
|
||||
return !portal.uploadOnly;
|
||||
}
|
||||
@@ -260,7 +283,7 @@ function setupPortalForm(slug) {
|
||||
const formSection = qs('portalFormSection');
|
||||
const uploadSection = qs('portalUploadSection');
|
||||
|
||||
if (!portal || !portal.requireForm) {
|
||||
if (!portal || !portal.requireForm || !portalCanUpload()) {
|
||||
if (formSection) formSection.style.display = 'none';
|
||||
if (uploadSection) uploadSection.style.opacity = '1';
|
||||
return;
|
||||
@@ -549,11 +572,21 @@ function renderPortalInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
const uploadsEnabled = portalCanUpload();
|
||||
const downloadsEnabled = portalCanDownload();
|
||||
|
||||
if (subtitleEl) {
|
||||
const parts = [];
|
||||
if (portal.uploadOnly) parts.push('upload only');
|
||||
if (portalCanDownload()) parts.push('download allowed');
|
||||
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
|
||||
let text = '';
|
||||
if (uploadsEnabled && downloadsEnabled) {
|
||||
text = 'Upload & download';
|
||||
} else if (uploadsEnabled && !downloadsEnabled) {
|
||||
text = 'Upload only';
|
||||
} else if (!uploadsEnabled && downloadsEnabled) {
|
||||
text = 'Download only';
|
||||
} else {
|
||||
text = 'Access only';
|
||||
}
|
||||
subtitleEl.textContent = text;
|
||||
}
|
||||
|
||||
if (footerEl) {
|
||||
@@ -561,6 +594,26 @@ function renderPortalInfo() {
|
||||
? portal.footerText.trim()
|
||||
: '';
|
||||
}
|
||||
|
||||
const formSection = qs('portalFormSection');
|
||||
const uploadSection = qs('portalUploadSection');
|
||||
|
||||
// If uploads are disabled, hide upload + form (form is only meaningful for uploads)
|
||||
if (!uploadsEnabled) {
|
||||
if (formSection) {
|
||||
formSection.style.display = 'none';
|
||||
}
|
||||
if (uploadSection) {
|
||||
uploadSection.style.display = 'none';
|
||||
}
|
||||
|
||||
const statusEl = qs('portalStatus');
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'Uploads are disabled for this portal.';
|
||||
statusEl.classList.remove('text-muted');
|
||||
statusEl.classList.add('text-warning');
|
||||
}
|
||||
}
|
||||
applyPortalFormLabels();
|
||||
const color = portal.brandColor && portal.brandColor.trim();
|
||||
if (color) {
|
||||
@@ -741,6 +794,13 @@ async function loadPortalFiles() {
|
||||
// ----------------- Upload -----------------
|
||||
async function uploadFiles(fileList) {
|
||||
if (!portal || !fileList || !fileList.length) return;
|
||||
|
||||
if (!portalCanUpload()) {
|
||||
showToast('Uploads are disabled for this portal.');
|
||||
setStatus('Uploads are disabled for this portal.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (portal.requireForm && !portalFormDone) {
|
||||
showToast('Please fill in your details before uploading.');
|
||||
return;
|
||||
@@ -900,11 +960,23 @@ async function uploadFiles(fileList) {
|
||||
|
||||
// ----------------- Upload UI wiring -----------------
|
||||
function wireUploadUI() {
|
||||
const drop = qs('portalDropzone');
|
||||
const input = qs('portalFileInput');
|
||||
const drop = qs('portalDropzone');
|
||||
const input = qs('portalFileInput');
|
||||
const refreshBtn = qs('portalRefreshBtn');
|
||||
|
||||
if (drop && input) {
|
||||
const uploadsEnabled = portalCanUpload();
|
||||
const downloadsEnabled = portalCanDownload();
|
||||
|
||||
// Upload UI
|
||||
if (drop) {
|
||||
if (!uploadsEnabled) {
|
||||
// Visually dim + disable clicks
|
||||
drop.classList.add('portal-dropzone-disabled');
|
||||
drop.style.cursor = 'not-allowed';
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadsEnabled && drop && input) {
|
||||
drop.addEventListener('click', () => input.click());
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
@@ -938,10 +1010,15 @@ function wireUploadUI() {
|
||||
});
|
||||
}
|
||||
|
||||
// Download / refresh
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPortalFiles();
|
||||
});
|
||||
if (!downloadsEnabled) {
|
||||
refreshBtn.style.display = 'none';
|
||||
} else {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadPortalFiles();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v2.3.4';
|
||||
window.APP_VERSION = 'v2.3.6';
|
||||
|
||||
@@ -172,6 +172,11 @@
|
||||
.portal-required-star {
|
||||
color: #dc3545;
|
||||
}
|
||||
.portal-dropzone.portal-dropzone-disabled {
|
||||
opacity: 0.5;
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
BIN
resources/filerise-v2.3.4.png
Normal file
BIN
resources/filerise-v2.3.4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 914 KiB |
@@ -317,99 +317,103 @@ public function saveProPortals(array $portalsPayload): void
|
||||
|
||||
require_once $proPortalsPath;
|
||||
|
||||
if (!is_array($portalsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid portals format.');
|
||||
}
|
||||
if (!is_array($portalsPayload)) {
|
||||
throw new InvalidArgumentException('Invalid portals format.');
|
||||
}
|
||||
|
||||
$data = ['portals' => []];
|
||||
$invalid = [];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
|
||||
// Require both slug and folder; collect invalid ones so the UI can warn.
|
||||
if ($slug === '' || $folder === '') {
|
||||
$invalid[] = $label !== '' ? $label : ($slug !== '' ? $slug : '(unnamed portal)');
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// Branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($info['showThankYou']);
|
||||
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
|
||||
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
|
||||
// Required flags
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
|
||||
// Labels
|
||||
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
|
||||
? $info['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($formLabels['name'] ?? 'Name')),
|
||||
'email' => trim((string)($formLabels['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Visibility
|
||||
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
|
||||
? $info['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
|
||||
'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
|
||||
'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
|
||||
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
||||
];
|
||||
|
||||
$data = ['portals' => []];
|
||||
|
||||
foreach ($portalsPayload as $slug => $info) {
|
||||
$slug = trim((string)$slug);
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
if (!is_array($info)) {
|
||||
$info = [];
|
||||
}
|
||||
|
||||
$label = trim((string)($info['label'] ?? $slug));
|
||||
$folder = trim((string)($info['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($info['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $info)
|
||||
? !empty($info['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
|
||||
|
||||
// Branding + form behavior
|
||||
$title = trim((string)($info['title'] ?? ''));
|
||||
$introText = trim((string)($info['introText'] ?? ''));
|
||||
$requireForm = !empty($info['requireForm']);
|
||||
$brandColor = trim((string)($info['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($info['footerText'] ?? ''));
|
||||
|
||||
// Optional logo info
|
||||
$logoFile = trim((string)($info['logoFile'] ?? ''));
|
||||
$logoUrl = trim((string)($info['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($info['uploadMaxSizeMb']) ? (int)$info['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($info['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($info['uploadMaxPerDay']) ? (int)$info['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($info['showThankYou']);
|
||||
$thankYouText = trim((string)($info['thankYouText'] ?? ''));
|
||||
|
||||
// Form defaults
|
||||
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
|
||||
? $info['formDefaults']
|
||||
: [];
|
||||
|
||||
$formDefaults = [
|
||||
'name' => trim((string)($formDefaults['name'] ?? '')),
|
||||
'email' => trim((string)($formDefaults['email'] ?? '')),
|
||||
'reference' => trim((string)($formDefaults['reference'] ?? '')),
|
||||
'notes' => trim((string)($formDefaults['notes'] ?? '')),
|
||||
];
|
||||
|
||||
// Required flags
|
||||
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
|
||||
? $info['formRequired']
|
||||
: [];
|
||||
|
||||
$formRequired = [
|
||||
'name' => !empty($formRequired['name']),
|
||||
'email' => !empty($formRequired['email']),
|
||||
'reference' => !empty($formRequired['reference']),
|
||||
'notes' => !empty($formRequired['notes']),
|
||||
];
|
||||
|
||||
// Labels
|
||||
$formLabels = isset($info['formLabels']) && is_array($info['formLabels'])
|
||||
? $info['formLabels']
|
||||
: [];
|
||||
|
||||
$formLabels = [
|
||||
'name' => trim((string)($formLabels['name'] ?? 'Name')),
|
||||
'email' => trim((string)($formLabels['email'] ?? 'Email')),
|
||||
'reference' => trim((string)($formLabels['reference'] ?? 'Reference / Case / Order #')),
|
||||
'notes' => trim((string)($formLabels['notes'] ?? 'Notes')),
|
||||
];
|
||||
|
||||
// Visibility
|
||||
$formVisible = isset($info['formVisible']) && is_array($info['formVisible'])
|
||||
? $info['formVisible']
|
||||
: [];
|
||||
|
||||
$formVisible = [
|
||||
'name' => !array_key_exists('name', $formVisible) || !empty($formVisible['name']),
|
||||
'email' => !array_key_exists('email', $formVisible) || !empty($formVisible['email']),
|
||||
'reference' => !array_key_exists('reference', $formVisible) || !empty($formVisible['reference']),
|
||||
'notes' => !array_key_exists('notes', $formVisible) || !empty($formVisible['notes']),
|
||||
];
|
||||
|
||||
if ($folder === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data['portals'][$slug] = [
|
||||
'label' => $label,
|
||||
@@ -436,6 +440,12 @@ public function saveProPortals(array $portalsPayload): void
|
||||
'formVisible' => $formVisible,
|
||||
];
|
||||
}
|
||||
if (!empty($invalid)) {
|
||||
throw new InvalidArgumentException(
|
||||
'One or more portals are missing a slug or folder: ' . implode(', ', $invalid)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
|
||||
$ok = $store->savePortals($data);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
|
||||
final class PortalController
|
||||
{
|
||||
@@ -11,29 +12,31 @@ final class PortalController
|
||||
*
|
||||
* Returns:
|
||||
* [
|
||||
* 'slug' => string,
|
||||
* 'label' => string,
|
||||
* 'folder' => string,
|
||||
* 'clientEmail' => string,
|
||||
* 'uploadOnly' => bool,
|
||||
* 'allowDownload' => bool,
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool,
|
||||
* 'brandColor' => string,
|
||||
* 'footerText' => string,
|
||||
* 'formDefaults' => array,
|
||||
* 'formRequired' => array,
|
||||
* 'formLabels' => array,
|
||||
* 'formVisible' => array,
|
||||
* 'logoFile' => string,
|
||||
* 'logoUrl' => string,
|
||||
* 'uploadMaxSizeMb' => int,
|
||||
* 'slug' => string,
|
||||
* 'label' => string,
|
||||
* 'folder' => string,
|
||||
* 'clientEmail' => string,
|
||||
* 'uploadOnly' => bool, // stored flag (legacy name)
|
||||
* 'allowDownload' => bool, // stored flag
|
||||
* 'expiresAt' => string,
|
||||
* 'title' => string,
|
||||
* 'introText' => string,
|
||||
* 'requireForm' => bool,
|
||||
* 'brandColor' => string,
|
||||
* 'footerText' => string,
|
||||
* 'formDefaults' => array,
|
||||
* 'formRequired' => array,
|
||||
* 'formLabels' => array,
|
||||
* 'formVisible' => array,
|
||||
* 'logoFile' => string,
|
||||
* 'logoUrl' => string,
|
||||
* 'uploadMaxSizeMb' => int,
|
||||
* 'uploadExtWhitelist' => string,
|
||||
* 'uploadMaxPerDay' => int,
|
||||
* 'showThankYou' => bool,
|
||||
* 'thankYouText' => string,
|
||||
* 'uploadMaxPerDay' => int,
|
||||
* 'showThankYou' => bool,
|
||||
* 'thankYouText' => string,
|
||||
* 'canUpload' => bool, // ACL + portal flags
|
||||
* 'canDownload' => bool, // ACL + portal flags
|
||||
* ]
|
||||
*/
|
||||
public static function getPortalBySlug(string $slug): array
|
||||
@@ -66,21 +69,50 @@ final class PortalController
|
||||
|
||||
$p = $portals[$slug];
|
||||
|
||||
$label = trim((string)($p['label'] ?? $slug));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
$uploadOnly = !empty($p['uploadOnly']);
|
||||
$allowDownload = array_key_exists('allowDownload', $p)
|
||||
? !empty($p['allowDownload'])
|
||||
: true;
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
// ─────────────────────────────────────────────
|
||||
// Normalize upload/download flags (old + new)
|
||||
// ─────────────────────────────────────────────
|
||||
//
|
||||
// Storage:
|
||||
// - OLD (no allowDownload):
|
||||
// uploadOnly=true => upload yes, download no
|
||||
// uploadOnly=false => upload yes, download yes
|
||||
//
|
||||
// - NEW:
|
||||
// "Allow upload" checkbox is stored as uploadOnly (🤮 name, but we keep it)
|
||||
// "Allow download" checkbox is stored as allowDownload
|
||||
//
|
||||
// Normalized flags we want here:
|
||||
// - $allowUpload (bool)
|
||||
// - $allowDownload (bool)
|
||||
$hasAllowDownload = array_key_exists('allowDownload', $p);
|
||||
$rawUploadOnly = !empty($p['uploadOnly']); // legacy name
|
||||
$rawAllowDownload = $hasAllowDownload ? !empty($p['allowDownload']) : null;
|
||||
|
||||
if ($hasAllowDownload) {
|
||||
// New JSON – trust both checkboxes exactly
|
||||
$allowUpload = $rawUploadOnly; // "Allow upload" in UI
|
||||
$allowDownload = (bool)$rawAllowDownload;
|
||||
} else {
|
||||
// Legacy JSON – no separate allowDownload
|
||||
// uploadOnly=true => upload yes, download no
|
||||
// uploadOnly=false => upload yes, download yes
|
||||
$allowUpload = true;
|
||||
$allowDownload = !$rawUploadOnly;
|
||||
}
|
||||
|
||||
$label = trim((string)($p['label'] ?? $slug));
|
||||
$folder = trim((string)($p['folder'] ?? ''));
|
||||
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
|
||||
|
||||
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
|
||||
|
||||
// Branding + intake behavior
|
||||
$title = trim((string)($p['title'] ?? ''));
|
||||
$introText = trim((string)($p['introText'] ?? ''));
|
||||
$requireForm = !empty($p['requireForm']);
|
||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||
$title = trim((string)($p['title'] ?? ''));
|
||||
$introText = trim((string)($p['introText'] ?? ''));
|
||||
$requireForm = !empty($p['requireForm']);
|
||||
$brandColor = trim((string)($p['brandColor'] ?? ''));
|
||||
$footerText = trim((string)($p['footerText'] ?? ''));
|
||||
|
||||
// Defaults / required
|
||||
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
|
||||
@@ -134,11 +166,11 @@ final class PortalController
|
||||
$logoUrl = trim((string)($p['logoUrl'] ?? ''));
|
||||
|
||||
// Upload rules / thank-you behavior
|
||||
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($p['showThankYou']);
|
||||
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
||||
$uploadMaxSizeMb = isset($p['uploadMaxSizeMb']) ? (int)$p['uploadMaxSizeMb'] : 0;
|
||||
$uploadExtWhitelist = trim((string)($p['uploadExtWhitelist'] ?? ''));
|
||||
$uploadMaxPerDay = isset($p['uploadMaxPerDay']) ? (int)$p['uploadMaxPerDay'] : 0;
|
||||
$showThankYou = !empty($p['showThankYou']);
|
||||
$thankYouText = trim((string)($p['thankYouText'] ?? ''));
|
||||
|
||||
if ($folder === '') {
|
||||
throw new RuntimeException('Portal misconfigured: empty folder.');
|
||||
@@ -152,13 +184,48 @@ final class PortalController
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────
|
||||
// Capability flags (portal + ACL)
|
||||
// ──────────────────────────────
|
||||
//
|
||||
// Base from portal config:
|
||||
$canUpload = (bool)$allowUpload;
|
||||
$canDownload = (bool)$allowDownload;
|
||||
|
||||
// Refine with ACL for the current logged-in user (if any)
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
];
|
||||
|
||||
if ($user !== '') {
|
||||
// Upload: must also pass folder-level ACL
|
||||
if ($canUpload && !ACL::canUpload($user, $perms, $folder)) {
|
||||
$canUpload = false;
|
||||
}
|
||||
|
||||
// Download: require read or read_own
|
||||
if (
|
||||
$canDownload
|
||||
&& !ACL::canRead($user, $perms, $folder)
|
||||
&& !ACL::canReadOwn($user, $perms, $folder)
|
||||
) {
|
||||
$canDownload = false;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'label' => $label,
|
||||
'folder' => $folder,
|
||||
'clientEmail' => $clientEmail,
|
||||
'uploadOnly' => $uploadOnly,
|
||||
'allowDownload' => $allowDownload,
|
||||
// Store flags as-is so old code / JSON stay compatible
|
||||
'uploadOnly' => (bool)$rawUploadOnly,
|
||||
'allowDownload' => $hasAllowDownload
|
||||
? (bool)$rawAllowDownload
|
||||
: $allowDownload,
|
||||
'expiresAt' => $expiresAt,
|
||||
'title' => $title,
|
||||
'introText' => $introText,
|
||||
@@ -176,6 +243,9 @@ final class PortalController
|
||||
'uploadMaxPerDay' => $uploadMaxPerDay,
|
||||
'showThankYou' => $showThankYou,
|
||||
'thankYouText' => $thankYouText,
|
||||
// New ACL-aware caps for portal.js
|
||||
'canUpload' => $canUpload,
|
||||
'canDownload' => $canDownload,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user