import { oxiIconsInit } from '../../core/icons.js'; /** * publicShare.js — client-side logic for /s/{token}. * Drives the password / file / folder / expired states and renders the * folder gallery against /api/s/{token}/contents and friends. */ import { uiFileTypes } from '../../app/uiFileTypes.js'; (() => { // ── DOM refs ────────────────────────────────────────────────── const $loading = document.getElementById('share-loading'); const $password = document.getElementById('share-password'); const $expired = document.getElementById('share-expired'); const $file = document.getElementById('share-file'); const $folder = document.getElementById('share-folder'); const $pwForm = document.getElementById('password-form'); const $pwInput = /** @type {HTMLInputElement} */ (document.getElementById('password-input')); const $pwError = document.getElementById('password-error'); const $fileName = document.getElementById('file-name'); const $fileMeta = document.getElementById('file-meta'); const $fileDl = /** @type {HTMLAnchorElement} */ (document.getElementById('file-download')); const $expiredMsg = document.getElementById('expired-message'); // ── Token from URL path (/s/{token}) ────────────────────────── const pathParts = window.location.pathname.split('/'); const tokenIdx = pathParts.indexOf('s'); const TOKEN = tokenIdx !== -1 ? pathParts[tokenIdx + 1] : null; oxiIconsInit(); if (!TOKEN) { showState('expired'); if ($expiredMsg) $expiredMsg.textContent = 'Invalid share link.'; return; } const TOKEN_ENC = encodeURIComponent(TOKEN); // ── State management ────────────────────────────────────────── const VIEW_KEY = 'oxi.share.view'; let viewMode = 'grid'; try { viewMode = localStorage.getItem(VIEW_KEY) || 'grid'; } catch (_) { // localStorage unavailable } let rootDisplayName = 'Shared folder'; /** * @param {'loading'|'password'|'expired'|'file'|'folder'} name */ function showState(name) { for (const el of [$loading, $password, $expired, $file, $folder]) { if (el) el.classList.add('hidden'); } /** @type {{ loading: HTMLElement|null, password: HTMLElement|null, expired: HTMLElement|null, file: HTMLElement|null, folder: HTMLElement|null }} */ const map = { loading: $loading, password: $password, expired: $expired, file: $file, folder: $folder }; const target = map[name]; if (target) target.classList.remove('hidden'); document.body.classList.toggle('gallery-mode', name === 'folder'); } // ── Utilities ───────────────────────────────────────────────── /** * @param {string|null|undefined} s * @returns {string} */ function escapeHtml(s) { return String(s == null ? '' : s).replace( /[&<>"']/g, (c) => /** @type {Record} */ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c ); } /** * @param {number} bytes * @returns {string} */ function formatSize(bytes) { if (bytes == null || Number.isNaN(bytes)) return ''; if (bytes < 1024) return `${bytes} B`; const units = ['KB', 'MB', 'GB', 'TB']; let v = bytes / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; } /** * @param {string|null|undefined} mime * @returns {'image'|'video'|null} */ function mediaKind(mime) { const m = (mime || '').toLowerCase(); if (m.startsWith('image/')) return 'image'; if (m.startsWith('video/')) return 'video'; return null; } // ── Render share data ───────────────────────────────────────── /** * @param {any} data */ function renderShare(data) { if (data.item_type === 'folder') { rootDisplayName = data.item_name || 'Shared folder'; initFolderGallery(); } else { $fileName.textContent = data.item_name || 'Shared File'; $fileMeta.textContent = data.item_name ? 'Shared file' : ''; $fileDl.href = `/api/s/${TOKEN_ENC}/download`; showState('file'); } } function fetchShare() { fetch(`/api/s/${TOKEN_ENC}`) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { return res.json().then( /** @type {(body:any) => null} */ (body) => { if (body?.requiresPassword) { showState('password'); return null; } throw new Error('Unauthorized'); } ); } if (res.status === 410) { showState('expired'); return null; } throw new Error(`HTTP ${res.status}`); }) .then((data) => { if (data) renderShare(data); }) .catch(() => { showState('expired'); if ($expiredMsg) { $expiredMsg.textContent = 'This share link is no longer available.'; } }); } if ($pwForm) { $pwForm.addEventListener('submit', (e) => { e.preventDefault(); $pwError.classList.add('hidden'); const password = $pwInput.value; if (!password) return; fetch(`/api/s/${TOKEN_ENC}/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { $pwError.textContent = 'Incorrect password. Please try again.'; $pwError.classList.remove('hidden'); return null; } throw new Error(`HTTP ${res.status}`); }) .then((data) => { if (data) renderShare(data); }) .catch(() => { $pwError.textContent = 'An error occurred. Please try again.'; $pwError.classList.remove('hidden'); }); }); } // ── Folder gallery ──────────────────────────────────────────── /** @type {string | null} */ let currentFolderId = null; /** @type {string | null} */ let currentFolderName = null; function initFolderGallery() { showState('folder'); const hashFolderId = parseHashFolderId(); if (hashFolderId) { currentFolderId = hashFolderId; currentFolderName = null; loadAndRender(hashFolderId); } else { currentFolderId = null; currentFolderName = null; loadAndRender(null); } } function parseHashFolderId() { const h = window.location.hash; if (!h) return null; const m = h.match(/[#&]folder=([A-Za-z0-9-]{1,64})/); return m ? m[1] : null; } /** * @param {string | null} folderId * @returns {string} */ function listingUrl(folderId) { return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`; } /** * @param {string} fileId * @returns {string} */ function fileUrl(fileId) { return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`; } /** * @param {string | null} folderId * @returns {string} */ function zipUrl(folderId) { return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; } /** @type {AbortController | null} */ let currentLoadController = null; /** * @param {string | null} folderId */ function loadAndRender(folderId) { if (currentLoadController) currentLoadController.abort(); const controller = new AbortController(); currentLoadController = controller; $folder.innerHTML = ''; fetch(listingUrl(folderId), { signal: controller.signal }) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { showState('password'); return null; } if (res.status === 410 || res.status === 404) { showState('expired'); return null; } throw new Error(`HTTP ${res.status}`); }) .then((listing) => { if (controller.signal.aborted || !listing) return; renderGallery(listing, folderId); }) .catch((err) => { if (err.name === 'AbortError') return; $folder.innerHTML = ''; console.error('share gallery load failed:', err); }); } /** * @param {any} listing * @param {string | null} folderId */ function renderGallery(listing, folderId) { const isSubfolder = folderId !== null; const title = isSubfolder ? currentFolderName || 'Subfolder' : rootDisplayName; const empty = (!listing.folders || listing.folders.length === 0) && (!listing.files || listing.files.length === 0); const backHtml = isSubfolder ? ' Back to share root' : ''; const headerHtml = ` `; const foldersHtml = listing.folders && listing.folders.length > 0 ? `` : ''; const filesHtml = listing.files && listing.files.length > 0 ? `` : ''; const emptyHtml = empty ? '' : ''; $folder.innerHTML = headerHtml + emptyHtml + foldersHtml + filesHtml; document.body.dataset.shareView = viewMode; wireGallery(); wireLazyVideos(); wireImageRetry(); } /** * @param {any} folder * @returns {string} */ function folderCardHtml(folder) { return `
${escapeHtml(folder.name)}
Subfolder
`; } /** * @param {any} file * @returns {string} */ function fileCardHtml(file) { const url = fileUrl(file.id); const kind = mediaKind(file.mime_type); let thumbInner; if (kind === 'image') { thumbInner = ``; } else if (kind === 'video') { thumbInner = ``; } else { thumbInner = ``; } const mediaAttrs = kind ? ` data-mediakind="${kind}" data-src="${escapeHtml(url)}"` : ''; const dataAttrs = ` data-id="${escapeHtml(file.id)}" data-name="${escapeHtml(file.name)}" data-mime="${escapeHtml(file.mime_type || '')}"${mediaAttrs}`; return `
${thumbInner}
${escapeHtml(file.name)}
${escapeHtml(formatSize(file.size))}
`; } function wireGallery() { for (const btn of /** @type {NodeListOf} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) { btn.addEventListener('click', () => setViewMode(btn.dataset.view)); } const backBtn = $folder.querySelector('[data-action="back"]'); if (backBtn) { backBtn.addEventListener('click', (e) => { e.preventDefault(); navigate(null, rootDisplayName); }); } for (const card of /** @type {NodeListOf} */ ($folder.querySelectorAll('[data-action="open-folder"]'))) { card.addEventListener('click', (e) => { if (!(e instanceof MouseEvent)) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); navigate(card.dataset.id, card.dataset.name); }); } const mediaCards = Array.from(/** @type {NodeListOf} */ ($folder.querySelectorAll('.file-card[data-mediakind]'))); const items = mediaCards.map((el) => ({ kind: el.dataset.mediakind, src: el.dataset.src, name: el.dataset.name })); mediaCards.forEach((el, i) => { el.addEventListener('click', (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); openLightbox(items, i); }); }); } /** * @param {string | null | undefined} v */ function setViewMode(v) { const mode = v === 'list' ? 'list' : 'grid'; viewMode = mode; try { localStorage.setItem(VIEW_KEY, mode); } catch (_) { // ignore } document.body.dataset.shareView = mode; for (const b of /** @type {NodeListOf} */ ($folder.querySelectorAll('.gallery-view-toggle button'))) { b.setAttribute('aria-pressed', String(b.dataset.view === mode)); } } /** * @param {string | null} folderId * @param {string | null | undefined} folderName */ function navigate(folderId, folderName) { currentFolderId = folderId; currentFolderName = folderName; const hash = folderId ? `#folder=${encodeURIComponent(folderId)}` : ''; history.pushState({ folderId, folderName }, '', window.location.pathname + hash); loadAndRender(folderId); } window.addEventListener('popstate', (e) => { if (!$folder || $folder.classList.contains('hidden')) return; const state = e.state || {}; currentFolderId = state.folderId || parseHashFolderId(); currentFolderName = state.folderName || null; loadAndRender(currentFolderId); }); // ── Lazy video posters + image retry ────────────────────────── function wireLazyVideos() { const lazy = $folder.querySelectorAll('.file-thumb video[data-lazy-src]'); if (!lazy.length) return; const start = (/** @type {HTMLVideoElement} */ v) => { v.addEventListener( 'loadedmetadata', () => { const t = Math.min(0.1, (v.duration || 1) * 0.1); try { v.currentTime = t; } catch (_) { // unsupported } }, { once: true } ); v.addEventListener( 'error', () => { if (v.dataset.retried === '1') return; v.dataset.retried = '1'; const original = v.dataset.lazySrc; setTimeout(() => { const sep = original.indexOf('?') === -1 ? '?' : '&'; v.src = `${original}${sep}_r=${Date.now()}`; }, 250); }, { once: true } ); v.src = v.dataset.lazySrc; }; if ('IntersectionObserver' in window) { const obs = new IntersectionObserver( (entries) => { for (const e of entries) { if (!e.isIntersecting) continue; const target = /** @type {HTMLVideoElement} */ (e.target); if (target.dataset.lazySrc && !target.src) start(target); obs.unobserve(target); } }, { rootMargin: '300px' } ); for (const v of lazy) obs.observe(v); } else { for (const v of lazy) start(/** @type {HTMLVideoElement} */ (v)); } } function wireImageRetry() { for (const img of /** @type {NodeListOf} */ ($folder.querySelectorAll('.file-thumb img'))) { img.addEventListener('error', () => { if (img.dataset.retried === '1') return; img.dataset.retried = '1'; const original = img.src; setTimeout(() => { const sep = original.indexOf('?') === -1 ? '?' : '&'; img.src = `${original}${sep}_r=${Date.now()}`; }, 250); }); } } // ── Lightbox ────────────────────────────────────────────────── /** * @typedef {{ root: HTMLElement, title: Element|null, download: HTMLAnchorElement|null, close: HTMLButtonElement|null, stage: Element|null, content: Element|null, prev: HTMLButtonElement|null, next: HTMLButtonElement|null }} LightboxRefs */ /** @type {LightboxRefs | null} */ let lb = null; /** @type {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} */ let lbItems = []; let lbIndex = -1; function ensureLightbox() { if (lb) return lb; const root = document.createElement('div'); root.className = 'lightbox hidden'; root.id = 'share-lightbox'; root.setAttribute('role', 'dialog'); root.innerHTML = '
' + '' + '' + '' + '
' + '
' + '' + '
' + '' + '
'; document.body.appendChild(root); lb = { root, title: root.querySelector('.lb-title'), download: root.querySelector('.lb-download'), close: root.querySelector('.lb-close'), stage: root.querySelector('.lb-stage'), content: root.querySelector('.lb-content'), prev: root.querySelector('.lb-prev'), next: root.querySelector('.lb-next') }; lb.close.addEventListener('click', closeLightbox); lb.prev.addEventListener('click', () => stepLightbox(-1)); lb.next.addEventListener('click', () => stepLightbox(1)); lb.root.addEventListener('click', (e) => { if (e.target === lb.root) closeLightbox(); }); document.addEventListener('keydown', (e) => { if (lb.root.classList.contains('hidden')) return; if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); stepLightbox(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); stepLightbox(1); } }); return lb; } /** * @param {Array<{kind: string|undefined, src: string|undefined, name: string|undefined}>} items * @param {number} index */ function openLightbox(items, index) { ensureLightbox(); lbItems = items; showLightboxItem(index); } /** * @param {number} i */ function showLightboxItem(i) { if (i < 0 || i >= lbItems.length) return; lbIndex = i; const item = lbItems[i]; lb.content.innerHTML = ''; if (item.kind === 'image') { const img = document.createElement('img'); img.src = item.src; img.alt = item.name || ''; lb.content.appendChild(img); } else { const v = document.createElement('video'); v.src = item.src; v.controls = true; v.autoplay = true; v.preload = 'metadata'; lb.content.appendChild(v); } lb.title.textContent = item.name || ''; lb.download.href = item.src; if (item.name) lb.download.setAttribute('download', item.name); lb.prev.disabled = i === 0; lb.next.disabled = i === lbItems.length - 1; lb.root.classList.remove('hidden'); lb.root.setAttribute('aria-hidden', 'false'); } /** * @param {number} delta */ function stepLightbox(delta) { const next = lbIndex + delta; if (next >= 0 && next < lbItems.length) showLightboxItem(next); } function closeLightbox() { if (!lb) return; lb.root.classList.add('hidden'); lb.root.setAttribute('aria-hidden', 'true'); lb.content.innerHTML = ''; lbIndex = -1; } // ── Init ────────────────────────────────────────────────────── fetchShare(); })();