mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:21:08 +02:00
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:
parent
9f66800945
commit
56307a3dbb
7 changed files with 371 additions and 39 deletions
324
apps/storage/apps/web/src/lib/components/files/EmptyState.svelte
Normal file
324
apps/storage/apps/web/src/lib/components/files/EmptyState.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue