diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eca25c..0815adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # 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) release(v1.8.1): fix(security,onlyoffice): sanitize DS origin; safe api.js/iframe probes; better UX placeholder diff --git a/README.md b/README.md index 7142e43..34e3615 100644 --- a/README.md +++ b/README.md @@ -369,12 +369,13 @@ FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Documen **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** - ```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** diff --git a/public/api/media/getProgress.php b/public/api/media/getProgress.php new file mode 100644 index 0000000..9ef3a3a --- /dev/null +++ b/public/api/media/getProgress.php @@ -0,0 +1,7 @@ +getProgress(); \ No newline at end of file diff --git a/public/api/media/getViewedMap.php b/public/api/media/getViewedMap.php new file mode 100644 index 0000000..737b2bb --- /dev/null +++ b/public/api/media/getViewedMap.php @@ -0,0 +1,7 @@ +getViewedMap(); \ No newline at end of file diff --git a/public/api/media/updateProgress.php b/public/api/media/updateProgress.php new file mode 100644 index 0000000..72564ce --- /dev/null +++ b/public/api/media/updateProgress.php @@ -0,0 +1,7 @@ +updateProgress(); \ No newline at end of file diff --git a/public/assets/icons/apple-touch-icon.png b/public/assets/icons/apple-touch-icon.png new file mode 100644 index 0000000..969aed7 Binary files /dev/null and b/public/assets/icons/apple-touch-icon.png differ diff --git a/public/assets/icons/base-1024.png b/public/assets/icons/base-1024.png new file mode 100644 index 0000000..fbfd104 Binary files /dev/null and b/public/assets/icons/base-1024.png differ diff --git a/public/assets/icons/icon-192.png b/public/assets/icons/icon-192.png new file mode 100644 index 0000000..4a72fdf Binary files /dev/null and b/public/assets/icons/icon-192.png differ diff --git a/public/assets/icons/icon-512.png b/public/assets/icons/icon-512.png new file mode 100644 index 0000000..43edf97 Binary files /dev/null and b/public/assets/icons/icon-512.png differ diff --git a/public/assets/icons/maskable-512.png b/public/assets/icons/maskable-512.png new file mode 100644 index 0000000..db415d2 Binary files /dev/null and b/public/assets/icons/maskable-512.png differ diff --git a/public/css/styles.css b/public/css/styles.css index 2b39410..17d8a29 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1900,4 +1900,29 @@ body { background: #fafafa; border-color: #e2e2e2; } - \ No newline at end of file + /* 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); +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 48adfa4..4e2b0aa 100644 --- a/public/index.html +++ b/public/index.html @@ -10,7 +10,8 @@ - + + @@ -27,8 +28,8 @@ - - + +
-
- \ No newline at end of file diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 6550c2e..4d5db68 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -157,7 +157,121 @@ function wireSelectAll(fileListContent) { } 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. */ @@ -548,6 +662,7 @@ function searchFiles(searchTerm) { } updateFileActionButtons(); fileListContainer.style.visibility = "visible"; + // ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) ----- try { @@ -712,9 +827,14 @@ function searchFiles(searchTerm) { if (totalFiles > 0) { filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => { // 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 - rowHTML = rowHTML.replace(" 0) { @@ -724,9 +844,13 @@ function searchFiles(searchTerm) { }); tagBadgesHTML += ""; } - rowsHTML += rowHTML.replace(/()(.*?)(<\/td>)/, (match, p1, p2, p3) => { - return p1 + p2 + tagBadgesHTML + p3; - }); + rowsHTML += rowHTML.replace( + /()([\s\S]*?)(<\/td>)/, + (m, open, inner, close) => { + // keep the original filename content, then add your tag badges, then close + return `${open}${inner}${tagBadgesHTML}${close}`; + } + ); }); } else { rowsHTML += `No files found.`; @@ -904,6 +1028,7 @@ function searchFiles(searchTerm) { }); }); updateFileActionButtons(); + document.querySelectorAll("#fileList tbody tr").forEach(row => { row.setAttribute("draggable", "true"); import('./fileDragDrop.js?v={{APP_QVER}}').then(module => { @@ -914,6 +1039,7 @@ function searchFiles(searchTerm) { btn.addEventListener("click", e => e.stopPropagation()); }); bindFileListContextMenu(); + refreshViewedBadges(folder).catch(() => {}); } // 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 galleryHTML += `

${t("password_optional")}

- + - @@ -79,392 +69,524 @@ export function openShareModal(file, folder) { document.body.appendChild(modal); modal.style.display = "block"; - // Close handler - document.getElementById("closeShareModal") - .addEventListener("click", () => modal.remove()); + document.getElementById("closeShareModal").addEventListener("click", () => modal.remove()); + document.getElementById("shareExpiration").addEventListener("change", e => { + const container = document.getElementById("customExpirationContainer"); + container.style.display = e.target.value === "custom" ? "block" : "none"; + }); - // Show/hide custom-duration inputs - document.getElementById("shareExpiration") - .addEventListener("change", e => { - const container = document.getElementById("customExpirationContainer"); - container.style.display = e.target.value === "custom" ? "block" : "none"; - }); + document.getElementById("generateShareLinkBtn").addEventListener("click", () => { + const sel = document.getElementById("shareExpiration"); + let value, unit; - // Generate share link - document.getElementById("generateShareLinkBtn") - .addEventListener("click", () => { - const sel = document.getElementById("shareExpiration"); - let value, unit; - - 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 = ` - `; - 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(); + if (sel.value === "custom") { + value = parseInt(document.getElementById("customExpirationValue").value, 10); + unit = document.getElementById("customExpirationUnit").value; + } else { + value = parseInt(sel.value, 10); + unit = "minutes"; } - document.getElementById("closeFileModal").addEventListener("click", closeModal); - 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 password = document.getElementById("sharePassword").value; - const extension = fileName.split('.').pop().toLowerCase(); - const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(fileName); + 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")); + }); + }); + + 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 = ` + `; + + 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) { - // Create the image element with default transform data. const img = document.createElement("img"); img.src = fileUrl; img.className = "image-modal-img"; - img.style.maxWidth = "80vw"; - img.style.maxHeight = "80vh"; + img.style.maxWidth = "88vw"; + img.style.maxHeight = "88vh"; img.style.transition = "transform 0.3s ease"; img.dataset.scale = 1; img.dataset.rotate = 0; - img.style.position = 'relative'; - img.style.zIndex = '1'; + container.appendChild(img); - // Filter gallery images for navigation. - const images = fileData.filter(file => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)); + const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In'); + 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. - const wrapper = document.createElement('div'); - wrapper.className = 'image-wrapper'; - wrapper.style.display = 'flex'; - wrapper.style.alignItems = 'center'; - wrapper.style.justifyContent = 'center'; - wrapper.style.position = 'relative'; + zoomInBtn.addEventListener('click', (e) => { + e.stopPropagation(); + let s = parseFloat(img.dataset.scale) || 1; s += 0.1; + img.dataset.scale = s; + img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`; + }); + 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 leftPanel = document.createElement('div'); - leftPanel.className = 'left-panel'; - leftPanel.style.display = 'flex'; - leftPanel.style.flexDirection = 'column'; - leftPanel.style.justifyContent = 'space-between'; - leftPanel.style.alignItems = 'center'; - leftPanel.style.width = '60px'; - leftPanel.style.height = '100%'; - leftPanel.style.zIndex = '10'; + const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name)); + overlay.mediaType = 'image'; + overlay.mediaList = images; + overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name)); + setNavVisibility(overlay, images.length > 1, images.length > 1); - // Top container for zoom buttons. - const leftTop = document.createElement('div'); - leftTop.style.display = 'flex'; - leftTop.style.flexDirection = 'column'; - leftTop.style.gap = '4px'; - // Zoom In button. - const zoomInBtn = document.createElement('button'); - zoomInBtn.className = 'material-icons zoom_in'; - zoomInBtn.title = 'Zoom In'; - 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); + const navigate = (dir) => { + if (!overlay.mediaList || overlay.mediaList.length < 2) return; + overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length; + const newFile = overlay.mediaList[overlay.mediaIndex].name; + setTitle(overlay, newFile); + img.dataset.scale = 1; + img.dataset.rotate = 0; + img.style.transform = 'scale(1) rotate(0deg)'; + img.src = buildPreviewUrl(folder, newFile); + }; - // 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) { - const prevBtn = document.createElement("button"); - prevBtn.textContent = "‹"; - prevBtn.className = "gallery-nav-btn"; - prevBtn.style.background = 'transparent'; - prevBtn.style.border = 'none'; - prevBtn.style.color = 'white'; - prevBtn.style.fontSize = '48px'; - prevBtn.style.cursor = 'pointer'; - prevBtn.addEventListener("click", function (e) { - 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 = ' '; + 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; } - leftPanel.appendChild(leftBottom); - // --- Center Panel: Contains the image --- - const centerPanel = document.createElement('div'); - centerPanel.className = 'center-image-container'; - centerPanel.style.flexGrow = '1'; - centerPanel.style.textAlign = 'center'; - centerPanel.style.position = 'relative'; - centerPanel.style.zIndex = '1'; - centerPanel.appendChild(img); + overlay.style.display = "flex"; + return; + } - // --- Right Panel: Contains Rotate controls (top) and Next button (bottom) --- - const rightPanel = document.createElement('div'); - rightPanel.className = 'right-panel'; - rightPanel.style.display = 'flex'; - rightPanel.style.flexDirection = 'column'; - rightPanel.style.justifyContent = 'space-between'; - rightPanel.style.alignItems = 'center'; - rightPanel.style.width = '60px'; - rightPanel.style.height = '100%'; - rightPanel.style.zIndex = '10'; + /* -------------------- PDF => new tab -------------------- */ + if (lower.endsWith('.pdf')) { + const separator = fileUrl.includes('?') ? '&' : '?'; + const urlWithTs = fileUrl + separator + 't=' + Date.now(); + window.open(urlWithTs, "_blank"); + overlay.remove(); + return; + } - // Top container for rotate buttons. - const rightTop = document.createElement('div'); - rightTop.style.display = 'flex'; - rightTop.style.flexDirection = 'column'; - rightTop.style.gap = '4px'; - // Rotate Left button. - const rotateLeftBtn = document.createElement('button'); - rotateLeftBtn.className = 'material-icons rotate_left'; - 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); + /* -------------------- VIDEOS -------------------- */ + if (isVideo) { + let video = document.createElement("video"); // let so we can rebind + video.controls = true; + video.style.maxWidth = "88vw"; + video.style.maxHeight = "88vh"; + video.style.objectFit = "contain"; + container.appendChild(video); - // Bottom container for next button. - const rightBottom = document.createElement('div'); - rightBottom.style.display = 'flex'; - rightBottom.style.justifyContent = 'center'; - rightBottom.style.alignItems = 'center'; - rightBottom.style.width = '100%'; - if (images.length > 1) { - const nextBtn = document.createElement("button"); - nextBtn.textContent = "›"; - nextBtn.className = "gallery-nav-btn"; - nextBtn.style.background = 'transparent'; - nextBtn.style.border = 'none'; - nextBtn.style.color = 'white'; - nextBtn.style.fontSize = '48px'; - nextBtn.style.cursor = 'pointer'; - nextBtn.addEventListener("click", function (e) { - e.stopPropagation(); - // Safety check: - if (!modal.galleryImages || modal.galleryImages.length === 0) return; - modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % 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)'; - }); - rightBottom.appendChild(nextBtn); - } else { - // Insert a placeholder so that center remains properly aligned. - rightBottom.innerHTML = ' '; + const markBtn = document.createElement('button'); + const clearBtn = document.createElement('button'); + markBtn.className = 'btn btn-sm btn-success'; + clearBtn.className = 'btn btn-sm btn-secondary'; + markBtn.textContent = t("mark_as_viewed") || "Mark as viewed"; + clearBtn.textContent = t("clear_progress") || "Clear progress"; + actionWrap.appendChild(markBtn); + actionWrap.appendChild(clearBtn); + + const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name)); + overlay.mediaType = 'video'; + overlay.mediaList = videos; + overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name)); + setNavVisibility(overlay, videos.length > 1, videos.length > 1); + + const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); }; + + const SAVE_INTERVAL_MS = 5000; + let lastSaveAt = 0; + let pending = false; + + async function getProgress(nm) { + try { + const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" }); + const data = await res.json(); + return data && data.state ? data.state : null; + } catch { return null; } } - rightPanel.appendChild(rightBottom); - - // Assemble panels into the wrapper. - wrapper.appendChild(leftPanel); - wrapper.appendChild(centerPanel); - wrapper.appendChild(rightPanel); - container.appendChild(wrapper); - - // --- Set up zoom controls event listeners --- - zoomInBtn.addEventListener('click', function (e) { - e.stopPropagation(); - let scale = parseFloat(img.dataset.scale) || 1; - scale += 0.1; - 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); + async function sendProgress({nm, seconds, duration, completed, clear}) { + try { + pending = true; + const res = await fetch("/api/media/updateProgress.php", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, + body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear }) + }); + const data = await res.json(); + pending = false; + return data; + } catch (e) { pending = false; console.error(e); return null; } } - } else { - // Handle non-image file previews. - if (extension === "pdf") { - // build a cache‐busted URL - const separator = fileUrl.includes('?') ? '&' : '?'; - const urlWithTs = fileUrl + separator + 't=' + Date.now(); + const lsKey = (nm) => `videoProgress-${folder}/${nm}`; - // open in a new tab (avoids CSP frame-ancestors) - window.open(urlWithTs, "_blank"); + function renderStatus(state) { + if (!statusChip) return; + // Completed + if (state && state.completed) { + + statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓'; + statusChip.style.display = 'inline-block'; + statusChip.style.borderColor = 'rgba(34,197,94,.45)'; + statusChip.style.background = 'rgba(34,197,94,.15)'; + statusChip.style.color = '#22c55e'; + 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 - const modal = document.getElementById("filePreviewModal"); - if (modal) modal.remove(); + function bindVideoEvents(nm) { + const nv = video.cloneNode(true); + video.replaceWith(nv); + video = nv; - // stop further preview logic - return; - } else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { - const video = document.createElement("video"); - video.src = fileUrl; - video.controls = true; - video.className = "image-modal-img"; - - const progressKey = 'videoProgress-' + fileUrl; - video.addEventListener("loadedmetadata", () => { - const savedTime = localStorage.getItem(progressKey); - if (savedTime) { - video.currentTime = parseFloat(savedTime); + video.addEventListener("loadedmetadata", async () => { + try { + const state = await getProgress(nm); + if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) { + video.currentTime = state.seconds; + const seconds = Math.floor(video.currentTime || 0); +const duration = Math.floor(video.duration || 0); +setFileProgressBadge(nm, seconds, duration); + showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s"); + } else { + const ls = localStorage.getItem(lsKey(nm)); + if (ls) video.currentTime = parseFloat(ls); + } + renderStatus(state || null); + } catch { + renderStatus(null); } }); - video.addEventListener("timeupdate", () => { - 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)) { - const audio = document.createElement("audio"); - audio.src = fileUrl; - audio.controls = true; - audio.className = "audio-modal"; - audio.style.maxWidth = "80vw"; - container.appendChild(audio); - } else { - container.textContent = "Preview not available for this file type."; + + markBtn.onclick = async () => { + const duration = Math.floor(video.duration || 0); + await sendProgress({ nm, seconds: duration, duration, completed: true }); + showToast(t("marked_viewed") || "Marked as viewed"); + setFileWatchedBadge(nm, true); + renderStatus({ seconds: duration, duration, completed: true }); + }; + clearBtn.onclick = async () => { + 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) { const actualFile = file.file || file; if (!(actualFile instanceof File)) { @@ -472,10 +594,9 @@ export function displayFilePreview(file, container) { return; } container.style.display = "inline-block"; - while (container.firstChild) { - container.removeChild(container.firstChild); - } - if (/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(actualFile.name)) { + while (container.firstChild) container.removeChild(container.firstChild); + + if (IMG_RE.test(actualFile.name)) { const img = document.createElement("img"); img.src = URL.createObjectURL(actualFile); img.classList.add("file-preview-img"); @@ -488,5 +609,6 @@ export function displayFilePreview(file, container) { } } +// expose for HTML onclick usage window.previewFile = previewFile; window.openShareModal = openShareModal; \ No newline at end of file diff --git a/public/js/i18n.js b/public/js/i18n.js index ba66d38..3484e48 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -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.", "context_move_folder": "Move Folder...", "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: { "please_log_in_to_continue": "Por favor, inicie sesión para continuar.", diff --git a/public/js/main.js b/public/js/main.js index 90d3fda..70e4989 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1057,4 +1057,52 @@ function bindDarkMode() { if (overlay) overlay.style.display = 'none'; }, { 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(() => {}); + }); + } })(); \ No newline at end of file diff --git a/public/js/mobile/switcher.js b/public/js/mobile/switcher.js new file mode 100644 index 0000000..447df3c --- /dev/null +++ b/public/js/mobile/switcher.js @@ -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=` + + ${t}`; + 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'}, ` +
+
+ FileRiseFileRise Switcher +
+
+ + +
+
+
+
+
+ + +
+
+ + `); + const fab = el('div',{class:'frx-fab', id:'frx-fab', title:'Switch server'}, ``); + 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',{}, `
${item.name || hv}
${hv}
`); + left.appendChild(ico); left.appendChild(txt); + const status = el('div',{class:'frx-status'}, `Checking…`); + 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); + })(); + })(); \ No newline at end of file diff --git a/public/js/pwa/register-sw.js b/public/js/pwa/register-sw.js new file mode 100644 index 0000000..304b149 --- /dev/null +++ b/public/js/pwa/register-sw.js @@ -0,0 +1,5 @@ +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js?v={{APP_QVER}}').catch(() => {}); + }); + } \ No newline at end of file diff --git a/public/js/pwa/sw.js b/public/js/pwa/sw.js new file mode 100644 index 0000000..a5d6c84 --- /dev/null +++ b/public/js/pwa/sw.js @@ -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}}' +]; \ No newline at end of file diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..aada9e4 --- /dev/null +++ b/public/manifest.webmanifest @@ -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" } + ] + } \ No newline at end of file diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..760999a --- /dev/null +++ b/public/sw.js @@ -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 +} \ No newline at end of file diff --git a/src/controllers/MediaController.php b/src/controllers/MediaController.php new file mode 100644 index 0000000..ff5f201 --- /dev/null +++ b/src/controllers/MediaController.php @@ -0,0 +1,135 @@ +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(); } + } +} \ No newline at end of file diff --git a/src/models/MediaModel.php b/src/models/MediaModel.php new file mode 100644 index 0000000..7837508 --- /dev/null +++ b/src/models/MediaModel.php @@ -0,0 +1,94 @@ +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; + } +} \ No newline at end of file