release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/4/2025 (v1.8.2)
|
||||||
|
|
||||||
|
release(v1.8.2): media progress tracking + watched badges; PWA scaffolding; mobile switcher (closes #37)
|
||||||
|
|
||||||
|
- **Highlights**
|
||||||
|
- Video: auto-save playback progress and mark “Watched”, with resume-on-open and inline status chips on list/gallery.
|
||||||
|
- Mobile: introduced FileRise Mobile (Capacitor) companion repo + in-app server switcher and PWA bits.
|
||||||
|
|
||||||
|
- **Details**
|
||||||
|
- API (new):
|
||||||
|
- POST /api/media/updateProgress.php — persist per-user progress (seconds/duration/completed).
|
||||||
|
- GET /api/media/getProgress.php — fetch per-file progress.
|
||||||
|
- GET /api/media/getViewedMap.php — folder map for badges.
|
||||||
|
|
||||||
|
- **Frontend (media):**
|
||||||
|
- Video previews now resume from last position, periodically save progress, and mark completed on end, with toasts.
|
||||||
|
- Added status badges (“Watched” / %-complete) in table & gallery; CSS polish for badges.
|
||||||
|
- Badges render during list/gallery refresh; safer filename wrapping for badge injection.
|
||||||
|
|
||||||
|
- **Mobile & PWA:**
|
||||||
|
- New in-app server switcher (Capacitor-aware) loaded only in app/standalone contexts.
|
||||||
|
- Service Worker + manifest added (root scope via /public/sw.js; worker body in /js/pwa/sw.js; manifest icons).
|
||||||
|
- main.js conditionally imports the mobile switcher and registers the SW on web origins only.
|
||||||
|
|
||||||
|
- **Notes**
|
||||||
|
- Companion repo: **filerise-mobile** (Capacitor app shell) created for iOS/Android distribution.
|
||||||
|
- No breaking changes expected; endpoints are additive.
|
||||||
|
|
||||||
|
Closes #37.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/3/2025 (V1.8.1)
|
## Changes 11/3/2025 (V1.8.1)
|
||||||
|
|
||||||
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
|
release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder
|
||||||
|
|||||||
@@ -369,12 +369,13 @@ FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Documen
|
|||||||
**Apache**
|
**Apache**
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
Header always set Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'"
|
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nginx**
|
**Nginx**
|
||||||
|
|
||||||
```add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://docs.example.com; script-src 'self' https://docs.example.com https://docs.example.com/web-apps/apps/api/documents/api.js; connect-src 'self' https://docs.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'" always;
|
```nginx
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
|
|||||||
7
public/api/media/getProgress.php
Normal file
7
public/api/media/getProgress.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/getProgress.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->getProgress();
|
||||||
7
public/api/media/getViewedMap.php
Normal file
7
public/api/media/getViewedMap.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/getViewedMap.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->getViewedMap();
|
||||||
7
public/api/media/updateProgress.php
Normal file
7
public/api/media/updateProgress.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// public/api/media/updateProgress.php
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/controllers/MediaController.php';
|
||||||
|
|
||||||
|
$ctl = new MediaController();
|
||||||
|
$ctl->updateProgress();
|
||||||
BIN
public/assets/icons/apple-touch-icon.png
Normal file
BIN
public/assets/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/icons/base-1024.png
Normal file
BIN
public/assets/icons/base-1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
public/assets/icons/icon-192.png
Normal file
BIN
public/assets/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/assets/icons/icon-512.png
Normal file
BIN
public/assets/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/icons/maskable-512.png
Normal file
BIN
public/assets/icons/maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -1900,4 +1900,29 @@ body {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-color: #e2e2e2;
|
border-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
|
/* media modal polish */
|
||||||
|
.media-modal { background: var(--panel-bg, #121212); }
|
||||||
|
.media-header-bar .btn { padding: 6px 10px; }
|
||||||
|
.gallery-nav-btn { color: #fff; opacity: 0.85; }
|
||||||
|
.gallery-nav-btn:hover { opacity: 1; transform: scale(1.05); }
|
||||||
|
|
||||||
|
/* badges */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,.15);
|
||||||
|
background: rgba(255,255,255,.08);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.status-badge.watched {
|
||||||
|
border-color: rgba(34,197,94,.35); /* green-ish */
|
||||||
|
background: rgba(34,197,94,.15);
|
||||||
|
}
|
||||||
|
.status-badge.progress {
|
||||||
|
border-color: rgba(250,204,21,.35); /* amber-ish */
|
||||||
|
background: rgba(250,204,21,.15);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||||
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- Critical CSS -->
|
<!-- Critical CSS -->
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
|
|
||||||
<!-- App entry -->
|
<!-- App entry -->
|
||||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}"><script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||||
</head>
|
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="appRoot" style="visibility:hidden">
|
<div id="appRoot" style="visibility:hidden">
|
||||||
<header class="header-container">
|
<header class="header-container">
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
<!-- Trash items will be loaded here -->
|
<!-- Trash items will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: right;">
|
<div style="text-align: right;">
|
||||||
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected" style="display: none;">Restore
|
<button id="restoreSelectedBtn" class="btn btn-primary" data-i18n-key="restore_selected">Restore
|
||||||
Selected</button>
|
Selected</button>
|
||||||
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
<button id="restoreAllBtn" class="btn btn-secondary" data-i18n-key="restore_all">Restore All</button>
|
||||||
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
<button id="deleteTrashSelectedBtn" class="btn btn-warning" data-i18n-key="delete_selected_trash">Delete
|
||||||
@@ -485,5 +486,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -157,7 +157,121 @@ function wireSelectAll(fileListContent) {
|
|||||||
}
|
}
|
||||||
return body ?? {};
|
return body ?? {};
|
||||||
}
|
}
|
||||||
|
// ---- Viewed badges (table + gallery) ----
|
||||||
|
// ---------- Badge factory (center text vertically) ----------
|
||||||
|
function makeBadge(state) {
|
||||||
|
if (!state) return null;
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.className = 'status-badge';
|
||||||
|
el.style.cssText = [
|
||||||
|
'display:inline-flex',
|
||||||
|
'align-items:center',
|
||||||
|
'justify-content:center',
|
||||||
|
'vertical-align:middle',
|
||||||
|
'margin-left:6px',
|
||||||
|
'padding:2px 8px',
|
||||||
|
'min-height:18px',
|
||||||
|
'line-height:1',
|
||||||
|
'border-radius:999px',
|
||||||
|
'font-size:.78em',
|
||||||
|
'border:1px solid rgba(0,0,0,.2)',
|
||||||
|
'background:rgba(0,0,0,.06)'
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
if (state.completed) {
|
||||||
|
el.classList.add('watched');
|
||||||
|
el.textContent = (t('watched') || t('viewed') || 'Watched');
|
||||||
|
el.style.borderColor = 'rgba(34,197,94,.45)';
|
||||||
|
el.style.background = 'rgba(34,197,94,.12)';
|
||||||
|
el.style.color = '#22c55e';
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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)));
|
||||||
|
el.classList.add('progress');
|
||||||
|
el.textContent = `${pct}%`;
|
||||||
|
el.style.borderColor = 'rgba(245,158,11,.45)';
|
||||||
|
el.style.background = 'rgba(245,158,11,.12)';
|
||||||
|
el.style.color = '#f59e0b';
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Public: set/clear badges for one file (table + gallery) ----------
|
||||||
|
function applyBadgeToDom(name, state) {
|
||||||
|
const safe = CSS.escape(name);
|
||||||
|
|
||||||
|
// Table
|
||||||
|
document.querySelectorAll(`tr[data-file-name="${safe}"] .name-cell, tr[data-file-name="${safe}"] .file-name-cell`)
|
||||||
|
.forEach(cell => {
|
||||||
|
cell.querySelector('.status-badge')?.remove();
|
||||||
|
const b = makeBadge(state);
|
||||||
|
if (b) cell.appendChild(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery
|
||||||
|
document.querySelectorAll(`.gallery-card[data-file-name="${safe}"] .gallery-file-name`)
|
||||||
|
.forEach(title => {
|
||||||
|
title.querySelector('.status-badge')?.remove();
|
||||||
|
const b = makeBadge(state);
|
||||||
|
if (b) title.appendChild(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFileWatchedBadge(name, watched = true) {
|
||||||
|
applyBadgeToDom(name, watched ? { completed: true } : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFileProgressBadge(name, seconds, duration) {
|
||||||
|
if (duration > 0 && seconds >= 0) {
|
||||||
|
applyBadgeToDom(name, { seconds, duration, completed: seconds >= duration - 1 });
|
||||||
|
} else {
|
||||||
|
applyBadgeToDom(name, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshViewedBadges(folder) {
|
||||||
|
let map = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/media/getViewedMap.php?folder=${encodeURIComponent(folder)}&t=${Date.now()}`, { credentials: 'include' });
|
||||||
|
const j = await res.json();
|
||||||
|
map = j?.map || null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Clear any existing badges
|
||||||
|
document.querySelectorAll(
|
||||||
|
'#fileList tr[data-file-name] .file-name-cell .status-badge, ' +
|
||||||
|
'#fileList tr[data-file-name] .name-cell .status-badge, ' +
|
||||||
|
'.gallery-card[data-file-name] .gallery-file-name .status-badge'
|
||||||
|
).forEach(n => n.remove());
|
||||||
|
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
// Table rows
|
||||||
|
document.querySelectorAll('#fileList tr[data-file-name]').forEach(tr => {
|
||||||
|
const name = tr.getAttribute('data-file-name');
|
||||||
|
const state = map[name];
|
||||||
|
if (!state) return;
|
||||||
|
const cell = tr.querySelector('.name-cell, .file-name-cell');
|
||||||
|
if (!cell) return;
|
||||||
|
const badge = makeBadge(state);
|
||||||
|
if (badge) cell.appendChild(badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery cards
|
||||||
|
document.querySelectorAll('.gallery-card[data-file-name]').forEach(card => {
|
||||||
|
const name = card.getAttribute('data-file-name');
|
||||||
|
const state = map[name];
|
||||||
|
if (!state) return;
|
||||||
|
const title = card.querySelector('.gallery-file-name');
|
||||||
|
if (!title) return;
|
||||||
|
const badge = makeBadge(state);
|
||||||
|
if (badge) title.appendChild(badge);
|
||||||
|
});
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
* Convert a file size string (e.g. "456.9KB", "1.2 MB", "1024") into bytes.
|
||||||
*/
|
*/
|
||||||
@@ -548,6 +662,7 @@ function searchFiles(searchTerm) {
|
|||||||
}
|
}
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
fileListContainer.style.visibility = "visible";
|
fileListContainer.style.visibility = "visible";
|
||||||
|
|
||||||
|
|
||||||
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
|
||||||
try {
|
try {
|
||||||
@@ -712,9 +827,14 @@ function searchFiles(searchTerm) {
|
|||||||
if (totalFiles > 0) {
|
if (totalFiles > 0) {
|
||||||
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
|
||||||
// Build row with a neutral base, then correct the links/preview below.
|
// Build row with a neutral base, then correct the links/preview below.
|
||||||
let rowHTML = buildFileTableRow(file, fakeBase);
|
|
||||||
// Give the row an ID so we can patch attributes safely
|
// Give the row an ID so we can patch attributes safely
|
||||||
rowHTML = rowHTML.replace("<tr", `<tr id="file-row-${encodeURIComponent(file.name)}-${startIndex + idx}"`);
|
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
|
||||||
|
let rowHTML = buildFileTableRow(file, fakeBase);
|
||||||
|
|
||||||
|
// add row id + data-file-name, and ensure the name cell also has "name-cell"
|
||||||
|
rowHTML = rowHTML
|
||||||
|
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
|
||||||
|
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
|
||||||
|
|
||||||
let tagBadgesHTML = "";
|
let tagBadgesHTML = "";
|
||||||
if (file.tags && file.tags.length > 0) {
|
if (file.tags && file.tags.length > 0) {
|
||||||
@@ -724,9 +844,13 @@ function searchFiles(searchTerm) {
|
|||||||
});
|
});
|
||||||
tagBadgesHTML += "</div>";
|
tagBadgesHTML += "</div>";
|
||||||
}
|
}
|
||||||
rowsHTML += rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
|
rowsHTML += rowHTML.replace(
|
||||||
return p1 + p2 + tagBadgesHTML + p3;
|
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
|
||||||
});
|
(m, open, inner, close) => {
|
||||||
|
// keep the original filename content, then add your tag badges, then close
|
||||||
|
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
|
||||||
@@ -904,6 +1028,7 @@ function searchFiles(searchTerm) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
|
|
||||||
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
document.querySelectorAll("#fileList tbody tr").forEach(row => {
|
||||||
row.setAttribute("draggable", "true");
|
row.setAttribute("draggable", "true");
|
||||||
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
|
||||||
@@ -914,6 +1039,7 @@ function searchFiles(searchTerm) {
|
|||||||
btn.addEventListener("click", e => e.stopPropagation());
|
btn.addEventListener("click", e => e.stopPropagation());
|
||||||
});
|
});
|
||||||
bindFileListContextMenu();
|
bindFileListContextMenu();
|
||||||
|
refreshViewedBadges(folder).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// A helper to compute the max image height based on the current column count.
|
// A helper to compute the max image height based on the current column count.
|
||||||
@@ -1040,6 +1166,7 @@ function searchFiles(searchTerm) {
|
|||||||
// card with checkbox, preview, info, buttons
|
// card with checkbox, preview, info, buttons
|
||||||
galleryHTML += `
|
galleryHTML += `
|
||||||
<div class="gallery-card"
|
<div class="gallery-card"
|
||||||
|
data-file-name="${escapeHTML(file.name)}"
|
||||||
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
style="position:relative; border:1px solid #ccc; padding:5px; text-align:center;">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="file-checkbox"
|
class="file-checkbox"
|
||||||
@@ -1236,7 +1363,7 @@ function searchFiles(searchTerm) {
|
|||||||
if (window.viewMode === "gallery") renderGalleryView(folder);
|
if (window.viewMode === "gallery") renderGalleryView(folder);
|
||||||
else renderFileTable(folder);
|
else renderFileTable(folder);
|
||||||
};
|
};
|
||||||
|
refreshViewedBadges(folder).catch(() => {});
|
||||||
updateFileActionButtons();
|
updateFileActionButtons();
|
||||||
createViewToggleButton();
|
createViewToggleButton();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// filePreview.js
|
// filePreview.js
|
||||||
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}';
|
||||||
import { fileData } from './fileListView.js?v={{APP_QVER}}';
|
|
||||||
import { t } from './i18n.js?v={{APP_QVER}}';
|
import { t } from './i18n.js?v={{APP_QVER}}';
|
||||||
|
import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
|
// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR)
|
||||||
export function buildPreviewUrl(folder, name) {
|
export function buildPreviewUrl(folder, name) {
|
||||||
@@ -9,12 +9,11 @@ export function buildPreviewUrl(folder, name) {
|
|||||||
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- Share modal (existing) -------------------------------- */
|
||||||
export function openShareModal(file, folder) {
|
export function openShareModal(file, folder) {
|
||||||
// Remove any existing modal
|
|
||||||
const existing = document.getElementById("shareModal");
|
const existing = document.getElementById("shareModal");
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
|
|
||||||
// Build the modal
|
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
modal.id = "shareModal";
|
modal.id = "shareModal";
|
||||||
modal.classList.add("modal");
|
modal.classList.add("modal");
|
||||||
@@ -51,18 +50,9 @@ export function openShareModal(file, folder) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:15px;">${t("password_optional")}</p>
|
<p style="margin-top:15px;">${t("password_optional")}</p>
|
||||||
<input
|
<input type="text" id="sharePassword" placeholder="${t("password_optional")}" style="width:100%;padding:5px;"/>
|
||||||
type="text"
|
|
||||||
id="sharePassword"
|
|
||||||
placeholder="${t("password_optional")}"
|
|
||||||
style="width:100%;padding:5px;"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:15px;">
|
||||||
id="generateShareLinkBtn"
|
|
||||||
class="btn btn-primary"
|
|
||||||
style="margin-top:15px;"
|
|
||||||
>
|
|
||||||
${t("generate_share_link")}
|
${t("generate_share_link")}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -79,392 +69,524 @@ export function openShareModal(file, folder) {
|
|||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
modal.style.display = "block";
|
modal.style.display = "block";
|
||||||
|
|
||||||
// Close handler
|
document.getElementById("closeShareModal").addEventListener("click", () => modal.remove());
|
||||||
document.getElementById("closeShareModal")
|
document.getElementById("shareExpiration").addEventListener("change", e => {
|
||||||
.addEventListener("click", () => modal.remove());
|
const container = document.getElementById("customExpirationContainer");
|
||||||
|
container.style.display = e.target.value === "custom" ? "block" : "none";
|
||||||
|
});
|
||||||
|
|
||||||
// Show/hide custom-duration inputs
|
document.getElementById("generateShareLinkBtn").addEventListener("click", () => {
|
||||||
document.getElementById("shareExpiration")
|
const sel = document.getElementById("shareExpiration");
|
||||||
.addEventListener("change", e => {
|
let value, unit;
|
||||||
const container = document.getElementById("customExpirationContainer");
|
|
||||||
container.style.display = e.target.value === "custom" ? "block" : "none";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate share link
|
if (sel.value === "custom") {
|
||||||
document.getElementById("generateShareLinkBtn")
|
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
||||||
.addEventListener("click", () => {
|
unit = document.getElementById("customExpirationUnit").value;
|
||||||
const sel = document.getElementById("shareExpiration");
|
} else {
|
||||||
let value, unit;
|
value = parseInt(sel.value, 10);
|
||||||
|
unit = "minutes";
|
||||||
if (sel.value === "custom") {
|
|
||||||
value = parseInt(document.getElementById("customExpirationValue").value, 10);
|
|
||||||
unit = document.getElementById("customExpirationUnit").value;
|
|
||||||
} else {
|
|
||||||
value = parseInt(sel.value, 10);
|
|
||||||
unit = "minutes";
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = document.getElementById("sharePassword").value;
|
|
||||||
|
|
||||||
fetch("/api/file/createShareLink.php", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
folder,
|
|
||||||
file: file.name,
|
|
||||||
expirationValue: value,
|
|
||||||
expirationUnit: unit,
|
|
||||||
password
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.token) {
|
|
||||||
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
|
|
||||||
document.getElementById("shareLinkInput").value = url;
|
|
||||||
document.getElementById("shareLinkDisplay").style.display = "block";
|
|
||||||
} else {
|
|
||||||
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
showToast(t("error_generating_share"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy to clipboard
|
|
||||||
document.getElementById("copyShareLinkBtn")
|
|
||||||
.addEventListener("click", () => {
|
|
||||||
const input = document.getElementById("shareLinkInput");
|
|
||||||
input.select();
|
|
||||||
document.execCommand("copy");
|
|
||||||
showToast(t("link_copied"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function previewFile(fileUrl, fileName) {
|
|
||||||
let modal = document.getElementById("filePreviewModal");
|
|
||||||
if (!modal) {
|
|
||||||
modal = document.createElement("div");
|
|
||||||
modal.id = "filePreviewModal";
|
|
||||||
Object.assign(modal.style, {
|
|
||||||
position: "fixed",
|
|
||||||
top: "0",
|
|
||||||
left: "0",
|
|
||||||
width: "100vw",
|
|
||||||
height: "100vh",
|
|
||||||
backgroundColor: "rgba(0,0,0,0.7)",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
zIndex: "1000"
|
|
||||||
});
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="modal-content image-preview-modal-content" style="position: relative; max-width: 90vw; max-height: 90vh;">
|
|
||||||
<span id="closeFileModal" class="close-image-modal" style="position: absolute; top: 10px; right: 10px; font-size: 24px; cursor: pointer;">×</span>
|
|
||||||
<h4 class="image-modal-header"></h4>
|
|
||||||
<div class="file-preview-container" style="position: relative; text-align: center;"></div>
|
|
||||||
</div>`;
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
const mediaElements = modal.querySelectorAll("video, audio");
|
|
||||||
mediaElements.forEach(media => {
|
|
||||||
media.pause();
|
|
||||||
if (media.tagName.toLowerCase() !== 'video') {
|
|
||||||
try { media.currentTime = 0; } catch (e) { }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
modal.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("closeFileModal").addEventListener("click", closeModal);
|
const password = document.getElementById("sharePassword").value;
|
||||||
modal.addEventListener("click", function (e) {
|
|
||||||
if (e.target === modal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
modal.querySelector("h4").textContent = fileName;
|
|
||||||
const container = modal.querySelector(".file-preview-container");
|
|
||||||
container.innerHTML = "";
|
|
||||||
|
|
||||||
const extension = fileName.split('.').pop().toLowerCase();
|
fetch("/api/file/createShareLink.php", {
|
||||||
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName);
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
|
body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.token) {
|
||||||
|
const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
|
||||||
|
document.getElementById("shareLinkInput").value = url;
|
||||||
|
document.getElementById("shareLinkDisplay").style.display = "block";
|
||||||
|
} else {
|
||||||
|
showToast(t("error_generating_share") + ": " + (data.error || "Unknown"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
showToast(t("error_generating_share"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("copyShareLinkBtn").addEventListener("click", () => {
|
||||||
|
const input = document.getElementById("shareLinkInput");
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
showToast(t("link_copied"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- Media modal viewer -------------------------------- */
|
||||||
|
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
|
||||||
|
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
|
||||||
|
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
|
||||||
|
|
||||||
|
function ensureMediaModal() {
|
||||||
|
let overlay = document.getElementById("filePreviewModal");
|
||||||
|
if (overlay) return overlay;
|
||||||
|
|
||||||
|
overlay = document.createElement("div");
|
||||||
|
overlay.id = "filePreviewModal";
|
||||||
|
Object.assign(overlay.style, {
|
||||||
|
position: "fixed",
|
||||||
|
inset: "0",
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
backgroundColor: "rgba(0,0,0,0.7)",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: "1000"
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const styles = getComputedStyle(root);
|
||||||
|
const isDark = root.classList.contains('dark-mode');
|
||||||
|
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
|
||||||
|
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
|
||||||
|
|
||||||
|
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
|
||||||
|
const navFg = '#fff';
|
||||||
|
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content media-modal" style="
|
||||||
|
position: relative;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 92vh;
|
||||||
|
width: 92vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 12px;
|
||||||
|
background: ${panelBg};
|
||||||
|
color: ${textCol};
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
">
|
||||||
|
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);">
|
||||||
|
<!-- filename badge (top-left) -->
|
||||||
|
<div class="media-title-badge" style="
|
||||||
|
position:absolute; top:8px; left:12px; max-width:60vw;
|
||||||
|
padding:4px 10px; border-radius:10px;
|
||||||
|
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'};
|
||||||
|
color: ${isDark ? '#fff' : '#111'};
|
||||||
|
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top-right actions row (aligned with your X at top:10px) -->
|
||||||
|
<div class="media-actions-bar" style="
|
||||||
|
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
|
||||||
|
<span class="status-chip" style="
|
||||||
|
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
|
||||||
|
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span>
|
||||||
|
<div class="action-group" style="display:flex; gap:6px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your absolute close X -->
|
||||||
|
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">×</span>
|
||||||
|
|
||||||
|
<!-- centered media -->
|
||||||
|
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
|
||||||
|
|
||||||
|
<!-- high-contrast prev/next -->
|
||||||
|
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
|
||||||
|
position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||||
|
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
||||||
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">‹</button>
|
||||||
|
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
|
||||||
|
position:absolute; right:8px; top:50%; transform:translateY(-50%);
|
||||||
|
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
|
||||||
|
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,.6);
|
||||||
|
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.35);">›</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
|
||||||
|
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||||
|
overlay.remove();
|
||||||
|
}
|
||||||
|
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
|
||||||
|
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(overlay, name) {
|
||||||
|
const el = overlay.querySelector('.media-title-badge');
|
||||||
|
if (el) el.textContent = name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMI(name, title) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = `material-icons ${name}`;
|
||||||
|
b.textContent = name; // Material Icons font
|
||||||
|
b.title = title;
|
||||||
|
Object.assign(b.style, {
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,.25)",
|
||||||
|
border: "1px solid rgba(255,255,255,.25)",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
fontSize: "20px",
|
||||||
|
padding: "0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
lineHeight: "1"
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNavVisibility(overlay, showPrev, showNext) {
|
||||||
|
const prev = overlay.querySelector('.nav-left');
|
||||||
|
const next = overlay.querySelector('.nav-right');
|
||||||
|
prev.style.display = showPrev ? 'inline-flex' : 'none';
|
||||||
|
next.style.display = showNext ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowWatchedBadge(name, watched) {
|
||||||
|
try {
|
||||||
|
const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`);
|
||||||
|
if (!cell) return;
|
||||||
|
const old = cell.querySelector('.status-badge.watched');
|
||||||
|
if (watched) {
|
||||||
|
if (!old) {
|
||||||
|
const b = document.createElement('span');
|
||||||
|
b.className = 'status-badge watched';
|
||||||
|
b.textContent = t("watched") || t("viewed") || "Watched";
|
||||||
|
b.style.marginLeft = "6px";
|
||||||
|
cell.appendChild(b);
|
||||||
|
}
|
||||||
|
} else if (old) {
|
||||||
|
old.remove();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------- Entry -------------------------------- */
|
||||||
|
export function previewFile(fileUrl, fileName) {
|
||||||
|
const overlay = ensureMediaModal();
|
||||||
|
const container = overlay.querySelector(".file-preview-container");
|
||||||
|
const actionWrap = overlay.querySelector(".media-actions-bar .action-group");
|
||||||
|
const statusChip = overlay.querySelector(".media-actions-bar .status-chip");
|
||||||
|
|
||||||
|
// replace nav buttons to clear old listeners
|
||||||
|
let prevBtn = overlay.querySelector('.nav-left');
|
||||||
|
let nextBtn = overlay.querySelector('.nav-right');
|
||||||
|
const newPrev = prevBtn.cloneNode(true);
|
||||||
|
const newNext = nextBtn.cloneNode(true);
|
||||||
|
prevBtn.replaceWith(newPrev);
|
||||||
|
nextBtn.replaceWith(newNext);
|
||||||
|
prevBtn = newPrev; nextBtn = newNext;
|
||||||
|
|
||||||
|
// reset
|
||||||
|
container.innerHTML = "";
|
||||||
|
actionWrap.innerHTML = "";
|
||||||
|
if (statusChip) statusChip.style.display = 'none';
|
||||||
|
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
|
||||||
|
overlay._onKey = null;
|
||||||
|
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
const name = fileName;
|
||||||
|
const lower = (name || '').toLowerCase();
|
||||||
|
const isImage = IMG_RE.test(lower);
|
||||||
|
const isVideo = VID_RE.test(lower);
|
||||||
|
const isAudio = AUD_RE.test(lower);
|
||||||
|
|
||||||
|
setTitle(overlay, name);
|
||||||
|
|
||||||
|
/* -------------------- IMAGES -------------------- */
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
// Create the image element with default transform data.
|
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = fileUrl;
|
img.src = fileUrl;
|
||||||
img.className = "image-modal-img";
|
img.className = "image-modal-img";
|
||||||
img.style.maxWidth = "80vw";
|
img.style.maxWidth = "88vw";
|
||||||
img.style.maxHeight = "80vh";
|
img.style.maxHeight = "88vh";
|
||||||
img.style.transition = "transform 0.3s ease";
|
img.style.transition = "transform 0.3s ease";
|
||||||
img.dataset.scale = 1;
|
img.dataset.scale = 1;
|
||||||
img.dataset.rotate = 0;
|
img.dataset.rotate = 0;
|
||||||
img.style.position = 'relative';
|
container.appendChild(img);
|
||||||
img.style.zIndex = '1';
|
|
||||||
|
|
||||||
// Filter gallery images for navigation.
|
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In');
|
||||||
const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name));
|
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out');
|
||||||
|
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left');
|
||||||
|
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right');
|
||||||
|
actionWrap.appendChild(zoomInBtn);
|
||||||
|
actionWrap.appendChild(zoomOutBtn);
|
||||||
|
actionWrap.appendChild(rotateLeft);
|
||||||
|
actionWrap.appendChild(rotateRight);
|
||||||
|
|
||||||
// Create a flex wrapper to hold left panel, center image, and right panel.
|
zoomInBtn.addEventListener('click', (e) => {
|
||||||
const wrapper = document.createElement('div');
|
e.stopPropagation();
|
||||||
wrapper.className = 'image-wrapper';
|
let s = parseFloat(img.dataset.scale) || 1; s += 0.1;
|
||||||
wrapper.style.display = 'flex';
|
img.dataset.scale = s;
|
||||||
wrapper.style.alignItems = 'center';
|
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||||
wrapper.style.justifyContent = 'center';
|
});
|
||||||
wrapper.style.position = 'relative';
|
zoomOutBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1);
|
||||||
|
img.dataset.scale = s;
|
||||||
|
img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`;
|
||||||
|
});
|
||||||
|
rotateLeft.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360;
|
||||||
|
img.dataset.rotate = r;
|
||||||
|
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||||
|
});
|
||||||
|
rotateRight.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360;
|
||||||
|
img.dataset.rotate = r;
|
||||||
|
img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`;
|
||||||
|
});
|
||||||
|
|
||||||
// --- Left Panel: Contains Zoom controls (top) and Prev button (bottom) ---
|
const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name));
|
||||||
const leftPanel = document.createElement('div');
|
overlay.mediaType = 'image';
|
||||||
leftPanel.className = 'left-panel';
|
overlay.mediaList = images;
|
||||||
leftPanel.style.display = 'flex';
|
overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name));
|
||||||
leftPanel.style.flexDirection = 'column';
|
setNavVisibility(overlay, images.length > 1, images.length > 1);
|
||||||
leftPanel.style.justifyContent = 'space-between';
|
|
||||||
leftPanel.style.alignItems = 'center';
|
|
||||||
leftPanel.style.width = '60px';
|
|
||||||
leftPanel.style.height = '100%';
|
|
||||||
leftPanel.style.zIndex = '10';
|
|
||||||
|
|
||||||
// Top container for zoom buttons.
|
const navigate = (dir) => {
|
||||||
const leftTop = document.createElement('div');
|
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||||
leftTop.style.display = 'flex';
|
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||||
leftTop.style.flexDirection = 'column';
|
const newFile = overlay.mediaList[overlay.mediaIndex].name;
|
||||||
leftTop.style.gap = '4px';
|
setTitle(overlay, newFile);
|
||||||
// Zoom In button.
|
img.dataset.scale = 1;
|
||||||
const zoomInBtn = document.createElement('button');
|
img.dataset.rotate = 0;
|
||||||
zoomInBtn.className = 'material-icons zoom_in';
|
img.style.transform = 'scale(1) rotate(0deg)';
|
||||||
zoomInBtn.title = 'Zoom In';
|
img.src = buildPreviewUrl(folder, newFile);
|
||||||
zoomInBtn.style.background = 'transparent';
|
};
|
||||||
zoomInBtn.style.border = 'none';
|
|
||||||
zoomInBtn.style.cursor = 'pointer';
|
|
||||||
zoomInBtn.textContent = 'zoom_in';
|
|
||||||
// Zoom Out button.
|
|
||||||
const zoomOutBtn = document.createElement('button');
|
|
||||||
zoomOutBtn.className = 'material-icons zoom_out';
|
|
||||||
zoomOutBtn.title = 'Zoom Out';
|
|
||||||
zoomOutBtn.style.background = 'transparent';
|
|
||||||
zoomOutBtn.style.border = 'none';
|
|
||||||
zoomOutBtn.style.cursor = 'pointer';
|
|
||||||
zoomOutBtn.textContent = 'zoom_out';
|
|
||||||
leftTop.appendChild(zoomInBtn);
|
|
||||||
leftTop.appendChild(zoomOutBtn);
|
|
||||||
leftPanel.appendChild(leftTop);
|
|
||||||
|
|
||||||
// Bottom container for prev button.
|
|
||||||
const leftBottom = document.createElement('div');
|
|
||||||
leftBottom.style.display = 'flex';
|
|
||||||
leftBottom.style.justifyContent = 'center';
|
|
||||||
leftBottom.style.alignItems = 'center';
|
|
||||||
leftBottom.style.width = '100%';
|
|
||||||
if (images.length > 1) {
|
if (images.length > 1) {
|
||||||
const prevBtn = document.createElement("button");
|
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||||
prevBtn.textContent = "‹";
|
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||||
prevBtn.className = "gallery-nav-btn";
|
const onKey = (e) => {
|
||||||
prevBtn.style.background = 'transparent';
|
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||||
prevBtn.style.border = 'none';
|
if (e.key === "ArrowLeft") navigate(-1);
|
||||||
prevBtn.style.color = 'white';
|
if (e.key === "ArrowRight") navigate(+1);
|
||||||
prevBtn.style.fontSize = '48px';
|
};
|
||||||
prevBtn.style.cursor = 'pointer';
|
window.addEventListener("keydown", onKey);
|
||||||
prevBtn.addEventListener("click", function (e) {
|
overlay._onKey = onKey;
|
||||||
e.stopPropagation();
|
|
||||||
// Safety check:
|
|
||||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
|
||||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length;
|
|
||||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
|
||||||
modal.querySelector("h4").textContent = newFile.name;
|
|
||||||
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
|
|
||||||
// Reset transforms.
|
|
||||||
img.dataset.scale = 1;
|
|
||||||
img.dataset.rotate = 0;
|
|
||||||
img.style.transform = 'scale(1) rotate(0deg)';
|
|
||||||
});
|
|
||||||
leftBottom.appendChild(prevBtn);
|
|
||||||
} else {
|
|
||||||
// Insert an empty placeholder for consistent layout.
|
|
||||||
leftBottom.innerHTML = ' ';
|
|
||||||
}
|
}
|
||||||
leftPanel.appendChild(leftBottom);
|
|
||||||
|
|
||||||
// --- Center Panel: Contains the image ---
|
overlay.style.display = "flex";
|
||||||
const centerPanel = document.createElement('div');
|
return;
|
||||||
centerPanel.className = 'center-image-container';
|
}
|
||||||
centerPanel.style.flexGrow = '1';
|
|
||||||
centerPanel.style.textAlign = 'center';
|
|
||||||
centerPanel.style.position = 'relative';
|
|
||||||
centerPanel.style.zIndex = '1';
|
|
||||||
centerPanel.appendChild(img);
|
|
||||||
|
|
||||||
// --- Right Panel: Contains Rotate controls (top) and Next button (bottom) ---
|
/* -------------------- PDF => new tab -------------------- */
|
||||||
const rightPanel = document.createElement('div');
|
if (lower.endsWith('.pdf')) {
|
||||||
rightPanel.className = 'right-panel';
|
const separator = fileUrl.includes('?') ? '&' : '?';
|
||||||
rightPanel.style.display = 'flex';
|
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
||||||
rightPanel.style.flexDirection = 'column';
|
window.open(urlWithTs, "_blank");
|
||||||
rightPanel.style.justifyContent = 'space-between';
|
overlay.remove();
|
||||||
rightPanel.style.alignItems = 'center';
|
return;
|
||||||
rightPanel.style.width = '60px';
|
}
|
||||||
rightPanel.style.height = '100%';
|
|
||||||
rightPanel.style.zIndex = '10';
|
|
||||||
|
|
||||||
// Top container for rotate buttons.
|
/* -------------------- VIDEOS -------------------- */
|
||||||
const rightTop = document.createElement('div');
|
if (isVideo) {
|
||||||
rightTop.style.display = 'flex';
|
let video = document.createElement("video"); // let so we can rebind
|
||||||
rightTop.style.flexDirection = 'column';
|
video.controls = true;
|
||||||
rightTop.style.gap = '4px';
|
video.style.maxWidth = "88vw";
|
||||||
// Rotate Left button.
|
video.style.maxHeight = "88vh";
|
||||||
const rotateLeftBtn = document.createElement('button');
|
video.style.objectFit = "contain";
|
||||||
rotateLeftBtn.className = 'material-icons rotate_left';
|
container.appendChild(video);
|
||||||
rotateLeftBtn.title = 'Rotate Left';
|
|
||||||
rotateLeftBtn.style.background = 'transparent';
|
|
||||||
rotateLeftBtn.style.border = 'none';
|
|
||||||
rotateLeftBtn.style.cursor = 'pointer';
|
|
||||||
rotateLeftBtn.textContent = 'rotate_left';
|
|
||||||
// Rotate Right button.
|
|
||||||
const rotateRightBtn = document.createElement('button');
|
|
||||||
rotateRightBtn.className = 'material-icons rotate_right';
|
|
||||||
rotateRightBtn.title = 'Rotate Right';
|
|
||||||
rotateRightBtn.style.background = 'transparent';
|
|
||||||
rotateRightBtn.style.border = 'none';
|
|
||||||
rotateRightBtn.style.cursor = 'pointer';
|
|
||||||
rotateRightBtn.textContent = 'rotate_right';
|
|
||||||
rightTop.appendChild(rotateLeftBtn);
|
|
||||||
rightTop.appendChild(rotateRightBtn);
|
|
||||||
rightPanel.appendChild(rightTop);
|
|
||||||
|
|
||||||
// Bottom container for next button.
|
const markBtn = document.createElement('button');
|
||||||
const rightBottom = document.createElement('div');
|
const clearBtn = document.createElement('button');
|
||||||
rightBottom.style.display = 'flex';
|
markBtn.className = 'btn btn-sm btn-success';
|
||||||
rightBottom.style.justifyContent = 'center';
|
clearBtn.className = 'btn btn-sm btn-secondary';
|
||||||
rightBottom.style.alignItems = 'center';
|
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
|
||||||
rightBottom.style.width = '100%';
|
clearBtn.textContent = t("clear_progress") || "Clear progress";
|
||||||
if (images.length > 1) {
|
actionWrap.appendChild(markBtn);
|
||||||
const nextBtn = document.createElement("button");
|
actionWrap.appendChild(clearBtn);
|
||||||
nextBtn.textContent = "›";
|
|
||||||
nextBtn.className = "gallery-nav-btn";
|
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||||
nextBtn.style.background = 'transparent';
|
overlay.mediaType = 'video';
|
||||||
nextBtn.style.border = 'none';
|
overlay.mediaList = videos;
|
||||||
nextBtn.style.color = 'white';
|
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||||
nextBtn.style.fontSize = '48px';
|
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||||
nextBtn.style.cursor = 'pointer';
|
|
||||||
nextBtn.addEventListener("click", function (e) {
|
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||||
e.stopPropagation();
|
|
||||||
// Safety check:
|
const SAVE_INTERVAL_MS = 5000;
|
||||||
if (!modal.galleryImages || modal.galleryImages.length === 0) return;
|
let lastSaveAt = 0;
|
||||||
modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length;
|
let pending = false;
|
||||||
let newFile = modal.galleryImages[modal.galleryCurrentIndex];
|
|
||||||
modal.querySelector("h4").textContent = newFile.name;
|
async function getProgress(nm) {
|
||||||
img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name);
|
try {
|
||||||
// Reset transforms.
|
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||||
img.dataset.scale = 1;
|
const data = await res.json();
|
||||||
img.dataset.rotate = 0;
|
return data && data.state ? data.state : null;
|
||||||
img.style.transform = 'scale(1) rotate(0deg)';
|
} catch { return null; }
|
||||||
});
|
|
||||||
rightBottom.appendChild(nextBtn);
|
|
||||||
} else {
|
|
||||||
// Insert a placeholder so that center remains properly aligned.
|
|
||||||
rightBottom.innerHTML = ' ';
|
|
||||||
}
|
}
|
||||||
rightPanel.appendChild(rightBottom);
|
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||||
|
try {
|
||||||
// Assemble panels into the wrapper.
|
pending = true;
|
||||||
wrapper.appendChild(leftPanel);
|
const res = await fetch("/api/media/updateProgress.php", {
|
||||||
wrapper.appendChild(centerPanel);
|
method: "POST",
|
||||||
wrapper.appendChild(rightPanel);
|
credentials: "include",
|
||||||
container.appendChild(wrapper);
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||||
|
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||||
// --- Set up zoom controls event listeners ---
|
});
|
||||||
zoomInBtn.addEventListener('click', function (e) {
|
const data = await res.json();
|
||||||
e.stopPropagation();
|
pending = false;
|
||||||
let scale = parseFloat(img.dataset.scale) || 1;
|
return data;
|
||||||
scale += 0.1;
|
} catch (e) { pending = false; console.error(e); return null; }
|
||||||
img.dataset.scale = scale;
|
|
||||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
|
||||||
});
|
|
||||||
zoomOutBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
let scale = parseFloat(img.dataset.scale) || 1;
|
|
||||||
scale = Math.max(0.1, scale - 0.1);
|
|
||||||
img.dataset.scale = scale;
|
|
||||||
img.style.transform = 'scale(' + scale + ') rotate(' + img.dataset.rotate + 'deg)';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach rotation control listeners (always present now).
|
|
||||||
rotateLeftBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
|
||||||
rotate = (rotate - 90 + 360) % 360;
|
|
||||||
img.dataset.rotate = rotate;
|
|
||||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
|
||||||
});
|
|
||||||
rotateRightBtn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
let rotate = parseFloat(img.dataset.rotate) || 0;
|
|
||||||
rotate = (rotate + 90) % 360;
|
|
||||||
img.dataset.rotate = rotate;
|
|
||||||
img.style.transform = 'scale(' + img.dataset.scale + ') rotate(' + rotate + 'deg)';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save gallery details if there is more than one image.
|
|
||||||
if (images.length > 1) {
|
|
||||||
modal.galleryImages = images;
|
|
||||||
modal.galleryCurrentIndex = images.findIndex(f => f.name === fileName);
|
|
||||||
}
|
}
|
||||||
} else {
|
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||||
// Handle non-image file previews.
|
|
||||||
if (extension === "pdf") {
|
|
||||||
// build a cache‐busted URL
|
|
||||||
const separator = fileUrl.includes('?') ? '&' : '?';
|
|
||||||
const urlWithTs = fileUrl + separator + 't=' + Date.now();
|
|
||||||
|
|
||||||
// open in a new tab (avoids CSP frame-ancestors)
|
function renderStatus(state) {
|
||||||
window.open(urlWithTs, "_blank");
|
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';
|
||||||
|
markBtn.style.display = 'none';
|
||||||
|
clearBtn.style.display = '';
|
||||||
|
clearBtn.textContent = 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';
|
||||||
|
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
|
||||||
|
statusChip.style.background = 'rgba(250,204,21,.15)';
|
||||||
|
statusChip.style.color = '#facc15';
|
||||||
|
markBtn.style.display = '';
|
||||||
|
clearBtn.style.display = '';
|
||||||
|
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No progress
|
||||||
|
statusChip.style.display = 'none';
|
||||||
|
markBtn.style.display = '';
|
||||||
|
clearBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// tear down the just-created modal
|
function bindVideoEvents(nm) {
|
||||||
const modal = document.getElementById("filePreviewModal");
|
const nv = video.cloneNode(true);
|
||||||
if (modal) modal.remove();
|
video.replaceWith(nv);
|
||||||
|
video = nv;
|
||||||
|
|
||||||
// stop further preview logic
|
video.addEventListener("loadedmetadata", async () => {
|
||||||
return;
|
try {
|
||||||
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) {
|
const state = await getProgress(nm);
|
||||||
const video = document.createElement("video");
|
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
|
||||||
video.src = fileUrl;
|
video.currentTime = state.seconds;
|
||||||
video.controls = true;
|
const seconds = Math.floor(video.currentTime || 0);
|
||||||
video.className = "image-modal-img";
|
const duration = Math.floor(video.duration || 0);
|
||||||
|
setFileProgressBadge(nm, seconds, duration);
|
||||||
const progressKey = 'videoProgress-' + fileUrl;
|
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
|
||||||
video.addEventListener("loadedmetadata", () => {
|
} else {
|
||||||
const savedTime = localStorage.getItem(progressKey);
|
const ls = localStorage.getItem(lsKey(nm));
|
||||||
if (savedTime) {
|
if (ls) video.currentTime = parseFloat(ls);
|
||||||
video.currentTime = parseFloat(savedTime);
|
}
|
||||||
|
renderStatus(state || null);
|
||||||
|
} catch {
|
||||||
|
renderStatus(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
video.addEventListener("timeupdate", () => {
|
|
||||||
localStorage.setItem(progressKey, video.currentTime);
|
video.addEventListener("timeupdate", async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||||
|
lastSaveAt = now;
|
||||||
|
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", () => {
|
|
||||||
localStorage.removeItem(progressKey);
|
video.addEventListener("ended", async () => {
|
||||||
|
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 });
|
||||||
});
|
});
|
||||||
container.appendChild(video);
|
|
||||||
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(fileName)) {
|
markBtn.onclick = async () => {
|
||||||
const audio = document.createElement("audio");
|
const duration = Math.floor(video.duration || 0);
|
||||||
audio.src = fileUrl;
|
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||||
audio.controls = true;
|
showToast(t("marked_viewed") || "Marked as viewed");
|
||||||
audio.className = "audio-modal";
|
setFileWatchedBadge(nm, true);
|
||||||
audio.style.maxWidth = "80vw";
|
renderStatus({ seconds: duration, duration, completed: true });
|
||||||
container.appendChild(audio);
|
};
|
||||||
} else {
|
clearBtn.onclick = async () => {
|
||||||
container.textContent = "Preview not available for this file type.";
|
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);
|
||||||
|
bindVideoEvents(nm);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
bindVideoEvents(name);
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- AUDIO / OTHER -------------------- */
|
||||||
|
if (isAudio) {
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.src = fileUrl;
|
||||||
|
audio.controls = true;
|
||||||
|
audio.className = "audio-modal";
|
||||||
|
audio.style.maxWidth = "88vw";
|
||||||
|
container.appendChild(audio);
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
} else {
|
||||||
|
container.textContent = t("preview_not_available") || "Preview not available for this file type.";
|
||||||
|
overlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
modal.style.display = "flex";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve original functionality.
|
/* -------------------------------- Small display helper -------------------------------- */
|
||||||
export function displayFilePreview(file, container) {
|
export function displayFilePreview(file, container) {
|
||||||
const actualFile = file.file || file;
|
const actualFile = file.file || file;
|
||||||
if (!(actualFile instanceof File)) {
|
if (!(actualFile instanceof File)) {
|
||||||
@@ -472,10 +594,9 @@ export function displayFilePreview(file, container) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.style.display = "inline-block";
|
container.style.display = "inline-block";
|
||||||
while (container.firstChild) {
|
while (container.firstChild) container.removeChild(container.firstChild);
|
||||||
container.removeChild(container.firstChild);
|
|
||||||
}
|
if (IMG_RE.test(actualFile.name)) {
|
||||||
if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) {
|
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.src = URL.createObjectURL(actualFile);
|
img.src = URL.createObjectURL(actualFile);
|
||||||
img.classList.add("file-preview-img");
|
img.classList.add("file-preview-img");
|
||||||
@@ -488,5 +609,6 @@ export function displayFilePreview(file, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expose for HTML onclick usage
|
||||||
window.previewFile = previewFile;
|
window.previewFile = previewFile;
|
||||||
window.openShareModal = openShareModal;
|
window.openShareModal = openShareModal;
|
||||||
@@ -302,7 +302,17 @@ const translations = {
|
|||||||
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
"acl_move_folder_info": "Moving folders is restricted to folder owners or managers. Destination folders must also allow moves in.",
|
||||||
"context_move_folder": "Move Folder...",
|
"context_move_folder": "Move Folder...",
|
||||||
"context_move_here": "Move Here",
|
"context_move_here": "Move Here",
|
||||||
"context_move_cancel": "Cancel Move"
|
"context_move_cancel": "Cancel Move",
|
||||||
|
"mark_as_viewed": "Mark as viewed",
|
||||||
|
"viewed": "Viewed",
|
||||||
|
"resumed_from": "Resumed from",
|
||||||
|
"clear_progress": "Clear progress",
|
||||||
|
"marked_viewed": "Marked as viewed",
|
||||||
|
"progress_cleared": "Progress cleared",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"watched": "Watched",
|
||||||
|
"reset_progress": "Reset Progress"
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||||
|
|||||||
@@ -1057,4 +1057,52 @@ function bindDarkMode() {
|
|||||||
|
|
||||||
if (overlay) overlay.style.display = 'none';
|
if (overlay) overlay.style.display = 'none';
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// --- Mobile switcher + PWA SW (mobile-only) ---
|
||||||
|
(() => {
|
||||||
|
// keep it simple + robust
|
||||||
|
const qs = new URLSearchParams(location.search);
|
||||||
|
const hasFrAppHint = qs.get('frapp') === '1';
|
||||||
|
|
||||||
|
const isStandalone =
|
||||||
|
(window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
|
||||||
|
(typeof navigator.standalone === 'boolean' && navigator.standalone);
|
||||||
|
|
||||||
|
const isCapUA = /\bCapacitor\b/i.test(navigator.userAgent);
|
||||||
|
const hasCapBridge = !!(window.Capacitor && window.Capacitor.Plugins);
|
||||||
|
|
||||||
|
// “mobile-ish”: native mobile UAs OR touch + reasonably narrow viewport (covers iPad-on-Mac UA)
|
||||||
|
const isMobileish =
|
||||||
|
/Android|iPhone|iPad|iPod|Mobile|Silk|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||||
|
(navigator.maxTouchPoints > 1 && Math.min(screen.width, screen.height) <= 900);
|
||||||
|
|
||||||
|
// load the switcher only in the mobile app, or mobile standalone PWA, or when explicitly hinted
|
||||||
|
const shouldLoadSwitcher =
|
||||||
|
hasCapBridge || isCapUA || (isStandalone && isMobileish) || (hasFrAppHint && isMobileish);
|
||||||
|
|
||||||
|
// expose a flag to inspect later
|
||||||
|
window.FR_APP = !!(hasCapBridge || isCapUA || (isStandalone && isMobileish));
|
||||||
|
|
||||||
|
const QVER = (window.APP_QVER && String(window.APP_QVER)) || '{{APP_QVER}}';
|
||||||
|
|
||||||
|
if (shouldLoadSwitcher) {
|
||||||
|
import(`/js/mobile/switcher.js?v=${encodeURIComponent(QVER)}`)
|
||||||
|
.then(() => {
|
||||||
|
if (hasFrAppHint && !sessionStorage.getItem('frx_opened_once')) {
|
||||||
|
sessionStorage.setItem('frx_opened_once', '1');
|
||||||
|
window.dispatchEvent(new CustomEvent('frx:openSwitcher'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.info('[FileRise] switcher import failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SW only for web (https or localhost), never in Capacitor
|
||||||
|
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
|
||||||
|
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
287
public/js/mobile/switcher.js
Normal file
287
public/js/mobile/switcher.js
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
(function(){
|
||||||
|
const isCap = !!window.Capacitor || /Capacitor/i.test(navigator.userAgent);
|
||||||
|
if (!isCap) return;
|
||||||
|
if ((location.origin || '').startsWith('capacitor://')) return;
|
||||||
|
|
||||||
|
const Plugins = (window.Capacitor && window.Capacitor.Plugins) || {};
|
||||||
|
const Pref = Plugins.Preferences ? {
|
||||||
|
get: ({key}) => Plugins.Preferences.get({key}),
|
||||||
|
set: ({key,value}) => Plugins.Preferences.set({key,value}),
|
||||||
|
remove:({key}) => Plugins.Preferences.remove({key})
|
||||||
|
} : {
|
||||||
|
get: async ({key}) => ({ value: localStorage.getItem(key) || null }),
|
||||||
|
set: async ({key,value}) => localStorage.setItem(key, value),
|
||||||
|
remove: async ({key}) => localStorage.removeItem(key)
|
||||||
|
};
|
||||||
|
const Http = (Plugins.Http || Plugins.CapacitorHttp) || null;
|
||||||
|
|
||||||
|
const K_INST='fr_instances_v1', K_ACTIVE='fr_active_v1', K_STATUS='fr_status_v1';
|
||||||
|
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const el = (t,a={},html='') => { const n=document.createElement(t); for (const k in a) n.setAttribute(k,a[k]); n.innerHTML=html; return n; };
|
||||||
|
const normalize = u => { if(!u) return ''; let v=u.trim(); if(!/^https?:\/\//i.test(v)) v='https://'+v; return v.replace(/\/+$/,''); };
|
||||||
|
const host = u => { try{ return new URL(normalize(u)).hostname }catch{ return '' } };
|
||||||
|
const originOf = u => { try{ return new URL(normalize(u)).origin }catch{ return '' } };
|
||||||
|
const faviconUrl = u => { try{ const x=new URL(normalize(u)); return x.origin+'/favicon.ico' }catch{ return '' } };
|
||||||
|
const initialsIcon = (hn='FR') => {
|
||||||
|
const t=(hn||'FR').replace(/^www\./,'').slice(0,2).toUpperCase();
|
||||||
|
const svg=`<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>
|
||||||
|
<rect width='100%' height='100%' rx='12' ry='12' fill='#2196F3'/>
|
||||||
|
<text x='50%' y='54%' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,Roboto,sans-serif'
|
||||||
|
font-size='28' font-weight='700' fill='#fff'>${t}</text></svg>`;
|
||||||
|
return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getStatusCache(){ const raw=(await Pref.get({key:K_STATUS})).value; try{ return raw?JSON.parse(raw):{} }catch{ return {}; } }
|
||||||
|
async function writeStatus(origin, ok){ const cache=await getStatusCache(); cache[origin]={ ok, ts: Date.now() }; await Pref.set({key:K_STATUS, value:JSON.stringify(cache)}); }
|
||||||
|
|
||||||
|
async function verifyFileRise(u, timeout=5000){
|
||||||
|
if (!u || !Http) return {ok:false};
|
||||||
|
const base = normalize(u), origin = originOf(base);
|
||||||
|
const tryJson = async (url, validate) => {
|
||||||
|
try{
|
||||||
|
const r = await Http.get({ url, connectTimeout:timeout, readTimeout:timeout, headers:{'Accept':'application/json','Cache-Control':'no-cache'} });
|
||||||
|
if (r && r.data) {
|
||||||
|
const j = (typeof r.data === 'string') ? JSON.parse(r.data) : r.data;
|
||||||
|
return !!validate(j);
|
||||||
|
}
|
||||||
|
}catch(_){}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if (await tryJson(origin + '/siteConfig.json', j => j && (j.appTitle || j.headerTitle || j.auth || j.oidc || j.basicAuth))) return {ok:true, origin};
|
||||||
|
if (await tryJson(origin + '/api/ping.php', j => j && (j.ok===true || j.status==='ok' || j.pong || j.app==='FileRise'))) return {ok:true, origin};
|
||||||
|
if (await tryJson(origin + '/api/version.php', j => j && (j.version || j.app==='FileRise'))) return {ok:true, origin};
|
||||||
|
try{
|
||||||
|
const r = await Http.get({ url: origin+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (typeof r.data === 'string' && /FileRise/i.test(r.data)) return {ok:true, origin};
|
||||||
|
}catch(_){}
|
||||||
|
return {ok:false, origin};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeReachable(u, timeout=3000){
|
||||||
|
try{
|
||||||
|
const base = new URL(normalize(u)).origin, ico=base+'/favicon.ico';
|
||||||
|
if (Http){
|
||||||
|
try{ const r=await Http.get({ url: ico, connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (r && typeof r.status==='number' && r.status<500) return true; }catch(e){}
|
||||||
|
try{ const r2=await Http.get({ url: base+'/', connectTimeout:timeout, readTimeout:timeout, headers:{'Cache-Control':'no-cache'} });
|
||||||
|
if (r2 && typeof r2.status==='number' && r2.status<500) return true; }catch(e){}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await new Promise(res=>{
|
||||||
|
const img=new Image(), t=setTimeout(()=>done(false), timeout);
|
||||||
|
function done(ok){ clearTimeout(t); img.onload=img.onerror=null; res(ok); }
|
||||||
|
img.onload=()=>done(true); img.onerror=()=>done(false);
|
||||||
|
img.src = ico + (ico.includes('?')?'&':'?') + '__fr=' + Date.now();
|
||||||
|
});
|
||||||
|
}catch{ return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstances(){ const raw=(await Pref.get({key:K_INST})).value; try{ return raw?JSON.parse(raw):[] }catch{ return [] } }
|
||||||
|
async function saveInstances(list){ await Pref.set({key:K_INST, value:JSON.stringify(list)}); }
|
||||||
|
async function getActive(){ return (await Pref.get({key:K_ACTIVE})).value }
|
||||||
|
async function setActive(id){ await Pref.set({key:K_ACTIVE, value:id||''}) }
|
||||||
|
|
||||||
|
// ---- Styles (slide-up sheet + disabled buttons + safe-area) ----
|
||||||
|
if (!$('#frx-mobile-style')) {
|
||||||
|
const css = `
|
||||||
|
.frx-fab { position:fixed; right:16px; bottom:calc(env(safe-area-inset-bottom,0px) + 18px); width:52px; height:52px; border-radius:26px;
|
||||||
|
background: linear-gradient(180deg,#64B5F6,#2196F3 65%,#1976D2); color:#fff; display:grid; place-items:center;
|
||||||
|
box-shadow:0 10px 22px rgba(33,150,243,.38); z-index:2147483647; cursor:pointer; user-select:none; }
|
||||||
|
.frx-fab:active { transform: translateY(1px) scale(.98); }
|
||||||
|
.frx-fab svg { width:26px; height:26px; fill:white }
|
||||||
|
.frx-scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483645;opacity:0;visibility:hidden;transition:opacity .24s ease}
|
||||||
|
.frx-scrim.show{opacity:1;visibility:visible}
|
||||||
|
.frx-sheet{position:fixed;left:0;right:0;bottom:0;background:#0f172a;color:#e5e7eb;
|
||||||
|
border-top-left-radius:16px;border-top-right-radius:16px;box-shadow:0 -10px 30px rgba(0,0,0,.3);
|
||||||
|
z-index:2147483646;transform:translateY(100%);opacity:0;visibility:hidden;
|
||||||
|
transition:transform .28s cubic-bezier(.2,.8,.2,1), opacity .28s ease; will-change:transform}
|
||||||
|
.frx-sheet.show{transform:translateY(0);opacity:1;visibility:visible}
|
||||||
|
.frx-sheet .hdr{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.frx-title{display:flex;align-items:center;gap:10px;font-weight:800}
|
||||||
|
.frx-title img{width:22px;height:22px}
|
||||||
|
.frx-list{max-height:60vh;overflow:auto;padding:8px 12px}
|
||||||
|
.frx-chip{border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin:8px 4px;background:rgba(255,255,255,.04)}
|
||||||
|
.frx-chip.active{outline:3px solid rgba(33,150,243,.35); border-color:#2196F3}
|
||||||
|
.frx-top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||||
|
.frx-left{display:flex;gap:10px;align-items:center}
|
||||||
|
.frx-ico{width:20px;height:20px;border-radius:6px;overflow:hidden;background:#fff;display:grid;place-items:center}
|
||||||
|
.frx-ico img{width:100%;height:100%;object-fit:cover;display:block}
|
||||||
|
.frx-name{font-weight:800}
|
||||||
|
.frx-host{font-size:12px;opacity:.8;margin-top:2px}
|
||||||
|
.frx-status{display:flex;align-items:center;gap:6px;font-size:12px;opacity:.9}
|
||||||
|
.frx-dot{width:10px;height:10px;border-radius:50%;}
|
||||||
|
.frx-dot.on{background:#10B981;box-shadow:0 0 0 3px rgba(16,185,129,.18)}
|
||||||
|
.frx-dot.off{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.18)}
|
||||||
|
.frx-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.frx-btn{appearance:none;border:0;border-radius:10px;padding:10px 12px;font-weight:700;cursor:pointer;transition:.15s ease opacity, .15s ease filter}
|
||||||
|
.frx-btn[disabled]{opacity:.5;cursor:not-allowed;filter:grayscale(20%)}
|
||||||
|
.frx-primary{background:linear-gradient(180deg,#64B5F6,#2196F3);color:#fff}
|
||||||
|
.frx-ghost{background:transparent;color:#cbd5e1;border:1px solid rgba(255,255,255,.12)}
|
||||||
|
.frx-danger{background:transparent;color:#f44336;border:1px solid rgba(244,67,54,.45)}
|
||||||
|
.frx-row{display:flex;gap:8px;align-items:center}
|
||||||
|
.frx-field{display:grid;gap:6px;margin:8px 4px}
|
||||||
|
.frx-input{width:100%;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:transparent;color:inherit}
|
||||||
|
.frx-footer{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid rgba(255,255,255,.08)}
|
||||||
|
@media (pointer:coarse) { .frx-fab { width:58px; height:58px; border-radius:29px; } }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(el('style',{id:'frx-mobile-style'}, css));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM
|
||||||
|
const scrim = el('div',{class:'frx-scrim', id:'frx-scrim'});
|
||||||
|
const sheet = el('div',{class:'frx-sheet', id:'frx-sheet'}, `
|
||||||
|
<div class="hdr">
|
||||||
|
<div class="frx-title">
|
||||||
|
<img src="/assets/logo.svg" alt="FileRise" onerror="this.style.display='none'"><span>FileRise Switcher</span>
|
||||||
|
</div>
|
||||||
|
<div class="frx-row">
|
||||||
|
<button class="frx-btn frx-ghost" id="frx-home">Home</button>
|
||||||
|
<button class="frx-btn frx-ghost" id="frx-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="frx-list" id="frx-list"></div>
|
||||||
|
<div style="padding:10px 12px">
|
||||||
|
<div class="frx-field">
|
||||||
|
<input class="frx-input" id="frx-name" placeholder="Display name (optional)"/>
|
||||||
|
<input class="frx-input" id="frx-url" placeholder="https://files.example.com"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="frx-footer">
|
||||||
|
<button class="frx-btn frx-ghost" id="frx-add-cancel">Close</button>
|
||||||
|
<button class="frx-btn frx-primary" id="frx-add-save">+ Add server</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'}, `<svg viewBox="0 0 24 24"><path d="M7 7h10v2H7V7zm0 4h10v2H7v-2zm0 4h10v2H7v-2z"/></svg>`);
|
||||||
|
document.body.appendChild(scrim); document.body.appendChild(sheet); document.body.appendChild(fab);
|
||||||
|
|
||||||
|
function show(){ scrim.classList.add('show'); sheet.classList.add('show'); fab.style.display='none'; }
|
||||||
|
function hide(){ scrim.classList.remove('show'); sheet.classList.remove('show'); fab.style.display='grid'; }
|
||||||
|
$('#frx-close').addEventListener('click', hide);
|
||||||
|
$('#frx-add-cancel').addEventListener('click', hide);
|
||||||
|
$('#frx-home').addEventListener('click', ()=>{ try{ location.href='capacitor://localhost/index.html'; }catch{} });
|
||||||
|
scrim.addEventListener('click', hide);
|
||||||
|
document.addEventListener('keydown', e=>{ if(e.key==='Escape') hide(); });
|
||||||
|
|
||||||
|
function chipNode(item, isActive){
|
||||||
|
const hv=host(item.url);
|
||||||
|
const node = el('div',{class:'frx-chip'+(isActive?' active':''), 'data-id':item.id});
|
||||||
|
const top = el('div',{class:'frx-top'});
|
||||||
|
const left = el('div',{class:'frx-left'});
|
||||||
|
const ico = el('div',{class:'frx-ico'});
|
||||||
|
const img = new Image();
|
||||||
|
img.alt=''; img.src=item.favicon||faviconUrl(item.url)||initialsIcon(hv);
|
||||||
|
img.onerror=()=>{ img.onerror=null; img.src=initialsIcon(hv); };
|
||||||
|
ico.appendChild(img);
|
||||||
|
const txt = el('div',{}, `<div class="frx-name">${item.name || hv}</div><div class="frx-host">${hv}</div>`);
|
||||||
|
left.appendChild(ico); left.appendChild(txt);
|
||||||
|
const status = el('div',{class:'frx-status'}, `<span class="frx-dot" id="frx-dot-${item.id}"></span><span id="frx-lbl-${item.id}">Checking…</span>`);
|
||||||
|
top.appendChild(left); top.appendChild(status);
|
||||||
|
const actions = el('div',{class:'frx-actions'});
|
||||||
|
const bOpen = el('button',{class:'frx-btn frx-primary', 'data-act':'open', disabled:true}, 'Open');
|
||||||
|
const bRen = el('button',{class:'frx-btn frx-ghost', 'data-act':'rename'}, 'Rename');
|
||||||
|
const bDel = el('button',{class:'frx-btn frx-danger', 'data-act':'remove'}, 'Remove');
|
||||||
|
actions.appendChild(bOpen); actions.appendChild(bRen); actions.appendChild(bDel);
|
||||||
|
node.appendChild(top); node.appendChild(actions);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderList(){
|
||||||
|
const listEl=$('#frx-list'); listEl.innerHTML='';
|
||||||
|
const list=await loadInstances(); const active=await getActive();
|
||||||
|
const cache=await getStatusCache();
|
||||||
|
|
||||||
|
list.sort((a,b)=>(b.lastUsed||0)-(a.lastUsed||0)).forEach(item=>{
|
||||||
|
const chip = chipNode(item, item.id===active);
|
||||||
|
const o = originOf(item.url), cached = cache[o];
|
||||||
|
const dot = chip.querySelector(`#frx-dot-${item.id}`), lbl = chip.querySelector(`#frx-lbl-${item.id}`);
|
||||||
|
const openBtn = chip.querySelector('[data-act="open"]');
|
||||||
|
|
||||||
|
if (cached){
|
||||||
|
dot.classList.add(cached.ok ? 'on':'off');
|
||||||
|
lbl.textContent = cached.ok ? 'Online' : 'Offline';
|
||||||
|
openBtn.disabled = !cached.ok;
|
||||||
|
} else {
|
||||||
|
lbl.textContent = 'Unknown';
|
||||||
|
openBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
chip.addEventListener('click', async (e)=>{
|
||||||
|
const act = e.target?.dataset?.act;
|
||||||
|
if (!act) return;
|
||||||
|
|
||||||
|
if (act==='open'){
|
||||||
|
if (openBtn.disabled) return;
|
||||||
|
await setActive(item.id);
|
||||||
|
const url=normalize(item.url), withFlag=url+(url.includes('?')?'&':'?')+'frapp=1';
|
||||||
|
window.location.replace(withFlag);
|
||||||
|
} else if (act==='rename'){
|
||||||
|
const nn=prompt('New display name:', item.name || host(item.url));
|
||||||
|
if (nn!=null){
|
||||||
|
const L=await loadInstances(); const it=L.find(x=>x.id===item.id);
|
||||||
|
if (it){ it.name=nn.trim(); it.lastUsed=Date.now(); await saveInstances(L); renderList(); }
|
||||||
|
}
|
||||||
|
} else if (act==='remove'){
|
||||||
|
if (!confirm('Remove this server?')) return;
|
||||||
|
let L=await loadInstances(); L=L.filter(x=>x.id!==item.id); await saveInstances(L);
|
||||||
|
const a=await getActive(); if (a===item.id) await setActive(L[0]?.id||''); renderList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.appendChild(chip);
|
||||||
|
|
||||||
|
// Live refresh (best effort)
|
||||||
|
(async ()=>{
|
||||||
|
const ok = await probeReachable(item.url, 2500);
|
||||||
|
const d = document.getElementById(`frx-dot-${item.id}`);
|
||||||
|
const l = document.getElementById(`frx-lbl-${item.id}`);
|
||||||
|
const b = chip.querySelector('[data-act="open"]');
|
||||||
|
if (d && l && b){
|
||||||
|
d.classList.remove('on','off');
|
||||||
|
d.classList.add(ok?'on':'off');
|
||||||
|
l.textContent = ok ? 'Online' : 'Offline';
|
||||||
|
b.disabled = !ok;
|
||||||
|
}
|
||||||
|
const o2 = originOf(item.url); if (o2) writeStatus(o2, ok);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#frx-add-save').addEventListener('click', async ()=>{
|
||||||
|
const name = $('#frx-name').value.trim();
|
||||||
|
const url = $('#frx-url').value.trim();
|
||||||
|
if (!url) { alert('Enter a valid URL'); return; }
|
||||||
|
|
||||||
|
// Verify: must be FileRise
|
||||||
|
const vf = await verifyFileRise(url);
|
||||||
|
if (!vf.ok) { alert('That address does not look like a FileRise server.'); return; }
|
||||||
|
|
||||||
|
let L = await loadInstances();
|
||||||
|
const h = host(url);
|
||||||
|
const dupe = L.find(i => host(i.url)===h);
|
||||||
|
const inst = dupe || { id:'i'+Math.random().toString(36).slice(2)+Date.now().toString(36) };
|
||||||
|
inst.name = name || inst.name || h;
|
||||||
|
inst.url = normalize(url);
|
||||||
|
inst.favicon = faviconUrl(url);
|
||||||
|
inst.lastUsed = Date.now();
|
||||||
|
if (!dupe) L.push(inst);
|
||||||
|
await saveInstances(L);
|
||||||
|
await setActive(inst.id);
|
||||||
|
|
||||||
|
if (vf.origin) await writeStatus(vf.origin, true);
|
||||||
|
|
||||||
|
window.location.replace(inst.url + (inst.url.includes('?')?'&':'?') + 'frapp=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
fab.addEventListener('click', async ()=>{ await renderList(); show(); });
|
||||||
|
|
||||||
|
|
||||||
|
// Ensure zoom gestures work if the host page tried to disable them
|
||||||
|
(function ensureZoomable(){
|
||||||
|
let m = document.querySelector('meta[name=viewport]');
|
||||||
|
const desired = 'width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=yes, minimum-scale=1, maximum-scale=5';
|
||||||
|
if (!m){ m = document.createElement('meta'); m.setAttribute('name','viewport'); document.head.appendChild(m); }
|
||||||
|
const c = m.getAttribute('content') || '';
|
||||||
|
if (/user-scalable=no|maximum-scale=1/.test(c)) m.setAttribute('content', desired);
|
||||||
|
})();
|
||||||
|
})();
|
||||||
5
public/js/pwa/register-sw.js
Normal file
5
public/js/pwa/register-sw.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
9
public/js/pwa/sw.js
Normal file
9
public/js/pwa/sw.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// public/js/pwa/sw.js
|
||||||
|
const SW_VERSION = '{{APP_QVER}}';
|
||||||
|
const STATIC_CACHE = `fr-static-${SW_VERSION}`;
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/', '/index.html',
|
||||||
|
'/css/styles.css?v={{APP_QVER}}',
|
||||||
|
'/js/main.js?v={{APP_QVER}}',
|
||||||
|
'/assets/logo.svg?v={{APP_QVER}}'
|
||||||
|
];
|
||||||
14
public/manifest.webmanifest
Normal file
14
public/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "FileRise",
|
||||||
|
"short_name": "FileRise",
|
||||||
|
"start_url": "/?pwa=1",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111111",
|
||||||
|
"theme_color": "#0b5ed7",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/assets/icons/icon-192.png?v={{APP_QVER}}", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/assets/icons/icon-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/assets/icons/maskable-512.png?v={{APP_QVER}}", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
6
public/sw.js
Normal file
6
public/sw.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Root-scoped stub. Keeps the worker’s scope at “/” level
|
||||||
|
try {
|
||||||
|
self.importScripts('/js/pwa/sw.js?v={{APP_QVER}}');
|
||||||
|
} catch (_) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
135
src/controllers/MediaController.php
Normal file
135
src/controllers/MediaController.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
// src/controllers/MediaController.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/models/MediaModel.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
|
class MediaController
|
||||||
|
{
|
||||||
|
private function jsonStart(): void {
|
||||||
|
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
set_error_handler(function ($severity, $message, $file, $line) {
|
||||||
|
if (!(error_reporting() & $severity)) return;
|
||||||
|
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private function jsonEnd(): void { restore_error_handler(); }
|
||||||
|
private function out($payload, int $status=200): void {
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
private function readJson(): array {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
private function requireAuth(): ?string {
|
||||||
|
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
$this->out(['error'=>'Unauthorized'], 401); return 'no';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function checkCsrf(): ?string {
|
||||||
|
$headers = function_exists('getallheaders') ? array_change_key_case(getallheaders(), CASE_LOWER) : [];
|
||||||
|
$received = $headers['x-csrf-token'] ?? '';
|
||||||
|
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
|
||||||
|
$this->out(['error'=>'Invalid CSRF token'], 403); return 'no';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private function normalizeFolder($f): string {
|
||||||
|
$f = trim((string)$f);
|
||||||
|
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
|
||||||
|
}
|
||||||
|
private function validFolder($f): bool {
|
||||||
|
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
|
||||||
|
}
|
||||||
|
private function validFile($f): bool {
|
||||||
|
$f = basename((string)$f);
|
||||||
|
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
|
||||||
|
}
|
||||||
|
private function enforceRead(string $folder, string $username): ?string {
|
||||||
|
$perms = loadUserPermissions($username) ?: [];
|
||||||
|
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/media/updateProgress.php */
|
||||||
|
public function updateProgress(): void {
|
||||||
|
$this->jsonStart();
|
||||||
|
try {
|
||||||
|
if ($this->requireAuth()) return;
|
||||||
|
if ($this->checkCsrf()) return;
|
||||||
|
|
||||||
|
$u = $_SESSION['username'] ?? '';
|
||||||
|
$d = $this->readJson();
|
||||||
|
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
|
||||||
|
$file = (string)($d['file'] ?? '');
|
||||||
|
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
|
||||||
|
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
|
||||||
|
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
|
||||||
|
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
|
||||||
|
|
||||||
|
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||||
|
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||||
|
}
|
||||||
|
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||||
|
|
||||||
|
if ($clear) {
|
||||||
|
$ok = MediaModel::clearProgress($u, $folder, $file);
|
||||||
|
$this->out(['success'=>$ok]); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = MediaModel::saveProgress($u, $folder, $file, $seconds, $duration, $completed);
|
||||||
|
$this->out(['success'=>true, 'state'=>$row]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('MediaController::updateProgress: '.$e->getMessage());
|
||||||
|
$this->out(['error'=>'Internal server error'], 500);
|
||||||
|
} finally { $this->jsonEnd(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/media/getProgress.php?folder=…&file=… */
|
||||||
|
public function getProgress(): void {
|
||||||
|
$this->jsonStart();
|
||||||
|
try {
|
||||||
|
if ($this->requireAuth()) return;
|
||||||
|
$u = $_SESSION['username'] ?? '';
|
||||||
|
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
|
||||||
|
$file = (string)($_GET['file'] ?? '');
|
||||||
|
|
||||||
|
if (!$this->validFolder($folder) || !$this->validFile($file)) {
|
||||||
|
$this->out(['error'=>'Invalid folder/file'], 400); return;
|
||||||
|
}
|
||||||
|
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||||
|
|
||||||
|
$row = MediaModel::getProgress($u, $folder, $file);
|
||||||
|
$this->out(['state'=>$row]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('MediaController::getProgress: '.$e->getMessage());
|
||||||
|
$this->out(['error'=>'Internal server error'], 500);
|
||||||
|
} finally { $this->jsonEnd(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/media/getViewedMap.php?folder=… (optional, for badges) */
|
||||||
|
public function getViewedMap(): void {
|
||||||
|
$this->jsonStart();
|
||||||
|
try {
|
||||||
|
if ($this->requireAuth()) return;
|
||||||
|
$u = $_SESSION['username'] ?? '';
|
||||||
|
$folder = $this->normalizeFolder($_GET['folder'] ?? 'root');
|
||||||
|
|
||||||
|
if (!$this->validFolder($folder)) {
|
||||||
|
$this->out(['error'=>'Invalid folder'], 400); return;
|
||||||
|
}
|
||||||
|
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
|
||||||
|
|
||||||
|
$map = MediaModel::getFolderMap($u, $folder);
|
||||||
|
$this->out(['map'=>$map]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('MediaController::getViewedMap: '.$e->getMessage());
|
||||||
|
$this->out(['error'=>'Internal server error'], 500);
|
||||||
|
} finally { $this->jsonEnd(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/models/MediaModel.php
Normal file
94
src/models/MediaModel.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
// src/models/MediaModel.php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once PROJECT_ROOT . '/config/config.php';
|
||||||
|
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||||
|
|
||||||
|
class MediaModel
|
||||||
|
{
|
||||||
|
private static function baseDir(): string {
|
||||||
|
$dir = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'user_state';
|
||||||
|
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||||
|
return $dir . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function filePathFor(string $username): string {
|
||||||
|
// case-insensitive username file
|
||||||
|
$safe = strtolower(preg_replace('/[^a-z0-9_\-\.]/i', '_', $username));
|
||||||
|
return self::baseDir() . $safe . '_media.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function loadState(string $username): array {
|
||||||
|
$path = self::filePathFor($username);
|
||||||
|
if (!file_exists($path)) return ["version"=>1, "items"=>[]];
|
||||||
|
$json = file_get_contents($path);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
return (is_array($data) && isset($data['items'])) ? $data : ["version"=>1, "items"=>[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function saveState(string $username, array $state): bool {
|
||||||
|
$path = self::filePathFor($username);
|
||||||
|
$tmp = $path . '.tmp';
|
||||||
|
$ok = file_put_contents($tmp, json_encode($state, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX);
|
||||||
|
if ($ok === false) return false;
|
||||||
|
return @rename($tmp, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save/merge a single file progress record. */
|
||||||
|
public static function saveProgress(string $username, string $folder, string $file, float $seconds, ?float $duration, ?bool $completed): array {
|
||||||
|
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||||
|
$nowIso = date('c');
|
||||||
|
|
||||||
|
$state = self::loadState($username);
|
||||||
|
if (!isset($state['items'][$folderKey])) $state['items'][$folderKey] = [];
|
||||||
|
if (!isset($state['items'][$folderKey][$file])) {
|
||||||
|
$state['items'][$folderKey][$file] = [
|
||||||
|
"seconds" => 0,
|
||||||
|
"duration" => $duration ?? 0,
|
||||||
|
"completed" => false,
|
||||||
|
"updatedAt" => $nowIso
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$row =& $state['items'][$folderKey][$file];
|
||||||
|
if ($duration !== null && $duration > 0) $row['duration'] = $duration;
|
||||||
|
if ($seconds >= 0) $row['seconds'] = $seconds;
|
||||||
|
if ($completed !== null) $row['completed'] = (bool)$completed;
|
||||||
|
// auto-complete if we’re basically done
|
||||||
|
if (!$row['completed'] && $row['duration'] > 0 && $row['seconds'] >= max(0, $row['duration'] * 0.95)) {
|
||||||
|
$row['completed'] = true;
|
||||||
|
}
|
||||||
|
$row['updatedAt'] = $nowIso;
|
||||||
|
|
||||||
|
self::saveState($username, $state);
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single file progress record. */
|
||||||
|
public static function getProgress(string $username, string $folder, string $file): array {
|
||||||
|
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||||
|
$state = self::loadState($username);
|
||||||
|
$row = $state['items'][$folderKey][$file] ?? null;
|
||||||
|
return is_array($row) ? $row : ["seconds"=>0,"duration"=>0,"completed"=>false,"updatedAt"=>null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folder map: filename => {seconds,duration,completed,updatedAt} */
|
||||||
|
public static function getFolderMap(string $username, string $folder): array {
|
||||||
|
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||||
|
$state = self::loadState($username);
|
||||||
|
$items = $state['items'][$folderKey] ?? [];
|
||||||
|
return is_array($items) ? $items : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear one file’s progress (e.g., “mark unviewed”). */
|
||||||
|
public static function clearProgress(string $username, string $folder, string $file): bool {
|
||||||
|
$folderKey = ($folder === '' || strtolower($folder)==='root') ? 'root' : $folder;
|
||||||
|
$state = self::loadState($username);
|
||||||
|
if (isset($state['items'][$folderKey][$file])) {
|
||||||
|
unset($state['items'][$folderKey][$file]);
|
||||||
|
return self::saveState($username, $state);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user