From a5c90c577380dc291e9f7b917f2069f6718ee0f2 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Fri, 25 Sep 2026 12:33:39 +0200 Subject: [PATCH 1/6] ref: shared storage error panel --- .../storage/shared/StorageErrorPanel.svelte | 55 +++++++++++++++++++ .../shared/StorageErrorPanel.svelte.spec.ts | 37 +++++++++++++ src/routes/(app)/storage/+error.svelte | 44 +-------------- .../storage/[bucket]/[...prefix]/+page.svelte | 48 +++------------- 4 files changed, 101 insertions(+), 83 deletions(-) create mode 100644 src/lib/components/storage/shared/StorageErrorPanel.svelte create mode 100644 src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts diff --git a/src/lib/components/storage/shared/StorageErrorPanel.svelte b/src/lib/components/storage/shared/StorageErrorPanel.svelte new file mode 100644 index 00000000..a102bf17 --- /dev/null +++ b/src/lib/components/storage/shared/StorageErrorPanel.svelte @@ -0,0 +1,55 @@ + + +
+ + +
+

+ {m.storage_error_title()} +

+

+ {status} +

+

+ {message} +

+
+ +
+ + {m.storage_error_back_to_storage()} + + +
+
diff --git a/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts b/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts new file mode 100644 index 00000000..f96158c5 --- /dev/null +++ b/src/lib/components/storage/shared/StorageErrorPanel.svelte.spec.ts @@ -0,0 +1,37 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StorageErrorPanel from './StorageErrorPanel.svelte'; + +describe('StorageErrorPanel', () => { + it('should render the status code as the heading', async () => { + render(StorageErrorPanel, { status: 403, message: 'Access denied' }); + + await expect.element(page.getByRole('heading', { name: '403' })).toBeInTheDocument(); + }); + + it('should render the supplied message', async () => { + render(StorageErrorPanel, { status: 404, message: 'No such bucket: reports' }); + + await expect.element(page.getByText('No such bucket: reports')).toBeInTheDocument(); + }); + + it('should tolerate an empty message', async () => { + render(StorageErrorPanel, { status: 500, message: '' }); + + await expect.element(page.getByRole('heading', { name: '500' })).toBeInTheDocument(); + }); + + it('should offer a link back to storage', async () => { + render(StorageErrorPanel, { status: 403, message: 'Access denied' }); + + const link = page.getByRole('link', { name: /back to storage/i }); + await expect.element(link).toHaveAttribute('href', '/storage'); + }); + + it('should offer a go-back button', async () => { + render(StorageErrorPanel, { status: 403, message: 'Access denied' }); + + await expect.element(page.getByRole('button', { name: /go back/i })).toBeInTheDocument(); + }); +}); diff --git a/src/routes/(app)/storage/+error.svelte b/src/routes/(app)/storage/+error.svelte index bcc2ce6a..246655df 100644 --- a/src/routes/(app)/storage/+error.svelte +++ b/src/routes/(app)/storage/+error.svelte @@ -1,8 +1,7 @@ -
- - -
-

- {m.storage_error_title()} -

-

- {page.status} -

-

- {errorMessage ?? page.error?.message ?? ''} -

-
- -
- - {m.storage_error_back_to_storage()} - - -
-
+ diff --git a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte b/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte index 49c8f26b..fd08925e 100644 --- a/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte +++ b/src/routes/(app)/storage/[bucket]/[...prefix]/+page.svelte @@ -3,7 +3,7 @@ import { resolve } from '$app/paths'; import { getStorageState } from '$lib/storage/context.js'; import FileExplorer from '$lib/components/storage/explorer/FileExplorer.svelte'; - import IconWarning from 'virtual:icons/material-symbols/warning'; + import StorageErrorPanel from '$lib/components/storage/shared/StorageErrorPanel.svelte'; import * as m from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -36,49 +36,15 @@ {#if data.accessDenied} -
- - -
-

- {m.storage_error_title()} -

-

403

-

- {m.storage_error_access_denied({ bucket: data.bucket })} -

-
- -
- - {m.storage_error_back_to_storage()} - - -
-
+ {:else} {/if} From 0cdf1930d786be363787b87450a9942a2b3b5fce Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Fri, 25 Sep 2026 12:59:22 +0200 Subject: [PATCH 2/6] refactor connectionLabel --- src/lib/components/storage/DeleteConnectionModal.svelte | 6 +----- .../storage/sidebar/StorageConnectionSidebar.svelte | 6 +----- src/routes/(app)/storage/connections/+page.svelte | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/lib/components/storage/DeleteConnectionModal.svelte b/src/lib/components/storage/DeleteConnectionModal.svelte index 39d33390..4613a1f9 100644 --- a/src/lib/components/storage/DeleteConnectionModal.svelte +++ b/src/lib/components/storage/DeleteConnectionModal.svelte @@ -4,6 +4,7 @@ import Modal from '$lib/components/Modal.svelte'; import * as m from '$lib/paraglide/messages.js'; import type { SavedConnection } from '$lib/storage/connection-storage.js'; + import { connectionLabel } from '$lib/storage/display-helpers.js'; interface Props { open: boolean; @@ -16,11 +17,6 @@ let copiedField: string | null = $state(null); - function connectionLabel(conn: SavedConnection): string { - if (conn.name) return conn.name; - return conn.port ? `${conn.host}:${conn.port}` : conn.host; - } - function copyField(value: string, field: string) { navigator.clipboard.writeText(value); copiedField = field; diff --git a/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte b/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte index 10080e85..645def1c 100644 --- a/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte +++ b/src/lib/components/storage/sidebar/StorageConnectionSidebar.svelte @@ -14,6 +14,7 @@ removeConnectionLocally, type SavedConnection } from '$lib/storage/connection-storage.js'; + import { connectionLabel } from '$lib/storage/display-helpers.js'; interface Props { /** ID of the connection currently being acted on (highlighted in the list). */ @@ -54,11 +55,6 @@ }; }); - function connectionLabel(conn: SavedConnection): string { - if (conn.name) return conn.name; - return conn.port ? `${conn.host}:${conn.port}` : conn.host; - } - function openContextMenu(e: MouseEvent, conn: SavedConnection) { e.preventDefault(); e.stopPropagation(); diff --git a/src/routes/(app)/storage/connections/+page.svelte b/src/routes/(app)/storage/connections/+page.svelte index 7f5218b3..c58d6d6e 100644 --- a/src/routes/(app)/storage/connections/+page.svelte +++ b/src/routes/(app)/storage/connections/+page.svelte @@ -12,6 +12,7 @@ removeConnectionById, type SavedConnection } from '$lib/storage/connection-storage.js'; + import { connectionLabel } from '$lib/storage/display-helpers.js'; import DeleteConnectionModal from '$lib/components/storage/DeleteConnectionModal.svelte'; let connections = $state([]); @@ -45,11 +46,6 @@ loaded = true; }); - function connectionLabel(conn: SavedConnection): string { - if (conn.name) return conn.name; - return conn.port ? `${conn.host}:${conn.port}` : conn.host; - } - function openContextMenu(e: MouseEvent, conn: SavedConnection) { e.preventDefault(); e.stopPropagation(); From 764d07a1ed66059a5aaf9c9b1012d3826a4908b0 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Fri, 25 Sep 2026 13:01:22 +0200 Subject: [PATCH 3/6] ref: reuse keyToName --- .../components/storage/modals/DeleteConfirmModal.svelte | 3 ++- .../storage/modals/upload/UploadConflictEntry.svelte | 3 ++- .../storage/modals/upload/UploadEntryStatus.svelte | 3 ++- .../components/storage/modals/upload/UploadModal.svelte | 4 ++-- src/lib/storage/download.ts | 4 ++-- src/routes/(app)/api/storage/download/+server.ts | 8 ++------ 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/lib/components/storage/modals/DeleteConfirmModal.svelte b/src/lib/components/storage/modals/DeleteConfirmModal.svelte index 8e9a98ca..08773a8d 100644 --- a/src/lib/components/storage/modals/DeleteConfirmModal.svelte +++ b/src/lib/components/storage/modals/DeleteConfirmModal.svelte @@ -3,6 +3,7 @@ import IconDeleteForever from 'virtual:icons/material-symbols/delete-forever'; import IconWarningRounded from 'virtual:icons/material-symbols/warning-rounded'; import * as m from '$lib/paraglide/messages.js'; + import { keyToName } from '$lib/storage/utils.js'; interface Props { open: boolean; @@ -14,7 +15,7 @@ let { open = $bindable(), keys, onConfirm, onCancel }: Props = $props(); const count = $derived(keys.length); - const firstName = $derived(keys[0]?.split('/').filter(Boolean).pop() ?? ''); + const firstName = $derived(keys[0] ? keyToName(keys[0]) : ''); const hasDirectories = $derived(keys.some((k) => k.endsWith('/'))); diff --git a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte b/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte index 68fc270a..0747f6aa 100644 --- a/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte +++ b/src/lib/components/storage/modals/upload/UploadConflictEntry.svelte @@ -2,6 +2,7 @@ import IconCheck from 'virtual:icons/material-symbols/check'; import IconCheckCircle from 'virtual:icons/material-symbols/check-circle'; import * as m from '$lib/paraglide/messages.js'; + import { keyToName } from '$lib/storage/utils.js'; import type { FileEntry, Resolution } from './types.js'; interface Props { @@ -17,7 +18,7 @@ const uid = $props.id(); - let nameOnly = $derived(entry.targetKey.split('/').at(-1) ?? entry.file.name); + let nameOnly = $derived(keyToName(entry.targetKey) || entry.file.name); let badRename = $derived( entry.resolution === 'rename' && (entry.customName.trim() === '' || entry.customName.trim() === entry.file.name) diff --git a/src/lib/components/storage/modals/upload/UploadEntryStatus.svelte b/src/lib/components/storage/modals/upload/UploadEntryStatus.svelte index 16a6c88e..4d7f4cae 100644 --- a/src/lib/components/storage/modals/upload/UploadEntryStatus.svelte +++ b/src/lib/components/storage/modals/upload/UploadEntryStatus.svelte @@ -4,6 +4,7 @@ import IconBlock from 'virtual:icons/material-symbols/block'; import IconSchedule from 'virtual:icons/material-symbols/schedule'; import * as m from '$lib/paraglide/messages.js'; + import { keyToName } from '$lib/storage/utils.js'; import type { FileEntry } from './types.js'; interface Props { @@ -15,7 +16,7 @@ let nameOnly = $derived( entry.resolution === 'rename' && entry.customName.trim() ? entry.customName.trim() - : (entry.targetKey.split('/').at(-1) ?? entry.file.name) + : keyToName(entry.targetKey) || entry.file.name ); diff --git a/src/lib/components/storage/modals/upload/UploadModal.svelte b/src/lib/components/storage/modals/upload/UploadModal.svelte index baa67985..8e320d49 100644 --- a/src/lib/components/storage/modals/upload/UploadModal.svelte +++ b/src/lib/components/storage/modals/upload/UploadModal.svelte @@ -7,7 +7,7 @@ import * as m from '$lib/paraglide/messages.js'; import Modal from '$lib/components/Modal.svelte'; import { checkObjectExists, uploadFile, UploadError } from '$lib/storage/upload.js'; - import { formatFileSize } from '$lib/storage/utils.js'; + import { formatFileSize, keyToName } from '$lib/storage/utils.js'; import { loadConnectionLocally, getConnectionHeader } from '$lib/storage/connection-storage.js'; import { uploadConcurrency } from '$lib/client/feature-flags.js'; import UploadDropzone from './UploadDropzone.svelte'; @@ -488,7 +488,7 @@ {@const errName = entry.resolution === 'rename' && entry.customName.trim() ? entry.customName.trim() - : (entry.targetKey.split('/').at(-1) ?? entry.file.name)} + : keyToName(entry.targetKey) || entry.file.name}
  • {errName}: {entry.errorMessage}
  • diff --git a/src/lib/storage/download.ts b/src/lib/storage/download.ts index 61ea9460..bbae2fbe 100644 --- a/src/lib/storage/download.ts +++ b/src/lib/storage/download.ts @@ -16,6 +16,7 @@ */ import { STORAGE_CONNECTION_HEADER } from '$lib/storage/connection-storage.js'; +import { keyToName } from '$lib/storage/utils.js'; export type DownloadErrorCode = | 'not_connected' @@ -73,8 +74,7 @@ export async function downloadObject( const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); - // Derive filename from the key (last path segment). - const filename = key.split('/').filter(Boolean).pop() ?? key; + const filename = keyToName(key); const anchor = document.createElement('a'); anchor.href = blobUrl; diff --git a/src/routes/(app)/api/storage/download/+server.ts b/src/routes/(app)/api/storage/download/+server.ts index d8e09bb8..5a128231 100644 --- a/src/routes/(app)/api/storage/download/+server.ts +++ b/src/routes/(app)/api/storage/download/+server.ts @@ -1,11 +1,7 @@ import type { RequestHandler } from './$types'; import { downloadObject, getObjectMetadata } from '$lib/server/storage/service.js'; import { requireBucketKey } from '../params.js'; - -/** Derive the bare filename from a (possibly path-prefixed) object key. */ -function filenameFromKey(key: string): string { - return key.split('/').filter(Boolean).pop() ?? key; -} +import { keyToName } from '$lib/storage/utils.js'; /** * GET /storage/api/download?bucket=&key= @@ -25,7 +21,7 @@ export const GET: RequestHandler = async ({ locals, url }) => { const download = await downloadObject(locals.storageConfig!, bucket, key); - const filename = filenameFromKey(key); + const filename = keyToName(key); // RFC 5987 encoding for non-ASCII filenames in Content-Disposition const encodedFilename = encodeURIComponent(filename); const contentDisposition = `attachment; filename="${filename}"; filename*=UTF-8''${encodedFilename}`; From ed88379621baa935d39bb870b2e9a5d1ecec8621 Mon Sep 17 00:00:00 2001 From: Johannes Fleck Date: Fri, 25 Sep 2026 13:03:26 +0200 Subject: [PATCH 4/6] ref: locationLabels --- .../storage/landing/RecentItems.svelte | 15 ++--- .../storage/sidebar/BucketList.svelte | 8 +-- src/lib/storage/display-helpers.ts | 65 +++++++++---------- 3 files changed, 41 insertions(+), 47 deletions(-) diff --git a/src/lib/components/storage/landing/RecentItems.svelte b/src/lib/components/storage/landing/RecentItems.svelte index f86534a5..463cddf0 100644 --- a/src/lib/components/storage/landing/RecentItems.svelte +++ b/src/lib/components/storage/landing/RecentItems.svelte @@ -7,14 +7,13 @@ import IconStorage from 'virtual:icons/material-symbols/storage'; import IconFolderOutline from 'virtual:icons/material-symbols/folder-outline'; import * as m from '$lib/paraglide/messages.js'; - import { formatFileSize } from '$lib/storage/utils.js'; + import { formatFileSize, keyToName } from '$lib/storage/utils.js'; import { getStorageState } from '$lib/storage/context.js'; import { - fileName, fileHref, fileLocation, locationHref, - locationName, + locationLabel, locationPath } from '$lib/storage/display-helpers.js'; import PreviewModal from '$lib/components/storage/modals/PreviewModal.svelte'; @@ -63,7 +62,7 @@
    @@ -88,7 +87,7 @@
    @@ -164,7 +163,7 @@ href={locationHref(loc)} data-sveltekit-preload-data="off" class="btn btn-ghost btn-xs" - aria-label="{m.storage_recent_go_to_location()} — {locationName(loc)}" + aria-label="{m.storage_recent_go_to_location()} — {locationLabel(loc)}" >