2026-06-17 17:06:30 -06:00
|
|
|
|
<script lang="ts">
|
|
|
|
|
|
import { page } from '$app/state';
|
|
|
|
|
|
import { onMount } from 'svelte';
|
|
|
|
|
|
import Icon from '$lib/icons/Icon.svelte';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getShareContents,
|
|
|
|
|
|
getShareMeta,
|
|
|
|
|
|
shareDownloadUrl,
|
|
|
|
|
|
shareFileUrl,
|
|
|
|
|
|
shareZipUrl,
|
|
|
|
|
|
verifySharePassword,
|
|
|
|
|
|
type ShareListing,
|
|
|
|
|
|
type ShareMeta
|
|
|
|
|
|
} from '$lib/api/endpoints/share';
|
|
|
|
|
|
import { t } from '$lib/i18n/index.svelte';
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
type State = 'loading' | 'password' | 'expired' | 'invalid' | 'file' | 'folder';
|
|
|
|
|
|
type Crumb = { id?: string; name: string };
|
|
|
|
|
|
type ViewMode = 'grid' | 'list';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
const VIEW_KEY = 'oxicloud_share_view';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
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);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
let crumbs = $state<Crumb[]>([]);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
let pwInput = $state('');
|
|
|
|
|
|
let pwError = $state('');
|
|
|
|
|
|
let busy = $state(false);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
|
|
async function loadMeta() {
|
|
|
|
|
|
view = 'loading';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
// Guard a missing/blank token before hitting the API.
|
|
|
|
|
|
if (!token) {
|
|
|
|
|
|
view = 'invalid';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
try {
|
|
|
|
|
|
const r = await getShareMeta(token);
|
|
|
|
|
|
if (r.status === 'password') {
|
|
|
|
|
|
view = 'password';
|
|
|
|
|
|
} else if (r.status === 'expired') {
|
|
|
|
|
|
view = 'expired';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
} else if (r.status === 'invalid') {
|
|
|
|
|
|
view = 'invalid';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
} else {
|
|
|
|
|
|
meta = r.data;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
view = 'expired';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/** 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) {
|
2026-06-17 17:06:30 -06:00
|
|
|
|
const r = await getShareContents(token, id);
|
|
|
|
|
|
if (r.status === 'password') {
|
|
|
|
|
|
view = 'password';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.status === 'expired') {
|
|
|
|
|
|
view = 'expired';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
listing = r.data;
|
|
|
|
|
|
folderId = id;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (crumb) crumbs = [...crumbs, crumb];
|
|
|
|
|
|
lightbox = -1;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
view = 'folder';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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();
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitPassword(e: SubmitEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (!pwInput) return;
|
|
|
|
|
|
busy = true;
|
|
|
|
|
|
pwError = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ok = await verifySharePassword(token, pwInput);
|
|
|
|
|
|
if (!ok) {
|
|
|
|
|
|
pwError = t('share.bad_password', 'Incorrect password. Please try again.');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await loadMeta();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
pwError = t('share.error', 'Something went wrong. Please try again.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
busy = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onMount(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const saved = localStorage.getItem(VIEW_KEY);
|
|
|
|
|
|
if (saved === 'list' || saved === 'grid') viewMode = saved;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadMeta();
|
|
|
|
|
|
});
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<svelte:window onkeydown={onKeydown} onpopstate={onPopState} />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
|
|
<main class="share">
|
|
|
|
|
|
{#if view === 'loading'}
|
|
|
|
|
|
<p class="share__status">{t('common.loading', 'Loading…')}</p>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{: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>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{:else if view === 'expired'}
|
|
|
|
|
|
<div class="share__center">
|
|
|
|
|
|
<Icon name="ban" class="share__big-icon" />
|
|
|
|
|
|
<p>{t('share.expired', 'This share link is no longer available.')}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if view === 'password'}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<form class="share__pw" data-testid="public-share-password-form" onsubmit={submitPassword}>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<h1>{t('share.password_title', 'Password required')}</h1>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="password"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-password-input"
|
2026-06-17 17:06:30 -06:00
|
|
|
|
bind:value={pwInput}
|
|
|
|
|
|
placeholder={t('share.password', 'Password')}
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button type="submit" data-testid="public-share-unlock-btn" disabled={busy}
|
|
|
|
|
|
>{t('share.unlock', 'Unlock')}</button
|
|
|
|
|
|
>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</form>
|
|
|
|
|
|
{:else if view === 'file'}
|
|
|
|
|
|
<div class="share__center">
|
|
|
|
|
|
<Icon name="file" class="share__big-icon" />
|
|
|
|
|
|
<h1>{meta?.item_name}</h1>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="share__btn"
|
|
|
|
|
|
data-testid="public-share-download-btn"
|
|
|
|
|
|
href={shareDownloadUrl(token)}
|
|
|
|
|
|
download
|
|
|
|
|
|
rel="external"
|
|
|
|
|
|
>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{t('share.download', 'Download')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else if view === 'folder' && listing}
|
|
|
|
|
|
<header class="share__header">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<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}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="breadcrumb__link"
|
|
|
|
|
|
data-testid={`public-share-breadcrumb-${i}`}
|
|
|
|
|
|
onclick={() => gotoCrumb(i)}>{c.name}</button
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{/if}
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</nav>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<div class="share__header-actions">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<div class="view-toggle" role="group" aria-label={t('files.view', 'View')}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-pressed={viewMode === 'grid'}
|
|
|
|
|
|
class:active={viewMode === 'grid'}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-view-grid-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
title={t('files.grid', 'Grid')}
|
|
|
|
|
|
onclick={() => setViewMode('grid')}><Icon name="th" /></button
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-pressed={viewMode === 'list'}
|
|
|
|
|
|
class:active={viewMode === 'list'}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-view-list-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
title={t('files.list', 'List')}
|
|
|
|
|
|
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="share__btn"
|
|
|
|
|
|
data-testid="public-share-download-zip-btn"
|
|
|
|
|
|
href={shareZipUrl(token, folderId)}
|
|
|
|
|
|
download
|
|
|
|
|
|
rel="external"
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="file-archive" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{t('share.download_zip', 'Download ZIP')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{#if listing.folders.length === 0 && listing.files.length === 0}
|
|
|
|
|
|
<p class="share__status">{t('share.empty_folder', 'This folder is empty.')}</p>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if listing.folders.length > 0}
|
|
|
|
|
|
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{#each listing.folders as f (f.id)}
|
|
|
|
|
|
<li>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="card"
|
|
|
|
|
|
data-testid={f.name}
|
|
|
|
|
|
onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<span class="card__name">{f.name}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
{#if listing.files.length > 0}
|
|
|
|
|
|
<h2 class="share__section">{t('share.files', 'Files')}</h2>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{#each listing.files as f (f.id)}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{@const kind = mediaKind(f.mime_type)}
|
|
|
|
|
|
{#if kind}
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="card"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid={f.name}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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>
|
2026-06-21 19:03:07 +02:00
|
|
|
|
<a
|
|
|
|
|
|
class="card"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid={f.name}
|
2026-06-21 19:03:07 +02:00
|
|
|
|
href={shareFileUrl(token, f.id)}
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="external noreferrer"
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<span class="card__thumb"><Icon name="file" class="card__icon" /></span>
|
|
|
|
|
|
<span class="card__name">{f.name}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{#if lightbox >= 0 && mediaFiles[lightbox]}
|
|
|
|
|
|
{@const m = mediaFiles[lightbox]}
|
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="lb"
|
|
|
|
|
|
role="dialog"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-lightbox-dialog"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-label={m.name}
|
|
|
|
|
|
tabindex="-1"
|
|
|
|
|
|
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="lb__close"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-lightbox-close-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
aria-label={t('common.close', 'Close')}
|
|
|
|
|
|
onclick={() => (lightbox = -1)}>×</button
|
|
|
|
|
|
>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="lb__nav lb__nav--prev"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-lightbox-prev-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="public-share-lightbox-next-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
aria-label={t('common.next', 'Next')}
|
|
|
|
|
|
disabled={lightbox === mediaFiles.length - 1}
|
|
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
lbNext();
|
|
|
|
|
|
}}><Icon name="chevron-right" /></button
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<style>
|
|
|
|
|
|
.share {
|
|
|
|
|
|
max-width: 60rem;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
padding: 2rem 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__center {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
padding: 4rem 0;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:global(.share__big-icon) {
|
|
|
|
|
|
font-size: 3rem;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__status {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
padding: 3rem 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__pw {
|
|
|
|
|
|
max-width: 22rem;
|
|
|
|
|
|
margin: 4rem auto;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__pw input {
|
|
|
|
|
|
padding: 0.625rem 0.75rem;
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__error {
|
|
|
|
|
|
color: var(--color-danger-text);
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__header-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__section {
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
margin: 1.5rem 0 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share__grid {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
width: 100%;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
padding: 0.5rem;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
:global(.card__icon) {
|
|
|
|
|
|
font-size: 2rem;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card__name {
|
|
|
|
|
|
font-size: 0.8125rem;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
.share__btn {
|
2026-06-17 22:07:18 -06:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.4rem;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-primary);
|
|
|
|
|
|
color: var(--color-text-light);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.breadcrumb {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.breadcrumb__link {
|
2026-06-17 17:06:30 -06:00
|
|
|
|
background: none;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
cursor: pointer;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
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;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|