feat(storage): add SVG empty state illustrations for all pages

Replace plain text empty states with themed SVG illustrations:
- files/folder: cloud folder with upload arrows
- trash: empty bin with checkmark
- favorites: star outline
- search: magnifying glass
- shared: connected nodes

Reusable EmptyState component with snippet-based action slots.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 13:01:02 +01:00
parent 9f66800945
commit 56307a3dbb
7 changed files with 371 additions and 39 deletions

View file

@ -0,0 +1,324 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Which illustration to show */
type: 'files' | 'trash' | 'favorites' | 'search' | 'folder' | 'shared';
title: string;
description: string;
actions?: Snippet;
}
let { type, title, description, actions }: Props = $props();
</script>
<div class="empty-state">
<div class="illustration">
{#if type === 'files' || type === 'folder'}
<!-- Cloud folder illustration -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" fill="rgb(var(--color-primary) / 0.06)" />
<rect
x="30"
y="42"
width="60"
height="40"
rx="4"
fill="rgb(var(--color-primary) / 0.15)"
stroke="rgb(var(--color-primary) / 0.3)"
stroke-width="1.5"
/>
<path
d="M30 46C30 43.7909 31.7909 42 34 42H48L53 36H86C88.2091 36 90 37.7909 90 40V42H30Z"
fill="rgb(var(--color-primary) / 0.25)"
stroke="rgb(var(--color-primary) / 0.3)"
stroke-width="1.5"
/>
<path
d="M52 60L56 56L60 60"
stroke="rgb(var(--color-primary) / 0.5)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="56"
y1="56"
x2="56"
y2="72"
stroke="rgb(var(--color-primary) / 0.5)"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M64 60L68 56L72 60"
stroke="rgb(var(--color-primary) / 0.3)"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="68"
y1="56"
x2="68"
y2="68"
stroke="rgb(var(--color-primary) / 0.3)"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
{:else if type === 'trash'}
<!-- Empty trash illustration -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" fill="rgb(var(--color-text-secondary) / 0.05)" />
<rect
x="40"
y="44"
width="40"
height="42"
rx="3"
fill="rgb(var(--color-text-secondary) / 0.08)"
stroke="rgb(var(--color-text-secondary) / 0.2)"
stroke-width="1.5"
/>
<rect
x="36"
y="38"
width="48"
height="6"
rx="2"
fill="rgb(var(--color-text-secondary) / 0.12)"
stroke="rgb(var(--color-text-secondary) / 0.2)"
stroke-width="1.5"
/>
<line
x1="50"
y1="52"
x2="50"
y2="78"
stroke="rgb(var(--color-text-secondary) / 0.15)"
stroke-width="1.5"
stroke-linecap="round"
/>
<line
x1="60"
y1="52"
x2="60"
y2="78"
stroke="rgb(var(--color-text-secondary) / 0.15)"
stroke-width="1.5"
stroke-linecap="round"
/>
<line
x1="70"
y1="52"
x2="70"
y2="78"
stroke="rgb(var(--color-text-secondary) / 0.15)"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M52 38V34C52 32.8954 52.8954 32 54 32H66C67.1046 32 68 32.8954 68 34V38"
stroke="rgb(var(--color-text-secondary) / 0.2)"
stroke-width="1.5"
/>
<circle
cx="60"
cy="62"
r="8"
fill="rgb(var(--color-success) / 0.1)"
stroke="rgb(var(--color-success) / 0.3)"
stroke-width="1.5"
/>
<path
d="M56 62L59 65L65 59"
stroke="rgb(var(--color-success) / 0.5)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{:else if type === 'favorites'}
<!-- No favorites illustration -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" fill="rgb(var(--color-warning) / 0.05)" />
<path
d="M60 40L65.5 51.2L78 53L69 61.8L71 74L60 68.2L49 74L51 61.8L42 53L54.5 51.2L60 40Z"
fill="rgb(var(--color-warning) / 0.1)"
stroke="rgb(var(--color-warning) / 0.3)"
stroke-width="1.5"
stroke-linejoin="round"
/>
<circle cx="60" cy="57" r="4" fill="rgb(var(--color-warning) / 0.15)" />
<path
d="M60 45L63 51"
stroke="rgb(var(--color-warning) / 0.2)"
stroke-width="1"
stroke-linecap="round"
/>
<path
d="M60 45L57 51"
stroke="rgb(var(--color-warning) / 0.2)"
stroke-width="1"
stroke-linecap="round"
/>
</svg>
{:else if type === 'search'}
<!-- No results illustration -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" fill="rgb(var(--color-primary) / 0.05)" />
<circle
cx="54"
cy="54"
r="16"
fill="rgb(var(--color-primary) / 0.06)"
stroke="rgb(var(--color-primary) / 0.25)"
stroke-width="2"
/>
<line
x1="65"
y1="65"
x2="78"
y2="78"
stroke="rgb(var(--color-primary) / 0.25)"
stroke-width="3"
stroke-linecap="round"
/>
<path
d="M48 54H60"
stroke="rgb(var(--color-primary) / 0.15)"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M54 48V60"
stroke="rgb(var(--color-primary) / 0.15)"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
{:else if type === 'shared'}
<!-- No shares illustration -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" fill="rgb(var(--color-primary) / 0.05)" />
<circle
cx="44"
cy="52"
r="6"
fill="rgb(var(--color-primary) / 0.1)"
stroke="rgb(var(--color-primary) / 0.25)"
stroke-width="1.5"
/>
<circle
cx="76"
cy="42"
r="6"
fill="rgb(var(--color-primary) / 0.1)"
stroke="rgb(var(--color-primary) / 0.25)"
stroke-width="1.5"
/>
<circle
cx="76"
cy="68"
r="6"
fill="rgb(var(--color-primary) / 0.1)"
stroke="rgb(var(--color-primary) / 0.25)"
stroke-width="1.5"
/>
<line
x1="50"
y1="50"
x2="70"
y2="44"
stroke="rgb(var(--color-primary) / 0.2)"
stroke-width="1.5"
stroke-dasharray="3 3"
/>
<line
x1="50"
y1="55"
x2="70"
y2="65"
stroke="rgb(var(--color-primary) / 0.2)"
stroke-width="1.5"
stroke-dasharray="3 3"
/>
</svg>
{/if}
</div>
<h2 class="empty-title">{title}</h2>
<p class="empty-description">{description}</p>
{#if actions}
<div class="empty-actions">
{@render actions()}
</div>
{/if}
</div>
<style>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
}
.illustration {
margin-bottom: 1rem;
}
.empty-title {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: rgb(var(--color-text-primary));
}
.empty-description {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: rgb(var(--color-text-secondary));
max-width: 320px;
line-height: 1.5;
}
.empty-actions {
display: flex;
gap: 0.75rem;
}
</style>

View file

@ -11,6 +11,7 @@
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
import FileSkeletonGrid from '$lib/components/files/FileSkeletonGrid.svelte';
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
let previewFile = $state<StorageFile | null>(null);
let files = $state<StorageFile[]>([]);
@ -111,11 +112,11 @@
<button onclick={loadFavorites}>Erneut versuchen</button>
</div>
{:else if files.length === 0 && folders.length === 0}
<div class="empty-state">
<Heart size={48} />
<h2>Keine Favoriten</h2>
<p>Markiere Dateien und Ordner als Favoriten, um sie hier schnell zu finden.</p>
</div>
<EmptyState
type="favorites"
title="Keine Favoriten"
description="Markiere Dateien und Ordner als Favoriten, um sie hier schnell zu finden."
/>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
{files}

View file

@ -13,6 +13,7 @@
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
import FileSkeletonGrid from '$lib/components/files/FileSkeletonGrid.svelte';
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
let previewFile = $state<StorageFile | null>(null);
let showUploadZone = $state(false);
@ -247,11 +248,12 @@
<button onclick={() => filesStore.loadFolder()}>Erneut versuchen</button>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
<div class="empty-state">
<UploadSimple size={48} />
<h2>Noch keine Dateien</h2>
<p>Lade deine ersten Dateien hoch oder erstelle einen Ordner.</p>
<div class="empty-actions">
<EmptyState
type="files"
title="Noch keine Dateien"
description="Lade deine ersten Dateien hoch oder erstelle einen Ordner."
>
{#snippet actions()}
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
@ -260,8 +262,8 @@
<UploadSimple size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{/snippet}
</EmptyState>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}

View file

@ -14,6 +14,7 @@
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
import FileSkeletonGrid from '$lib/components/files/FileSkeletonGrid.svelte';
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
let previewFile = $state<StorageFile | null>(null);
let showUploadZone = $state(false);
@ -263,11 +264,12 @@
<button onclick={() => filesStore.loadFolder(folderId)}>Erneut versuchen</button>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
<div class="empty-state">
<UploadSimple size={48} />
<h2>Leerer Ordner</h2>
<p>Dieser Ordner ist leer. Lade Dateien hoch oder erstelle Unterordner.</p>
<div class="empty-actions">
<EmptyState
type="folder"
title="Leerer Ordner"
description="Dieser Ordner ist leer. Lade Dateien hoch oder erstelle Unterordner."
>
{#snippet actions()}
<button class="action-btn" onclick={() => (showNewFolderModal = true)}>
<FolderPlus size={18} />
<span>Neuer Ordner</span>
@ -276,8 +278,8 @@
<UploadSimple size={18} />
<span>Hochladen</span>
</button>
</div>
</div>
{/snippet}
</EmptyState>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}

View file

@ -7,6 +7,7 @@
import { StorageEvents } from '@manacore/shared-utils/analytics';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { filesStore } from '$lib/stores/files.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
import FileGrid from '$lib/components/files/FileGrid.svelte';
import FileList from '$lib/components/files/FileList.svelte';
import FilePreviewModal from '$lib/components/files/FilePreviewModal.svelte';
@ -118,11 +119,11 @@
<p>Suche läuft...</p>
</div>
{:else if searched && files.length === 0 && folders.length === 0}
<div class="empty-state">
<MagnifyingGlass size={48} />
<h2>Keine Ergebnisse</h2>
<p>Keine Dateien oder Ordner für "{query}" gefunden.</p>
</div>
<EmptyState
type="search"
title="Keine Ergebnisse"
description={'Keine Dateien oder Ordner für "' + query + '" gefunden.'}
/>
{:else if searched}
<div class="results-header">
<span>{files.length + folders.length} Ergebnis(se) für "{query}"</span>
@ -134,11 +135,11 @@
<FileList {files} {folders} onFileClick={handleFileClick} onFolderClick={handleFolderClick} />
{/if}
{:else}
<div class="empty-state">
<MagnifyingGlass size={48} />
<h2>Dateien durchsuchen</h2>
<p>Gib einen Suchbegriff ein, um Dateien und Ordner zu finden.</p>
</div>
<EmptyState
type="search"
title="Dateien durchsuchen"
description="Gib einen Suchbegriff ein, um Dateien und Ordner zu finden."
/>
{/if}
</div>

View file

@ -5,6 +5,7 @@
import { StorageEvents } from '@manacore/shared-utils/analytics';
import type { Share } from '$lib/api/client';
import { toastStore } from '@manacore/shared-ui';
import EmptyState from '$lib/components/files/EmptyState.svelte';
let shares = $state<Share[]>([]);
let loading = $state(true);
@ -95,11 +96,11 @@
<button onclick={loadShares}>Erneut versuchen</button>
</div>
{:else if shares.length === 0}
<div class="empty-state">
<ShareNetwork size={48} />
<h2>Keine geteilten Links</h2>
<p>Teile Dateien oder Ordner, um Links hier zu verwalten.</p>
</div>
<EmptyState
type="shared"
title="Keine geteilten Links"
description="Teile Dateien oder Ordner, um Links hier zu verwalten."
/>
{:else}
<div class="shares-list">
{#each shares as share (share.id)}

View file

@ -6,6 +6,7 @@
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { toastStore } from '@manacore/shared-ui';
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
@ -117,11 +118,11 @@
<button onclick={loadTrash}>Erneut versuchen</button>
</div>
{:else if files.length === 0 && folders.length === 0}
<div class="empty-state">
<Trash size={48} />
<h2>Papierkorb ist leer</h2>
<p>Gelöschte Dateien und Ordner erscheinen hier.</p>
</div>
<EmptyState
type="trash"
title="Papierkorb ist leer"
description="Gelöschte Dateien und Ordner erscheinen hier."
/>
{:else}
<div class="trash-list">
{#each folders as folder (folder.id)}