visual continunity
This commit is contained in:
@@ -14,38 +14,87 @@
|
||||
} from '$lib/api/endpoints/share';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
|
||||
type State = 'loading' | 'password' | 'expired' | 'file' | 'folder';
|
||||
type State = 'loading' | 'password' | 'expired' | 'invalid' | 'file' | 'folder';
|
||||
type Crumb = { id?: string; name: string };
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
const VIEW_KEY = 'oxicloud_share_view';
|
||||
const token = $derived(page.params.token ?? '');
|
||||
|
||||
let view = $state<State>('loading');
|
||||
let meta = $state<ShareMeta | null>(null);
|
||||
let listing = $state<ShareListing | null>(null);
|
||||
let folderId = $state<string | undefined>(undefined);
|
||||
let folderName = $state<string>('');
|
||||
let crumbs = $state<Crumb[]>([]);
|
||||
let pwInput = $state('');
|
||||
let pwError = $state('');
|
||||
let busy = $state(false);
|
||||
let viewMode = $state<ViewMode>('grid');
|
||||
|
||||
// Lightbox over the media files in the current folder
|
||||
let lightbox = $state(-1);
|
||||
|
||||
function mediaKind(mime: string | undefined): 'image' | 'video' | null {
|
||||
const m = (mime ?? '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
if (m.startsWith('video/')) return 'video';
|
||||
return null;
|
||||
}
|
||||
|
||||
const mediaFiles = $derived(
|
||||
(listing?.files ?? []).filter((f) => mediaKind(f.mime_type) !== null)
|
||||
);
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode = mode;
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, mode);
|
||||
} catch {
|
||||
/* storage unavailable — keep in-memory only */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
view = 'loading';
|
||||
// Guard a missing/blank token before hitting the API.
|
||||
if (!token) {
|
||||
view = 'invalid';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await getShareMeta(token);
|
||||
if (r.status === 'password') {
|
||||
view = 'password';
|
||||
} else if (r.status === 'expired') {
|
||||
view = 'expired';
|
||||
} else if (r.status === 'invalid') {
|
||||
view = 'invalid';
|
||||
} else {
|
||||
meta = r.data;
|
||||
if (r.data.item_type === 'folder') await openFolder(undefined, r.data.item_name);
|
||||
else view = 'file';
|
||||
if (r.data.item_type === 'folder') {
|
||||
crumbs = [{ name: r.data.item_name }];
|
||||
// Deep-link support: honour an initial #folder=<id> hash.
|
||||
await openFolder(hashFolderId(), undefined, false);
|
||||
} else view = 'file';
|
||||
}
|
||||
} catch {
|
||||
view = 'expired';
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder(id: string | undefined, name: string) {
|
||||
/** Parse the `#folder=<id>` fragment from the URL, if present. */
|
||||
function hashFolderId(): string | undefined {
|
||||
if (typeof location === 'undefined') return undefined;
|
||||
const m = location.hash.match(/[#&]folder=([A-Za-z0-9-]{1,64})/);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a folder's contents. When `crumb` is given, push it onto the trail.
|
||||
* `pushHistory` controls whether we sync the URL hash + push a history entry
|
||||
* (true for user navigation, false when restoring from popstate / deep link).
|
||||
*/
|
||||
async function openFolder(id: string | undefined, crumb?: Crumb, pushHistory = false) {
|
||||
const r = await getShareContents(token, id);
|
||||
if (r.status === 'password') {
|
||||
view = 'password';
|
||||
@@ -57,8 +106,109 @@
|
||||
}
|
||||
listing = r.data;
|
||||
folderId = id;
|
||||
folderName = name;
|
||||
if (crumb) crumbs = [...crumbs, crumb];
|
||||
lightbox = -1;
|
||||
view = 'folder';
|
||||
if (pushHistory && typeof history !== 'undefined') {
|
||||
const hash = id ? `#folder=${encodeURIComponent(id)}` : '';
|
||||
history.pushState({ folderId: id }, '', location.pathname + location.search + hash);
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate to a breadcrumb at depth `index` (0 = share root). */
|
||||
async function gotoCrumb(index: number) {
|
||||
const target = crumbs[index];
|
||||
crumbs = crumbs.slice(0, index + 1);
|
||||
await openFolder(target.id, undefined, true);
|
||||
}
|
||||
|
||||
/** Browser back/forward — re-resolve the folder from the popped state/hash. */
|
||||
async function onPopState() {
|
||||
if (view !== 'folder') return;
|
||||
await openFolder(hashFolderId(), undefined, false);
|
||||
}
|
||||
|
||||
/** Append a cache-busting query param to retry a failed media load once. */
|
||||
function retrySrc(original: string): string {
|
||||
const sep = original.indexOf('?') === -1 ? '?' : '&';
|
||||
return `${original}${sep}_r=${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy video poster: defer loading until near the viewport, then seek a few
|
||||
* frames in to render a thumbnail. Retries once with cache-busting on error.
|
||||
* Ported from publicShare.js wireLazyVideos().
|
||||
*/
|
||||
function lazyVideo(node: HTMLVideoElement, src: string) {
|
||||
let retried = false;
|
||||
const start = () => {
|
||||
node.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
const at = Math.min(0.1, (node.duration || 1) * 0.1);
|
||||
try {
|
||||
node.currentTime = at;
|
||||
} catch {
|
||||
/* seeking unsupported */
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
node.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
if (retried) return;
|
||||
retried = true;
|
||||
setTimeout(() => (node.src = retrySrc(src)), 250);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
node.src = src;
|
||||
};
|
||||
let obs: IntersectionObserver | null = null;
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
start();
|
||||
obs?.unobserve(node);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '300px' }
|
||||
);
|
||||
obs.observe(node);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return { destroy: () => obs?.disconnect() };
|
||||
}
|
||||
|
||||
/** Retry a failed image load once with cache-busting. Ported from wireImageRetry(). */
|
||||
function imageRetry(node: HTMLImageElement) {
|
||||
let retried = false;
|
||||
const onError = () => {
|
||||
if (retried) return;
|
||||
retried = true;
|
||||
const original = node.src;
|
||||
setTimeout(() => (node.src = retrySrc(original)), 250);
|
||||
};
|
||||
node.addEventListener('error', onError);
|
||||
return { destroy: () => node.removeEventListener('error', onError) };
|
||||
}
|
||||
|
||||
function lbPrev() {
|
||||
if (lightbox > 0) lightbox -= 1;
|
||||
}
|
||||
function lbNext() {
|
||||
if (lightbox >= 0 && lightbox < mediaFiles.length - 1) lightbox += 1;
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (lightbox < 0) return;
|
||||
if (e.key === 'Escape') lightbox = -1;
|
||||
else if (e.key === 'ArrowLeft') lbPrev();
|
||||
else if (e.key === 'ArrowRight') lbNext();
|
||||
}
|
||||
|
||||
async function submitPassword(e: SubmitEvent) {
|
||||
@@ -80,14 +230,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadMeta);
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(VIEW_KEY);
|
||||
if (saved === 'list' || saved === 'grid') viewMode = saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void loadMeta();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
|
||||
<svelte:window onkeydown={onKeydown} onpopstate={onPopState} />
|
||||
|
||||
<main class="share">
|
||||
{#if view === 'loading'}
|
||||
<p class="share__status">{t('common.loading', 'Loading…')}</p>
|
||||
{:else if view === 'invalid'}
|
||||
<div class="share__center">
|
||||
<Icon name="ban" class="share__big-icon" />
|
||||
<p>{t('share.invalid', 'This share link is invalid.')}</p>
|
||||
</div>
|
||||
{:else if view === 'expired'}
|
||||
<div class="share__center">
|
||||
<Icon name="ban" class="share__big-icon" />
|
||||
@@ -116,14 +280,35 @@
|
||||
</div>
|
||||
{:else if view === 'folder' && listing}
|
||||
<header class="share__header">
|
||||
<h1>{folderName}</h1>
|
||||
<nav class="breadcrumb" aria-label={t('files.breadcrumb', 'Breadcrumb')}>
|
||||
{#each crumbs as c, i (i)}
|
||||
{#if i > 0}<Icon name="chevron-right" class="breadcrumb__sep" />{/if}
|
||||
{#if i === crumbs.length - 1}
|
||||
<span class="breadcrumb__current">{c.name}</span>
|
||||
{:else}
|
||||
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="share__header-actions">
|
||||
{#if folderId}
|
||||
<button class="link-btn" onclick={() => openFolder(undefined, meta?.item_name ?? '')}>
|
||||
← {t('share.back_to_root', 'Back to share root')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="view-toggle" role="group" aria-label={t('files.view', 'View')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
class:active={viewMode === 'grid'}
|
||||
title={t('files.grid', 'Grid')}
|
||||
onclick={() => setViewMode('grid')}><Icon name="th" /></button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={viewMode === 'list'}
|
||||
class:active={viewMode === 'list'}
|
||||
title={t('files.list', 'List')}
|
||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||
>
|
||||
</div>
|
||||
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
|
||||
<Icon name="file-archive" />
|
||||
{t('share.download_zip', 'Download ZIP')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -135,11 +320,11 @@
|
||||
|
||||
{#if listing.folders.length > 0}
|
||||
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
|
||||
<ul class="share__grid">
|
||||
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
||||
{#each listing.folders as f (f.id)}
|
||||
<li>
|
||||
<button class="card" onclick={() => openFolder(f.id, f.name)}>
|
||||
<Icon name="folder" class="card__icon" />
|
||||
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
|
||||
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -149,20 +334,94 @@
|
||||
|
||||
{#if listing.files.length > 0}
|
||||
<h2 class="share__section">{t('share.files', 'Files')}</h2>
|
||||
<ul class="share__grid">
|
||||
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
||||
{#each listing.files as f (f.id)}
|
||||
<li>
|
||||
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
|
||||
<Icon name="file" class="card__icon" />
|
||||
<span class="card__name">{f.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
{@const kind = mediaKind(f.mime_type)}
|
||||
{#if kind}
|
||||
<li>
|
||||
<button
|
||||
class="card"
|
||||
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
|
||||
>
|
||||
<span class="card__thumb">
|
||||
{#if kind === 'image'}
|
||||
<img
|
||||
src={shareFileUrl(token, f.id)}
|
||||
alt={f.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
use:imageRetry
|
||||
/>
|
||||
{:else}
|
||||
<video
|
||||
use:lazyVideo={shareFileUrl(token, f.id)}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
<span class="card__play"><Icon name="play" /></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
|
||||
<span class="card__thumb"><Icon name="file" class="card__icon" /></span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
{#if lightbox >= 0 && mediaFiles[lightbox]}
|
||||
{@const m = mediaFiles[lightbox]}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="lb"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.name}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
|
||||
>
|
||||
<button
|
||||
class="lb__close"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
onclick={() => (lightbox = -1)}>×</button
|
||||
>
|
||||
<button
|
||||
class="lb__nav lb__nav--prev"
|
||||
aria-label={t('common.previous', 'Previous')}
|
||||
disabled={lightbox === 0}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
lbPrev();
|
||||
}}><Icon name="chevron-left" /></button
|
||||
>
|
||||
{#if mediaKind(m.mime_type) === 'image'}
|
||||
<img class="lb__media" src={shareFileUrl(token, m.id)} alt={m.name} />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video class="lb__media" src={shareFileUrl(token, m.id)} controls autoplay></video>
|
||||
{/if}
|
||||
<button
|
||||
class="lb__nav lb__nav--next"
|
||||
aria-label={t('common.next', 'Next')}
|
||||
disabled={lightbox === mediaFiles.length - 1}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
lbNext();
|
||||
}}><Icon name="chevron-right" /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.share {
|
||||
max-width: 60rem;
|
||||
@@ -248,7 +507,7 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 1rem 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface);
|
||||
@@ -261,6 +520,35 @@
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.card__thumb {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.card__thumb img,
|
||||
.card__thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card__play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-on-accent);
|
||||
font-size: 1.5rem;
|
||||
background: var(--color-scrim-control, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
:global(.card__icon) {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
@@ -275,7 +563,36 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* List mode: cards become single-row rows with a small leading thumb. */
|
||||
.share__grid--list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.share__grid--list .card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
.share__grid--list .card__thumb {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
aspect-ratio: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.share__grid--list .card__name {
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.share__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
@@ -285,10 +602,101 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb__link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.breadcrumb__current {
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
}
|
||||
|
||||
:global(.breadcrumb__sep) {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: none;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.lb {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--color-lightbox-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lb__media {
|
||||
max-width: 92vw;
|
||||
max-height: 88vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.lb__close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lb__nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lb__nav:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lb__nav--prev {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.lb__nav--next {
|
||||
right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user