From cb454d8aa2ea1a70d47ef10f5ad43a301bc73bd6 Mon Sep 17 00:00:00 2001 From: abnvle Date: Tue, 5 May 2026 22:42:11 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(share-public):=20folder=20gallery?= =?UTF-8?q?=20UI=20in=20share.html?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the placeholder with a gallery rendered by publicShare.js against the new public endpoints. - Grid / list view toggle (localStorage) - Image thumbs and lazy-loaded video posters - Click-to-open lightbox with Esc / arrow nav - ZIP download for the current folder - Subfolder navigation via URL hash, History API for back Adds five overlay-related tokens to base/variables.css for the lightbox surface (on-overlay text, translucent button, drop shadow). share.html itself only adds an icons.js module import and empties #share-folder for JS to populate. --- static/css/base/variables.css | 7 + static/css/views/share-public.css | 388 ++++++++++++++++++++++ static/js/views/public/publicShare.js | 453 +++++++++++++++++++++++--- static/share.html | 13 +- 4 files changed, 814 insertions(+), 47 deletions(-) diff --git a/static/css/base/variables.css b/static/css/base/variables.css index 73f73ffa..df3a1e14 100644 --- a/static/css/base/variables.css +++ b/static/css/base/variables.css @@ -116,6 +116,13 @@ --color-overlay-light: rgba(0, 0, 0, 0.45); --color-overlay-heavy: rgba(0, 0, 0, 0.85); --color-overlay-darkest: rgba(0, 0, 0, 0.92); + --color-overlay-shadow: rgba(0, 0, 0, 0.6); + + /* Foreground / control surfaces on top of dark overlays */ + --color-on-overlay: rgba(255, 255, 255, 0.95); + --color-on-overlay-muted: rgba(255, 255, 255, 0.9); + --color-overlay-button: rgba(255, 255, 255, 0.12); + --color-overlay-button-hover: rgba(255, 255, 255, 0.22); /* Items */ --color-item: var(--color-bg-surface); diff --git a/static/css/views/share-public.css b/static/css/views/share-public.css index 2d5d4d05..7f979204 100644 --- a/static/css/views/share-public.css +++ b/static/css/views/share-public.css @@ -144,3 +144,391 @@ FIXME: use components/buttons.css ? padding: 1.5rem; } } + +/* ── Folder gallery ────────────────────────────────────────────── */ +body.gallery-mode { + align-items: flex-start; + padding: 1.5rem 1rem; +} +body.gallery-mode .share-card { + max-width: 1100px; + padding: 1.75rem 1.5rem 2.5rem; + text-align: left; +} +body.gallery-mode .share-logo { + margin-bottom: 1rem; + text-align: center; +} + +#share-folder { + width: 100%; +} + +.gallery-loading, +.gallery-empty, +.gallery-error { + text-align: center; + color: var(--color-text-gray); + padding: 3rem 1rem; + border: 1px dashed var(--color-border); + border-radius: var(--radius, 12px); +} +.gallery-error { + color: var(--color-error-text); +} + +.gallery-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} +.gallery-title { + flex: 1 1 200px; + font-size: 1.25rem; + font-weight: 600; + margin: 0; + word-break: break-word; + color: var(--color-text-heading); +} +.gallery-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +.gallery-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + text-decoration: none; + color: var(--color-text-heading); + font-size: 0.85rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); + transition: background 0.15s; +} +.gallery-back:hover { + background: var(--color-border); +} + +.gallery-view-toggle { + display: inline-flex; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 3px; +} +.gallery-view-toggle button { + background: transparent; + border: none; + color: var(--color-text-gray); + padding: 0.35rem 0.75rem; + border-radius: 999px; + cursor: pointer; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + transition: + background 0.15s, + color 0.15s; +} +.gallery-view-toggle button[aria-pressed="true"] { + background: var(--color-primary); + color: var(--color-bg-surface); +} +.gallery-zip { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 1rem; + border-radius: 999px; + width: auto; + font-size: 0.85rem; +} + +.gallery-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--color-text-gray); + margin: 1.25rem 0 0.5rem; +} + +.gallery-folders { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.6rem; +} +.folder-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); + border-radius: 12px; + color: var(--color-text-heading); + text-decoration: none; + transition: + background 0.15s, + transform 0.15s; +} +.folder-card:hover { + background: var(--color-border); + transform: translateY(-1px); +} +.folder-card .folder-icon { + width: 28px; + height: 28px; + color: var(--color-primary); +} +.folder-card .card-name { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder-card .card-meta { + font-size: 0.75rem; + color: var(--color-text-gray); +} + +.gallery-files { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 0.75rem; +} +.file-card { + display: block; + background: var(--color-bg-hover); + border: 1px solid var(--color-border); + border-radius: 12px; + color: var(--color-text-heading); + text-decoration: none; + overflow: hidden; + transition: + background 0.15s, + transform 0.15s, + box-shadow 0.15s; +} +.file-card:hover { + background: var(--color-border); + transform: translateY(-2px); + box-shadow: 0 8px 20px -10px var(--color-shadow-sm); +} +.file-thumb { + width: 100%; + aspect-ratio: 4 / 3; + background: var(--color-bg-surface); + position: relative; + overflow: hidden; +} +.file-thumb img, +.file-thumb video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.file-thumb video { + pointer-events: none; + background: var(--color-black); +} +.file-thumb .file-type-icon { + position: absolute; + inset: 0; + margin: auto; + width: 48px; + height: 48px; + color: var(--color-text-gray); +} +.file-thumb .video-marker { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} +.file-thumb .video-marker::before { + content: "▶"; + color: var(--color-on-overlay); + font-size: 28px; + text-shadow: 0 2px 8px var(--color-overlay-shadow); +} +.file-card .card-body { + padding: 0.5rem 0.7rem 0.65rem; + min-width: 0; +} +.file-card .card-name { + font-size: 0.85rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-card .card-meta { + font-size: 0.75rem; + color: var(--color-text-gray); + margin-top: 2px; +} + +/* ── List view ───────────────────────────────────────────────── */ +body[data-share-view="list"] .gallery-files { + display: block; +} +body[data-share-view="list"] .file-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0.35rem; + border-radius: 10px; +} +body[data-share-view="list"] .file-card:hover { + transform: none; +} +body[data-share-view="list"] .file-thumb { + width: 44px; + height: 44px; + aspect-ratio: 1 / 1; + flex: none; + border-radius: 8px; +} +body[data-share-view="list"] .file-thumb .file-type-icon { + width: 24px; + height: 24px; +} +body[data-share-view="list"] .file-thumb .video-marker::before { + font-size: 14px; +} +body[data-share-view="list"] .file-card .card-body { + padding: 0; + flex: 1 1 auto; + min-width: 0; +} + +/* ── Lightbox ──────────────────────────────────────────────────── */ +.lightbox { + position: fixed; + inset: 0; + background: var(--color-overlay-darkest); + display: flex; + flex-direction: column; + z-index: 1000; + animation: lb-fade 180ms ease; +} +.lightbox.hidden { + display: none; +} +@keyframes lb-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.lb-bar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + color: var(--color-on-overlay-muted); +} +.lb-title { + flex: 1 1 auto; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-bar a, +.lb-bar button { + background: var(--color-overlay-button); + color: var(--color-on-overlay); + border: none; + border-radius: 999px; + padding: 0.5rem 0.85rem; + font-size: 0.85rem; + text-decoration: none; + cursor: pointer; + display: inline-flex; + align-items: center; + transition: background 0.15s; +} +.lb-bar a:hover, +.lb-bar button:hover { + background: var(--color-overlay-button-hover); +} +.lb-stage { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + padding: 0 1rem 1.5rem; + position: relative; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.lb-content { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; +} +.lb-content img, +.lb-content video { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + background: var(--color-black); + box-shadow: 0 20px 60px var(--color-overlay-shadow); +} +.lb-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: var(--color-overlay-button); + color: var(--color-on-overlay); + border: none; + width: 44px; + height: 44px; + border-radius: 50%; + cursor: pointer; + display: grid; + place-items: center; + transition: background 0.15s; +} +.lb-nav:hover { + background: var(--color-overlay-button-hover); +} +.lb-nav:disabled { + opacity: 0.3; + cursor: default; +} +.lb-prev { + left: 1rem; +} +.lb-next { + right: 1rem; +} +.lb-nav .oxi-icon { + width: 16px; + height: 16px; +} diff --git a/static/js/views/public/publicShare.js b/static/js/views/public/publicShare.js index 08bbebe6..231d3651 100644 --- a/static/js/views/public/publicShare.js +++ b/static/js/views/public/publicShare.js @@ -1,11 +1,13 @@ /** - * publicShare.js — Client-side logic for the public share page (/s/{token}). - * - * Fetches share metadata from the API, handles password-protected shares, - * and renders file download or folder info. + * 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 ─────────────────────────────────────────────────── + // ── DOM refs ────────────────────────────────────────────────── const $loading = document.getElementById('share-loading'); const $password = document.getElementById('share-password'); const $expired = document.getElementById('share-expired'); @@ -19,26 +21,34 @@ const $fileName = document.getElementById('file-name'); const $fileMeta = document.getElementById('file-meta'); const $fileDl = document.getElementById('file-download'); - const $folderName = document.getElementById('folder-name'); const $expiredMsg = document.getElementById('expired-message'); - // ── Extract token from URL path (/s/{token}) ────────────────── + // ── 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; - if (!TOKEN) { showState('expired'); - $expiredMsg.textContent = 'Invalid share link.'; + 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'; - // ── Helpers ──────────────────────────────────────────────────── function showState(name) { - [$loading, $password, $expired, $file, $folder].forEach((el) => { - el.classList.add('hidden'); - }); - var target = { + for (const el of [$loading, $password, $expired, $file, $folder]) { + if (el) el.classList.add('hidden'); + } + const target = { loading: $loading, password: $password, expired: $expired, @@ -46,24 +56,56 @@ folder: $folder }[name]; if (target) target.classList.remove('hidden'); + document.body.classList.toggle('gallery-mode', name === 'folder'); } + // ── Utilities ───────────────────────────────────────────────── + function escapeHtml(s) { + return String(s == null ? '' : s).replace( + /[&<>"']/g, + (c) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + })[c] + ); + } + 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]}`; + } + function mediaKind(mime) { + const m = (mime || '').toLowerCase(); + if (m.startsWith('image/')) return 'image'; + if (m.startsWith('video/')) return 'video'; + return null; + } // ── Render share data ───────────────────────────────────────── function renderShare(data) { if (data.item_type === 'folder') { - $folderName.textContent = data.item_name || 'Shared Folder'; - showState('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}/download`; + $fileDl.href = `/api/s/${TOKEN_ENC}/download`; showState('file'); } } - // ── Fetch share metadata ────────────────────────────────────── function fetchShare() { - fetch(`/api/s/${encodeURIComponent(TOKEN)}`) + fetch(`/api/s/${TOKEN_ENC}`) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { @@ -86,41 +128,376 @@ }) .catch(() => { showState('expired'); - $expiredMsg.textContent = 'This share link is no longer available.'; + if ($expiredMsg) { + $expiredMsg.textContent = 'This share link is no longer available.'; + } }); } - // ── Password form ───────────────────────────────────────────── - $pwForm.addEventListener('submit', (e) => { - e.preventDefault(); - $pwError.classList.add('hidden'); + 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'); + }); + }); + } - var password = $pwInput.value; - if (!password) return; + // ── Folder gallery ──────────────────────────────────────────── - fetch(`/api/s/${encodeURIComponent(TOKEN)}/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password: password }) - }) + let currentFolderId = 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; + } + + function listingUrl(folderId) { + return folderId ? `/api/s/${TOKEN_ENC}/contents/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/contents`; + } + function fileUrl(fileId) { + return `/api/s/${TOKEN_ENC}/file/${encodeURIComponent(fileId)}`; + } + function zipUrl(folderId) { + return folderId ? `/api/s/${TOKEN_ENC}/zip/${encodeURIComponent(folderId)}` : `/api/s/${TOKEN_ENC}/zip`; + } + + function loadAndRender(folderId) { + $folder.innerHTML = ''; + fetch(listingUrl(folderId)) .then((res) => { if (res.ok) return res.json(); if (res.status === 401) { - $pwError.textContent = 'Incorrect password. Please try again.'; - $pwError.classList.remove('hidden'); + showState('password'); + return null; + } + if (res.status === 410 || res.status === 404) { + showState('expired'); return null; } throw new Error(`HTTP ${res.status}`); }) - .then((data) => { - if (data) renderShare(data); + .then((listing) => { + if (!listing) return; + renderGallery(listing, folderId); }) - .catch(() => { - $pwError.textContent = 'An error occurred. Please try again.'; - $pwError.classList.remove('hidden'); + .catch((err) => { + $folder.innerHTML = ''; + console.error('share gallery load failed:', err); }); + } + + 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(); + } + + function folderCardHtml(folder) { + return `
${escapeHtml(folder.name)}
Subfolder
`; + } + + 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 $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 $folder.querySelectorAll('[data-action="open-folder"]')) { + card.addEventListener('click', (e) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + navigate(card.dataset.id, card.dataset.name); + }); + } + const mediaCards = Array.from($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); + }); + }); + } + + 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 $folder.querySelectorAll('.gallery-view-toggle button')) { + b.setAttribute('aria-pressed', String(b.dataset.view === mode)); + } + } + + 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 = (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; + if (e.target.dataset.lazySrc && !e.target.src) start(e.target); + obs.unobserve(e.target); + } + }, + { rootMargin: '300px' } + ); + for (const v of lazy) obs.observe(v); + } else { + for (const v of lazy) start(v); + } + } + + function wireImageRetry() { + for (const img of $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 ────────────────────────────────────────────────── + let lb = null; + 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; + } + + function openLightbox(items, index) { + ensureLightbox(); + lbItems = items; + showLightboxItem(index); + } + 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'); + } + 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(); })(); diff --git a/static/share.html b/static/share.html index a8c15edc..309bdc30 100644 --- a/static/share.html +++ b/static/share.html @@ -5,6 +5,7 @@ OxiCloud — Shared + @@ -49,17 +50,11 @@ - - + + - +