visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme } from '$lib/stores/theme.svelte';
interface Command {
id: string;
label: string;
icon: string;
hint?: string;
run: () => void;
}
let open = $state(false);
// Drives the enter animation: flipped on after mount so the overlay/panel
// transition from their initial (faded/offset) state.
let entered = $state(false);
let query = $state('');
let index = $state(0);
let input = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
let fileMatches = $state<Command[]>([]);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Element focused before the palette opened, restored on close.
let prevFocus: HTMLElement | null = null;
function close() {
open = false;
entered = false;
query = '';
fileMatches = [];
index = 0;
prevFocus?.focus?.();
prevFocus = null;
}
function nav(path: string): Command['run'] {
return () => {
close();
void goto(path);
};
}
/**
* Trigger the file picker in the files view. The input lives in the files
* route, so we navigate there first and broadcast an event the page listens
* for. (Follow-up: wire `oxicloud:upload-files` in the files route page.)
*/
function uploadFiles() {
close();
void goto('/files').then(() => {
window.dispatchEvent(new CustomEvent('oxicloud:upload-files'));
});
}
async function showAbout() {
close();
await confirmDialog({
title: t('user_menu.about', 'About OxiCloud'),
message: t(
'about.description',
'OxiCloud — a fast, self-hosted file storage and sync server.'
),
confirmText: t('common.ok', 'OK'),
cancelText: t('common.close', 'Close')
});
}
const baseCommands = $derived.by<Command[]>(() => {
const cmds: Command[] = [
{ id: 'files', label: t('nav.files', 'Files'), icon: 'folder', run: nav('/files') },
{ id: 'shared', label: t('nav.shared', 'Shared'), icon: 'oxiexport', run: nav('/shared') },
{
id: 'swm',
label: t('nav.shared_with_me', 'Shared with me'),
icon: 'oxiimport',
run: nav('/shared-with-me')
},
{ id: 'recent', label: t('nav.recent', 'Recent'), icon: 'clock', run: nav('/recent') },
{ id: 'fav', label: t('nav.favorites', 'Favorites'), icon: 'star', run: nav('/favorites') },
{ id: 'photos', label: t('nav.photos', 'Photos'), icon: 'images', run: nav('/photos') },
{ id: 'music', label: t('nav.music', 'Music'), icon: 'music', run: nav('/music') },
{ id: 'groups', label: t('nav.groups', 'Groups'), icon: 'users', run: nav('/groups') },
{ id: 'trash', label: t('nav.trash', 'Trash'), icon: 'trash', run: nav('/trash') },
{
id: 'upload',
label: t('actions.upload_files', 'Upload files'),
icon: 'cloud-upload-alt',
run: uploadFiles
},
{
id: 'profile',
label: t('user_menu.profile', 'Profile'),
icon: 'user',
run: nav('/profile')
}
];
if (session.user?.role === 'admin') {
cmds.push({
id: 'admin',
label: t('user_menu.admin_panel', 'Admin'),
icon: 'shield-alt',
run: nav('/admin')
});
}
cmds.push(
{
id: 'theme',
label: t('cmdk.toggle_theme', 'Toggle theme'),
icon: 'moon',
run: () => {
theme.set(theme.current === 'dark' ? 'light' : 'dark');
close();
}
},
{
id: 'about',
label: t('user_menu.about', 'About'),
icon: 'info-circle',
run: showAbout
},
{
id: 'logout',
label: t('actions.logout', 'Log out'),
icon: 'sign-out-alt',
run: async () => {
close();
try {
await logout();
} catch {
/* clear locally regardless */
}
session.reset();
await goto('/login');
}
}
);
return cmds;
});
const filtered = $derived.by<Command[]>(() => {
const q = query.trim().toLowerCase();
const base = q ? baseCommands.filter((c) => c.label.toLowerCase().includes(q)) : baseCommands;
return [...base, ...fileMatches];
});
function runFileSearch() {
if (searchTimer) clearTimeout(searchTimer);
const q = query.trim();
if (q.length < 2) {
fileMatches = [];
return;
}
searchTimer = setTimeout(async () => {
try {
const r = await searchFiles(q, { recursive: true, limit: 5 });
const folders: Command[] = r.folders.slice(0, 3).map((f) => ({
id: `fld-${f.id}`,
label: f.name,
icon: 'folder',
hint: t('files.folder', 'Folder'),
run: nav(`/files/${f.id}`)
}));
const files: Command[] = r.files.slice(0, 5).map((f) => ({
id: `fil-${f.id}`,
label: f.name,
icon: 'file',
hint: t('files.file', 'File'),
run: () => {
close();
window.open(fileInlineUrl(f.id), '_blank', 'noopener');
}
}));
fileMatches = [...folders, ...files];
} catch {
fileMatches = [];
}
}, 250);
}
function scrollActiveIntoView() {
queueMicrotask(() => {
listEl?.querySelector('.cmdk__item.active')?.scrollIntoView({ block: 'nearest' });
});
}
function onGlobalKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
if (open) {
close();
} else {
prevFocus = document.activeElement as HTMLElement | null;
open = true;
requestAnimationFrame(() => (entered = true));
queueMicrotask(() => input?.focus());
}
} else if (open && e.key === 'Escape') {
close();
}
}
function onListKey(e: KeyboardEvent) {
const items = filtered;
if (e.key === 'ArrowDown') {
e.preventDefault();
index = Math.min(index + 1, items.length - 1);
scrollActiveIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
index = Math.max(index - 1, 0);
scrollActiveIntoView();
} else if (e.key === 'Enter') {
e.preventDefault();
items[index]?.run();
}
}
$effect(() => {
void query;
index = 0;
runFileSearch();
});
</script>
<svelte:window onkeydown={onGlobalKey} />
{#if open}
<div
class="cmdk"
class:active={entered}
role="presentation"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div
class="cmdk__panel"
role="dialog"
aria-modal="true"
aria-label={t('cmdk.title', 'Command palette')}
>
<div class="cmdk__search">
<Icon name="search" />
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={input}
bind:value={query}
onkeydown={onListKey}
placeholder={t('cmdk.placeholder', 'Type a command or search…')}
autocomplete="off"
autofocus
/>
</div>
{#if filtered.length === 0}
<p class="cmdk__empty">{t('cmdk.no_results', 'No matching commands')}</p>
{:else}
<ul class="cmdk__list" role="listbox" bind:this={listEl}>
{#each filtered as cmd, i (cmd.id)}
<li>
<button
class="cmdk__item"
class:active={i === index}
role="option"
aria-selected={i === index}
onmouseenter={() => (index = i)}
onclick={cmd.run}
>
<Icon name={cmd.icon} />
<span class="cmdk__label">{cmd.label}</span>
{#if cmd.hint}<span class="cmdk__hint">{cmd.hint}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
<style>
.cmdk {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--color-overlay, var(--color-overlay-light));
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
opacity: 0;
transition: opacity var(--motion-base) var(--ease-standard);
}
.cmdk.active {
opacity: 1;
}
.cmdk__panel {
width: min(560px, 92vw);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-2xl);
overflow: hidden;
transform: translateY(-8px) scale(0.98);
transition: transform var(--motion-base) var(--ease-standard);
}
.cmdk.active .cmdk__panel {
transform: none;
}
.cmdk__search {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.cmdk__search input {
flex: 1;
border: none;
background: none;
color: var(--color-text);
font-size: 1rem;
outline: none;
}
.cmdk__list {
list-style: none;
margin: 0;
padding: 0.25rem;
max-height: 50vh;
overflow: auto;
}
.cmdk__item {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.55rem 0.7rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
text-align: left;
border-radius: var(--radius-sm);
}
.cmdk__item.active {
background: var(--color-accent-bg-sm);
}
.cmdk__item.active :global(.oxi-icon) {
color: var(--color-accent);
}
.cmdk__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmdk__hint {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-muted);
}
.cmdk__empty {
padding: 1.5rem;
text-align: center;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,118 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
// Local input value for prompt dialogs; reset whenever a new prompt opens.
let value = $state('');
let lastId: object | null = null;
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
const c = dialogs.current;
if (c && c !== lastId) {
lastId = c;
value = c.kind === 'prompt' ? (c.opts.defaultValue ?? '') : '';
if (c.kind === 'prompt' && c.opts.selectOnOpen) {
const select = c.opts.selectOnOpen;
requestAnimationFrame(() => {
const el = inputEl;
if (!el) return;
el.focus();
if (select === 'name') {
// Select the filename stem only — leave the extension
// untouched so a rename replaces just the name.
const dot = value.lastIndexOf('.');
const end = dot > 0 ? dot : value.length;
el.setSelectionRange(0, end);
} else {
el.select();
}
});
}
}
});
const open = $derived(dialogs.current !== null);
function submit(e?: SubmitEvent) {
e?.preventDefault();
const c = dialogs.current;
if (!c || dialogs.busy) return;
// `resolve` runs any async action and keeps the dialog open on failure.
if (c.kind === 'prompt') void dialogs.resolve(value);
else void dialogs.resolve(true);
}
</script>
{#if dialogs.current}
{@const c = dialogs.current}
<Modal {open} title={c.opts.title} onclose={() => dialogs.cancel()}>
{#if c.kind === 'prompt'}
<form id="dialog-form" onsubmit={submit}>
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
<input
class="dlg-input"
type="text"
bind:this={inputEl}
bind:value
placeholder={c.opts.placeholder ?? ''}
autocomplete="off"
disabled={dialogs.busy}
/>
</form>
{:else if c.opts.message}
<p class="dlg-msg">{c.opts.message}</p>
{/if}
{#if dialogs.error}
<p class="dlg-error" role="alert">{dialogs.error}</p>
{/if}
{#snippet footer()}
<button class="btn btn-secondary" disabled={dialogs.busy} onclick={() => dialogs.cancel()}>
{c.opts.cancelText ?? t('common.cancel', 'Cancel')}
</button>
{#if c.kind === 'prompt'}
<button class="btn btn-primary" type="submit" form="dialog-form" disabled={dialogs.busy}>
{dialogs.busy
? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
</button>
{:else}
<button
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
disabled={dialogs.busy}
onclick={() => dialogs.resolve(true)}
>
{dialogs.busy
? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
</button>
{/if}
{/snippet}
</Modal>
{/if}
<style>
.dlg-msg {
margin: 0 0 var(--space-3);
color: var(--color-text);
}
.dlg-input {
width: 100%;
padding: var(--space-2-5) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-base);
}
.dlg-error {
margin: var(--space-3) 0 0;
color: var(--color-danger-text);
font-size: var(--text-sm);
}
</style>
@@ -1,79 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
interface Props {
name: string;
iconClass?: string;
subtitle?: string;
date?: string;
actions?: Snippet;
}
let { name, iconClass, subtitle, date, actions }: Props = $props();
</script>
<li class="row">
<span class="row__icon"><Icon name={iconNameFromClass(iconClass)} /></span>
<span class="row__main">
<span class="row__name" title={name}>{name}</span>
{#if subtitle}<span class="row__sub" title={subtitle}>{subtitle}</span>{/if}
</span>
{#if date}<span class="row__date">{date}</span>{/if}
{#if actions}<span class="row__actions">{@render actions()}</span>{/if}
</li>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
}
.row:hover {
background: var(--color-bg-hover);
}
.row__icon {
font-size: 1.25rem;
color: var(--color-text-muted);
flex: none;
}
.row__main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.row__sub {
font-size: 0.8125rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row__date {
font-size: 0.8125rem;
color: var(--color-text-muted);
flex: none;
}
.row__actions {
display: flex;
gap: 0.25rem;
flex: none;
}
</style>
@@ -0,0 +1,401 @@
<script lang="ts">
import { apiFetch } from '$lib/api/client';
import { fileDownloadUrl, fileInlineUrl } from '$lib/api/endpoints/files';
import { canEditWithWopi } from '$lib/api/endpoints/wopi';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import WopiEditor from '$lib/components/WopiEditor.svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
open: boolean;
file: FileItem | null;
/** Emitted when the viewer (or its embedded editor) closes, so the
* consumer can refresh the file list to pick up saves. */
onrefresh?: () => void;
}
let { open = $bindable(false), file, onrefresh }: Props = $props();
type Kind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'other';
const IMAGE_EXTS = [
'jpg',
'jpeg',
'png',
'gif',
'svg',
'webp',
'bmp',
'ico',
'heic',
'heif',
'avif',
'tiff'
];
let textContent = $state('');
let textLoading = $state(false);
let wopiOpen = $state(false);
let canEdit = $state(false);
/** Image zoom factor (1 = fit). */
let zoom = $state(1);
/** PDF embed fallback engaged when the <object> stays blank ~2s. */
let pdfFallback = $state(false);
let pdfObjectEl = $state<HTMLObjectElement | null>(null);
function isImage(f: FileItem): boolean {
const m = (f.mime_type ?? '').toLowerCase();
const ext = (f.name || '').split('.').pop()?.toLowerCase() ?? '';
return m.startsWith('image/') || IMAGE_EXTS.includes(ext);
}
function kindOf(f: FileItem): Kind {
const m = (f.mime_type ?? '').toLowerCase();
if (isImage(f)) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m === 'application/pdf') return 'pdf';
if (
m.startsWith('text/') ||
m === 'application/json' ||
m === 'application/xml' ||
m === 'application/javascript'
)
return 'text';
return 'other';
}
const kind = $derived(file ? kindOf(file) : 'other');
function close() {
open = false;
textContent = '';
zoom = 1;
pdfFallback = false;
onrefresh?.();
}
function onKeydown(e: KeyboardEvent) {
if (open && !wopiOpen && e.key === 'Escape') close();
}
function zoomBy(factor: number) {
zoom = Math.max(0.1, Math.min(5, zoom * factor));
}
function resetZoom() {
zoom = 1;
}
// Load text content + decide editability/auto-open whenever the file changes.
$effect(() => {
if (!open || !file) return;
const f = file;
canEdit = false;
zoom = 1;
pdfFallback = false;
const k = kindOf(f);
// Office docs (WOPI-editable, non-image) open straight in the editor
// rather than showing "No preview available" with an extra Edit click.
// Images never route through WOPI even if an editor claims the ext.
if (k === 'other' && !isImage(f)) {
void canEditWithWopi(f.name).then((v) => {
canEdit = v;
if (v && file === f && open) wopiOpen = true;
});
} else {
void canEditWithWopi(f.name).then((v) => (canEdit = v));
}
if (k === 'text') {
textLoading = true;
textContent = '';
apiFetch(fileInlineUrl(f.id), { credentials: 'same-origin' })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((txt) => (textContent = txt.slice(0, 500_000)))
.catch(() => (textContent = t('files.preview_failed', 'Could not load preview.')))
.finally(() => (textLoading = false));
}
// PDF blank-render guard: if the <object> shows nothing after ~2s,
// fall back to an <embed> (some browsers refuse <object> for PDFs).
if (k === 'pdf') {
const timer = setTimeout(() => {
const el = pdfObjectEl;
let blank = false;
try {
const doc = el?.contentDocument;
blank = !doc || doc.body?.innerHTML === '';
} catch {
blank = false; // cross-origin: assume it rendered
}
if (blank) pdfFallback = true;
}, 2000);
return () => clearTimeout(timer);
}
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if open && file}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fv"
role="dialog"
aria-modal="true"
aria-label={file.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div class="fv__panel">
<header class="fv__bar">
<span class="fv__title">{file.name}</span>
<div class="fv__actions">
{#if kind === 'image'}
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_out', 'Zoom out')}
aria-label={t('viewer.zoom_out', 'Zoom out')}
onclick={() => zoomBy(0.8)}
>
<Icon name="search-minus" />
</button>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_reset', 'Reset zoom')}
aria-label={t('viewer.zoom_reset', 'Reset zoom')}
onclick={resetZoom}
>
<Icon name="expand" />
</button>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_in', 'Zoom in')}
aria-label={t('viewer.zoom_in', 'Zoom in')}
onclick={() => zoomBy(1.2)}
>
<Icon name="search-plus" />
</button>
</div>
{/if}
{#if canEdit}
<button class="btn btn-primary btn-sm" onclick={() => (wopiOpen = true)}>
<Icon name="pen" />
{t('files.edit', 'Edit')}
</button>
{/if}
<a class="btn btn-secondary btn-sm" href={fileDownloadUrl(file.id)} download>
<Icon name="download" />
{t('common.download', 'Download')}
</a>
<a
class="btn btn-secondary btn-sm"
href={fileInlineUrl(file.id)}
target="_blank"
rel="noreferrer"
>
<Icon name="external-link-alt" />
</a>
<button class="fv__close" aria-label={t('common.close', 'Close')} onclick={close}>
<Icon name="times" />
</button>
</div>
</header>
<div class="fv__body">
{#if kind === 'image'}
<img
class="fv__media fv__image"
src={fileInlineUrl(file.id)}
alt={file.name}
style:transform="scale({zoom})"
/>
{:else if kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="fv__media" src={fileInlineUrl(file.id)} controls preload="metadata"></video>
{:else if kind === 'audio'}
<audio class="fv__audio" src={fileInlineUrl(file.id)} controls></audio>
{:else if kind === 'pdf'}
{#if pdfFallback}
<embed class="fv__pdf" src={fileInlineUrl(file.id)} type="application/pdf" />
{:else}
<object
bind:this={pdfObjectEl}
class="fv__pdf"
data={fileInlineUrl(file.id)}
type="application/pdf"
title={file.name}
>
<p>{t('files.preview_failed', 'Could not load preview.')}</p>
</object>
{/if}
{:else if kind === 'text'}
{#if textLoading}
<p class="fv__status">{t('common.loading', 'Loading…')}</p>
{:else}
<pre class="fv__text">{textContent}</pre>
{/if}
{:else}
<div class="fv__status fv__status--center">
<Icon name="file" class="fv__big-icon" />
<p>{t('files.no_preview', 'No preview available for this file type.')}</p>
</div>
{/if}
</div>
</div>
</div>
<WopiEditor
bind:open={wopiOpen}
fileId={file.id}
fileName={file.name}
action="edit"
onclose={() => {
onrefresh?.();
// If the editor was auto-opened for an Office doc, closing it should
// dismiss the whole viewer (there's nothing to preview behind it).
if (kind === 'other') close();
}}
/>
{/if}
<style>
.fv {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-overlay, var(--color-overlay-heavy));
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.fv__panel {
display: flex;
flex-direction: column;
width: min(1100px, 100%);
height: min(90vh, 100%);
background: var(--color-bg-surface);
border-radius: var(--radius-lg, var(--radius-md));
overflow: hidden;
}
.fv__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--color-border);
}
.fv__title {
font-weight: var(--weight-semibold, 600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-heading);
}
.fv__actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.fv__close {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem 0.5rem;
}
.fv__zoom {
display: inline-flex;
align-items: center;
gap: 0.15rem;
margin-right: 0.3rem;
}
.fv__zoom-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.fv__zoom-btn:hover {
background: var(--color-bg-hover);
}
.fv__image {
transition: transform 0.12s ease;
}
.fv__body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
background: var(--color-bg-muted);
}
.fv__media {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.fv__audio {
width: min(600px, 90%);
}
.fv__pdf {
width: 100%;
height: 100%;
}
.fv__text {
width: 100%;
height: 100%;
margin: 0;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, monospace);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg-surface);
}
.fv__status {
color: var(--color-text-muted);
}
.fv__status--center {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
:global(.fv__big-icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,125 @@
<script lang="ts" module>
/** A group-by dimension shown in the toolbar's popup menu. */
export interface GroupOption {
key: string;
label: string;
/** Optional glyph for the menu option (defaults to the group glyph). */
icon?: string;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
interface Props {
/** Group-by dimensions; omit/empty to hide the group-by control. */
groups?: GroupOption[];
/** Active group-by key (controlled by the parent). */
groupBy?: string;
/** Whether the sort direction is reversed (controlled by the parent). */
reversed?: boolean;
/** Fired when a group-by dimension is chosen. */
ongroup?: (key: string) => void;
/** Fired when the sort-direction toggle is clicked. */
ondirection?: () => void;
/** Show the grid/list view toggle (default true). */
showViewToggle?: boolean;
/** Left-hand actions (upload/new-folder/empty-trash/batch bar, …). */
start?: Snippet;
}
let {
groups,
groupBy = '',
reversed = false,
ongroup,
ondirection,
showViewToggle = true,
start
}: Props = $props();
// The group-by button always reflects the active dimension (default = first).
const active = $derived(groups?.find((g) => g.key === groupBy) ?? groups?.[0]);
let menuOpen = $state(false);
// Close the popup on outside click.
$effect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.group-by-selector')) menuOpen = false;
};
window.addEventListener('pointerdown', onDown);
return () => window.removeEventListener('pointerdown', onDown);
});
function pick(key: string) {
menuOpen = false;
ongroup?.(key);
}
</script>
<div class="actions-bar">
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
{#if groups?.length || showViewToggle}
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
{#if groups?.length}
<div class="group-by-selector">
<button
class="toggle-btn group-by-btn active"
title={t('groupby.title', 'Group by')}
aria-haspopup="true"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<Icon name={active?.icon ?? 'layer-group'} />
<span class="group-by-label">{active?.label ?? ''}</span>
</button>
<button
class="toggle-btn sort-dir-btn"
class:active={reversed}
title={t('sortdir.title', 'Sort direction')}
aria-label={t('sort.direction', 'Sort direction')}
onclick={() => ondirection?.()}
>
<Icon name="arrow-up" />
</button>
{#if menuOpen}
<div class="group-by-menu">
{#each groups as g (g.key)}
<button
class="group-by-option"
class:active={groupBy === g.key}
onclick={() => pick(g.key)}
>
<Icon name={g.icon ?? 'layer-group'} />
{g.label}
</button>
{/each}
</div>
{/if}
</div>
{#if showViewToggle}<span class="view-toggle-separator"></span>{/if}
{/if}
{#if showViewToggle}
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'grid'}
title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
>
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'list'}
title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
>
{/if}
</div>
{/if}
</div>
+87 -3
View File
@@ -12,14 +12,60 @@
let { open = $bindable(false), title, onclose, children, footer }: Props = $props();
let dialogEl = $state<HTMLElement | null>(null);
let prevFocus: HTMLElement | null = null;
function close() {
open = false;
onclose?.();
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function focusables(): HTMLElement[] {
if (!dialogEl) return [];
return Array.from(dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
return;
}
// Focus trap: keep Tab cycling inside the dialog.
if (e.key === 'Tab') {
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
// On open: remember the previously focused element and move focus into the
// dialog. On close: restore focus so keyboard users aren't dumped at <body>.
$effect(() => {
if (open) {
prevFocus = (document.activeElement as HTMLElement | null) ?? null;
requestAnimationFrame(() => {
const items = focusables();
(items[0] ?? dialogEl)?.focus();
});
} else if (prevFocus) {
prevFocus.focus();
prevFocus = null;
}
});
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
@@ -33,7 +79,14 @@
if (e.target === e.currentTarget) close();
}}
>
<div class="modal" role="dialog" aria-modal="true" aria-label={title}>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
bind:this={dialogEl}
>
{#if title}
<header class="modal__header">
<h2 class="modal__title">{title}</h2>
@@ -62,6 +115,7 @@
justify-content: center;
z-index: 900;
padding: 1rem;
animation: modal-fade 0.16s ease;
}
.modal {
@@ -74,6 +128,7 @@
overflow: auto;
display: flex;
flex-direction: column;
animation: modal-pop 0.18s ease;
}
.modal__header {
@@ -109,4 +164,33 @@
padding: 1rem 1.25rem;
border-top: 1px solid var(--color-border);
}
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.modal__backdrop,
.modal {
animation: none;
}
}
</style>
@@ -0,0 +1,273 @@
<script lang="ts">
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
import { moveFile } from '$lib/api/endpoints/files';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
import type { FolderItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Target {
id: string;
name: string;
kind: 'file' | 'folder';
}
interface Props {
open: boolean;
item: Target | null;
/** Optional multi-item batch; takes precedence over `item`. */
items?: Target[] | null;
/** 'move' (default) relocates; 'copy' duplicates into the picked folder. */
mode?: 'move' | 'copy';
onmoved?: () => void;
}
let { open = $bindable(false), item, items = null, mode = 'move', onmoved }: Props = $props();
const targets = $derived(items && items.length ? items : item ? [item] : []);
const targetIds = $derived(new Set(targets.map((x) => x.id)));
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let folders = $state<FolderItem[]>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
let working = $state(false);
async function loadInto(id: string) {
loading = true;
try {
currentId = id;
folders = (await listFolder(id)).folders;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
loading = false;
}
}
async function init() {
const home = await session.loadHomeFolder();
if (!home) return;
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
await loadInto(home);
}
function enter(f: FolderItem) {
crumbs = [...crumbs, { id: f.id, name: f.name }];
void loadInto(f.id);
}
function gotoCrumb(index: number) {
crumbs = crumbs.slice(0, index + 1);
void loadInto(crumbs[index].id);
}
/** Jump to the home (root) folder — the first crumb. */
function goHome() {
if (crumbs.length) gotoCrumb(0);
}
/** Step up one level to the parent folder (no-op at home). */
function goParent() {
if (crumbs.length > 1) gotoCrumb(crumbs.length - 2);
}
const atHome = $derived(crumbs.length <= 1);
async function confirmMove() {
if (!targets.length || !currentId) return;
working = true;
try {
if (mode === 'copy') {
const fileIds = targets.filter((x) => x.kind === 'file').map((x) => x.id);
const folderIds = targets.filter((x) => x.kind === 'folder').map((x) => x.id);
await copyFiles(fileIds, currentId);
await copyFolders(folderIds, currentId);
ui.notify(t('files.copied', 'Copied'), 'success');
} else {
for (const tgt of targets) {
if (tgt.id === currentId) continue;
if (tgt.kind === 'file') await moveFile(tgt.id, currentId);
else await moveFolder(tgt.id, currentId);
}
ui.notify(t('files.moved', 'Moved'), 'success');
}
open = false;
onmoved?.();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
working = false;
}
}
// (Re)initialise the picker each time it opens.
$effect(() => {
if (open && targets.length) void init();
});
const moveTitle = $derived.by(() => {
if (mode === 'copy') {
return targets.length > 1
? t('files.copy_n', { n: targets.length }, 'Copy {{n}} items')
: t('files.copy_title', { name: targets[0]?.name ?? '' }, 'Copy “{{name}}”');
}
return targets.length > 1
? t('files.move_n', { n: targets.length }, 'Move {{n}} items')
: t('files.move_title', { name: targets[0]?.name ?? '' }, 'Move “{{name}}”');
});
</script>
<Modal bind:open title={moveTitle}>
<div class="mv-nav">
<button
class="mv-nav-btn"
title={t('breadcrumb.home', 'Home')}
aria-label={t('breadcrumb.home', 'Home')}
disabled={atHome}
onclick={goHome}><Icon name="home" /></button
>
<button
class="mv-nav-btn"
title={t('dialogs.go_to_parent', 'Go to parent')}
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
disabled={atHome}
onclick={goParent}><Icon name="level-up-alt" /></button
>
<nav class="mv-crumbs" aria-label="Breadcrumb">
{#each crumbs as c, i (c.id)}
{#if i > 0}<span class="mv-sep">/</span>{/if}
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button>
{/each}
</nav>
</div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button class="mv-folder" disabled={targetIds.has(f.id)} onclick={() => enter(f)}>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
{t('common.cancel', 'Cancel')}
</button>
<button class="btn btn-primary" disabled={working || !currentId} onclick={confirmMove}>
{mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')}
</button>
{/snippet}
</Modal>
<style>
.mv-nav {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.mv-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
cursor: pointer;
flex: none;
}
.mv-nav-btn:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.mv-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mv-crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.mv-crumb {
background: none;
border: none;
color: var(--color-accent-text, var(--color-primary));
cursor: pointer;
padding: 0.125rem 0.25rem;
}
.mv-sep {
color: var(--color-text-muted);
}
.mv-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 50vh;
overflow: auto;
}
.mv-folder {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
text-align: left;
}
.mv-folder:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.mv-folder:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mv-folder span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.mv-enter) {
color: var(--color-text-muted);
}
.mv-status {
color: var(--color-text-muted);
padding: 1rem 0;
text-align: center;
}
</style>
@@ -0,0 +1,709 @@
<script lang="ts" module>
import type { ItemType } from '$lib/api/types';
/** Normalised row passed to ResourceList; views map their items to this. */
export interface ResourceEntry {
id: string;
name: string;
kind: ItemType;
iconClass?: string;
path?: string | null;
size?: number | null;
date?: number | string | null;
typeLabel?: string;
/** Owner user id — enables the owner column + vignette when `showOwner`. */
ownerId?: string | null;
/** Owner display name (resolved by the page). */
ownerName?: string | null;
/** Per-entry favorite state for the star-toggle widget. */
isFavorite?: boolean;
/** Stable category key (Folder / Image / …) used by the `type` group-by. */
category?: string;
/** Modified timestamp (epoch seconds/ms or ISO) for the `modifiedAt` group-by. */
modifiedAt?: number | string | null;
}
/**
* A group-by ("swimlane") dimension a page can offer. `orderBy` is sent to the
* API; the optional `bucketOf` maps an entry to a section key, and `labelOf`
* maps that key to a header label. Omitting `bucketOf` means a flat list.
*/
export interface GroupByDef {
key: string;
label: string;
orderBy: string;
/** Optional icon for the dropdown option (defaults to the group glyph). */
icon?: string;
bucketOf?: (entry: ResourceEntry) => string | null;
labelOf?: (bucketKey: string) => string;
}
/** A right-click / overflow context-menu action. */
export interface ContextAction {
key: string;
label: string;
icon: string;
danger?: boolean;
run: (entry: ResourceEntry) => void;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
interface Props {
title: string;
items: ResourceEntry[];
loading?: boolean;
error?: string | null;
/** Empty-state primary line. */
emptyText?: string;
/** Empty-state secondary hint line. */
emptyHint?: string;
/** Empty-state icon-registry name (e.g. "star", "clock", "trash"). */
emptyIcon?: string;
hasMore?: boolean;
onloadmore?: () => void;
/** Show the path/location column (list view only). */
showPath?: boolean;
/** Override the path column header label (e.g. trash → "Original location"). */
pathLabel?: string;
showSize?: boolean;
showType?: boolean;
showDate?: boolean;
/** Override the date column header label (e.g. trash → "Remaining"). */
dateLabel?: string;
/** Custom renderer for the date cell (e.g. trash expiry chip). */
dateCell?: Snippet<[ResourceEntry]>;
/** Show the owner column + vignette (list view) and hover tooltip. */
showOwner?: boolean;
/** Allow grid/list toggle (shares the app-wide view mode). */
showViewToggle?: boolean;
/** Multi-select checkboxes + selection model. */
selectable?: boolean;
/** Right-click / overflow context-menu actions. */
contextActions?: ContextAction[];
/** Group-by dimensions; when provided, a swimlane selector is shown. */
groupBys?: GroupByDef[];
/** Active group-by key (bind:groupBy from the page). */
groupBy?: string;
/** Reverse sort toggle state (bind:reversed from the page). */
reversed?: boolean;
/** Called when group-by or direction changes; page should reload page 1. */
onreload?: (orderBy: string, reversed: boolean) => void;
onopen?: (entry: ResourceEntry) => void;
/** Per-entry favorite star toggle. */
onfavorite?: (entry: ResourceEntry) => void;
/** Selection changed (set of selected entry ids). */
onselectionchange?: (ids: Set<string>) => void;
actions?: Snippet<[ResourceEntry]>;
toolbar?: Snippet;
/** Batch toolbar shown when items are selected; receives selected entries. */
batchToolbar?: Snippet<[ResourceEntry[]]>;
}
let {
title,
items,
loading = false,
error = null,
emptyText,
emptyHint,
emptyIcon,
hasMore = false,
onloadmore,
showPath = true,
pathLabel,
showSize = true,
showType = false,
showDate = true,
dateLabel,
dateCell,
showOwner = false,
showViewToggle = true,
selectable = false,
contextActions,
groupBys,
groupBy = $bindable(''),
reversed = $bindable(false),
onreload,
onopen,
onfavorite,
onselectionchange,
actions,
toolbar,
batchToolbar
}: Props = $props();
const isEmpty = $derived(items.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
// Build the list-view column track from the enabled cells.
const columns = $derived(
[
selectable ? '36px' : '',
'minmax(200px, 2fr)',
showOwner ? 'minmax(120px, 1fr)' : '',
showPath ? 'minmax(140px, 1.5fr)' : '',
showType ? '120px' : '',
showSize ? '110px' : '',
showDate ? '160px' : '',
actions ? '120px' : ''
]
.filter(Boolean)
.join(' ')
);
const SKELETON = [0, 1, 2, 3, 4, 5];
// ── Group-by / direction ──────────────────────────────────────────────────
const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy));
function selectGroup(key: string) {
if (groupBy === key) return;
groupBy = key;
const def = groupBys?.find((g) => g.key === key);
onreload?.(def?.orderBy ?? 'name', reversed);
}
function toggleDirection() {
reversed = !reversed;
onreload?.(activeGroup?.orderBy ?? 'name', reversed);
}
/**
* Partition the visible items into grouped sections when a `bucketOf` is
* active. Server order is preserved within and across buckets (first-seen).
*/
const sections = $derived.by((): Array<{ key: string; label: string; rows: ResourceEntry[] }> => {
const bucketOf = activeGroup?.bucketOf;
if (!bucketOf) return [{ key: '', label: '', rows: items }];
const order: string[] = [];
const map = new Map<string, ResourceEntry[]>();
for (const entry of items) {
const k = bucketOf(entry) ?? '∅';
if (!map.has(k)) {
map.set(k, []);
order.push(k);
}
map.get(k)!.push(entry);
}
return order.map((k) => ({
key: k,
label: activeGroup?.labelOf?.(k) ?? k,
rows: map.get(k)!
}));
});
const grouped = $derived(!!activeGroup?.bucketOf);
// ── Selection ─────────────────────────────────────────────────────────────
let selected = $state<Set<string>>(new Set());
function toggleSelected(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
onselectionchange?.(next);
}
function clearSelection() {
selected = new Set();
onselectionchange?.(selected);
}
const allSelected = $derived(items.length > 0 && selected.size === items.length);
function toggleSelectAll() {
if (allSelected) clearSelection();
else {
selected = new Set(items.map((i) => i.id));
onselectionchange?.(selected);
}
}
const selectedEntries = $derived(items.filter((i) => selected.has(i.id)));
// Drop selection ids that are no longer present after a reload.
$effect(() => {
const ids = new Set(items.map((i) => i.id));
let changed = false;
const next = new Set<string>();
for (const id of selected) {
if (ids.has(id)) next.add(id);
else changed = true;
}
if (changed) {
selected = next;
onselectionchange?.(next);
}
});
// ── Right-click context menu ──────────────────────────────────────────────
let ctxOpen = $state(false);
let ctxX = $state(0);
let ctxY = $state(0);
let ctxEntry = $state<ResourceEntry | null>(null);
function openContext(e: MouseEvent, entry: ResourceEntry) {
if (!contextActions?.length) return;
e.preventDefault();
e.stopPropagation();
ctxEntry = entry;
ctxX = Math.min(e.clientX, window.innerWidth - 220);
ctxY = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24));
ctxOpen = true;
}
function closeContext() {
ctxOpen = false;
ctxEntry = null;
}
// ── Infinite scroll (IntersectionObserver) ────────────────────────────────
let sentinel = $state<HTMLElement | null>(null);
$effect(() => {
const el = sentinel;
if (!el || typeof IntersectionObserver === 'undefined') return;
const obs = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (en.isIntersecting && hasMore && !loading) onloadmore?.();
}
},
{ rootMargin: '200px' }
);
obs.observe(el);
return () => obs.disconnect();
});
function ownerTitle(entry: ResourceEntry): string {
const owner = entry.ownerName ?? entry.ownerId ?? '';
const path = entry.path ?? '';
return [
owner && `${t('files.col_owner', 'Owner')}: ${owner}`,
path && `${t('files.col_path', 'Location')}: ${path}`
]
.filter(Boolean)
.join('\n');
}
</script>
{#snippet row(entry: ResourceEntry)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="file-item"
class:file-item--selected={selectable && selected.has(entry.id)}
role={onopen ? 'button' : undefined}
tabindex={onopen ? 0 : undefined}
title={showOwner ? ownerTitle(entry) : undefined}
onclick={onopen ? () => onopen(entry) : undefined}
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
oncontextmenu={contextActions?.length ? (e) => openContext(e, entry) : undefined}
>
{#if selectable}
<div class="select-cell" role="presentation" onclick={(e) => e.stopPropagation()}>
<input
type="checkbox"
aria-label={t('common.select', 'Select')}
checked={selected.has(entry.id)}
onchange={() => toggleSelected(entry.id)}
/>
</div>
{/if}
<div class="name-cell">
<span class="file-icon">
<Icon name={entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)} />
</span>
<span class="name-cell__text">{entry.name}</span>
</div>
{#if showOwner}
<div class="owner-cell">
<span class="rl-vignette">
<span class="rl-vignette__avatar" aria-hidden="true"
>{(entry.ownerName ?? '?').slice(0, 1).toUpperCase()}</span
>
<span class="rl-vignette__name">{entry.ownerName ?? entry.ownerId ?? ''}</span>
</span>
</div>
{/if}
{#if showPath}<div class="path-cell">{entry.path ?? ''}</div>{/if}
{#if showType}<div class="type-cell">{entry.typeLabel ?? ''}</div>{/if}
{#if showSize}
<div class="size-cell">{entry.size != null ? formatBytes(entry.size) : '—'}</div>
{/if}
{#if showDate}
<div class="date-cell">
{#if dateCell}{@render dateCell(entry)}{:else}{formatDate(entry.date)}{/if}
</div>
{/if}
<div class="grid-meta">
{#if showDate && dateCell}<span class="grid-meta__chip">{@render dateCell(entry)}</span>{/if}
<span class="grid-meta__line">
{#if entry.size != null}<span class="grid-meta__size">{formatBytes(entry.size)}</span>{/if}
{#if entry.date != null}<span class="grid-meta__date">{formatDate(entry.date)}</span>{/if}
</span>
</div>
{#if onfavorite}
<button
class="rl-star"
class:rl-star--on={entry.isFavorite}
title={entry.isFavorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={!!entry.isFavorite}
onclick={(e) => {
e.stopPropagation();
onfavorite(entry);
}}><Icon name={entry.isFavorite ? 'star' : 'star-outline'} /></button
>
{/if}
{#if actions}
<div class="action-cell">{@render actions(entry)}</div>
{/if}
</div>
{/snippet}
<div class="page-sticky-header">
<h1 class="page-title">{title}</h1>
<ListToolbar
groups={groupBys}
{groupBy}
{reversed}
ongroup={selectGroup}
ondirection={toggleDirection}
{showViewToggle}
>
{#snippet start()}
<div class="action-buttons">{@render toolbar?.()}</div>
{/snippet}
</ListToolbar>
</div>
{#if selectable && selected.size > 0 && batchToolbar}
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}>
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}>
<Icon name="times" />
</button>
<span class="rl-batch__count"
>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span
>
<div class="rl-batch__actions">{@render batchToolbar(selectedEntries)}</div>
</div>
{/if}
{#if error}
<div class="empty-state">
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
<p>{error}</p>
</div>
{:else if loading && isEmpty}
<div class="files-container">
<div
class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}
>
{#each SKELETON as i (i)}
{#if filesStore.viewMode === 'grid'}
<div class="skeleton-card">
<div class="skeleton skeleton-thumb"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
{:else}
<div class="skeleton-row">
<div class="skeleton skeleton-icon"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
{/if}
{/each}
</div>
</div>
{:else if isEmpty}
<div class="empty-state">
{#if emptyIcon}<Icon name={emptyIcon} class="empty-state-icon" />{/if}
<p>{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
{#if emptyHint}<p class="empty-state__hint">{emptyHint}</p>{/if}
</div>
{:else}
<div class="files-container">
<div class={viewClass} style="--files-list-columns: {columns}">
<div class="list-header">
{#if selectable}
<div class="select-cell">
<input
type="checkbox"
aria-label={t('common.select_all', 'Select all')}
checked={allSelected}
onchange={toggleSelectAll}
/>
</div>
{/if}
<div>{t('files.col_name', 'Name')}</div>
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
{#if onfavorite || actions}<div></div>{/if}
</div>
{#if grouped}
{#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
{#each section.rows as entry (entry.id)}
{@render row(entry)}
{/each}
{/each}
{:else}
{#each items as entry (entry.id)}
{@render row(entry)}
{/each}
{/if}
</div>
{#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
<!-- Infinite-scroll sentinel: auto-loads the next page as it nears the viewport. -->
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
</div>
{/if}
{#if ctxOpen && ctxEntry && contextActions}
<div
class="rl-ctx-scrim"
role="presentation"
onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()}
></div>
<div class="rl-ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
{#each contextActions as action (action.key)}
<button
class="rl-ctx-item"
class:rl-ctx-item--danger={action.danger}
role="menuitem"
onclick={() => {
const e = ctxEntry!;
closeContext();
action.run(e);
}}
>
<Icon name={action.icon} />
{action.label}
</button>
{/each}
</div>
{/if}
<style>
.rl-more {
margin: var(--space-4) auto 0;
}
.rl-sentinel {
height: 1px;
width: 100%;
}
/* ── Batch toolbar ── */
.rl-batch {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
margin-bottom: var(--space-3);
background: var(--color-accent-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.rl-batch__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.rl-batch__close:hover {
background: var(--color-bg-hover);
}
.rl-batch__count {
font-weight: var(--weight-semibold);
color: var(--color-text);
}
.rl-batch__actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
/* ── Selection column ── */
.select-cell {
display: flex;
align-items: center;
justify-content: center;
}
.file-item--selected {
background: var(--color-accent-bg);
}
/* ── Owner vignette ── */
.owner-cell {
display: flex;
align-items: center;
min-width: 0;
}
.rl-vignette {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.rl-vignette__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-accent-bg-sm);
color: var(--color-accent-text);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
flex: none;
}
.rl-vignette__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-secondary);
}
.name-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Favorite star ── */
.rl-star {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
}
.rl-star:hover {
background: var(--color-bg-hover);
color: var(--color-text-secondary);
}
.rl-star--on {
color: var(--color-warning-text-amber);
}
/* ── Swimlane section header ── */
.rl-swimlane-header {
grid-column: 1 / -1;
padding: var(--space-3) var(--space-1) var(--space-1);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-faint);
}
/* Grid view date meta line. */
.grid-meta__line {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Grid view: overlay a custom date chip (e.g. trash expiry) on the card corner. */
:global(.files-grid-view) .grid-meta__chip {
position: absolute;
top: var(--space-2);
right: var(--space-2);
z-index: 1;
}
/* ── Context menu ── */
.rl-ctx-scrim {
position: fixed;
inset: 0;
z-index: 1000;
}
.rl-ctx-menu {
position: fixed;
z-index: 1001;
min-width: 200px;
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.rl-ctx-item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.rl-ctx-item:hover {
background: var(--color-bg-hover);
}
.rl-ctx-item--danger {
color: var(--color-danger-text);
}
.empty-state__hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* Empty/error icon lives inside the <Icon> child component's <svg>. */
.empty-state :global(.empty-state-icon) {
font-size: var(--text-5xl);
color: var(--color-text-faint);
margin-bottom: var(--space-2);
}
.empty-state :global(.empty-state-icon--error) {
color: var(--color-danger-text);
}
</style>
@@ -1,95 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
loading: boolean;
error?: string | null;
empty: boolean;
emptyText?: string;
hasMore?: boolean;
onloadmore?: () => void;
toolbar?: Snippet;
children: Snippet;
}
let {
loading,
error = null,
empty,
emptyText,
hasMore = false,
onloadmore,
toolbar,
children
}: Props = $props();
</script>
<section class="rl">
{#if toolbar}
<div class="rl__toolbar">{@render toolbar()}</div>
{/if}
{#if error}
<p class="rl__error" role="alert">{error}</p>
{:else if loading && empty}
<p class="rl__status">{t('common.loading', 'Loading…')}</p>
{:else if empty}
<p class="rl__status">{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
{:else}
<ul class="rl__list">
{@render children()}
</ul>
{#if hasMore}
<button class="rl__more" onclick={onloadmore} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
{/if}
</section>
<style>
.rl {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.rl__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rl__status,
.rl__error {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.rl__error {
color: var(--color-danger-text);
}
.rl__more {
align-self: center;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
</style>
@@ -0,0 +1,846 @@
<script lang="ts">
import {
copyShareLink,
createShare,
deleteShare,
listSharesForItem,
updateShare
} from '$lib/api/endpoints/shares';
import {
createGrant,
expiryToIso,
fetchGrantsForResource,
notifyGrantRecipient,
revokeGrant,
roleFromPermissions,
updateGrantRole,
type Grant,
type GrantSubject,
type GrantSubjectInput,
type NotifyOutcome,
type ShareRole
} from '$lib/api/endpoints/grants';
import {
ensureResolvers,
isDirectoryAvailable,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import type { ItemType, ShareItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Target {
id: string;
name: string;
kind: ItemType;
}
interface Props {
open: boolean;
item: Target | null;
}
let { open = $bindable(false), item }: Props = $props();
let tab = $state<'people' | 'link'>('people');
let directoryAvailable = $state(true);
const ROLES: { v: ShareRole; l: string; icon: string }[] = [
{ v: 'admin', l: t('share.role.canManage', 'Can manage'), icon: 'crown' },
{ v: 'editor', l: t('share.role.canEdit', 'Can edit'), icon: 'pencil-alt' },
{ v: 'viewer', l: t('share.role.canView', 'Can view'), icon: 'eye' }
];
const ROLE_ORDER: ShareRole[] = ['admin', 'editor', 'viewer'];
function roleLabel(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.l ?? r;
}
function roleIcon(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.icon ?? 'eye';
}
// ── People / grants ──────────────────────────────────────────────────────
interface Member {
subject: GrantSubject;
recipient: Recipient;
role: ShareRole;
grantIds: string[];
/** Representative grant id for notify (any grant on this subject). */
notifyGrantId?: string;
expiry: string | null; // YYYY-MM-DD or null
isExternal: boolean;
}
let members = $state<Member[]>([]);
let grantsLoading = $state(false);
let query = $state('');
let results = $state<Recipient[]>([]);
let newRole = $state<ShareRole>('viewer');
let newExpiry = $state<string | null>(null);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function isoToDate(iso: string | null | undefined): string | null {
return iso ? String(iso).slice(0, 10) : null;
}
function groupGrants(grants: Grant[]): Member[] {
const bySubject = new Map<
string,
{ subject: GrantSubject; perms: string[]; ids: string[]; expiry: string | null }
>();
for (const g of grants) {
if (g.subject.type === 'token') continue;
const key = `${g.subject.type}:${g.subject.id}`;
const entry = bySubject.get(key) ?? { subject: g.subject, perms: [], ids: [], expiry: null };
entry.perms.push(g.permission);
entry.ids.push(g.id);
if (g.expires_at && !entry.expiry) entry.expiry = isoToDate(g.expires_at);
bySubject.set(key, entry);
}
return [...bySubject.values()].map((e) => ({
subject: e.subject,
recipient: resolveRecipient(e.subject.type as 'user' | 'group', e.subject.id),
role: roleFromPermissions(e.perms),
grantIds: e.ids,
notifyGrantId: e.ids[0],
expiry: e.expiry,
isExternal: false
}));
}
async function loadGrants() {
if (!item) return;
grantsLoading = true;
try {
await ensureResolvers();
directoryAvailable = isDirectoryAvailable();
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
grantsLoading = false;
}
}
function onQueryInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
const existing = new Set(members.map((m) => `${m.subject.type}:${m.subject.id}`));
results = (await searchRecipients(query)).filter(
(r) => !existing.has(`${r.type === 'email' ? 'user' : r.type}:${r.id}`)
);
}, 200);
}
function subjectInput(r: Recipient): GrantSubjectInput {
if (r.type === 'email') return { type: 'email', email: r.id };
return { type: r.type, id: r.id };
}
async function addRecipient(r: Recipient) {
if (!item) return;
try {
const res = await createGrant(
subjectInput(r),
{ type: item.kind, id: item.id },
newRole,
expiryToIso(newExpiry)
);
query = '';
results = [];
summarizeNotifications(res.notification.outcomes);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeRole(m: Member, role: ShareRole) {
if (!item || role === m.role) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
role,
expiryToIso(m.expiry)
);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeMemberExpiry(m: Member, expiry: string | null) {
if (!item) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
m.role,
expiryToIso(expiry)
);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeMember(m: Member) {
try {
for (const id of m.grantIds) await revokeGrant(id);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function notifyMember(m: Member) {
if (!m.notifyGrantId) return;
try {
const set = await notifyGrantRecipient(m.notifyGrantId);
summarizeNotifications(set.outcomes);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
/** Aggregate notification outcomes into a single toast (mirrors OLD _surfaceNotifySummary). */
function summarizeNotifications(outcomes: NotifyOutcome[]) {
if (!outcomes || outcomes.length === 0) return;
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length;
const skipped = outcomes.filter((o) => o.kind === 'not_applicable').length;
const lines: string[] = [];
if (sent > 0) lines.push(t('share.notify.sent', { n: sent }, '{{n}} notified by email.'));
if (coalesced > 0)
lines.push(t('share.notify.coalesced', { n: coalesced }, '{{n}} already notified recently.'));
if (rateLimited > 0)
lines.push(
t('share.notify.rateLimited', { n: rateLimited }, '{{n}} hit the rate limit — try later.')
);
if (skipped > 0)
lines.push(
t('share.notify.skipped', { n: skipped }, '{{n}} skipped (no email / opted out).')
);
if (lines.length === 0) return;
const onlySent = coalesced === 0 && rateLimited === 0 && skipped === 0;
ui.notify(lines.join(' '), onlySent ? 'success' : 'info');
}
// Members grouped by role, highest privilege first.
const memberGroups = $derived(
ROLE_ORDER.map((role) => ({
role,
members: members.filter((m) => m.role === role)
})).filter((g) => g.members.length > 0)
);
// ── Public link ──────────────────────────────────────────────────────────
let shares = $state<ShareItem[]>([]);
let linkLoading = $state(false);
let creating = $state(false);
let newLinkName = $state('');
let password = $state('');
let expiresAt = $state<string | null>(null);
async function loadShares() {
if (!item) return;
linkLoading = true;
try {
shares = await listSharesForItem(item.id, item.kind);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
linkLoading = false;
}
}
async function createLink() {
if (!item) return;
creating = true;
try {
await createShare({
itemId: item.id,
itemName: newLinkName.trim() || item.name,
itemType: item.kind,
password: password || null,
expiresAt: expiresAt || null
});
newLinkName = '';
password = '';
expiresAt = null;
await loadShares();
ui.notify(t('share.created', 'Public link created'), 'success');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
creating = false;
}
}
async function editLinkExpiry(share: ShareItem, expiry: string | null) {
try {
await updateShare(share.id, { expiresAt: expiry });
await loadShares();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function editLinkPassword(share: ShareItem, pw: string | null) {
try {
await updateShare(share.id, { password: pw });
await loadShares();
ui.notify(
pw
? t('share.password_set', 'Password updated')
: t('share.password_cleared', 'Password removed'),
'success'
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeLink(share: ShareItem) {
try {
await deleteShare(share.id);
shares = shares.filter((s) => s.id !== share.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function copy(url: string) {
if (await copyShareLink(url)) ui.notify(t('share.copied', 'Link copied'), 'success');
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
}
function shareExpiryIso(s: ShareItem): string | null {
return s.expires_at ? new Date(s.expires_at * 1000).toISOString().slice(0, 10) : null;
}
$effect(() => {
if (open && item) {
void loadGrants();
void loadShares();
}
});
</script>
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
<span class="chip-edit">
{#if value}
<input
class="chip-edit__date"
type="date"
value={value ?? ''}
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.expiry', 'Expiry')}
/>
<button
class="chip-edit__clear"
title={t('actions.clear', 'Clear')}
onclick={() => onchange(null)}
aria-label={t('actions.clear', 'Clear')}>×</button
>
{:else}
<label class="chip chip--ghost">
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
<input
class="chip-edit__date chip-edit__date--hidden"
type="date"
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.set_expiry', 'Set expiry')}
/>
</label>
{/if}
</span>
{/snippet}
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
{t('share.people', 'People')}
</button>
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
{t('share.public_link', 'Public link')}
</button>
</div>
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}
<p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')}
</p>
{:else}
<div class="add-row">
<div class="search">
<input
placeholder={t('share.add_people', 'Add people, groups, or email…')}
bind:value={query}
oninput={onQueryInput}
autocomplete="off"
/>
{#if results.length > 0}
<ul class="results">
{#each results as r (r.type + r.id)}
<li>
<button class="result" onclick={() => addRecipient(r)}>
<Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<select class="role-select" bind:value={newRole} aria-label={t('share.role', 'Role')}>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
<Icon name={m.subject.type === 'group' ? 'user-group' : 'user'} />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
</div>
{/each}
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input type="text" bind:value={newLinkName} autocomplete="off" />
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input type="text" bind:value={password} autocomplete="off" />
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
{t('common.close', 'Close')}
</button>
{/snippet}
</Modal>
<style>
.tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.tabs button {
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-accent);
}
.add-row {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 12rem;
}
.search input,
.role-select,
.sh-fields input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.search input {
width: 100%;
}
.results {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 10;
list-style: none;
margin: var(--space-1) 0 0;
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 14rem;
overflow: auto;
}
.result {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
}
.result:hover {
background: var(--color-bg-hover);
}
.result__label {
flex: 1;
}
.result__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.member-group {
margin-bottom: var(--space-3);
}
.member-group__header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: var(--weight-semibold, 600);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.member-group__badge {
min-width: 1.25rem;
text-align: center;
padding: 0 var(--space-1);
border-radius: var(--radius-pill, 999px);
background: var(--color-bg-muted);
color: var(--color-text-muted);
font-size: var(--text-xs, 0.75rem);
}
.members,
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.member {
display: flex;
align-items: center;
gap: var(--space-2);
}
.member--expired {
opacity: 0.6;
}
.member__label {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sh-fields {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.sh-fields label {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 8rem;
font-size: var(--text-sm);
}
.link-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.link-row__title {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
overflow: hidden;
}
.link-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
color: var(--color-text-muted);
padding: var(--space-3) 0;
}
.status--note {
font-style: italic;
}
.btn-action--delete:hover {
color: var(--color-danger-text);
}
.btn-action--on {
color: var(--color-accent);
}
/* ── Expiry chip ─────────────────────────────────────────────────────── */
.chip-edit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
position: relative;
}
.chip--ghost {
border-style: dashed;
color: var(--color-text-muted);
}
.chip-edit__date {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-sm);
}
.chip-edit__date--hidden {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.chip-edit__clear {
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-md, 1rem);
line-height: 1;
}
/* ── Loading skeleton ────────────────────────────────────────────────── */
.skeleton {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) 0;
}
.skeleton__line {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-muted) 25%,
var(--color-bg-hover) 37%,
var(--color-bg-muted) 63%
);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
.skeleton__line--short {
width: 40%;
}
.skeleton__line--medium {
width: 65%;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
</style>
+7 -2
View File
@@ -16,13 +16,17 @@
<style>
.toaster {
position: fixed;
bottom: 1rem;
/* Offset clears any bottom-right FAB the file view may mount; the
--toaster-offset hook lets a page lift the stack further if needed. */
bottom: calc(1rem + env(safe-area-inset-bottom, 0px) + var(--toaster-offset, 0px));
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
z-index: 1200;
max-width: min(92vw, 24rem);
/* Let clicks pass through the gaps; individual toasts re-enable below. */
pointer-events: none;
}
.toast {
@@ -35,6 +39,7 @@
color: var(--color-text);
box-shadow: var(--shadow-md);
border-left: 4px solid var(--color-border);
pointer-events: auto;
}
.toast--success {
@@ -0,0 +1,173 @@
<script lang="ts">
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Props {
open: boolean;
fileId: string | null;
fileName: string;
action?: 'edit' | 'view';
onclose?: () => void;
}
let { open = $bindable(false), fileId, fileName, action = 'edit', onclose }: Props = $props();
let form = $state<HTMLFormElement | null>(null);
let editorUrl = $state('');
let token = $state('');
let tokenTtl = $state('');
let loading = $state(false);
function close() {
open = false;
editorUrl = '';
onclose?.();
}
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') close();
}
// The editor iframe posts status messages (Collabora / OnlyOffice WOPI
// protocol). We drop the spinner once it loads, and close the host modal
// when the editor's own close button fires UI_Close / Document close.
function onMessage(e: MessageEvent) {
if (!open) return;
let data: Record<string, unknown>;
try {
data = JSON.parse(typeof e.data === 'string' ? e.data : '') as Record<string, unknown>;
} catch {
return; // not a JSON message — ignore
}
const msgId = String(data.MessageId ?? data.messageId ?? '');
if (msgId === 'UI_Close' || msgId === 'close') {
close();
} else if (msgId === 'App_LoadingStatus') {
const values = data.Values as { Status?: string } | undefined;
const status = values?.Status;
if (status === 'Document_Loaded' || status === 'Frame_Ready') {
loading = false;
}
}
}
// When opened, fetch the editor URL + token, then submit the (hidden) form
// into the iframe — this is the WOPI host-page POST handshake.
$effect(() => {
if (!open || !fileId) return;
loading = true;
editorUrl = '';
getEditorUrlWithFallback(fileId, fileName, action)
.then((data) => {
editorUrl = data.editor_url;
token = data.access_token;
tokenTtl = String(data.access_token_ttl);
// Submit on the next microtask once the form has the values bound.
queueMicrotask(() => form?.submit());
})
.catch((e) => {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
close();
})
.finally(() => (loading = false));
});
</script>
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
{#if open}
<div class="wopi" role="dialog" aria-modal="true" aria-label={fileName}>
<header class="wopi__bar">
<span class="wopi__title">{fileName}</span>
<button class="wopi__close" aria-label={t('common.close', 'Close')} onclick={close}>
<Icon name="times" />
</button>
</header>
<div class="wopi__frame-wrap">
{#if loading}
<p class="wopi__status">{t('common.loading', 'Loading…')}</p>
{/if}
{#if editorUrl}
<form
bind:this={form}
action={editorUrl}
method="post"
target="wopi_frame"
class="wopi__form"
>
<input type="hidden" name="access_token" value={token} />
<input type="hidden" name="access_token_ttl" value={tokenTtl} />
</form>
{/if}
<iframe
name="wopi_frame"
title={t('files.editor', 'Document editor')}
class="wopi__frame"
allow="clipboard-read; clipboard-write"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox"
></iframe>
</div>
</div>
{/if}
<style>
.wopi {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
background: var(--color-bg-base, var(--color-bg-surface));
}
.wopi__bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 1rem;
background: var(--color-bg-elevated, var(--color-bg-surface));
border-bottom: 1px solid var(--color-border);
color: var(--color-text-heading);
}
.wopi__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wopi__close {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
}
.wopi__frame-wrap {
position: relative;
flex: 1;
}
.wopi__form {
display: none;
}
.wopi__frame {
width: 100%;
height: 100%;
border: none;
}
.wopi__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
}
</style>