Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35099a5fe1 | ||
|
|
bb0ac9f421 | ||
|
|
b06c44a5ba | ||
|
|
e58751dd83 | ||
|
|
6d4881b068 | ||
|
|
62aacd53c4 | ||
|
|
39e69882e5 | ||
|
|
909baed16c | ||
|
|
c61bbf67f8 | ||
|
|
d1ee6f11fb | ||
|
|
b417217552 |
87
CHANGELOG.md
87
CHANGELOG.md
@@ -1,5 +1,92 @@
|
||||
# Changelog
|
||||
|
||||
## 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: A–Z / Z–A.
|
||||
- **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 don’t 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
|
||||
|
||||
@@ -27,7 +27,7 @@ Drag & drop uploads, ACL-aware sharing, OnlyOffice integration, and a clean UI
|
||||
|
||||
Full list of features available at [Full Feature Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
|
||||

|
||||

|
||||
|
||||
> 💡 Looking for **FileRise Pro** (brandable header, **user groups**, **client upload portals**, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open-source (MIT).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -538,5 +538,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="siteFooter" class="site-footer">
|
||||
<span>
|
||||
© 2025
|
||||
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">
|
||||
FileRise
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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 =
|
||||
`© ${year} <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="© 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.'));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 it’s 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 doesn’t 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 don’t 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) --------------------
|
||||
|
||||
@@ -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
@@ -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 don’t 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";
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,16 @@ 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"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -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 =
|
||||
`© ${year} <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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v2.3.0';
|
||||
window.APP_VERSION = 'v2.3.4';
|
||||
|
||||
BIN
resources/filerise-v2.3.2.png
Normal file
BIN
resources/filerise-v2.3.2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1002 KiB |
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
@@ -581,6 +588,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 +752,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 +854,7 @@ public function installProBundle(): void
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
'footerHtml' => '',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -948,21 +994,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'])) {
|
||||
|
||||
@@ -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' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user