svelte refactor

This commit is contained in:
Bradley Nelson
2026-06-17 22:30:56 -06:00
parent 89e14f8f9e
commit e6279da5ce
26 changed files with 497 additions and 386 deletions
+43
View File
@@ -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">
import { errorToast } from '$lib/utils/errors';
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
import { moveFile } from '$lib/api/endpoints/files';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
@@ -42,7 +43,7 @@
currentId = id;
folders = (await listFolder(id)).folders;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
} finally {
loading = false;
}
@@ -98,7 +99,7 @@
open = false;
onmoved?.();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
} finally {
working = false;
}
@@ -51,6 +51,8 @@
<script lang="ts">
import type { Snippet } from '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 { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
@@ -396,37 +398,15 @@
{/if}
{#if error}
<div class="empty-state">
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
<p>{error}</p>
</div>
<EmptyState icon="exclamation-circle" title={error} error />
{: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>
<SkeletonList count={SKELETON.length} />
{: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>
<EmptyState
icon={emptyIcon}
title={emptyText ?? t('common.empty', 'Nothing here yet.')}
hint={emptyHint}
/>
{:else}
<div class="files-container">
<div class={viewClass} style="--files-list-columns: {columns}">
@@ -690,20 +670,4 @@
.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>
+12 -11
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
import {
copyShareLink,
createShare,
@@ -118,7 +119,7 @@
directoryAvailable = isDirectoryAvailable();
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
} finally {
grantsLoading = false;
}
@@ -153,7 +154,7 @@
summarizeNotifications(res.notification.outcomes);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -168,7 +169,7 @@
);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -183,7 +184,7 @@
);
await loadGrants();
} 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);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -202,7 +203,7 @@
const set = await notifyGrantRecipient(m.notifyGrantId);
summarizeNotifications(set.outcomes);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -252,7 +253,7 @@
try {
shares = await listSharesForItem(item.id, item.kind);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
} finally {
linkLoading = false;
}
@@ -275,7 +276,7 @@
await loadShares();
ui.notify(t('share.created', 'Public link created'), 'success');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
} finally {
creating = false;
}
@@ -286,7 +287,7 @@
await updateShare(share.id, { expiresAt: expiry });
await loadShares();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -301,7 +302,7 @@
'success'
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
}
}
@@ -310,7 +311,7 @@
await deleteShare(share.id);
shares = shares.filter((s) => s.id !== share.id);
} 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">
import { errorToast } from '$lib/utils/errors';
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;
@@ -68,7 +68,7 @@
queueMicrotask(() => form?.submit());
})
.catch((e) => {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
errorToast(e);
close();
})
.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();
}
+3 -1
View File
@@ -7,6 +7,8 @@
* 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.
*/
import { errorMessage } from '$lib/utils/errors';
export interface ConfirmOptions {
title: string;
message?: string;
@@ -87,7 +89,7 @@ class DialogStore {
else await (action as (v: string) => Promise<void> | void)(value as string);
} catch (err) {
this.busy = false;
this.error = err instanceof Error ? err.message : String(err);
this.error = errorMessage(err);
return; // keep the dialog open
}
}
+4 -22
View File
@@ -7,6 +7,10 @@
import type { FolderItem } from '$lib/api/types';
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';
// ── Group-by / display helpers ───────────────────────────────────────────────
@@ -41,28 +45,6 @@ export function dateBucket(value: number | null | undefined): string {
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. */
export function typeLabel(category: string | null | undefined): string {
if (!category) return t('files.file_types.document', 'Document');
+15
View File
@@ -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');
}
+43
View File
@@ -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');
}