mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
5c69dc7d5d
commit
a85682d829
6 changed files with 275 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue