svelte refactor
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
|
||||||
|
type Variant = 'primary' | 'secondary' | 'danger';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Visual style → `.btn-{variant}` (default `secondary`). */
|
||||||
|
variant?: Variant;
|
||||||
|
/** Optional leading icon-registry name. */
|
||||||
|
icon?: string;
|
||||||
|
/** Compact size → adds `.btn-sm`. */
|
||||||
|
small?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
onclick?: (e: MouseEvent) => void;
|
||||||
|
/** Extra classes appended after the base `.btn` classes. */
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = 'secondary',
|
||||||
|
icon,
|
||||||
|
small = false,
|
||||||
|
type = 'button',
|
||||||
|
disabled = false,
|
||||||
|
title,
|
||||||
|
onclick,
|
||||||
|
class: cls = '',
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const className = $derived(
|
||||||
|
['btn', `btn-${variant}`, small ? 'btn-sm' : '', cls].filter(Boolean).join(' ')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class={className} {type} {disabled} {title} {onclick}>
|
||||||
|
{#if icon}<Icon name={icon} />{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Icon-registry name shown above the title (omit for a text-only state). */
|
||||||
|
icon?: string;
|
||||||
|
/** Primary line. */
|
||||||
|
title?: string;
|
||||||
|
/** Secondary explanatory line. */
|
||||||
|
hint?: string;
|
||||||
|
/** Error styling (danger-coloured icon) + assertive `role="alert"`. */
|
||||||
|
error?: boolean;
|
||||||
|
/** Extra content (e.g. a call-to-action button) rendered below the hint. */
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon, title, hint, error = false, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="empty-state" class:empty-state--error={error} role={error ? 'alert' : undefined}>
|
||||||
|
{#if icon}<Icon name={icon} class="empty-state__icon" />{/if}
|
||||||
|
{#if title}<p class="empty-state__title">{title}</p>{/if}
|
||||||
|
{#if hint}<p class="empty-state__hint">{hint}</p>{/if}
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Layout comes from the global `.empty-state` (styles/ported/content.css);
|
||||||
|
these refine the icon/title/hint elements consistently across views. */
|
||||||
|
.empty-state :global(.empty-state__icon) {
|
||||||
|
font-size: var(--text-5xl);
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state--error :global(.empty-state__icon) {
|
||||||
|
color: var(--color-danger-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state__hint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
|
||||||
import { moveFile } from '$lib/api/endpoints/files';
|
import { moveFile } from '$lib/api/endpoints/files';
|
||||||
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
currentId = id;
|
currentId = id;
|
||||||
folders = (await listFolder(id)).folders;
|
folders = (await listFolder(id)).folders;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
open = false;
|
open = false;
|
||||||
onmoved?.();
|
onmoved?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
working = false;
|
working = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SkeletonList from '$lib/components/SkeletonList.svelte';
|
||||||
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
@@ -396,37 +398,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="empty-state">
|
<EmptyState icon="exclamation-circle" title={error} error />
|
||||||
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
{:else if loading && isEmpty}
|
{:else if loading && isEmpty}
|
||||||
<div class="files-container">
|
<SkeletonList count={SKELETON.length} />
|
||||||
<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}
|
{:else if isEmpty}
|
||||||
<div class="empty-state">
|
<EmptyState
|
||||||
{#if emptyIcon}<Icon name={emptyIcon} class="empty-state-icon" />{/if}
|
icon={emptyIcon}
|
||||||
<p>{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
|
title={emptyText ?? t('common.empty', 'Nothing here yet.')}
|
||||||
{#if emptyHint}<p class="empty-state__hint">{emptyHint}</p>{/if}
|
hint={emptyHint}
|
||||||
</div>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container">
|
||||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||||
@@ -690,20 +670,4 @@
|
|||||||
.rl-ctx-item--danger {
|
.rl-ctx-item--danger {
|
||||||
color: var(--color-danger-text);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import {
|
import {
|
||||||
copyShareLink,
|
copyShareLink,
|
||||||
createShare,
|
createShare,
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
directoryAvailable = isDirectoryAvailable();
|
directoryAvailable = isDirectoryAvailable();
|
||||||
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
|
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
grantsLoading = false;
|
grantsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -153,7 +154,7 @@
|
|||||||
summarizeNotifications(res.notification.outcomes);
|
summarizeNotifications(res.notification.outcomes);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +169,7 @@
|
|||||||
);
|
);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@
|
|||||||
);
|
);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
for (const id of m.grantIds) await revokeGrant(id);
|
for (const id of m.grantIds) await revokeGrant(id);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@
|
|||||||
const set = await notifyGrantRecipient(m.notifyGrantId);
|
const set = await notifyGrantRecipient(m.notifyGrantId);
|
||||||
summarizeNotifications(set.outcomes);
|
summarizeNotifications(set.outcomes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +253,7 @@
|
|||||||
try {
|
try {
|
||||||
shares = await listSharesForItem(item.id, item.kind);
|
shares = await listSharesForItem(item.id, item.kind);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
linkLoading = false;
|
linkLoading = false;
|
||||||
}
|
}
|
||||||
@@ -275,7 +276,7 @@
|
|||||||
await loadShares();
|
await loadShares();
|
||||||
ui.notify(t('share.created', 'Public link created'), 'success');
|
ui.notify(t('share.created', 'Public link created'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
creating = false;
|
creating = false;
|
||||||
}
|
}
|
||||||
@@ -286,7 +287,7 @@
|
|||||||
await updateShare(share.id, { expiresAt: expiry });
|
await updateShare(share.id, { expiresAt: expiry });
|
||||||
await loadShares();
|
await loadShares();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +302,7 @@
|
|||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@
|
|||||||
await deleteShare(share.id);
|
await deleteShare(share.id);
|
||||||
shares = shares.filter((s) => s.id !== share.id);
|
shares = shares.filter((s) => s.id !== share.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Number of placeholder cards/rows to render (default 6). */
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { count = 6 }: Props = $props();
|
||||||
|
|
||||||
|
const placeholders = $derived(Array.from({ length: count }, (_, i) => i));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="files-container">
|
||||||
|
<div class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}>
|
||||||
|
{#each placeholders 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 class="skeleton skeleton-line skeleton-line--short"></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>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
queueMicrotask(() => form?.submit());
|
queueMicrotask(() => form?.submit());
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
close();
|
close();
|
||||||
})
|
})
|
||||||
.finally(() => (loading = false));
|
.finally(() => (loading = false));
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Reactive cache of owner-id → display-name, with memoised parallel resolution.
|
||||||
|
*
|
||||||
|
* Replaces the identical `ownerNames` record + `resolveOwners()` block in the
|
||||||
|
* favorites and recent views. The id→name resolver is injected so the cache
|
||||||
|
* stays decoupled from any specific API endpoint.
|
||||||
|
*/
|
||||||
|
export class OwnerCache {
|
||||||
|
#names = $state<Record<string, string>>({});
|
||||||
|
#resolver: (id: string) => Promise<string>;
|
||||||
|
|
||||||
|
constructor(resolver: (id: string) => Promise<string>) {
|
||||||
|
this.#resolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved names so far (id → display name). */
|
||||||
|
get names(): Record<string, string> {
|
||||||
|
return this.#names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display name for an id, or `null` when unknown/empty (for cell rendering). */
|
||||||
|
name(id: string | null | undefined): string | null {
|
||||||
|
if (!id) return null;
|
||||||
|
return this.#names[id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display name for an id, falling back to the id itself (for group labels). */
|
||||||
|
label(id: string): string {
|
||||||
|
return this.#names[id] ?? id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */
|
||||||
|
async resolve(ids: Iterable<string | null | undefined>): Promise<void> {
|
||||||
|
const unique = [...new Set([...ids].filter((id): id is string => !!id))];
|
||||||
|
await Promise.all(
|
||||||
|
unique.map(async (id) => {
|
||||||
|
if (this.#names[id]) return;
|
||||||
|
const name = await this.#resolver(id);
|
||||||
|
this.#names = { ...this.#names, [id]: name };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a reactive {@link OwnerCache} backed by `resolver`. */
|
||||||
|
export function useOwnerCache(resolver: (id: string) => Promise<string>): OwnerCache {
|
||||||
|
return new OwnerCache(resolver);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Reactive multi-select over string ids. Backs the repeated
|
||||||
|
* `let selected = $state(new Set()); function toggle(id) { … }` pattern used by
|
||||||
|
* the photos grid, music picker and other list views with one source of truth.
|
||||||
|
*
|
||||||
|
* Mutations swap in a fresh Set so `$derived`/template reads re-run.
|
||||||
|
*/
|
||||||
|
export class Selection {
|
||||||
|
#ids = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
/** The live selection set (read-only intent — mutate via the methods). */
|
||||||
|
get ids(): Set<string> {
|
||||||
|
return this.#ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.#ids.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return this.#ids.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string): boolean {
|
||||||
|
return this.#ids.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selected ids as an array (e.g. for batch API calls). */
|
||||||
|
values(): string[] {
|
||||||
|
return [...this.#ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(id: string): void {
|
||||||
|
const next = new Set(this.#ids);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
this.#ids = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(id: string): void {
|
||||||
|
if (this.#ids.has(id)) return;
|
||||||
|
this.#ids = new Set(this.#ids).add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id: string): void {
|
||||||
|
if (!this.#ids.has(id)) return;
|
||||||
|
const next = new Set(this.#ids);
|
||||||
|
next.delete(id);
|
||||||
|
this.#ids = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the whole selection. */
|
||||||
|
set(ids: Iterable<string>): void {
|
||||||
|
this.#ids = new Set(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
if (this.#ids.size) this.#ids = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a reactive {@link Selection}. */
|
||||||
|
export function useSelection(): Selection {
|
||||||
|
return new Selection();
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
* and only closes the dialog if it resolves. A rejection keeps the dialog open
|
* and only closes the dialog if it resolves. A rejection keeps the dialog open
|
||||||
* and surfaces an inline error, so failed renames/deletes don't silently vanish.
|
* and surfaces an inline error, so failed renames/deletes don't silently vanish.
|
||||||
*/
|
*/
|
||||||
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
|
|
||||||
export interface ConfirmOptions {
|
export interface ConfirmOptions {
|
||||||
title: string;
|
title: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -87,7 +89,7 @@ class DialogStore {
|
|||||||
else await (action as (v: string) => Promise<void> | void)(value as string);
|
else await (action as (v: string) => Promise<void> | void)(value as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.error = err instanceof Error ? err.message : String(err);
|
this.error = errorMessage(err);
|
||||||
return; // keep the dialog open
|
return; // keep the dialog open
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
import type { FolderItem } from '$lib/api/types';
|
import type { FolderItem } from '$lib/api/types';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
|
// Re-exported so the files view's grouping-helper barrel stays a single import
|
||||||
|
// site; the implementation lives in the shared time util.
|
||||||
|
export { relativeTimeAgo } from '$lib/utils/time';
|
||||||
|
|
||||||
export type ViewMode = 'grid' | 'list';
|
export type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
// ── Group-by / display helpers ───────────────────────────────────────────────
|
// ── Group-by / display helpers ───────────────────────────────────────────────
|
||||||
@@ -41,28 +45,6 @@ export function dateBucket(value: number | null | undefined): string {
|
|||||||
return String(toDate(value).getFullYear());
|
return String(toDate(value).getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locale-aware relative "time ago" for grid-card metadata lines. */
|
|
||||||
export function relativeTimeAgo(value: number | null | undefined): string {
|
|
||||||
if (!value) return '';
|
|
||||||
const date = toDate(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
|
||||||
const abs = Math.abs(diffSec);
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
||||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
||||||
['year', 31536000],
|
|
||||||
['month', 2592000],
|
|
||||||
['week', 604800],
|
|
||||||
['day', 86400],
|
|
||||||
['hour', 3600],
|
|
||||||
['minute', 60]
|
|
||||||
];
|
|
||||||
for (const [unit, secs] of units) {
|
|
||||||
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
|
||||||
}
|
|
||||||
return rtf.format(diffSec, 'second');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Localise a file `category` (e.g. "Image") via files.file_types.* keys. */
|
/** Localise a file `category` (e.g. "Image") via files.file_types.* keys. */
|
||||||
export function typeLabel(category: string | null | undefined): string {
|
export function typeLabel(category: string | null | undefined): string {
|
||||||
if (!category) return t('files.file_types.document', 'Document');
|
if (!category) return t('files.file_types.document', 'Document');
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/** Error-handling helpers shared across pages and components. */
|
||||||
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
|
|
||||||
|
/** Normalise an unknown thrown value into a human-readable message. */
|
||||||
|
export function errorMessage(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raise an error toast for a caught value — the canonical catch-block handler.
|
||||||
|
* Replaces the repeated `ui.notify(e instanceof Error ? e.message : String(e), 'error')`.
|
||||||
|
*/
|
||||||
|
export function errorToast(e: unknown): void {
|
||||||
|
ui.notify(errorMessage(e), 'error');
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/** Time-formatting helpers shared across views. */
|
||||||
|
|
||||||
|
/** Options for {@link relativeTimeAgo}. */
|
||||||
|
export interface RelativeTimeOptions {
|
||||||
|
/** Label returned for a null/empty value (default `''`). */
|
||||||
|
empty?: string;
|
||||||
|
/** When the value can't be parsed, return it stringified instead of `empty`. */
|
||||||
|
invalidAsString?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale-aware relative "time ago" via `Intl.RelativeTimeFormat`.
|
||||||
|
*
|
||||||
|
* Accepts an epoch (seconds or milliseconds) or an ISO-8601 string, so the
|
||||||
|
* grid-card metadata lines (epoch) and the profile/app-password tables (ISO)
|
||||||
|
* share one implementation.
|
||||||
|
*/
|
||||||
|
export function relativeTimeAgo(
|
||||||
|
value: number | string | null | undefined,
|
||||||
|
opts: RelativeTimeOptions = {}
|
||||||
|
): string {
|
||||||
|
const empty = opts.empty ?? '';
|
||||||
|
if (value === null || value === undefined || value === '') return empty;
|
||||||
|
const date =
|
||||||
|
typeof value === 'number' ? new Date(value < 1e12 ? value * 1000 : value) : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return opts.invalidAsString ? String(value) : empty;
|
||||||
|
|
||||||
|
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
||||||
|
const abs = Math.abs(diffSec);
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
|
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||||
|
['year', 31536000],
|
||||||
|
['month', 2592000],
|
||||||
|
['week', 604800],
|
||||||
|
['day', 86400],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60]
|
||||||
|
];
|
||||||
|
for (const [unit, secs] of units) {
|
||||||
|
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
||||||
|
}
|
||||||
|
return rtf.format(diffSec, 'second');
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import {
|
import {
|
||||||
clearPluginLogs,
|
clearPluginLogs,
|
||||||
createUser,
|
createUser,
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
try {
|
try {
|
||||||
dashboard = await getDashboard();
|
dashboard = await getDashboard();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dashboardError = e instanceof Error ? e.message : String(e);
|
dashboardError = errorMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
try {
|
try {
|
||||||
smtpResult = await sendSmtpTest(smtpTo.trim());
|
smtpResult = await sendSmtpTest(smtpTo.trim());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
smtpResult = { success: false, message: e instanceof Error ? e.message : String(e) };
|
smtpResult = { success: false, message: errorMessage(e) };
|
||||||
} finally {
|
} finally {
|
||||||
smtpSending = false;
|
smtpSending = false;
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
try {
|
try {
|
||||||
oidc = await getOidcSettings();
|
oidc = await getOidcSettings();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
oidcMsg = { text: e instanceof Error ? e.message : String(e), ok: false };
|
oidcMsg = { text: errorMessage(e), ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function runOidcTest() {
|
async function runOidcTest() {
|
||||||
@@ -185,7 +186,7 @@
|
|||||||
});
|
});
|
||||||
oidcMsg = { text: t('admin.settings_saved_ok', 'Settings saved.'), ok: true };
|
oidcMsg = { text: t('admin.settings_saved_ok', 'Settings saved.'), ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
oidcMsg = { text: e instanceof Error ? e.message : String(e), ok: false };
|
oidcMsg = { text: errorMessage(e), ok: false };
|
||||||
} finally {
|
} finally {
|
||||||
oidcSaving = false;
|
oidcSaving = false;
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
pathStyle: storage.s3_force_path_style ?? false
|
pathStyle: storage.s3_force_path_style ?? false
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
storageMsg = { text: e instanceof Error ? e.message : String(e), ok: false };
|
storageMsg = { text: errorMessage(e), ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function applyPreset() {
|
function applyPreset() {
|
||||||
@@ -275,7 +276,7 @@
|
|||||||
storageMsg = { text: t('admin.storage_saved', 'Storage settings saved.'), ok: true };
|
storageMsg = { text: t('admin.storage_saved', 'Storage settings saved.'), ok: true };
|
||||||
await loadStorage();
|
await loadStorage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
storageMsg = { text: e instanceof Error ? e.message : String(e), ok: false };
|
storageMsg = { text: errorMessage(e), ok: false };
|
||||||
} finally {
|
} finally {
|
||||||
storageBusy = false;
|
storageBusy = false;
|
||||||
}
|
}
|
||||||
@@ -299,7 +300,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
storageMsg = { text: e instanceof Error ? e.message : String(e), ok: false };
|
storageMsg = { text: errorMessage(e), ok: false };
|
||||||
} finally {
|
} finally {
|
||||||
storageBusy = false;
|
storageBusy = false;
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,7 @@
|
|||||||
try {
|
try {
|
||||||
verifyResult = await verifyMigration(100);
|
verifyResult = await verifyMigration(100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
verifyError = e instanceof Error ? e.message : String(e);
|
verifyError = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
verifying = false;
|
verifying = false;
|
||||||
}
|
}
|
||||||
@@ -505,7 +506,7 @@
|
|||||||
});
|
});
|
||||||
retentionMsg = t('admin.plugins_retention_saved', 'Retention saved.');
|
retentionMsg = t('admin.plugins_retention_saved', 'Retention saved.');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
retentionMsg = e instanceof Error ? e.message : String(e);
|
retentionMsg = errorMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function purgeLogs() {
|
async function purgeLogs() {
|
||||||
@@ -541,7 +542,7 @@
|
|||||||
};
|
};
|
||||||
await loadPlugins();
|
await loadPlugins();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
installMsg = { ok: false, text: err instanceof Error ? err.message : String(err) };
|
installMsg = { ok: false, text: errorMessage(err) };
|
||||||
} finally {
|
} finally {
|
||||||
installing = false;
|
installing = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -609,7 +610,7 @@
|
|||||||
users = page.users;
|
users = page.users;
|
||||||
total = page.total;
|
total = page.total;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
usersError = e instanceof Error ? e.message : String(e);
|
usersError = errorMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,12 +621,12 @@
|
|||||||
pluginsAvailable = res.available;
|
pluginsAvailable = res.available;
|
||||||
plugins = res.plugins;
|
plugins = res.plugins;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pluginsError = e instanceof Error ? e.message : String(e);
|
pluginsError = errorMessage(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportError(e: unknown) {
|
function reportError(e: unknown) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
@@ -720,7 +721,7 @@
|
|||||||
resetModal = null;
|
resetModal = null;
|
||||||
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
|
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
resetError = err instanceof Error ? err.message : String(err);
|
resetError = errorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
resetting = false;
|
resetting = false;
|
||||||
}
|
}
|
||||||
@@ -776,7 +777,7 @@
|
|||||||
};
|
};
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
createError = err instanceof Error ? err.message : String(err);
|
createError = errorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
creating = false;
|
creating = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
return t('device.lookup_failed', 'Failed to verify code. Please try again.');
|
return t('device.lookup_failed', 'Failed to verify code. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err instanceof Error ? err.message : String(err);
|
return errorMessage(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lookup(e?: SubmitEvent) {
|
async function lookup(e?: SubmitEvent) {
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
await decideDevice(code, action);
|
await decideDevice(code, action);
|
||||||
step = action === 'approve' ? 'approved' : 'denied';
|
step = action === 'approve' ? 'approved' : 'denied';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorText = err instanceof Error ? err.message : String(err);
|
errorText = errorMessage(err);
|
||||||
step = 'error';
|
step = 'error';
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +17,6 @@
|
|||||||
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
|
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||||
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||||
import type { FileItem } from '$lib/api/types';
|
import type { FileItem } from '$lib/api/types';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
|
||||||
import FileViewer from '$lib/components/FileViewer.svelte';
|
import FileViewer from '$lib/components/FileViewer.svelte';
|
||||||
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
@@ -25,7 +27,6 @@
|
|||||||
} from '$lib/components/ResourceList.svelte';
|
} from '$lib/components/ResourceList.svelte';
|
||||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
|
||||||
|
|
||||||
let raw = $state<FavoritesResourceItem[]>([]);
|
let raw = $state<FavoritesResourceItem[]>([]);
|
||||||
let cursor = $state<string | undefined>(undefined);
|
let cursor = $state<string | undefined>(undefined);
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let groupBy = $state('');
|
let groupBy = $state('');
|
||||||
let reversed = $state(false);
|
let reversed = $state(false);
|
||||||
let ownerNames = $state<Record<string, string>>({});
|
const owners = useOwnerCache(resolveOwnerName);
|
||||||
|
|
||||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
size: isFile ? (it.resource as FileItem).size : null,
|
size: isFile ? (it.resource as FileItem).size : null,
|
||||||
date: it.favorited_at,
|
date: it.favorited_at,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
|
ownerName: owners.name(ownerId),
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
category: isFile ? it.resource.category : 'Folder',
|
category: isFile ? it.resource.category : 'Folder',
|
||||||
modifiedAt: it.resource.modified_at
|
modifiedAt: it.resource.modified_at
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
label: t('groupby.owner', 'Owner'),
|
label: t('groupby.owner', 'Owner'),
|
||||||
orderBy: 'owner',
|
orderBy: 'owner',
|
||||||
bucketOf: (e) => e.ownerId ?? null,
|
bucketOf: (e) => e.ownerId ?? null,
|
||||||
labelOf: (id) => ownerNames[id] ?? id
|
labelOf: (id) => owners.label(id)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
@@ -94,19 +95,6 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
async function resolveOwners(items: FavoritesResourceItem[]) {
|
|
||||||
const ids = [
|
|
||||||
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
|
|
||||||
];
|
|
||||||
await Promise.all(
|
|
||||||
ids.map(async (id) => {
|
|
||||||
if (ownerNames[id]) return;
|
|
||||||
const name = await resolveOwnerName(id);
|
|
||||||
ownerNames = { ...ownerNames, [id]: name };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(reset = false, orderBy = 'name', rev = reversed) {
|
async function load(reset = false, orderBy = 'name', rev = reversed) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
@@ -119,7 +107,7 @@
|
|||||||
});
|
});
|
||||||
raw = reset ? page.items : [...raw, ...page.items];
|
raw = reset ? page.items : [...raw, ...page.items];
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
void resolveOwners(page.items);
|
void owners.resolve(page.items.map((i) => i.resource.owner_id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('favorites: load error', e);
|
console.error('favorites: load error', e);
|
||||||
error = t('errors_loadFailed', 'Failed to load items');
|
error = t('errors_loadFailed', 'Failed to load items');
|
||||||
@@ -152,7 +140,7 @@
|
|||||||
await removeFavorite(entry.kind, entry.id);
|
await removeFavorite(entry.kind, entry.id);
|
||||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +163,7 @@
|
|||||||
else await renameFolder(entry.id, name);
|
else await renameFolder(entry.id, name);
|
||||||
await load(true, orderByForGroup());
|
await load(true, orderByForGroup());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +180,7 @@
|
|||||||
else await deleteFolder(entry.id);
|
else await deleteFolder(entry.id);
|
||||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +256,7 @@
|
|||||||
raw = raw.filter((i) => !removed.has(i.resource.id));
|
raw = raw.filter((i) => !removed.has(i.resource.id));
|
||||||
selectedIds = new Set();
|
selectedIds = new Set();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,22 +290,18 @@
|
|||||||
onselectionchange={(ids) => (selectedIds = ids)}
|
onselectionchange={(ids) => (selectedIds = ids)}
|
||||||
>
|
>
|
||||||
{#snippet batchToolbar()}
|
{#snippet batchToolbar()}
|
||||||
<button class="btn btn-secondary" onclick={batchDownload}>
|
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||||
<Icon name="download" />
|
<Button
|
||||||
{t('common.download', 'Download')}
|
icon="arrows-alt"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
moveTarget = null;
|
moveTarget = null;
|
||||||
moveItems = batchTargets();
|
moveItems = batchTargets();
|
||||||
moveOpen = true;
|
moveOpen = true;
|
||||||
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
|
}}>{t('files.move', 'Move')}</Button
|
||||||
|
>
|
||||||
|
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||||
|
>{t('common.delete', 'Delete')}</Button
|
||||||
>
|
>
|
||||||
<button class="btn btn-danger" onclick={batchDelete}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
{t('common.delete', 'Delete')}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ResourceList>
|
</ResourceList>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import SkeletonList from '$lib/components/SkeletonList.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
@@ -121,7 +124,7 @@
|
|||||||
if (isFav) await removeFavorite(kind, id);
|
if (isFav) await removeFavorite(kind, id);
|
||||||
else await addFavorite(kind, id);
|
else await addFavorite(kind, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
await loadBadges();
|
await loadBadges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,7 @@
|
|||||||
await createFolder(name, currentId);
|
await createFolder(name, currentId);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@
|
|||||||
ui.finishProgress(nid, done, 'success');
|
ui.finishProgress(nid, done, 'success');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.finishProgress(nid, err instanceof Error ? err.message : String(err), 'error');
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
}
|
}
|
||||||
@@ -292,7 +295,7 @@
|
|||||||
else await renameFolder(id, name);
|
else await renameFolder(id, name);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +312,7 @@
|
|||||||
else await deleteFolder(id);
|
else await deleteFolder(id);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +436,7 @@
|
|||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +463,7 @@
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
void loadBadges();
|
void loadBadges();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +526,7 @@
|
|||||||
if (folder) await deleteFolder(id);
|
if (folder) await deleteFolder(id);
|
||||||
else await deleteFile(id);
|
else await deleteFile(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -568,7 +571,7 @@
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +647,7 @@
|
|||||||
else window.open(url, '_blank');
|
else window.open(url, '_blank');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
win?.close();
|
win?.close();
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +666,7 @@
|
|||||||
try {
|
try {
|
||||||
playlists = await listPlaylists();
|
playlists = await listPlaylists();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const existing = playlists.map((p) => p.name).join(', ');
|
const existing = playlists.map((p) => p.name).join(', ');
|
||||||
@@ -686,7 +689,7 @@
|
|||||||
await addTracks(playlist.id, [file.id]);
|
await addTracks(playlist.id, [file.id]);
|
||||||
ui.notify(t('music.added_to_playlist', 'Added to playlist'), 'success');
|
ui.notify(t('music.added_to_playlist', 'Added to playlist'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,7 +748,7 @@
|
|||||||
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
@@ -1076,36 +1079,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="empty-state"><p>{error}</p></div>
|
<EmptyState title={error} error />
|
||||||
{:else if showSkeleton && isEmpty}
|
{:else if showSkeleton && isEmpty}
|
||||||
<div class="files-container">
|
<SkeletonList count={SKELETON.length} />
|
||||||
<div
|
|
||||||
class={viewClass === 'files-grid-view'
|
|
||||||
? '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 class="skeleton skeleton-line skeleton-line--short"></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}
|
{:else if isEmpty}
|
||||||
<div class="empty-state">
|
<EmptyState
|
||||||
<p>{t('files.empty_title', 'This folder is empty')}</p>
|
title={t('files.empty_title', 'This folder is empty')}
|
||||||
<p>{t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}</p>
|
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||||
</div>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container">
|
||||||
<div class={viewClass}>
|
<div class={viewClass}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
@@ -54,7 +55,7 @@
|
|||||||
groups = page.items;
|
groups = page.items;
|
||||||
total = page.total;
|
total = page.total;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function report(e: unknown) {
|
function report(e: unknown) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localised "(N members)" label. Project i18n has no plural rules, so we
|
/** Localised "(N members)" label. Project i18n has no plural rules, so we
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { useSelection } from '$lib/composables/useSelection.svelte';
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +69,7 @@
|
|||||||
playlists = await listPlaylists();
|
playlists = await listPlaylists();
|
||||||
if (!current && playlists.length > 0) await select(playlists[0]);
|
if (!current && playlists.length > 0) await select(playlists[0]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@
|
|||||||
try {
|
try {
|
||||||
tracks = await listTracks(p.id);
|
tracks = await listTracks(p.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
await select(p);
|
await select(p);
|
||||||
ui.notify(t('music.created', { name: p.name }, 'Created “{{name}}”.'), 'success');
|
ui.notify(t('music.created', { name: p.name }, 'Created “{{name}}”.'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@
|
|||||||
current.name = name;
|
current.name = name;
|
||||||
playlists = playlists.map((p) => (p.id === current!.id ? { ...p, name } : p));
|
playlists = playlists.map((p) => (p.id === current!.id ? { ...p, name } : p));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +133,7 @@
|
|||||||
p.id === current!.id ? { ...p, description: desc || null } : p
|
p.id === current!.id ? { ...p, description: desc || null } : p
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@
|
|||||||
}
|
}
|
||||||
ui.notify(t('music.deleted', { name: p.name }, 'Deleted “{{name}}”.'), 'success');
|
ui.notify(t('music.deleted', { name: p.name }, 'Deleted “{{name}}”.'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@
|
|||||||
tracks = tracks.filter((x) => x.id !== track.id);
|
tracks = tracks.filter((x) => x.id !== track.id);
|
||||||
ui.notify(t('music.track_removed', 'Track removed.'), 'success');
|
ui.notify(t('music.track_removed', 'Track removed.'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +183,7 @@
|
|||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +209,7 @@
|
|||||||
);
|
);
|
||||||
ui.notify(t('music.reordered', 'Playlist reordered.'), 'success');
|
ui.notify(t('music.reordered', 'Playlist reordered.'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
await select(current);
|
await select(current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -380,7 +382,7 @@
|
|||||||
);
|
);
|
||||||
ui.notify(t('music.cover_updated', 'Cover updated.'), 'success');
|
ui.notify(t('music.cover_updated', 'Cover updated.'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +408,7 @@
|
|||||||
try {
|
try {
|
||||||
shares = await listShares(current.id);
|
shares = await listShares(current.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
} finally {
|
} finally {
|
||||||
sharesLoading = false;
|
sharesLoading = false;
|
||||||
}
|
}
|
||||||
@@ -420,7 +422,7 @@
|
|||||||
await loadShares();
|
await loadShares();
|
||||||
ui.notify(t('music.share_added', 'Shared.'), 'success');
|
ui.notify(t('music.share_added', 'Shared.'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onRemoveShare(userId: string) {
|
async function onRemoveShare(userId: string) {
|
||||||
@@ -429,7 +431,7 @@
|
|||||||
await removeShare(current.id, userId);
|
await removeShare(current.id, userId);
|
||||||
await loadShares();
|
await loadShares();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +440,7 @@
|
|||||||
let addOpen = $state(false);
|
let addOpen = $state(false);
|
||||||
let addQuery = $state('');
|
let addQuery = $state('');
|
||||||
let addResults = $state<FileItem[]>([]);
|
let addResults = $state<FileItem[]>([]);
|
||||||
let addSelected = $state<Set<string>>(new Set());
|
const addSelected = useSelection();
|
||||||
let addSearching = $state(false);
|
let addSearching = $state(false);
|
||||||
let addDebounce: ReturnType<typeof setTimeout> | null = null;
|
let addDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -457,7 +459,7 @@
|
|||||||
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
|
AUDIO_TYPES.some((e) => f.name.toLowerCase().endsWith(`.${e}`))
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
addResults = [];
|
addResults = [];
|
||||||
} finally {
|
} finally {
|
||||||
addSearching = false;
|
addSearching = false;
|
||||||
@@ -471,25 +473,19 @@
|
|||||||
addOpen = true;
|
addOpen = true;
|
||||||
addQuery = '';
|
addQuery = '';
|
||||||
addResults = [];
|
addResults = [];
|
||||||
addSelected = new Set();
|
addSelected.clear();
|
||||||
void runAddSearch(''); // show all audio files immediately
|
void runAddSearch(''); // show all audio files immediately
|
||||||
}
|
}
|
||||||
function toggleAdd(id: string) {
|
|
||||||
const n = new Set(addSelected);
|
|
||||||
if (n.has(id)) n.delete(id);
|
|
||||||
else n.add(id);
|
|
||||||
addSelected = n;
|
|
||||||
}
|
|
||||||
async function confirmAdd() {
|
async function confirmAdd() {
|
||||||
if (!current || addSelected.size === 0) return;
|
if (!current || addSelected.size === 0) return;
|
||||||
const count = addSelected.size;
|
const count = addSelected.size;
|
||||||
try {
|
try {
|
||||||
await addTracks(current.id, [...addSelected]);
|
await addTracks(current.id, addSelected.values());
|
||||||
addOpen = false;
|
addOpen = false;
|
||||||
tracks = await listTracks(current.id);
|
tracks = await listTracks(current.id);
|
||||||
ui.notify(t('music.tracks_added', { n: count }, 'Added {{n}} track(s).'), 'success');
|
ui.notify(t('music.tracks_added', { n: count }, 'Added {{n}} track(s).'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -968,7 +964,7 @@
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={addSelected.has(f.id)}
|
checked={addSelected.has(f.id)}
|
||||||
onchange={() => toggleAdd(f.id)}
|
onchange={() => addSelected.toggle(f.id)}
|
||||||
/>
|
/>
|
||||||
<Icon name="file-audio" />
|
<Icon name="file-audio" />
|
||||||
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
<span class="music-picker-name" title={f.name}>{f.name}</span>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { useSelection } from '$lib/composables/useSelection.svelte';
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
batchTrash,
|
batchTrash,
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
type GroupMode = 'day' | 'month' | 'year';
|
type GroupMode = 'day' | 'month' | 'year';
|
||||||
const GROUP_KEY = 'oxicloud-photos-group';
|
const GROUP_KEY = 'oxicloud-photos-group';
|
||||||
let groupMode = $state<GroupMode>('month');
|
let groupMode = $state<GroupMode>('month');
|
||||||
let selected = $state<Set<string>>(new Set());
|
const selected = useSelection();
|
||||||
let lightbox = $state(-1); // index into `items`, -1 = closed
|
let lightbox = $state(-1); // index into `items`, -1 = closed
|
||||||
|
|
||||||
/** Client-generated video frame thumbnails (file id → data/URL). */
|
/** Client-generated video frame thumbnails (file id → data/URL). */
|
||||||
@@ -98,7 +102,7 @@
|
|||||||
cursor = page.nextCursor;
|
cursor = page.nextCursor;
|
||||||
if (!page.nextCursor) exhausted = true;
|
if (!page.nextCursor) exhausted = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
exhausted = true;
|
exhausted = true;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -111,21 +115,14 @@
|
|||||||
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, m);
|
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, m);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(id: string) {
|
|
||||||
const n = new Set(selected);
|
|
||||||
if (n.has(id)) n.delete(id);
|
|
||||||
else n.add(id);
|
|
||||||
selected = n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
|
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
|
||||||
function onTileClick(p: FileItem) {
|
function onTileClick(p: FileItem) {
|
||||||
if (selected.size > 0) toggle(p.id);
|
if (selected.size > 0) selected.toggle(p.id);
|
||||||
else openLightbox(p);
|
else openLightbox(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadSelected() {
|
function downloadSelected() {
|
||||||
for (const id of selected) {
|
for (const id of selected.ids) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = fileDownloadUrl(id);
|
a.href = fileDownloadUrl(id);
|
||||||
a.download = '';
|
a.download = '';
|
||||||
@@ -136,7 +133,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function trashSelected() {
|
async function trashSelected() {
|
||||||
const ids = [...selected];
|
const ids = selected.values();
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: t('photos.delete', 'Delete photos'),
|
title: t('photos.delete', 'Delete photos'),
|
||||||
message: t('photos.confirm_delete', { n: ids.length }, 'Move {{n}} photos to trash?'),
|
message: t('photos.confirm_delete', { n: ids.length }, 'Move {{n}} photos to trash?'),
|
||||||
@@ -148,9 +145,7 @@
|
|||||||
const trashed = await batchTrash(ids);
|
const trashed = await batchTrash(ids);
|
||||||
if (trashed.size > 0) {
|
if (trashed.size > 0) {
|
||||||
items = items.filter((p) => !trashed.has(p.id));
|
items = items.filter((p) => !trashed.has(p.id));
|
||||||
const n = new Set(selected);
|
for (const id of trashed) selected.delete(id);
|
||||||
for (const id of trashed) n.delete(id);
|
|
||||||
selected = n;
|
|
||||||
}
|
}
|
||||||
if (trashed.size < ids.length) {
|
if (trashed.size < ids.length) {
|
||||||
ui.notify(
|
ui.notify(
|
||||||
@@ -165,7 +160,7 @@
|
|||||||
ui.notify(t('photos.trashed', { n: trashed.size }, '{{n}} moved to trash.'), 'success');
|
ui.notify(t('photos.trashed', { n: trashed.size }, '{{n}} moved to trash.'), 'success');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +340,7 @@
|
|||||||
await addFavorite('file', lbItem.id);
|
await addFavorite('file', lbItem.id);
|
||||||
lbFavorited = !lbFavorited;
|
lbFavorited = !lbFavorited;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,7 +364,7 @@
|
|||||||
lightbox = Math.min(at, items.length - 1);
|
lightbox = Math.min(at, items.length - 1);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,13 +417,9 @@
|
|||||||
<div class="batch-bar">
|
<div class="batch-bar">
|
||||||
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
|
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
|
||||||
<div class="batch-bar__actions">
|
<div class="batch-bar__actions">
|
||||||
<button class="btn btn-secondary" onclick={downloadSelected}
|
<Button onclick={downloadSelected}>{t('common.download', 'Download')}</Button>
|
||||||
>{t('common.download', 'Download')}</button
|
<Button onclick={() => selected.clear()}>{t('common.clear', 'Clear')}</Button>
|
||||||
>
|
<Button variant="danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</Button>
|
||||||
<button class="btn btn-secondary" onclick={() => (selected = new Set())}
|
|
||||||
>{t('common.clear', 'Clear')}</button
|
|
||||||
>
|
|
||||||
<button class="btn btn-danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -436,13 +427,11 @@
|
|||||||
{#if error}
|
{#if error}
|
||||||
<p class="status status--error" role="alert">{error}</p>
|
<p class="status status--error" role="alert">{error}</p>
|
||||||
{:else if items.length === 0 && exhausted}
|
{:else if items.length === 0 && exhausted}
|
||||||
<div class="empty-state">
|
<EmptyState
|
||||||
<Icon name="images" class="empty-state__icon" />
|
icon="images"
|
||||||
<p class="empty-state__title">{t('photos.empty', 'No photos yet.')}</p>
|
title={t('photos.empty', 'No photos yet.')}
|
||||||
<p class="empty-state__hint">
|
hint={t('photos.empty_hint', 'Photos and videos you upload will appear here, grouped by date.')}
|
||||||
{t('photos.empty_hint', 'Photos and videos you upload will appear here, grouped by date.')}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
<h2 class="photos-group">
|
<h2 class="photos-group">
|
||||||
@@ -473,7 +462,7 @@
|
|||||||
class="photos__check"
|
class="photos__check"
|
||||||
class:on={selected.has(photo.id)}
|
class:on={selected.has(photo.id)}
|
||||||
aria-label={t('common.select', 'Select')}
|
aria-label={t('common.select', 'Select')}
|
||||||
onclick={() => toggle(photo.id)}
|
onclick={() => selected.toggle(photo.id)}
|
||||||
>
|
>
|
||||||
<Icon name="check" />
|
<Icon name="check" />
|
||||||
</button>
|
</button>
|
||||||
@@ -730,32 +719,6 @@
|
|||||||
color: var(--color-danger-text);
|
color: var(--color-danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 1rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state :global(.empty-state__icon) {
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--color-text-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state__hint {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 28rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sentinel {
|
.sentinel {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
|
import { relativeTimeAgo } from '$lib/utils/time';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
changePassword,
|
changePassword,
|
||||||
@@ -78,26 +80,8 @@
|
|||||||
const autoPasswords = $derived(appPasswords.filter((p) => isAutoAppPassword(p)));
|
const autoPasswords = $derived(appPasswords.filter((p) => isAutoAppPassword(p)));
|
||||||
|
|
||||||
/** Relative time (e.g. "3 days ago"); "Never" when absent. */
|
/** Relative time (e.g. "3 days ago"); "Never" when absent. */
|
||||||
function timeAgo(value: string | null | undefined): string {
|
const timeAgo = (value: string | null | undefined): string =>
|
||||||
if (!value) return t('profile.never', 'Never');
|
relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true });
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return String(value);
|
|
||||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
|
||||||
const abs = Math.abs(diffSec);
|
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
|
||||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
||||||
['year', 31536000],
|
|
||||||
['month', 2592000],
|
|
||||||
['week', 604800],
|
|
||||||
['day', 86400],
|
|
||||||
['hour', 3600],
|
|
||||||
['minute', 60]
|
|
||||||
];
|
|
||||||
for (const [unit, secs] of units) {
|
|
||||||
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
|
||||||
}
|
|
||||||
return rtf.format(diffSec, 'second');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hydrate() {
|
function hydrate() {
|
||||||
const u = session.user;
|
const u = session.user;
|
||||||
@@ -139,7 +123,7 @@
|
|||||||
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
|
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
|
||||||
ui.notify(t('profile.saved', 'Profile saved'), 'success');
|
ui.notify(t('profile.saved', 'Profile saved'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
savingProfile = false;
|
savingProfile = false;
|
||||||
}
|
}
|
||||||
@@ -164,7 +148,7 @@
|
|||||||
currentPw = newPw = confirmPw = '';
|
currentPw = newPw = confirmPw = '';
|
||||||
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
|
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
savingPassword = false;
|
savingPassword = false;
|
||||||
}
|
}
|
||||||
@@ -196,7 +180,7 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
uploadedDataUrl = null;
|
uploadedDataUrl = null;
|
||||||
avatarPreview = null;
|
avatarPreview = null;
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
@@ -210,7 +194,7 @@
|
|||||||
avatarImgFailed = false;
|
avatarImgFailed = false;
|
||||||
closeAvatarEdit();
|
closeAvatarEdit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
avatarBusy = false;
|
avatarBusy = false;
|
||||||
}
|
}
|
||||||
@@ -251,7 +235,7 @@
|
|||||||
newLabel = '';
|
newLabel = '';
|
||||||
await loadAppPasswords();
|
await loadAppPasswords();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
creatingPw = false;
|
creatingPw = false;
|
||||||
}
|
}
|
||||||
@@ -270,7 +254,7 @@
|
|||||||
generated = null;
|
generated = null;
|
||||||
await loadAppPasswords();
|
await loadAppPasswords();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.notify(err instanceof Error ? err.message : String(err), 'error');
|
errorToast(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Button from '$lib/components/Button.svelte';
|
||||||
|
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||||
@@ -14,7 +17,6 @@
|
|||||||
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
|
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
|
||||||
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
||||||
import type { FileItem, ItemType } from '$lib/api/types';
|
import type { FileItem, ItemType } from '$lib/api/types';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
|
||||||
import FileViewer from '$lib/components/FileViewer.svelte';
|
import FileViewer from '$lib/components/FileViewer.svelte';
|
||||||
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
@@ -25,7 +27,6 @@
|
|||||||
} from '$lib/components/ResourceList.svelte';
|
} from '$lib/components/ResourceList.svelte';
|
||||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
|
||||||
|
|
||||||
let raw = $state<RecentResourceItem[]>([]);
|
let raw = $state<RecentResourceItem[]>([]);
|
||||||
let cursor = $state<string | undefined>(undefined);
|
let cursor = $state<string | undefined>(undefined);
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let groupBy = $state('');
|
let groupBy = $state('');
|
||||||
let reversed = $state(false);
|
let reversed = $state(false);
|
||||||
let ownerNames = $state<Record<string, string>>({});
|
const owners = useOwnerCache(resolveOwnerName);
|
||||||
let favoriteIds = $state<Set<string>>(new Set());
|
let favoriteIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
size: isFile ? (it.resource as FileItem).size : null,
|
size: isFile ? (it.resource as FileItem).size : null,
|
||||||
date: it.accessed_at,
|
date: it.accessed_at,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
|
ownerName: owners.name(ownerId),
|
||||||
isFavorite: favoriteIds.has(it.resource.id),
|
isFavorite: favoriteIds.has(it.resource.id),
|
||||||
category: isFile ? it.resource.category : 'Folder',
|
category: isFile ? it.resource.category : 'Folder',
|
||||||
modifiedAt: it.resource.modified_at
|
modifiedAt: it.resource.modified_at
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
label: t('groupby.owner', 'Owner'),
|
label: t('groupby.owner', 'Owner'),
|
||||||
orderBy: 'owner',
|
orderBy: 'owner',
|
||||||
bucketOf: (e) => e.ownerId ?? null,
|
bucketOf: (e) => e.ownerId ?? null,
|
||||||
labelOf: (id) => ownerNames[id] ?? id
|
labelOf: (id) => owners.label(id)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
@@ -95,19 +96,6 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
async function resolveOwners(items: RecentResourceItem[]) {
|
|
||||||
const ids = [
|
|
||||||
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
|
|
||||||
];
|
|
||||||
await Promise.all(
|
|
||||||
ids.map(async (id) => {
|
|
||||||
if (ownerNames[id]) return;
|
|
||||||
const name = await resolveOwnerName(id);
|
|
||||||
ownerNames = { ...ownerNames, [id]: name };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFavoriteIds() {
|
async function loadFavoriteIds() {
|
||||||
try {
|
try {
|
||||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
||||||
@@ -130,7 +118,7 @@
|
|||||||
});
|
});
|
||||||
raw = reset ? page.items : [...raw, ...page.items];
|
raw = reset ? page.items : [...raw, ...page.items];
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
void resolveOwners(page.items);
|
void owners.resolve(page.items.map((i) => i.resource.owner_id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('recent: load error', e);
|
console.error('recent: load error', e);
|
||||||
error = t('errors_loadFailed', 'Failed to load items');
|
error = t('errors_loadFailed', 'Failed to load items');
|
||||||
@@ -172,7 +160,7 @@
|
|||||||
favoriteIds = isFav
|
favoriteIds = isFav
|
||||||
? new Set([...favoriteIds, entry.id])
|
? new Set([...favoriteIds, entry.id])
|
||||||
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
: new Set([...favoriteIds].filter((id) => id !== entry.id));
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +176,7 @@
|
|||||||
raw = [];
|
raw = [];
|
||||||
cursor = undefined;
|
cursor = undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +199,7 @@
|
|||||||
else await renameFolder(entry.id, name);
|
else await renameFolder(entry.id, name);
|
||||||
await load(true, orderByForGroup());
|
await load(true, orderByForGroup());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +216,7 @@
|
|||||||
else await deleteFolder(entry.id);
|
else await deleteFolder(entry.id);
|
||||||
raw = raw.filter((i) => i.resource.id !== entry.id);
|
raw = raw.filter((i) => i.resource.id !== entry.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +292,7 @@
|
|||||||
raw = raw.filter((i) => !removed.has(i.resource.id));
|
raw = raw.filter((i) => !removed.has(i.resource.id));
|
||||||
selectedIds = new Set();
|
selectedIds = new Set();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,29 +330,22 @@
|
|||||||
>
|
>
|
||||||
{#snippet toolbar()}
|
{#snippet toolbar()}
|
||||||
{#if entries.length > 0}
|
{#if entries.length > 0}
|
||||||
<button class="btn btn-secondary" onclick={clearAll}>
|
<Button icon="broom" onclick={clearAll}>{t('recent.clear', 'Clear recent')}</Button>
|
||||||
<Icon name="broom" />
|
|
||||||
{t('recent.clear', 'Clear recent')}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet batchToolbar()}
|
{#snippet batchToolbar()}
|
||||||
<button class="btn btn-secondary" onclick={batchDownload}>
|
<Button icon="download" onclick={batchDownload}>{t('common.download', 'Download')}</Button>
|
||||||
<Icon name="download" />
|
<Button
|
||||||
{t('common.download', 'Download')}
|
icon="arrows-alt"
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
moveTarget = null;
|
moveTarget = null;
|
||||||
moveItems = batchTargets();
|
moveItems = batchTargets();
|
||||||
moveOpen = true;
|
moveOpen = true;
|
||||||
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
|
}}>{t('files.move', 'Move')}</Button
|
||||||
|
>
|
||||||
|
<Button variant="danger" icon="trash" onclick={batchDelete}
|
||||||
|
>{t('common.delete', 'Delete')}</Button
|
||||||
>
|
>
|
||||||
<button class="btn btn-danger" onclick={batchDelete}>
|
|
||||||
<Icon name="trash" />
|
|
||||||
{t('common.delete', 'Delete')}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ResourceList>
|
</ResourceList>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { searchFiles } from '$lib/api/endpoints/search';
|
import { searchFiles } from '$lib/api/endpoints/search';
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
modifiedAfter: dateBound(dateFilter)
|
modifiedAfter: dateBound(dateFilter)
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -239,16 +241,11 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="empty-state"><p>{error}</p></div>
|
<EmptyState title={error} error />
|
||||||
{:else if !query}
|
{:else if !query}
|
||||||
<div class="empty-state">
|
<EmptyState title={t('search.prompt', 'Type a query in the search bar above.')} />
|
||||||
<p>{t('search.prompt', 'Type a query in the search bar above.')}</p>
|
|
||||||
</div>
|
|
||||||
{:else if isEmpty}
|
{:else if isEmpty}
|
||||||
<div class="empty-state search-empty">
|
<EmptyState icon="search" title={t('search.no_results', 'No results found for this search')} />
|
||||||
<Icon name="search" />
|
|
||||||
<p>{t('search.no_results', 'No results found for this search')}</p>
|
|
||||||
</div>
|
|
||||||
{:else if results}
|
{:else if results}
|
||||||
<div class="files-container">
|
<div class="files-container">
|
||||||
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
|
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
|
||||||
@@ -383,16 +380,4 @@
|
|||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-empty :global(.oxi-icon) {
|
|
||||||
font-size: var(--text-3xl);
|
|
||||||
color: var(--color-text-faint);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
raw = reset ? page.items : [...raw, ...page.items];
|
raw = reset ? page.items : [...raw, ...page.items];
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
@@ -152,7 +154,7 @@
|
|||||||
raw = reset ? page.items : [...raw, ...page.items];
|
raw = reset ? page.items : [...raw, ...page.items];
|
||||||
cursor = page.next_cursor;
|
cursor = page.next_cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -232,7 +234,7 @@
|
|||||||
g.role = role;
|
g.role = role;
|
||||||
raw = [...raw];
|
raw = [...raw];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
g.expires_at = iso;
|
g.expires_at = iso;
|
||||||
raw = [...raw];
|
raw = [...raw];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +259,7 @@
|
|||||||
try {
|
try {
|
||||||
summarize((await notifyGrantRecipient(g.grant_id)).outcomes);
|
summarize((await notifyGrantRecipient(g.grant_id)).outcomes);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@
|
|||||||
await revokeGrant(g.grant_id);
|
await revokeGrant(g.grant_id);
|
||||||
dropGrant(g.grant_id);
|
dropGrant(g.grant_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@
|
|||||||
if (await copyShareLink(share.url)) ui.notify(t('share.copied', 'Link copied'), 'success');
|
if (await copyShareLink(share.url)) ui.notify(t('share.copied', 'Link copied'), 'success');
|
||||||
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
|
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@
|
|||||||
g.expires_at = expiryToIso(date || null);
|
g.expires_at = expiryToIso(date || null);
|
||||||
raw = [...raw];
|
raw = [...raw];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +313,7 @@
|
|||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +323,7 @@
|
|||||||
await deleteShare(g.subject_id);
|
await deleteShare(g.subject_id);
|
||||||
dropGrant(g.grant_id);
|
dropGrant(g.grant_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,18 +365,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="empty-state">
|
<EmptyState icon="exclamation-circle" title={error} error />
|
||||||
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
{:else if isEmpty}
|
{:else if isEmpty}
|
||||||
<div class="empty-state">
|
<EmptyState
|
||||||
<Icon name="share-alt" class="empty-state-icon" />
|
icon="share-alt"
|
||||||
<p>{t('myshares.emptyStateTitle', "You haven't shared anything yet")}</p>
|
title={t('myshares.emptyStateTitle', "You haven't shared anything yet")}
|
||||||
<p class="empty-state__hint">
|
hint={t('myshares.emptyStateDesc', 'Items you share with others will appear here')}
|
||||||
{t('myshares.emptyStateDesc', 'Items you share with others will appear here')}
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="ms-lanes">
|
<div class="ms-lanes">
|
||||||
{#each lanes as lane (lane.key)}
|
{#each lanes as lane (lane.key)}
|
||||||
@@ -832,19 +829,4 @@
|
|||||||
.ms-more {
|
.ms-more {
|
||||||
margin: var(--space-3) auto 0;
|
margin: var(--space-3) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state__hint {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
deleteTrashItem,
|
deleteTrashItem,
|
||||||
@@ -110,7 +111,7 @@
|
|||||||
ui.notify(t('trash.restored', 'Restored'), 'success');
|
ui.notify(t('trash.restored', 'Restored'), 'success');
|
||||||
await reloadFromTop();
|
await reloadFromTop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@
|
|||||||
await deleteTrashItem(entry.id);
|
await deleteTrashItem(entry.id);
|
||||||
await reloadFromTop();
|
await reloadFromTop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
raw = [];
|
raw = [];
|
||||||
cursor = undefined;
|
cursor = undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.notify(e instanceof Error ? e.message : String(e), 'error');
|
errorToast(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user