Compare commits

...

16 Commits

Author SHA1 Message Date
github-actions[bot]
405ed7f925 chore(release): set APP_VERSION to v2.3.6 [skip ci] 2025-12-06 11:22:31 +00:00
Ryan
6491a7b1b3 release(v2.3.6): add non-zip multi-download, richer hover preview/peak, modified sort default 2025-12-06 06:22:20 -05:00
github-actions[bot]
3a5f5fcfd9 chore(release): set APP_VERSION to v2.3.5 [skip ci] 2025-12-06 09:02:26 +00:00
Ryan
a4efa4ff45 release(v2.3.5): make client portals ACL-aware and improve admin UX 2025-12-06 04:02:14 -05:00
Ryan
acac4235ad docs(readme): update screenshot to v2.3.4 2025-12-05 05:29:00 -05:00
github-actions[bot]
35099a5fe1 chore(release): set APP_VERSION to v2.3.4 [skip ci] 2025-12-05 10:09:53 +00:00
Ryan
bb0ac9f421 release(v2.3.4): fix(admin): use textContent for footer preview to satisfy CodeQL 2025-12-05 05:09:42 -05:00
github-actions[bot]
b06c44a5ba chore(release): set APP_VERSION to v2.3.3 [skip ci] 2025-12-05 09:59:32 +00:00
Ryan
e58751dd83 release(v2.3.3): footer branding, Pro bundle UX + file list polish 2025-12-05 04:59:20 -05:00
Ryan
6d4881b068 chore(resources-readme): add filerise-v2.3.2.png screenshot 2025-12-03 01:31:09 -05:00
github-actions[bot]
62aacd53c4 chore(release): set APP_VERSION to v2.3.2 [skip ci] 2025-12-03 06:25:32 +00:00
Ryan
39e69882e5 release(v2.3.2): fix media preview URLs and tighten hover card layout 2025-12-03 01:25:20 -05:00
Ryan
909baed16c chore(readme): update filerise-v2.3.1.png screenshot 2025-12-03 00:34:36 -05:00
Ryan
c61bbf67f8 chore(resources): add filerise-v2.3.1.png screenshot 2025-12-03 00:33:03 -05:00
github-actions[bot]
d1ee6f11fb chore(release): set APP_VERSION to v2.3.1 [skip ci] 2025-12-03 05:29:17 +00:00
Ryan
b417217552 release(v2.3.1): polish file list actions & hover preview peak 2025-12-03 00:29:08 -05:00
28 changed files with 3680 additions and 905 deletions

View File

@@ -1,5 +1,148 @@
# 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
**Branding & footer**
- Added **Pro-only footer branding** (`branding.footerHtml`) stored in `adminConfig.json` and exposed via the Admin API.
- Footer is now rendered from config; if no Pro footer is set, FileRise shows:
`© YEAR FileRise` with a link to **filerise.net**.
- New **“Header & Footer settings”** section in the Admin Panel, with a textarea for footer HTML (simple HTML + links allowed for Pro users).
**FileRise Pro & license UX**
- Bumped UI hint to `PRO_LATEST_BUNDLE_VERSION = v1.2.1`.
- Pro bundle install now:
- Parses the version from the uploaded ZIP basename (works with `C:\fakepath\FileRisePro-v1.2.1.zip`).
- Invalidates OPcache for updated Pro files so new code is active immediately.
- Re-fetches admin config after a successful install and displays the actual active Pro bundle version in the status line.
- Admin config now exposes richer Pro metadata (plan, expiresAt, maxMajor), and the Admin Panel shows:
- License type + email,
- Friendly **plan** description (early supporter vs personal/business),
- **Lifetime** vs **Valid until …** wording instead of a scary raw timestamp.
**Upload UX**
- Upload button is now only visible/enabled when there are files queued (regular or resumable):
- Hidden when the list is empty or after clearing uploads.
- Shown again when user picks or drags in files.
- Adjusted Upload / Choose Files button sizing and spacing for a cleaner upload card, especially on smaller screens.
**File list & hover preview polish**
- Inline folders now respect the current sort mode:
- **Name** sort: AZ / ZA.
- **Size** sort: uses folder stats (bytes) and sorts accordingly.
- Size and meta columns:
- Right-aligned **size**, **uploaded/created**, **modified**, and **owner/uploader** columns.
- Use tabular numerals for nicer numeric alignment.
- Hover preview:
- Skips “fake” rows (e.g. “No files found”) and rows that dont resolve to a real file.
- Uses `sizeBytes` + `formatSize()` for a consistent, human-readable size.
- `formatSize()` now uses 1 decimal place (KB/MB/GB) and short `B` label for bytes.
- File metadata normalization:
- Every file gets a `sizeBytes`, normalized display `size`, and a `cacheKey` derived from modified/uploaded/size, used for stable cache-busting.
- Gallery / preview URLs now use `apiFileUrl()` with a stable `t` parameter instead of `Date.now()`, improving browser caching behavior.
**Layout & animation tweaks**
- Slightly reduced default upload card padding and button sizes to make the homepage cards feel less “tall”.
- New **site footer** styling (subtle border, centered text) added below the main layout.
- Drag-and-drop card (upload/folder cards to header dock) animations:
- Crisper ghost cards with better text opacity and anti-jank tweaks.
- Longer, smoother easing and more readable motion (both collapse-to-header and expand-from-header).
---
## Changes 12/3/2025 (v2.3.2)
release(v2.3.2): fix media preview URLs and tighten hover card layout
- Reuse the working preview URL as a base when stepping between images/videos
so next/prev navigation keeps using the same inline/download endpoint
- Preserve video progress tracking and watched badges while fixing black-screen
playback issues across browsers
- Slightly shrink the file hover preview card (width/height, grid columns,
gaps, snippet/props heights) for a more compact, less intrusive peek
---
## Changes 12/3/2025 (v2.3.1)
release(v2.3.1): polish file list actions & hover preview peak
- Replace per-row action button stack with compact 3-dot “More actions” menu in file list and folder tree
- Add desktop hover preview peak card for files & folders (image thumb, text snippet, quick metadata)
- Add per-user toggle to disable file hover preview (stored in localStorage)
- Improve preview overlay: add Download button, Zoom/Rotate labels, keep download target in sync when navigating images/videos
- Fix mobile table layout so Size column is visible for files & folders
- Tweak dark/light glassmorphism styles for hover card and action buttons
- Clean up size parsing and editable flag logic for big/unknown files
---
## Changes 12/2/2025 (v2.3.0)
release(v2.3.0): feat(portals): branding, intake presets, limits & CSV export

View File

@@ -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)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.3.4.png)
> 💡 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).

View File

@@ -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'),
];

View File

@@ -543,21 +543,22 @@ body{letter-spacing: 0.2px;
flex-direction: column;
align-items: flex-end;
gap: 5px;}
#uploadBtn{font-size: 20px;
padding: 10px 22px;
align-items: center;}
#uploadBtn{font-size: 18px;
padding: 10px 18px;
align-items: center;
margin-top:20px;}
.card-body.d-flex.flex-column{padding: 0.75rem !important;}
#customChooseBtn{background-color: #9E9E9E;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 18px;
font-size: 16px;
padding: 8px 14px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;}
@media (max-width: 768px) {
#customChooseBtn{font-size: 14px;
padding: 6px 14px;}
#customChooseBtn{font-size: 12px;
padding: 6px 10px;}
}
.pause-resume-btn{background: none;
border: none;
@@ -772,7 +773,7 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
text-align: left !important;
line-height: 1.2 !important;
vertical-align: middle !important;
padding: 8px 10px !important;
padding: 2px 4px !important;
max-width: 250px !important;
min-width: 120px !important;}
@media (min-width: 500px) {
@@ -1442,8 +1443,6 @@ label{font-size: 0.9rem;}
#folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease;
width: 100%;
margin-bottom: 20px;
min-height: 320px;
border-radius: var(--menu-radius);
overflow: hidden;
border: 1px solid var(--card-border, #e5e7eb);
@@ -1475,7 +1474,7 @@ body.dark-mode #folderManagementCard{border-color: var(--card-border-dark, #3a3a
.dark-mode .card{background-color: #2c2c2c;
color: #e0e0e0;
border: 1px solid #444;}
.card-header{font-size: 1.2rem;
.card-header{font-size: 1.1rem;
font-weight: bold;}
.custom-folder-card-body{padding-top: 5px !important;
padding-right: 0 !important;
@@ -2560,4 +2559,385 @@ body.dark-mode .portal-submissions-block .portal-submissions-load-btn {
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:hover,
body.dark-mode .portal-submissions-block .portal-submissions-load-btn:focus-visible {
background: rgba(255, 255, 255, 0.10);
}
/* ============================================
TABLE ACTIONS: 3-dot header + row buttons
============================================ */
/* Compact "Actions" column */
th[data-column="actions"],
td.actions-cell,
td.folder-actions-cell {
width: 40px;
max-width: 40px;
text-align: center;
white-space: nowrap;
}
/* Hide "Actions" text but keep it for screen readers */
th[data-column="actions"] {
position: relative;
text-indent: -9999px;
}
/* Show a 3-dot Material icon in the header instead */
th[data-column="actions"]::after {
content: "more_horiz";
font-family: "Material Icons";
text-indent: 0;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #6b7280;
}
.dark-mode th[data-column="actions"]::after,
[data-theme="dark"] th[data-column="actions"]::after {
color: #9ca3af;
}
/* Row-level 3-dot button */
.btn-actions-ellipsis {
border: none;
background: transparent;
padding: 0;
line-height: 1;
box-shadow: none;
border-radius: 999px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color 0.16s ease-out,
box-shadow 0.16s ease-out,
transform 0.12s ease-out;
}
.btn-actions-ellipsis .material-icons {
font-size: 20px;
color: var(--filr-icon-muted, #6b7280);
}
/* Dark theme icon color */
.dark-mode .btn-actions-ellipsis .material-icons,
[data-theme="dark"] .btn-actions-ellipsis .material-icons {
color: #e5e7eb;
}
/* Glassy hover for 3-dot trigger (light) */
.btn-actions-ellipsis:hover,
.btn-actions-ellipsis:focus-visible {
outline: none;
background-color: rgba(148, 163, 184, 0.18);
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.4),
0 6px 14px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transform: translateY(-1px);
}
/* Glassy hover for 3-dot trigger (dark) */
.dark-mode .btn-actions-ellipsis:hover,
.dark-mode .btn-actions-ellipsis:focus-visible,
[data-theme="dark"] .btn-actions-ellipsis:hover,
[data-theme="dark"] .btn-actions-ellipsis:focus-visible {
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
box-shadow:
0 0 0 1px var(--fr-border-dark),
0 10px 24px rgba(0, 0, 0, 0.7);
}
.btn-actions-ellipsis.btn-link,
.btn-actions-ellipsis.btn-link:hover,
.btn-actions-ellipsis.btn-link:focus,
.btn-actions-ellipsis.btn-link:focus-visible {
text-decoration: none !important;
}
/* ============================================
HOVER PREVIEW CARD glassmorphism
============================================ */
/* Clickable glass hover card */
#hoverPreview {
pointer-events: auto;
}
/* === DARK THEME GLASS CARD (no banding) ======================= */
.hover-preview-card {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 420px;
max-width: 640px;
min-height: 220px;
padding: 10px 12px;
border-radius: 14px;
overflow: hidden;
/* Base: semi-opaque dark, no banding */
background-color: color-mix(
in srgb,
var(--fr-surface-dark, #0f172a) 78%,
transparent
) !important;
/* Very subtle linear sheen (small contrast = no visible bands) */
background-image: linear-gradient(
135deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0.0)
);
border: 1px solid color-mix(
in srgb,
var(--fr-border-dark, #1f2937) 70%,
transparent
);
box-shadow:
0 18px 40px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(0, 0, 0, 0.35);
color: #e5e7eb;
font-size: 12px;
/* Glass feel: blur + mild saturation */
backdrop-filter: blur(18px) saturate(135%);
-webkit-backdrop-filter: blur(18px) saturate(135%);
}
/* === LIGHT THEME GLASS CARD =================================== */
[data-theme="light"] .hover-preview-card {
background-color: rgba(255, 255, 255, 0.86) !important;
background-image: linear-gradient(
135deg,
rgba(255, 255, 255, 0.98),
rgba(249, 250, 251, 0.80)
);
border-color: rgba(148, 163, 184, 0.45);
box-shadow:
0 16px 32px rgba(15, 23, 42, 0.16),
0 0 0 1px rgba(255, 255, 255, 0.9);
color: #111827;
backdrop-filter: blur(16px) saturate(130%);
-webkit-backdrop-filter: blur(16px) saturate(130%);
}
/* Two-column inner layout */
.hover-preview-grid {
display: grid;
grid-template-columns: 220px minmax(260px, 1fr);
gap: 12px;
align-items: center; /* center LEFT + RIGHT in the same row */
width: 100%;
}
/* Left column: image + snippet */
.hover-preview-left {
display: flex;
flex-direction: column;
justify-content: center; /* center inside its own grid cell */
min-width: 0;
}
/* Right column: title + meta + props */
.hover-preview-right {
display: flex;
flex-direction: column;
justify-content: center; /* center inside its own grid cell */
min-width: 0;
overflow: hidden;
}
/* Thumb area */
.hover-preview-thumb {
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
margin-bottom: 6px;
}
/* Text / folder peek snippet block */
.hover-preview-snippet {
margin-top: 4px;
max-height: 140px;
overflow: auto;
font-size: 0.78rem;
white-space: pre-wrap;
padding: 6px 8px;
border-radius: 6px;
/* Dark chip so it always has contrast vs the card */
background-color: rgba(39, 39, 39, 0.92) !important;
color: #e5e7eb !important;
}
/* You can keep this same in light mode (still looks good), or tweak slightly */
[data-theme="light"] .hover-preview-snippet {
background-color: rgba(39, 39, 39, 0.92) !important;
color: #f9fafb !important;
}
/* Title + meta + props */
.hover-preview-title {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.hover-preview-meta {
font-size: 0.8rem;
opacity: 0.8;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
[data-theme="light"] .hover-preview-meta {
color: #6b7280;
}
.hover-preview-props {
font-size: 0.78rem;
line-height: 1.3;
max-height: 160px;
overflow: auto;
padding-right: 4px;
word-break: break-word;
}
.hover-prop-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Icon color */
.hover-preview-icon.material-icons {
font-size: 26px;
color: #93c5fd;
}
[data-theme="light"] .hover-preview-icon.material-icons {
color: #2563eb;
}
/* Row-level 3-dot button: shared between file list + folder tree */
.btn-actions-ellipsis,
.folder-kebab {
border: none;
background: transparent;
padding: 0;
line-height: 1;
box-shadow: none;
border-radius: 999px;
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color 0.16s ease-out,
box-shadow 0.16s ease-out,
transform 0.12s ease-out;
}
/* Icon sizing + base color */
.btn-actions-ellipsis .material-icons,
.folder-kebab.material-icons {
font-size: 20px;
color: var(--filr-icon-muted, #6b7280);
}
/* Dark theme icon color */
.dark-mode .btn-actions-ellipsis .material-icons,
[data-theme="dark"] .btn-actions-ellipsis .material-icons,
.dark-mode .folder-kebab.material-icons,
[data-theme="dark"] .folder-kebab.material-icons {
color: #e5e7eb;
}
/* Glassy hover for 3-dot trigger (light) */
.btn-actions-ellipsis:hover,
.btn-actions-ellipsis:focus-visible,
.folder-kebab:hover,
.folder-kebab:focus-visible {
outline: none;
background-color: rgba(148, 163, 184, 0.18);
box-shadow:
0 0 0 1px rgba(148, 163, 184, 0.4),
0 6px 14px rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transform: translateY(-1px);
}
/* Glassy hover for 3-dot trigger (dark) */
.dark-mode .btn-actions-ellipsis:hover,
.dark-mode .btn-actions-ellipsis:focus-visible,
[data-theme="dark"] .btn-actions-ellipsis:hover,
[data-theme="dark"] .btn-actions-ellipsis:focus-visible,
.dark-mode .folder-kebab:hover,
.dark-mode .folder-kebab:focus-visible,
[data-theme="dark"] .folder-kebab:hover,
[data-theme="dark"] .folder-kebab:focus-visible {
background-color: color-mix(in srgb, var(--fr-surface-dark) 70%, transparent);
box-shadow:
0 0 0 1px var(--fr-border-dark),
0 10px 24px rgba(0, 0, 0, 0.7);
}
/* Keep folder modals in DOM for JS, but hide the old toolbar icons */
.folder-actions {
/* still exists so modals can be found + detached */
display: block;
position: relative;
}
/* Hide the icon buttons, keep their IDs for JS wiring */
.folder-actions > button {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.site-footer {
margin-top: 12px;
padding: 8px 16px;
font-size: 0.8rem;
color: var(--filr-muted-text, #777);
border-top: 1px solid rgba(0,0,0,0.06);
display: flex;
justify-content: center;
align-items: center;
}
.site-footer span {
text-align: center;
max-width: 100%;
white-space: normal;
}

View File

@@ -188,7 +188,7 @@
<div class="card-header" data-i18n-key="upload_header">Upload Files/Folders</div>
<div class="card-body d-flex flex-column">
<form id="uploadFileForm" method="post" enctype="multipart/form-data" class="d-flex flex-column">
<div class="form-group flex-grow-1" style="margin-bottom: 1rem;">
<div class="form-group flex-grow-1" style="margin-bottom: 0rem;">
<div id="uploadDropArea"
style="border:2px dashed #ccc; padding:20px; cursor:pointer; display:flex; flex-direction:column; justify-content:center; align-items:center; position:relative;">
<span data-i18n-key="upload_instruction">Drop files/folders here or click 'Choose
@@ -199,7 +199,7 @@
<button type="button" id="customChooseBtn" data-i18n-key="choose_files">Choose Files</button>
</div>
</div>
<button type="submit" id="uploadBtn" class="btn btn-primary d-block mx-auto"
<button type="submit" id="uploadBtn" class="btn btn-primary mx-auto"
data-i18n-key="upload">Upload</button>
<div id="uploadProgressContainer"></div>
</form>
@@ -216,7 +216,7 @@
<div class="form-group d-flex align-items-top" style="padding-top:0; margin-bottom:0;">
<div id="folderTreeContainer"></div>
</div>
<div class="folder-actions mt-3">
<div class="folder-actions">
<button id="createFolderBtn" class="btn btn-primary" data-i18n-title="create_folder">
<i class="material-icons">create_new_folder</i>
</button>
@@ -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>
@@ -538,5 +608,14 @@
</div>
</div>
</div>
<footer id="siteFooter" class="site-footer">
<span>
&copy; 2025
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
FileRise
</a>
</span>
</footer>
</body>
</html>

View File

@@ -20,7 +20,7 @@ function normalizeLogoPath(raw) {
const version = window.APP_VERSION || "dev";
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
// Update this when I cut a new Pro ZIP.
const PRO_LATEST_BUNDLE_VERSION = 'v1.2.0';
const PRO_LATEST_BUNDLE_VERSION = 'v1.2.1';
function getAdminTitle(isPro, proVersion) {
const corePill = `
@@ -110,6 +110,25 @@ function applyHeaderColorsFromAdmin() {
console.warn('Failed to live-update header colors from admin panel', e);
}
}
function applyFooterFromAdmin() {
try {
const footerEl = document.getElementById('siteFooter');
if (!footerEl) return;
const val = (document.getElementById('brandingFooterHtml')?.value || '').trim();
if (val) {
// Show raw text in the live preview; HTML will be rendered on real page load
footerEl.textContent = val;
} else {
const year = new Date().getFullYear();
footerEl.innerHTML =
`&copy; ${year}&nbsp;<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
}
} catch (e) {
console.warn('Failed to live-update footer from admin panel', e);
}
}
function updateHeaderLogoFromAdmin() {
try {
const input = document.getElementById('brandingCustomLogoUrl');
@@ -295,6 +314,7 @@ function captureInitialAdminConfig() {
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
};
}
function hasUnsavedChanges() {
@@ -315,7 +335,8 @@ function hasUnsavedChanges() {
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "")
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") ||
getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "")
);
}
@@ -409,13 +430,42 @@ export function initProBundleInstaller() {
return;
}
const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
// --- NEW: ask the server what version is now active via getConfig.php ---
let finalVersion = '';
try {
const cfgRes = await fetch('/api/admin/getConfig.php?ts=' + Date.now(), {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-store' }
});
const cfg = await safeJson(cfgRes).catch(() => null);
const cfgVersion = cfg && cfg.pro && cfg.pro.version;
if (cfgVersion) {
finalVersion = String(cfgVersion);
}
} catch (e) {
// If this fails, just fall back to whatever installProBundle gave us.
console.warn('Failed to refresh config after Pro bundle install', e);
}
if (!finalVersion && data.proVersion) {
finalVersion = String(data.proVersion);
}
const versionText = finalVersion ? ` (version ${finalVersion})` : '';
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
statusEl.className = 'small text-success';
// Clear file input so repeat installs feel "fresh"
try { fileInput.value = ''; } catch (_) {}
// Keep existing behavior: refresh any admin config in the header, etc.
if (typeof loadAdminConfigFunc === 'function') {
loadAdminConfigFunc();
}
setTimeout(() => {
window.location.reload();
}, 800);
} catch (e) {
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
statusEl.className = 'small text-danger';
@@ -537,10 +587,19 @@ export function openAdminPanel() {
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
const proLicense = proInfo.license || '';
// New: richer license metadata from FR_PRO_INFO / backend
const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly"
const proExpiresAt = proInfo.expiresAt || ''; // ISO timestamp string or ""
const proMaxMajor = (
typeof proInfo.maxMajor === 'number'
? proInfo.maxMajor
: (proInfo.maxMajor ? Number(proInfo.maxMajor) : null)
);
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
const brandingFooterHtml = brandingCfg.footerHtml || "";
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const inner = `
background:${dark ? "#2c2c2c" : "#fff"};
@@ -569,7 +628,7 @@ export function openAdminPanel() {
<form id="adminPanelForm">
${[
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: t("header_settings") },
{ id: "headerSettings", label: tf("header_footer_settings", "Header & Footer settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "onlyoffice", label: "ONLYOFFICE" },
@@ -758,8 +817,8 @@ export function openAdminPanel() {
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Upload a logo image or paste a local path.'
: 'Requires FileRise Pro to enable custom header branding.'}
? 'Upload a logo image or paste a local path.'
: 'Requires FileRise Pro to enable custom header branding.'}
</small>
<div class="input-group mb-2">
@@ -818,12 +877,30 @@ export function openAdminPanel() {
</div>
</div>
<small class="text-muted d-block mt-1">
${isPro
? 'If left empty, FileRise uses its default blue and dark header colors.'
: 'Requires FileRise Pro to enable custom color branding.'}
${isPro
? 'If left empty, FileRise uses its default blue and dark header colors.'
: 'Requires FileRise Pro to enable custom color branding.'}
</small>
</div>
<!-- Pro: Footer text -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingFooterHtml">
Footer text
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Shown at the bottom of every page. You can include simple HTML like links.'
: 'Requires FileRise Pro to customize footer text.'}
</small>
<textarea
id="brandingFooterHtml"
class="form-control"
rows="2"
placeholder="&copy; 2025 Your Company. Powered by FileRise."
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}>${isPro ? (brandingFooterHtml || '') : ''}</textarea>
</div>
`;
wireHeaderTitleLive();
@@ -946,26 +1023,57 @@ export function openAdminPanel() {
const hasLatest = !!norm(latestVersionRaw);
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
const proMetaHtml =
isPro && (proType || proEmail || proVersion)
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
<div>
${proType ? `License type: ${proType}` : 'License active'}
${proType && proEmail ? ' • ' : ''}
${proEmail ? `Licensed to: ${proEmail}` : ''}
</div>
${hasCurrent ? `
<div>
Installed Pro bundle: v${norm(currentVersionRaw)}
</div>` : ''}
${hasLatest ? `
<div>
Latest Pro bundle (UI hint): ${latestVersionRaw}
</div>` : ''}
</div>
`
: '';
// Friendly description of plan + lifetime/expiry
let planLabel = '';
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
const mj = proMaxMajor || 1;
planLabel = `Early supporter lifetime for FileRise Pro ${mj}.x`;
} else if (proPlan) {
if (proPlan.startsWith('personal_') || proPlan === 'personal_yearly') {
planLabel = 'Personal license';
} else if (proPlan.startsWith('business_') || proPlan === 'business_yearly') {
planLabel = 'Business license';
} else {
planLabel = proPlan;
}
}
let expiryLabel = '';
if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) {
// Early supporters: we treat as lifetime for that major do NOT show an expiry date
expiryLabel = 'Lifetime license (no expiry)';
} else if (proExpiresAt) {
expiryLabel = `Valid until ${proExpiresAt}`;
}
const proMetaHtml =
isPro && (proType || proEmail || proVersion || planLabel || expiryLabel)
? `
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
<div>
${proType ? `License type: ${proType}` : 'License active'}
${proType && proEmail ? ' • ' : ''}
${proEmail ? `Licensed to: ${proEmail}` : ''}
</div>
${planLabel ? `
<div>
Plan: ${planLabel}
</div>` : ''}
${expiryLabel ? `
<div>
${expiryLabel}
</div>` : ''}
${hasCurrent ? `
<div>
Installed Pro bundle: v${norm(currentVersionRaw)}
</div>` : ''}
${hasLatest ? `
<div>
Latest Pro bundle (UI hint): ${latestVersionRaw}
</div>` : ''}
</div>
`
: '';
proContent.innerHTML = `
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
@@ -1309,6 +1417,7 @@ function handleSave() {
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(),
},
};
@@ -1348,6 +1457,7 @@ function handleSave() {
closeAdminPanel();
applyHeaderColorsFromAdmin();
updateHeaderLogoFromAdmin();
applyFooterFromAdmin();
})
.catch(() => showToast('Save failed.'));
}
@@ -1858,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}">

View File

@@ -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; // Dont hit the API if local validation failed
}
if (status) {
status.textContent = 'Saving…';
status.className = 'small text-muted';

View File

@@ -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');

View File

@@ -10,6 +10,15 @@ export function setLastLoginData(data) {
//window.__lastLoginData = data;
}
function isHoverPreviewDisabled() {
if (window.disableHoverPreview === true) return true;
try {
return localStorage.getItem('disableHoverPreview') === 'true';
} catch {
return false;
}
}
export function openTOTPLoginModal() {
let totpLoginModal = document.getElementById("totpLoginModal");
const isDarkMode = document.body.classList.contains("dark-mode");
@@ -454,6 +463,43 @@ export async function openUserPanel() {
}
});
// 4) Disable hover preview
const hoverLabel = document.createElement('label');
hoverLabel.style.cursor = 'pointer';
hoverLabel.style.display = 'block';
hoverLabel.style.marginTop = '4px';
const hoverCb = document.createElement('input');
hoverCb.type = 'checkbox';
hoverCb.id = 'disableHoverPreview';
hoverCb.style.verticalAlign = 'middle';
{
const storedHover = localStorage.getItem('disableHoverPreview');
hoverCb.checked = storedHover === 'true';
// also mirror into a global flag for runtime checks
window.disableHoverPreview = hoverCb.checked;
}
hoverLabel.appendChild(hoverCb);
hoverLabel.append(
` ${t('disable_hover_preview') || 'Disable file hover preview'}`
);
dispFs.appendChild(hoverLabel);
// Handler: toggle hover preview
hoverCb.addEventListener('change', () => {
const disabled = hoverCb.checked;
localStorage.setItem('disableHoverPreview', disabled ? 'true' : 'false');
window.disableHoverPreview = disabled;
// Hide any currently-visible preview right away
const preview = document.getElementById('hoverPreview');
if (preview) {
preview.style.display = 'none';
}
});
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
@@ -524,6 +570,13 @@ export async function openUserPanel() {
}
}
const hoverCb = modal.querySelector('#disableHoverPreview');
if (hoverCb) {
const storedHover = localStorage.getItem('disableHoverPreview');
hoverCb.checked = storedHover === 'true';
window.disableHoverPreview = hoverCb.checked;
}
// show
modal.style.display = 'flex';
}

View File

@@ -163,9 +163,9 @@ export function buildFileTableHeader(sortOrder) {
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="sortable-col"> ${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""} </th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th>
<th data-column="actions" class="actions-col">${t("actions")}</th>
</tr>
</thead>
`;
@@ -175,99 +175,32 @@ export function buildFileTableRow(file, folderPath) {
const safeFileName = escapeHTML(file.name);
const safeModified = escapeHTML(file.modified);
const safeUploaded = escapeHTML(file.uploaded);
const safeSize = escapeHTML(file.size);
const safeSize = escapeHTML(file.size);
const safeUploader = escapeHTML(file.uploader || "Unknown");
let previewButton = "";
const isSvg = /\.svg$/i.test(file.name);
// IMPORTANT: do NOT treat SVG as previewable
if (
!isSvg &&
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic|pdf|mp4|webm|mov|mp3|wav|m4a|ogg|flac|aac|wma|opus|mkv|ogv)$/i
.test(file.name)
) {
let previewIcon = "";
// images (SVG explicitly excluded)
if (
/\.(jpg|jpeg|png|gif|bmp|webp|ico|tif|tiff|eps|heic)$/i
.test(file.name)
) {
previewIcon = `<i class="material-icons">image</i>`;
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">videocam</i>`;
} else if (/\.pdf$/i.test(file.name)) {
previewIcon = `<i class="material-icons">picture_as_pdf</i>`;
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`;
}
previewButton = `
<button
type="button"
class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
}
return `
<tr class="clickable-row">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td>
<div class="btn-group btn-group-sm" role="group" aria-label="File actions">
<button
type="button"
class="btn btn-sm btn-success download-btn"
data-download-name="${file.name}"
data-download-folder="${file.folder || 'root'}"
title="${t('download')}">
<i class="material-icons">file_download</i>
</button>
${file.editable ? `
<button
type="button"
class="btn btn-sm btn-secondary edit-btn"
data-edit-name="${file.name}"
data-edit-folder="${file.folder || 'root'}"
title="${t('edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
${previewButton}
<button
type="button"
class="btn btn-sm btn-warning rename-btn"
data-rename-name="${file.name}"
data-rename-folder="${file.folder || 'root'}"
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i>
</button>
<!-- share -->
<button
<tr class="clickable-row" data-file-name="${safeFileName}">
<td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td>
<td class="file-name-cell name-cell">
${safeFileName}
</td>
<td class="hide-small nowrap">${safeModified}</td>
<td class="hide-small hide-medium nowrap">${safeUploaded}</td>
<td class="hide-small nowrap size-cell">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td class="actions-cell">
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
class="btn btn-link btn-actions-ellipsis"
title="${t("more_actions")}"
>
<span class="material-icons">more_vert</span>
</button>
</div>
</td>
</tr>
`;
</td>
</tr>
`;
}
export function buildBottomControls(itemsPerPageSetting) {

View File

@@ -80,7 +80,6 @@ function createCardGhost(card, rect, opts) {
const ghost = card.cloneNode(true);
const cs = window.getComputedStyle(card);
// Give the ghost the same “card” chrome even though its attached to <body>
Object.assign(ghost.style, {
position: 'fixed',
left: rect.left + 'px',
@@ -94,7 +93,6 @@ function createCardGhost(card, rect, opts) {
transform: 'scale(' + scale + ')',
opacity: String(opacity),
// pull key visuals from the real card
backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)',
borderRadius: cs.borderRadius || '',
boxShadow: cs.boxShadow || '',
@@ -102,8 +100,17 @@ function createCardGhost(card, rect, opts) {
borderWidth: cs.borderWidth || '',
borderStyle: cs.borderStyle || '',
backdropFilter: cs.backdropFilter || '',
// ✨ make the ghost crisper
overflow: 'hidden',
willChange: 'transform, opacity',
backfaceVisibility: 'hidden'
});
// Subtle: de-emphasize inner text so it doesnt look “smeared”
const ghBody = ghost.querySelector('.card-body');
if (ghBody) ghBody.style.opacity = '0.6';
return ghost;
}
@@ -396,7 +403,7 @@ function animateCardsIntoHeaderAndThen(done) {
return { card, rect };
});
// Show dock so icons exist / have positions
// Make sure header dock is visible so icons are laid out
showHeaderDockPersistent();
// Move real cards into header (hidden container + icons)
@@ -410,16 +417,16 @@ function animateCardsIntoHeaderAndThen(done) {
// remember the size for the expand animation later
card.dataset.lastWidth = String(rect.width);
card.dataset.lastHeight = String(rect.height);
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
const iconRect = iconBtn.getBoundingClientRect();
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 });
const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 });
ghost.id = card.id + '-ghost-collapse';
ghost.classList.add('card-collapse-ghost');
ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out';
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
document.body.appendChild(ghost);
ghosts.push({ ghost, from: rect, to: iconRect });
@@ -430,6 +437,7 @@ function animateCardsIntoHeaderAndThen(done) {
return;
}
// Kick off motion on next frame
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const fromCx = from.left + from.width / 2;
@@ -441,17 +449,18 @@ function animateCardsIntoHeaderAndThen(done) {
const dy = toCy - fromCy;
const rawScale = to.width / from.width;
const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9));
const scale = Math.max(0.35, Math.min(0.6, rawScale * 0.9));
// ✨ more readable: clear slide + shrink, but dont fully vanish mid-flight
ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
ghost.style.opacity = '0';
ghost.style.opacity = '0.35';
});
});
setTimeout(() => {
ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} });
done();
}, 260);
}, 430); // a bit over the 0.4s transition
}
function resolveTargetZoneForExpand(cardId) {
@@ -508,9 +517,9 @@ function animateCardsOutOfHeaderThen(done) {
if (sb) sb.style.display = '';
if (top) top.style.display = '';
const SAFE_TOP = 16; // minimum distance from top of viewport
const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost
const DEST_EXTRA_Y = 120; // how far down into the zone center we aim
const SAFE_TOP = 16;
const START_OFFSET_Y = 32; // a touch closer to header
const DEST_EXTRA_Y = 120;
const ghosts = [];
@@ -528,24 +537,20 @@ function animateCardsOutOfHeaderThen(done) {
const zoneRect = host.getBoundingClientRect();
if (!zoneRect.width) return;
// Where the ghost "comes from" (near the icon)
const fromCx = iconRect.left + iconRect.width / 2;
const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point
const fromCy = iconRect.bottom + START_OFFSET_Y;
// Where we want it to "land" (roughly center of the zone, a bit down)
let toCx = zoneRect.left + zoneRect.width / 2;
let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y);
// 🔹 If both cards are going to the sidebar, offset them so they don't stack
if (zoneId === ZONES.SIDEBAR) {
if (card.id === 'uploadCard') {
toCy -= 48; // a bit higher
toCy -= 48;
} else if (card.id === 'folderManagementCard') {
toCy += 48; // a bit lower
toCy += 48;
}
}
// Try to match the real card size we captured during collapse
const savedW = parseFloat(card.dataset.lastWidth || '');
const savedH = parseFloat(card.dataset.lastHeight || '');
const targetWidth = !Number.isNaN(savedW)
@@ -553,10 +558,8 @@ function animateCardsOutOfHeaderThen(done) {
: Math.min(280, Math.max(220, zoneRect.width * 0.85));
const targetHeight = !Number.isNaN(savedH) ? savedH : 190;
// Make sure the top of the ghost never goes above SAFE_TOP
const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2);
// Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow.
const ghostRect = {
left: fromCx - targetWidth / 2,
top: startTop,
@@ -564,13 +567,12 @@ function animateCardsOutOfHeaderThen(done) {
height: targetHeight
};
const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 });
const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 });
ghost.id = card.id + '-ghost-expand';
ghost.classList.add('card-expand-ghost');
// Override transform/transition for our flight animation
ghost.style.transform = 'translate(0,0) scale(0.7)';
ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out';
ghost.style.transform = 'translate(0,0) scale(0.75)';
ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear';
document.body.appendChild(ghost);
ghosts.push({
@@ -586,7 +588,6 @@ function animateCardsOutOfHeaderThen(done) {
return;
}
// Kick off the flight on the next frame
requestAnimationFrame(() => {
ghosts.forEach(({ ghost, from, to }) => {
const dx = to.cx - from.cx;
@@ -596,13 +597,12 @@ function animateCardsOutOfHeaderThen(done) {
});
});
// Clean up ghosts and then do real layout restore
setTimeout(() => {
ghosts.forEach(({ ghost }) => {
try { ghost.remove(); } catch {}
});
done();
}, 280); // just over the 0.25s transition
}, 430);
}
// -------------------- zones toggle (collapse to header) --------------------

View File

@@ -1,6 +1,6 @@
// fileDragDrop.js
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFileList, cancelHoverPreview } from './fileListView.js?v={{APP_QVER}}';
/* ---------------- helpers ---------------- */
function getRowEl(el) {
@@ -54,6 +54,7 @@ function makeDragImage(labelText, iconName = 'insert_drive_file') {
/* ---------------- drag start (rows/cards) ---------------- */
export function fileDragStartHandler(event) {
try { cancelHoverPreview(); } catch {}
const row = getRowEl(event.currentTarget);
if (!row) return;

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -9,6 +9,18 @@ export function buildPreviewUrl(folder, name) {
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
}
// New: build a download URL (attachment)
export function buildDownloadUrl(folder, name) {
const f = (!folder || folder === '') ? 'root' : String(folder);
const params = new URLSearchParams({
folder: f,
file: name,
inline: '0',
t: String(Date.now())
});
return `/api/file/download.php?${params.toString()}`;
}
const MEDIA_VOLUME_KEY = 'frMediaVolume';
const MEDIA_MUTED_KEY = 'frMediaMuted';
@@ -376,6 +388,27 @@ function setTitle(overlay, name) {
}
}
// New: Download icon that uses current file name
function makeDownloadButton(folder, getName) {
const btn = makeTopIcon('download', t('download') || 'Download');
btn.addEventListener('click', (e) => {
e.stopPropagation();
const nm = getName && getName();
if (!nm) return;
const url = buildDownloadUrl(folder, nm);
// Use a temporary <a> with download attribute for nicer behavior
const a = document.createElement('a');
a.href = url;
a.download = nm;
document.body.appendChild(a);
a.click();
a.remove();
});
return btn;
}
// Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button');
@@ -470,8 +503,28 @@ export function previewFile(fileUrl, fileName) {
const isVideo = VID_RE.test(lower);
const isAudio = AUD_RE.test(lower);
// Base preview URL from the link we clicked
const baseUrl = fileUrl;
// Use the same preview endpoint, just swap the "file" param.
function siblingPreviewUrl(newName) {
try {
const u = new URL(baseUrl, window.location.origin);
u.searchParams.set('file', newName);
// cache-bust so we dont get stale frames
u.searchParams.set('t', String(Date.now()));
return u.toString();
} catch {
// Fallback: go through generic download/inline endpoint
return buildPreviewUrl(folder, newName);
}
}
setTitle(overlay, name);
if (isSvg) {
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
container.textContent =
t("svg_preview_disabled") ||
"SVG preview is disabled for security. Use Download to view this file.";
@@ -490,12 +543,17 @@ export function previewFile(fileUrl, fileName) {
img.dataset.scale = 1;
img.dataset.rotate = 0;
container.appendChild(img);
let currentName = name;
// topbar-aligned, theme-aware icons
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
const downloadBtn = makeDownloadButton(folder, () => currentName);
actionWrap.appendChild(downloadBtn);
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
@@ -527,21 +585,22 @@ export function previewFile(fileUrl, fileName) {
});
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
overlay.mediaType = 'image';
overlay.mediaList = images;
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
setNavVisibility(overlay, images.length > 1, images.length > 1);
overlay.mediaType = 'image';
overlay.mediaList = images;
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
setNavVisibility(overlay, images.length > 1, images.length > 1);
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const newFile = overlay.mediaList[overlay.mediaIndex].name;
setTitle(overlay, newFile);
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
img.src = buildPreviewUrl(folder, newFile);
};
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const newFile = overlay.mediaList[overlay.mediaIndex].name;
currentName = newFile; // keep download button pointing to the right file
setTitle(overlay, newFile);
img.dataset.scale = 1;
img.dataset.rotate = 0;
img.style.transform = 'scale(1) rotate(0deg)';
img.src = siblingPreviewUrl(newFile); // <-- changed
};
if (images.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
@@ -568,207 +627,226 @@ export function previewFile(fileUrl, fileName) {
return;
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Apply last-used volume/mute, and persist future changes
loadSavedMediaVolume(video);
attachVolumePersistence(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
// Track which file is currently active
let currentName = name;
const setVideoSrc = (nm) => {
currentName = nm;
video.src = buildPreviewUrl(folder, nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
}
/* -------------------- VIDEOS -------------------- */
if (isVideo) {
let video = document.createElement("video");
video.controls = true;
video.preload = 'auto'; // hint browser to start fetching quickly
video.style.maxWidth = "88vw";
video.style.maxHeight = "88vh";
video.style.objectFit = "contain";
container.appendChild(video);
// Apply last-used volume/mute, and persist future changes
loadSavedMediaVolume(video);
attachVolumePersistence(video);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
// Track which file is currently active
let currentName = name;
// Use the URL we were passed in (old behavior) for the *first* video,
// fall back to API URL if for some reason it's empty.
const initialUrl = fileUrl && fileUrl.trim()
? fileUrl
: buildPreviewUrl(folder, name);
const downloadBtn = makeDownloadButton(folder, () => currentName);
// Order: Download | Mark | Reset
actionWrap.appendChild(downloadBtn);
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
overlay.mediaList = videos;
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
// Helper: set src for a given video name
const setVideoSrc = (nm) => {
currentName = nm;
// For the current file, reuse the original working URL.
// For other files (next/prev), go through the API.
const url = (nm === name) ? initialUrl : buildPreviewUrl(folder, nm);
video.src = url;
video.src = siblingPreviewUrl(nm);
setTitle(overlay, nm);
};
const SAVE_INTERVAL_MS = 5000;
let lastSaveAt = 0;
let pending = false;
async function getProgress(nm) {
try {
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
const data = await res.json();
return data && data.state ? data.state : null;
} catch { return null; }
}
async function sendProgress({nm, seconds, duration, completed, clear}) {
try {
pending = true;
const res = await fetch("/api/media/updateProgress.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
renderStatus(null);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
const data = await res.json();
pending = false;
return data;
} catch (e) {
pending = false;
console.error(e);
return null;
}
}
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
function renderStatus(state) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c';
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
// ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
if (ls) video.currentTime = parseFloat(ls);
}
renderStatus(state || null);
} catch {
renderStatus(null);
}
});
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
const nm = currentName;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
video.addEventListener("ended", async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtnIcon.onclick = async () => {
const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtnIcon.onclick = async () => {
const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
const navigate = (dir) => {
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
const nm = overlay.mediaList[overlay.mediaIndex].name;
setVideoSrc(nm);
renderStatus(null);
};
if (videos.length > 1) {
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
const onKey = (e) => {
if (!document.body.contains(overlay)) {
window.removeEventListener("keydown", onKey);
return;
}
if (e.key === "ArrowLeft") navigate(-1);
if (e.key === "ArrowRight") navigate(+1);
};
window.addEventListener("keydown", onKey);
overlay._onKey = onKey;
}
// Kick off first video using the original working URL
setVideoSrc(name);
renderStatus(null);
overlay.style.display = "flex";
return;
}
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");
@@ -782,8 +860,14 @@ export function previewFile(fileUrl, fileName) {
loadSavedMediaVolume(audio);
attachVolumePersistence(audio);
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
overlay.style.display = "flex";
} else {
const downloadBtn = makeDownloadButton(folder, () => name);
actionWrap.appendChild(downloadBtn);
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
overlay.style.display = "flex";
}

View File

@@ -1066,6 +1066,41 @@ export function openColorFolderModal(folder) {
}
});
}
function addFolderActionButton(rowEl, folderPath) {
if (!rowEl || !folderPath) return;
if (rowEl.querySelector('.folder-kebab')) return; // avoid duplicates
const btn = document.createElement('button');
btn.type = 'button';
// share styling with file list kebab
btn.className = 'folder-kebab btn-actions-ellipsis material-icons';
btn.textContent = 'more_vert';
const label = t('folder_actions') || 'Folder actions';
btn.title = label;
btn.setAttribute('aria-label', label);
// only control visibility/layout here; let CSS handle colors/hover
Object.assign(btn.style, {
display: 'none',
marginLeft: '4px',
flexShrink: '0'
});
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const rect = btn.getBoundingClientRect();
const x = rect.right;
const y = rect.bottom;
const opt = rowEl.querySelector('.folder-option');
await openFolderActionsMenu(folderPath, opt, x, y);
});
rowEl.appendChild(btn);
}
/* ----------------------
DOM builders & DnD
----------------------*/
@@ -1125,6 +1160,10 @@ function makeChildLi(parentPath, item) {
opt.append(icon, label);
row.append(spacer, opt);
// Add 3-dot actions button for unlocked folders
if (!locked) addFolderActionButton(row, fullPath);
li.append(row);
// <ul class="folder-tree collapsed" role="group"></ul>
@@ -1300,6 +1339,28 @@ function getULForFolder(folder) {
const li = opt ? opt.closest('li[role="treeitem"]') : null;
return li ? li.querySelector(':scope > ul.folder-tree') : null;
}
function updateFolderActionButtons() {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
// Hide all kebabs by default
container.querySelectorAll('.folder-kebab').forEach(btn => {
btn.style.display = 'none';
});
// Show only for the currently selected, unlocked folder
const selectedOpt = container.querySelector('.folder-option.selected');
if (!selectedOpt || selectedOpt.classList.contains('locked')) return;
const row = selectedOpt.closest('.folder-row');
if (!row) return;
const kebab = row.querySelector('.folder-kebab');
if (kebab) {
kebab.style.display = 'inline-flex';
}
}
async function selectFolder(selected) {
const container = document.getElementById('folderTreeContainer');
if (!container) return;
@@ -1368,6 +1429,9 @@ async function selectFolder(selected) {
saveFolderTreeState(st);
try { await ensureChildrenLoaded(selected, ul); primeChildToggles(ul); } catch {}
}
// Keep the 3-dot action aligned to the active folder
updateFolderActionButtons();
}
/* ----------------------
@@ -1432,6 +1496,12 @@ export async function loadFolderTree(selectedFolder) {
`;
container.innerHTML = html;
// Add 3-dot actions button for root
const rootRow = document.getElementById('rootRow');
if (rootRow) {
addFolderActionButton(rootRow, effectiveRoot);
}
// Determine root's lock state
const rootOpt = container.querySelector('.root-folder-option');
let rootLocked = false;
@@ -1654,13 +1724,57 @@ export function hideFolderManagerContextMenu() {
if (menu) menu.hidden = true;
}
async function openFolderActionsMenu(folder, targetEl, clientX, clientY) {
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
// Clear previous selection in tree + breadcrumb
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
// Mark the clicked thing selected (folder-option or breadcrumb)
if (targetEl) targetEl.classList.add('selected');
// Also sync selection in the tree if we invoked from a breadcrumb or kebab
const tree = document.getElementById('folderTreeContainer');
if (tree) {
const inTree = tree.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
if (inTree) inTree.classList.add('selected');
}
// Show the kebab only for this selected folder
updateFolderActionButtons();
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
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(clientX, clientY, menuItems);
}
async function folderManagerContextMenuHandler(e) {
const target = e.target.closest('.folder-option, .breadcrumb-link');
if (!target) return;
e.preventDefault();
e.stopPropagation();
// Toggle-only for locked nodes
// Toggle-only for locked nodes (no menu)
if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder);
@@ -1679,29 +1793,9 @@ async function folderManagerContextMenuHandler(e) {
const folder = target.getAttribute('data-folder');
if (!folder) return;
window.currentFolder = folder;
await applyFolderCapabilities(folder);
document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
target.classList.add('selected');
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
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.clientX, e.clientY, menuItems);
const x = e.clientX;
const y = e.clientY;
await openFolderActionsMenu(folder, target, x, y);
}
function bindFolderManagerContextMenu() {

View File

@@ -187,6 +187,7 @@ const translations = {
// Admin Panel
"header_settings": "Header Settings",
"header_footer_settings": "Header & Footer Settings",
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
@@ -343,7 +344,29 @@ const translations = {
"hide_header_zoom_controls": "Hide header zoom controls",
"preview_not_available": "Preview is not available for this file type.",
"storage_pro_bundle_outdated": "Please upgrade to the latest FileRise Pro bundle to use the Storage explorer.",
"svg_preview_disabled": "SVG preview is disabled for now for security reasons."
"svg_preview_disabled": "SVG preview is disabled for now for security reasons.",
"no_files_or_folders": "No files or folders to display.",
"no_preview_available": "No preview available.",
"more_actions": "More Actions",
"folder_actions": "Folder Actions",
"disable_hover_preview": "Disable hover preview in file list",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"rotate_left": "Rotate Left",
"rotate_right": "Rotate Right",
"download_plain": "Download (no ZIP)",
"download_next": "Download next",
"nonzip_queue_title": "Files queued for download",
"nonzip_queue_subtitle": "{count} files queued. Click \"Download next\" for each file.",
"nonzip_queue_cleared": "Download queue cleared.",
"your_access": "Your access",
"perm_upload": "Upload",
"perm_move": "Move",
"perm_rename": "Rename",
"perm_share": "Share",
"perm_delete": "Delete"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -445,107 +445,127 @@ function bindDarkMode() {
m.content = val;
};
// ---------- site config / auth ----------
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Header logo (branding) in BOTH phases ---
// ---------- site config / auth ----------
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const customLogoUrl = branding.customLogoUrl || "";
const logoImg = document.querySelector('.header-logo img');
if (logoImg) {
if (customLogoUrl) {
logoImg.setAttribute('src', customLogoUrl);
logoImg.setAttribute('alt', 'Site logo');
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Header logo (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const customLogoUrl = branding.customLogoUrl || "";
const logoImg = document.querySelector('.header-logo img');
if (logoImg) {
if (customLogoUrl) {
logoImg.setAttribute('src', customLogoUrl);
logoImg.setAttribute('alt', 'Site logo');
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
logoImg.setAttribute('alt', 'FileRise');
}
}
} catch (e) {
// non-fatal; ignore branding issues
}
// --- Header colors (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const root = document.documentElement;
const light = branding.headerBgLight || '';
const dark = branding.headerBgDark || '';
if (light) root.style.setProperty('--header-bg-light', light);
else root.style.removeProperty('--header-bg-light');
if (dark) root.style.setProperty('--header-bg-dark', dark);
else root.style.removeProperty('--header-bg-dark');
} catch (e) {
// non-fatal
}
// --- Footer HTML (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const footerEl = document.getElementById('siteFooter');
if (footerEl) {
const html = (branding.footerHtml || '').trim();
if (html) {
// allow simple HTML from config
footerEl.innerHTML = html;
} else {
const year = new Date().getFullYear();
footerEl.innerHTML =
`&copy; ${year}&nbsp;<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`;
}
}
} catch (e) {
// non-fatal
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
// fall back to default FileRise logo
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
logoImg.setAttribute('alt', 'FileRise');
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
} catch (e) {
// non-fatal; ignore branding issues
}
// --- Header colors (branding) in BOTH phases ---
try {
const branding = (cfg && cfg.branding) ? cfg.branding : {};
const root = document.documentElement;
const light = branding.headerBgLight || '';
const dark = branding.headerBgDark || '';
if (light) root.style.setProperty('--header-bg-light', light);
else root.style.removeProperty('--header-bg-light');
if (dark) root.style.setProperty('--header-bg-dark', dark);
else root.style.removeProperty('--header-bg-dark');
} catch (e) {
// non-fatal
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
if (h1.textContent !== title) h1.textContent = title;
});
mo.observe(h1, { childList: true, characterData: true, subtree: true });
h1.__titleLock = mo;
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
if (h1.textContent !== title) h1.textContent = title;
});
mo.observe(h1, { childList: true, characterData: true, subtree: true });
h1.__titleLock = mo;
}
}
}
}
} catch { }
}
} catch { }
}
async function readyToReveal() {
// Wait for CSS + fonts so the first revealed frame is fully styled

View File

@@ -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();
});
}
}
}

View File

@@ -103,6 +103,14 @@ function wireFileInputChange(fileInput) {
});
}
function setUploadButtonVisible(visible) {
const btn = document.getElementById('uploadBtn');
if (!btn) return;
btn.style.display = visible ? 'block' : 'none';
btn.disabled = !visible;
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
@@ -346,6 +354,8 @@ function setDropAreaDefault() {
const fileInput = dropArea.querySelector('#file');
wireFileInputChange(fileInput);
wireChooseButton();
setUploadButtonVisible(false);
}
function adjustFolderHelpExpansion() {
@@ -464,6 +474,8 @@ function createFileEntry(file) {
li.remove();
updateFileInfoCount();
const anyItems = !!document.querySelector('li.upload-progress-item');
setUploadButtonVisible(anyItems);
});
li.removeBtn = removeBtn;
li.appendChild(removeBtn);
@@ -674,6 +686,7 @@ function processFiles(filesInput) {
window.selectedFiles = files;
updateFileInfoCount();
setUploadButtonVisible(files.length > 0);
}
/* -----------------------------------------------------
@@ -770,6 +783,7 @@ async function initResumableUpload() {
list.appendChild(li);
updateFileInfoCount();
updateResumableQuery();
setUploadButtonVisible(true);
});
resumableInstance.on("fileProgress", function (file) {
@@ -931,6 +945,7 @@ async function initResumableUpload() {
}
clearResumableDraftsForFolder(window.currentFolder || 'root');
showResumableDraftBanner();
setUploadButtonVisible(false);
}, 5000);
} else {
showToast("Some files failed to upload. Please check the list.");
@@ -1183,6 +1198,8 @@ function submitFiles(allFiles) {
} else {
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
}
const anyItems = !!document.querySelector('li.upload-progress-item');
setUploadButtonVisible(anyItems);
})
.catch(error => {
console.error("Error fetching file list:", error);
@@ -1275,6 +1292,8 @@ function initUpload() {
return;
}
setUploadButtonVisible(false);
const hasResumableFiles =
useResumable &&
resumableInstance &&

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v2.3.0';
window.APP_VERSION = 'v2.3.6';

View File

@@ -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">

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# === Update FileRise to v2.1.0 (safe rsync, no composer on demo) ===
# === Update FileRise to v2.3.2 (safe rsync, no composer on demo) ===
set -Eeuo pipefail
VER="v2.1.0"
VER="v2.3.2"
ASSET="FileRise-${VER}.zip" # matches GitHub release asset name
WEBROOT="/var/www"

View File

@@ -144,6 +144,9 @@ class AdminController
$proType = $proPayload['type'] ?? null;
$proEmail = $proPayload['email'] ?? null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
$proPlan = $proPayload['plan'] ?? null;
$proExpiresAt = $proPayload['expiresAt'] ?? null;
$proMaxMajor = $proPayload['maxMajor'] ?? null;
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
@@ -169,6 +172,7 @@ class AdminController
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
],
'pro' => [
'active' => $proActive,
@@ -176,6 +180,9 @@ class AdminController
'email' => $proEmail,
'version' => $proVersion,
'license' => $licenseString,
'plan' => $proPlan,
'expiresAt' => $proExpiresAt,
'maxMajor' => $proMaxMajor,
],
'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false,
];
@@ -310,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,
@@ -429,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);
@@ -581,6 +598,28 @@ public function installProBundle(): void
return;
}
// NEW: normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works.
$basename = $origName;
if ($basename !== '') {
// Normalize slashes and then take basename
$basename = str_replace('\\', '/', $basename);
$basename = basename($basename);
}
// Try to parse the bundle version from the *basename*
// Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive)
$declaredVersion = null;
if (
$basename !== '' &&
preg_match(
'/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i',
$basename,
$m
)
) {
$declaredVersion = 'v' . $m[1];
}
// Prepare temp working dir
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
@@ -723,20 +762,36 @@ public function installProBundle(): void
// Best-effort cleanup; ignore failures
@unlink($zipPath);
@rmdir($workDir);
// NEW: ensure OPcache picks up new Pro bundle code immediately
if (function_exists('opcache_invalidate')) {
foreach ($installed['src'] as $pathInfo) {
// strip " (overwritten)" suffix if present
$path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo);
if (is_string($path) && $path !== '' && is_file($path)) {
@opcache_invalidate($path, true);
}
}
}
// Reflect current Pro status in response if bootstrap was loaded
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
$reportedVersion = $declaredVersion;
if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) {
$reportedVersion = FR_PRO_BUNDLE_VERSION;
}
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
? (FR_PRO_INFO['payload'] ?? null)
: null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
echo json_encode([
'success' => true,
'message' => 'Pro bundle installed.',
'installed' => $installed,
'proActive' => (bool)$proActive,
'proVersion' => $proVersion,
'proVersion' => $reportedVersion,
'proPayload' => $proPayload,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
@@ -809,6 +864,7 @@ public function installProBundle(): void
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
],
];
@@ -948,21 +1004,22 @@ public function installProBundle(): void
$merged['onlyoffice'] = $oo;
}
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
if (isset($data['branding']) && is_array($data['branding'])) {
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
$merged['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
}
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
if (array_key_exists($key, $data['branding'])) {
$merged['branding'][$key] = (string)$data['branding'][$key];
}
}
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
if (isset($data['branding']) && is_array($data['branding'])) {
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
$merged['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
];
}
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) {
if (array_key_exists($key, $data['branding'])) {
$merged['branding'][$key] = (string)$data['branding'][$key];
}
}
}
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {

View File

@@ -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,
];
}
}

View File

@@ -110,17 +110,18 @@ private static function sanitizeLogoUrl($url): string
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
],
'branding' => [
'customLogoUrl' => self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
),
'headerBgLight' => self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
),
'headerBgDark' => self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
),
'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''),
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
];
@@ -261,29 +262,31 @@ private static function sanitizeLogoUrl($url): string
$configUpdate['onlyoffice'] = $norm;
}
// Branding (Pro-only). Normalize and only persist when Pro is active.
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
} else {
// Free mode: always clear branding customizations
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
}
}
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
$configUpdate['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
];
} else {
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
$footer = trim((string)($configUpdate['branding']['footerHtml'] ?? ''));
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
$configUpdate['branding']['customLogoUrl'] = $logo;
$configUpdate['branding']['headerBgLight'] = $light;
$configUpdate['branding']['headerBgDark'] = $dark;
$configUpdate['branding']['footerHtml'] = $footer;
} else {
$configUpdate['branding']['customLogoUrl'] = '';
$configUpdate['branding']['headerBgLight'] = '';
$configUpdate['branding']['headerBgDark'] = '';
$configUpdate['branding']['footerHtml'] = '';
}
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -444,6 +447,7 @@ private static function sanitizeLogoUrl($url): string
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
];
} else {
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
@@ -486,6 +490,7 @@ private static function sanitizeLogoUrl($url): string
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
'footerHtml' => '',
],
];
}