feat(share-public): folder gallery UI in share.html

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.
This commit is contained in:
abnvle
2026-05-05 22:42:11 +02:00
parent d15d7b8f8e
commit cb454d8aa2
4 changed files with 814 additions and 47 deletions
+7
View File
@@ -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);
+388
View File
@@ -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;
}
+415 -38
View File
@@ -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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[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 = '<div class="gallery-loading"><div class="spinner"></div></div>';
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 = '<div class="gallery-error">Failed to load contents. Try again.</div>';
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 ? '<a class="gallery-back" href="#" data-action="back"><i class="fas fa-arrow-left"></i> Back to share root</a>' : '';
const headerHtml = `<header class="gallery-header"><h2 class="gallery-title">${escapeHtml(title)}</h2><div class="gallery-actions">${backHtml}<div class="gallery-view-toggle" role="group"><button type="button" data-view="grid" aria-pressed="${viewMode === 'grid'}"><i class="fas fa-th-large"></i></button><button type="button" data-view="list" aria-pressed="${viewMode === 'list'}"><i class="fas fa-bars"></i></button></div><a class="gallery-zip btn-primary" href="${escapeHtml(zipUrl(folderId))}" download><i class="fas fa-file-archive"></i> Download ZIP</a></div></header>`;
const foldersHtml =
listing.folders && listing.folders.length > 0
? `<h3 class="gallery-section-title">Folders</h3><div class="gallery-folders">${listing.folders.map(folderCardHtml).join('')}</div>`
: '';
const filesHtml =
listing.files && listing.files.length > 0
? `<h3 class="gallery-section-title">Files</h3><div class="gallery-files">${listing.files.map((f) => fileCardHtml(f)).join('')}</div>`
: '';
const emptyHtml = empty ? '<div class="gallery-empty">This folder is empty.</div>' : '';
$folder.innerHTML = headerHtml + emptyHtml + foldersHtml + filesHtml;
document.body.dataset.shareView = viewMode;
wireGallery();
wireLazyVideos();
wireImageRetry();
}
function folderCardHtml(folder) {
return `<a class="folder-card" href="#" data-action="open-folder" data-id="${escapeHtml(folder.id)}" data-name="${escapeHtml(folder.name)}"><i class="fas fa-folder folder-icon"></i><div class="card-body"><div class="card-name">${escapeHtml(folder.name)}</div><div class="card-meta">Subfolder</div></div></a>`;
}
function fileCardHtml(file) {
const url = fileUrl(file.id);
const kind = mediaKind(file.mime_type);
let thumbInner;
if (kind === 'image') {
thumbInner = `<img src="${escapeHtml(url)}" alt="" decoding="async" />`;
} else if (kind === 'video') {
thumbInner = `<video data-lazy-src="${escapeHtml(url)}" preload="metadata" muted playsinline disablepictureinpicture></video><span class="video-marker" aria-hidden="true"></span>`;
} else {
thumbInner = `<i class="${escapeHtml(uiFileTypes.getIconClass(file.name))} file-type-icon"></i>`;
}
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 `<a class="file-card" href="${escapeHtml(url)}"${dataAttrs}><div class="file-thumb">${thumbInner}</div><div class="card-body"><div class="card-name">${escapeHtml(file.name)}</div><div class="card-meta">${escapeHtml(formatSize(file.size))}</div></div></a>`;
}
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 =
'<div class="lb-bar">' +
'<span class="lb-title"></span>' +
'<a class="lb-download" href="#" download title="Download"><i class="fas fa-download"></i></a>' +
'<button class="lb-close" type="button" title="Close"><i class="fas fa-times"></i></button>' +
'</div>' +
'<div class="lb-stage">' +
'<button class="lb-nav lb-prev" type="button" aria-label="Previous"><i class="fas fa-chevron-left"></i></button>' +
'<div class="lb-content"></div>' +
'<button class="lb-nav lb-next" type="button" aria-label="Next"><i class="fas fa-chevron-right"></i></button>' +
'</div>';
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();
})();
+4 -9
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OxiCloud — Shared</title>
<script src="/js/core/theme-init.js"></script>
<script type="module" src="/js/core/icons.js"></script>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/views/share-public.css">
</head>
@@ -49,17 +50,11 @@
</a>
</div>
<!-- Folder share view -->
<div id="share-folder" class="share-state hidden">
<h2 id="folder-name"></h2>
<p class="subtitle" id="folder-meta">Shared folder</p>
<div id="folder-contents" class="folder-list">
<p class="subtitle">Folder browsing is not yet available. Use a WebDAV client for full access.</p>
</div>
</div>
<!-- Folder share view: populated by publicShare.js -->
<div id="share-folder" class="share-state hidden"></div>
</div>
<script src="/js/views/public/publicShare.js"></script>
<script type="module" src="/js/views/public/publicShare.js"></script>
</body>
</html>