visual continunity
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user