feat(storage): add bulk file operations with multi-select

- Selection state in files store with toggle/selectAll/clearSelection
- Checkboxes appear on FileCard/FolderCard when selection is active
- BulkActionBar with count display, delete, select all, clear
- Click toggles selection when in selection mode, normal click otherwise
- Selection cleared on folder navigation
- Animated slide-in action bar with primary color accent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 13:42:45 +01:00
parent 5c69dc7d5d
commit a85682d829
6 changed files with 275 additions and 2 deletions

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { X, Trash, CheckSquare } from '@manacore/shared-icons';
import { filesStore } from '$lib/stores/files.svelte';
import { toastStore } from '@manacore/shared-ui';
let deleting = $state(false);
async function handleDelete() {
const count = filesStore.selectionCount;
if (!confirm(`${count} Element(e) in den Papierkorb verschieben?`)) return;
deleting = true;
const result = await filesStore.deleteSelected();
deleting = false;
if (result.hasErrors) {
toastStore.error('Einige Elemente konnten nicht gelöscht werden');
} else {
toastStore.success(`${result.deleted} Element(e) gelöscht`);
}
}
</script>
{#if filesStore.selectionCount > 0}
<div class="bulk-bar">
<div class="bulk-info">
<CheckSquare size={16} />
<span>{filesStore.selectionCount} ausgewählt</span>
</div>
<div class="bulk-actions">
<button class="bulk-btn danger" onclick={handleDelete} disabled={deleting}>
<Trash size={16} />
<span>{deleting ? 'Lösche...' : 'Löschen'}</span>
</button>
<button class="bulk-btn" onclick={() => filesStore.selectAll()}> Alle auswählen </button>
<button
class="bulk-btn close"
onclick={() => filesStore.clearSelection()}
aria-label="Auswahl aufheben"
>
<X size={16} />
</button>
</div>
</div>
{/if}
<style>
.bulk-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1rem;
margin-bottom: 1rem;
background: rgb(var(--color-primary) / 0.08);
border: 1px solid rgb(var(--color-primary) / 0.2);
border-radius: var(--radius-lg);
animation: slideIn 200ms ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bulk-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: rgb(var(--color-primary));
}
.bulk-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bulk-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: rgb(var(--color-surface-elevated));
border: 1px solid rgb(var(--color-border));
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: rgb(var(--color-text-primary));
cursor: pointer;
transition: all 150ms ease;
}
.bulk-btn:hover {
background: rgb(var(--color-surface));
}
.bulk-btn.danger {
color: rgb(var(--color-error));
border-color: rgb(var(--color-error) / 0.3);
}
.bulk-btn.danger:hover {
background: rgb(var(--color-error));
color: white;
}
.bulk-btn.close {
padding: 0.375rem;
}
.bulk-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 640px) {
.bulk-btn span {
display: none;
}
}
</style>

View file

@ -11,6 +11,7 @@
DotsThreeVertical,
} from '@manacore/shared-icons';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { filesStore } from '$lib/stores/files.svelte';
interface Props {
file: StorageFile;
@ -20,6 +21,9 @@
let { file, onClick, onAction }: Props = $props();
let isSelected = $derived(filesStore.selectedFileIds.has(file.id));
let hasSelection = $derived(filesStore.selectionCount > 0);
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
@ -80,7 +84,15 @@
<div
class="file-card"
class:dragging={isDragging}
onclick={onClick}
class:selected={isSelected}
onclick={(e) => {
if (hasSelection) {
e.stopPropagation();
filesStore.toggleFileSelection(file.id);
} else {
onClick?.();
}
}}
oncontextmenu={handleContextMenu}
role="button"
tabindex="0"
@ -94,6 +106,18 @@
isDragging = false;
}}
>
{#if hasSelection}
<input
type="checkbox"
class="select-checkbox"
checked={isSelected}
onclick={(e) => {
e.stopPropagation();
filesStore.toggleFileSelection(file.id);
}}
aria-label="Datei auswählen"
/>
{/if}
<div class="file-icon">
<Icon size={40} strokeWidth={1.5} />
{#if file.isFavorite}
@ -146,6 +170,21 @@
box-shadow: var(--shadow-md);
}
.file-card.selected {
border-color: rgb(var(--color-primary));
background: rgb(var(--color-primary) / 0.06);
}
.select-checkbox {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 16px;
height: 16px;
accent-color: rgb(var(--color-primary));
cursor: pointer;
}
.file-card.dragging {
opacity: 0.5;
transform: scale(0.95);

View file

@ -1,6 +1,7 @@
<script lang="ts">
import type { StorageFolder } from '$lib/api/client';
import { Folder, Heart, DotsThreeVertical } from '@manacore/shared-icons';
import { filesStore } from '$lib/stores/files.svelte';
interface Props {
folder: StorageFolder;
@ -11,6 +12,9 @@
let { folder, onClick, onAction, onDrop }: Props = $props();
let isSelected = $derived(filesStore.selectedFolderIds.has(folder.id));
let hasSelection = $derived(filesStore.selectionCount > 0);
let showMenu = $state(false);
let isDragOver = $state(false);
let isDragging = $state(false);
@ -71,7 +75,15 @@
class="folder-card"
class:drag-over={isDragOver}
class:dragging={isDragging}
onclick={onClick}
class:selected={isSelected}
onclick={(e) => {
if (hasSelection) {
e.stopPropagation();
filesStore.toggleFolderSelection(folder.id);
} else {
onClick?.();
}
}}
role="button"
tabindex="0"
draggable="true"
@ -87,6 +99,18 @@
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
{#if hasSelection}
<input
type="checkbox"
class="select-checkbox"
checked={isSelected}
onclick={(e) => {
e.stopPropagation();
filesStore.toggleFolderSelection(folder.id);
}}
aria-label="Ordner auswählen"
/>
{/if}
<div class="folder-icon" style:color={folderColor}>
<Folder size={40} strokeWidth={1.5} fill="currentColor" />
{#if folder.isFavorite}
@ -143,6 +167,22 @@
box-shadow: var(--shadow-md);
}
.folder-card.selected {
border-color: rgb(var(--color-primary));
background: rgb(var(--color-primary) / 0.06);
}
.select-checkbox {
position: absolute;
top: 0.5rem;
left: 0.5rem;
width: 16px;
height: 16px;
accent-color: rgb(var(--color-primary));
cursor: pointer;
z-index: 2;
}
.folder-card.drag-over {
border-color: rgb(var(--color-success));
border-style: dashed;

View file

@ -12,6 +12,8 @@ let currentFolder = $state<StorageFolder | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let viewMode = $state<'grid' | 'list'>('grid');
let selectedFileIds = $state<Set<string>>(new Set());
let selectedFolderIds = $state<Set<string>>(new Set());
export const filesStore = {
get files() {
@ -32,6 +34,59 @@ export const filesStore = {
get viewMode() {
return viewMode;
},
get selectedFileIds() {
return selectedFileIds;
},
get selectedFolderIds() {
return selectedFolderIds;
},
get selectionCount() {
return selectedFileIds.size + selectedFolderIds.size;
},
toggleFileSelection(id: string) {
const next = new Set(selectedFileIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedFileIds = next;
},
toggleFolderSelection(id: string) {
const next = new Set(selectedFolderIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedFolderIds = next;
},
selectAll() {
selectedFileIds = new Set(files.map((f) => f.id));
selectedFolderIds = new Set(folders.map((f) => f.id));
},
clearSelection() {
selectedFileIds = new Set();
selectedFolderIds = new Set();
},
async deleteSelected() {
const fileIds = [...selectedFileIds];
const folderIds = [...selectedFolderIds];
const results = await Promise.all([
...fileIds.map((id) => filesApi.delete(id)),
...folderIds.map((id) => foldersApi.delete(id)),
]);
const hasErrors = results.some((r) => r.error);
if (!hasErrors) {
files = files.filter((f) => !selectedFileIds.has(f.id));
folders = folders.filter((f) => !selectedFolderIds.has(f.id));
}
selectedFileIds = new Set();
selectedFolderIds = new Set();
return { deleted: fileIds.length + folderIds.length, hasErrors };
},
setViewMode(mode: 'grid' | 'list') {
viewMode = mode;
@ -53,6 +108,8 @@ export const filesStore = {
async loadFolder(folderId?: string) {
loading = true;
error = null;
selectedFileIds = new Set();
selectedFolderIds = new Set();
try {
if (folderId) {

View file

@ -15,6 +15,7 @@
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
import ShareModal from '$lib/components/files/ShareModal.svelte';
import BulkActionBar from '$lib/components/files/BulkActionBar.svelte';
let previewFile = $state<StorageFile | null>(null);
let shareTarget = $state<{ fileId?: string; folderId?: string; name: string } | null>(null);
@ -236,6 +237,8 @@
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
<BulkActionBar />
{#if filesStore.loading}
{#if filesStore.viewMode === 'grid'}
<FileSkeletonGrid />

View file

@ -16,6 +16,7 @@
import FileSkeletonList from '$lib/components/files/FileSkeletonList.svelte';
import EmptyState from '$lib/components/files/EmptyState.svelte';
import ShareModal from '$lib/components/files/ShareModal.svelte';
import BulkActionBar from '$lib/components/files/BulkActionBar.svelte';
let previewFile = $state<StorageFile | null>(null);
let shareTarget = $state<{ fileId?: string; folderId?: string; name: string } | null>(null);
@ -254,6 +255,8 @@
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
<BulkActionBar />
{#if filesStore.loading}
{#if filesStore.viewMode === 'grid'}
<FileSkeletonGrid />