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 = '