mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 02:01:10 +02:00
feat(storage): add drag & drop file moving and Playwright E2E tests
Drag & Drop: - FileCard: draggable with type/id data transfer - FolderCard: draggable + drop target with visual feedback (dashed green border) - FileGrid: onMoveToFolder callback for drag-to-folder operations - filesStore: moveFile() and moveFolder() methods via API - Wired up in /files and /files/[folderId] pages with toast notifications E2E Tests (Playwright): - playwright.config.ts with multi-browser support - auth.spec.ts: login page rendering, invalid credentials, redirect - files.spec.ts: file list UI, view toggle, new folder modal, empty state - navigation.spec.ts: nav items, routing, page headings - search.spec.ts: search input, button state, initial state Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a12cbeb8bb
commit
4bbe4a27d1
12 changed files with 534 additions and 6 deletions
59
apps/storage/apps/web/e2e/auth.spec.ts
Normal file
59
apps/storage/apps/web/e2e/auth.spec.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Auth tests run WITHOUT storageState (unauthenticated)
|
||||
|
||||
// Helper: wait for the app to finish loading (skeleton disappears)
|
||||
async function waitForAppReady(page: import('@playwright/test').Page) {
|
||||
// The root layout shows a loading spinner until auth initializes
|
||||
// Wait for it to disappear and the actual page content to render
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Check if loading skeleton is gone
|
||||
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
|
||||
return !skeleton || skeleton.children.length === 0;
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
// Give Svelte time to render
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('login page renders with email and password fields', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// LoginPage uses id="email" and id="password" (from shared-auth-ui)
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email');
|
||||
const passwordInput = page.locator('input[type="password"], input[name="password"], #password');
|
||||
|
||||
await expect(emailInput.first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(passwordInput.first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('invalid credentials show error message', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
|
||||
const passwordInput = page
|
||||
.locator('input[type="password"], input[name="password"], #password')
|
||||
.first();
|
||||
|
||||
await emailInput.fill('nonexistent@test.local');
|
||||
await passwordInput.fill('WrongPassword123!');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Error alert should appear
|
||||
const errorAlert = page.locator('#form-error, [role="alert"]');
|
||||
await expect(errorAlert.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('unauthenticated access to /files redirects to /login', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
|
||||
// The app layout's onMount redirects unauthenticated users to /login
|
||||
await page.waitForURL(/\/login/, { timeout: 30000 });
|
||||
});
|
||||
});
|
||||
119
apps/storage/apps/web/e2e/files.spec.ts
Normal file
119
apps/storage/apps/web/e2e/files.spec.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper: wait for the app to finish loading
|
||||
async function waitForAppReady(page: import('@playwright/test').Page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
|
||||
return !skeleton || skeleton.children.length === 0;
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('File Management', () => {
|
||||
test('root page (/) redirects to /files', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// The root +page.svelte does goto('/files') on mount
|
||||
await page.waitForURL(/\/files/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test('files page shows "Meine Dateien" heading', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const heading = page.locator('h1', { hasText: 'Meine Dateien' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('files page has view toggle buttons (grid/list)', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const gridButton = page.locator('button[aria-label="Rasteransicht"]');
|
||||
const listButton = page.locator('button[aria-label="Listenansicht"]');
|
||||
|
||||
await expect(gridButton).toBeVisible({ timeout: 10000 });
|
||||
await expect(listButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('files page has "Hochladen" and "Neuer Ordner" buttons', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const uploadButton = page.locator('button', { hasText: 'Hochladen' });
|
||||
const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' });
|
||||
|
||||
await expect(uploadButton).toBeVisible({ timeout: 10000 });
|
||||
await expect(newFolderButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking "Neuer Ordner" opens modal with name input', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// Click the "Neuer Ordner" button
|
||||
const newFolderButton = page.locator('button', { hasText: 'Neuer Ordner' }).first();
|
||||
await newFolderButton.click();
|
||||
|
||||
// Modal should appear with role="dialog"
|
||||
const modal = page.locator('[role="dialog"]');
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Modal should have the title "Neuer Ordner"
|
||||
const modalTitle = page.locator('#modal-title');
|
||||
await expect(modalTitle).toHaveText('Neuer Ordner');
|
||||
|
||||
// Modal should have a folder name input
|
||||
const nameInput = page.locator('#folder-name');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await expect(nameInput).toHaveAttribute('type', 'text');
|
||||
|
||||
// Modal should have a submit button
|
||||
const createButton = page.locator('[role="dialog"] button[type="submit"]');
|
||||
await expect(createButton).toBeVisible();
|
||||
await expect(createButton).toHaveText('Erstellen');
|
||||
});
|
||||
|
||||
test('empty state shows upload prompt', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// When no files exist, the empty state should show
|
||||
// It may show loading first, then empty state or file list
|
||||
// We check for either empty state or file content
|
||||
const emptyState = page.locator('.empty-state');
|
||||
const fileContent = page.locator('.files-page');
|
||||
|
||||
await expect(fileContent).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// If empty state is visible, verify its content
|
||||
if (await emptyState.isVisible()) {
|
||||
const emptyHeading = page.locator('.empty-state h2', { hasText: 'Noch keine Dateien' });
|
||||
await expect(emptyHeading).toBeVisible();
|
||||
|
||||
const emptyText = page.locator('.empty-state p', {
|
||||
hasText: 'Lade deine ersten Dateien hoch',
|
||||
});
|
||||
await expect(emptyText).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('view toggle switches between grid and list view', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const gridButton = page.locator('button[aria-label="Rasteransicht"]');
|
||||
const listButton = page.locator('button[aria-label="Listenansicht"]');
|
||||
|
||||
// Click list view button
|
||||
await listButton.click();
|
||||
await expect(listButton).toHaveClass(/active/);
|
||||
|
||||
// Click grid view button
|
||||
await gridButton.click();
|
||||
await expect(gridButton).toHaveClass(/active/);
|
||||
});
|
||||
});
|
||||
98
apps/storage/apps/web/e2e/navigation.spec.ts
Normal file
98
apps/storage/apps/web/e2e/navigation.spec.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper: wait for the app to finish loading
|
||||
async function waitForAppReady(page: import('@playwright/test').Page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
|
||||
return !skeleton || skeleton.children.length === 0;
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('all nav items exist: Dateien, Geteilt, Favoriten, Papierkorb, Suche', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// The PillNavigation renders navigation links
|
||||
const navItems = ['Dateien', 'Geteilt', 'Favoriten', 'Papierkorb', 'Suche'];
|
||||
|
||||
for (const label of navItems) {
|
||||
const navLink = page.locator(`a, button`, { hasText: label }).first();
|
||||
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking navigation items changes URL', async ({ page }) => {
|
||||
await page.goto('/files');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// Navigate to Favoriten
|
||||
const favoritesLink = page.locator('a[href="/favorites"]').first();
|
||||
await favoritesLink.click();
|
||||
await expect(page).toHaveURL(/\/favorites/);
|
||||
|
||||
// Navigate to Papierkorb
|
||||
const trashLink = page.locator('a[href="/trash"]').first();
|
||||
await trashLink.click();
|
||||
await expect(page).toHaveURL(/\/trash/);
|
||||
|
||||
// Navigate to Suche
|
||||
const searchLink = page.locator('a[href="/search"]').first();
|
||||
await searchLink.click();
|
||||
await expect(page).toHaveURL(/\/search/);
|
||||
|
||||
// Navigate back to Dateien
|
||||
const filesLink = page.locator('a[href="/files"]').first();
|
||||
await filesLink.click();
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test('search page has search input', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
await expect(searchInput).toHaveAttribute('placeholder', 'Dateien und Ordner durchsuchen...');
|
||||
});
|
||||
|
||||
test('trash page shows "Papierkorb" heading', async ({ page }) => {
|
||||
await page.goto('/trash');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const heading = page.locator('h1', { hasText: 'Papierkorb' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('settings page loads', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// The settings page has a title "Einstellungen"
|
||||
const heading = page.getByText('Einstellungen', { exact: false }).first();
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('offline page renders correctly', async ({ page }) => {
|
||||
await page.goto('/offline');
|
||||
|
||||
// The offline page shows either "Du bist offline" or "Verbindung wiederhergestellt"
|
||||
// Since we are connected during tests, it may redirect
|
||||
// But we can check the page title
|
||||
await expect(page).toHaveTitle(/Offline - Storage/);
|
||||
|
||||
// The page should contain the offline heading or redirect message
|
||||
const offlineHeading = page.locator('h1');
|
||||
await expect(offlineHeading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show one of the expected texts
|
||||
const headingText = await offlineHeading.textContent();
|
||||
expect(
|
||||
headingText === 'Du bist offline' || headingText === 'Verbindung wiederhergestellt!'
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
79
apps/storage/apps/web/e2e/search.spec.ts
Normal file
79
apps/storage/apps/web/e2e/search.spec.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Helper: wait for the app to finish loading
|
||||
async function waitForAppReady(page: import('@playwright/test').Page) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
|
||||
return !skeleton || skeleton.children.length === 0;
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Search', () => {
|
||||
test('search page renders with input field', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// Page heading
|
||||
const heading = page.locator('h1', { hasText: 'Suche' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search input field
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
});
|
||||
|
||||
test('search input has correct type="search"', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
await expect(searchInput).toHaveAttribute('type', 'search');
|
||||
await expect(searchInput).toHaveAttribute('aria-label', 'Dateien und Ordner durchsuchen');
|
||||
});
|
||||
|
||||
test('empty search shows initial state message', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// When no search has been performed, the initial state should show
|
||||
const initialMessage = page.locator('.empty-state h2', {
|
||||
hasText: 'Dateien durchsuchen',
|
||||
});
|
||||
await expect(initialMessage).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const hintText = page.locator('.empty-state p', {
|
||||
hasText: 'Gib einen Suchbegriff ein',
|
||||
});
|
||||
await expect(hintText).toBeVisible();
|
||||
});
|
||||
|
||||
test('search button exists and is disabled when input is empty', async ({ page }) => {
|
||||
await page.goto('/search');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// The search bar has a submit button
|
||||
const searchButton = page.locator('.search-bar button', { hasText: 'Suchen' });
|
||||
await expect(searchButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Button should be disabled when input is empty
|
||||
await expect(searchButton).toBeDisabled();
|
||||
|
||||
// Type something into the search input
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill('test query');
|
||||
|
||||
// Button should now be enabled
|
||||
await expect(searchButton).toBeEnabled();
|
||||
|
||||
// Clear the input
|
||||
await searchInput.fill('');
|
||||
|
||||
// Button should be disabled again
|
||||
await expect(searchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -11,7 +11,9 @@
|
|||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/svelte": "^5.2.0",
|
||||
|
|
@ -36,6 +38,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
|
|
|
|||
39
apps/storage/apps/web/playwright.config.ts
Normal file
39
apps/storage/apps/web/playwright.config.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['html']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5185',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
timeout: 60000,
|
||||
expect: { timeout: 5000 },
|
||||
|
||||
projects: process.env.CI
|
||||
? [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
]
|
||||
: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm run build && pnpm run preview --port 5185',
|
||||
port: 5185,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
|
||||
outputDir: 'test-results/',
|
||||
});
|
||||
|
|
@ -51,7 +51,17 @@
|
|||
const Icon = getFileIcon(file.mimeType);
|
||||
</script>
|
||||
|
||||
<div class="file-card" onclick={onClick} role="button" tabindex="0">
|
||||
<div
|
||||
class="file-card"
|
||||
onclick={onClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
ondragstart={(e) => {
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'file', id: file.id }));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
>
|
||||
<div class="file-icon">
|
||||
<Icon size={40} strokeWidth={1.5} />
|
||||
{#if file.isFavorite}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,18 @@
|
|||
onFolderClick?: (folder: StorageFolder) => void;
|
||||
onFileAction?: (action: string, file: StorageFile) => void;
|
||||
onFolderAction?: (action: string, folder: StorageFolder) => void;
|
||||
onMoveToFolder?: (itemType: 'file' | 'folder', itemId: string, targetFolderId: string) => void;
|
||||
}
|
||||
|
||||
let { files, folders, onFileClick, onFolderClick, onFileAction, onFolderAction }: Props =
|
||||
$props();
|
||||
let {
|
||||
files,
|
||||
folders,
|
||||
onFileClick,
|
||||
onFolderClick,
|
||||
onFileAction,
|
||||
onFolderAction,
|
||||
onMoveToFolder,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="file-grid">
|
||||
|
|
@ -22,6 +30,7 @@
|
|||
{folder}
|
||||
onClick={() => onFolderClick?.(folder)}
|
||||
onAction={(action) => onFolderAction?.(action, folder)}
|
||||
onDrop={(data) => onMoveToFolder?.(data.type, data.id, folder.id)}
|
||||
/>
|
||||
{/each}
|
||||
{#each files as file (file.id)}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,40 @@
|
|||
folder: StorageFolder;
|
||||
onClick?: () => void;
|
||||
onAction?: (action: string) => void;
|
||||
onDrop?: (data: { type: 'file' | 'folder'; id: string }) => void;
|
||||
}
|
||||
|
||||
let { folder, onClick, onAction }: Props = $props();
|
||||
let { folder, onClick, onAction, onDrop }: Props = $props();
|
||||
|
||||
let showMenu = $state(false);
|
||||
let isDragOver = $state(false);
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
isDragOver = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragOver = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragOver = false;
|
||||
const raw = e.dataTransfer?.getData('application/json');
|
||||
if (raw) {
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
if (data.id !== folder.id) {
|
||||
onDrop?.(data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -37,7 +66,21 @@
|
|||
let folderColor = $derived(folder.color ? colorMap[folder.color] || folder.color : undefined);
|
||||
</script>
|
||||
|
||||
<div class="folder-card" onclick={onClick} role="button" tabindex="0">
|
||||
<div
|
||||
class="folder-card"
|
||||
class:drag-over={isDragOver}
|
||||
onclick={onClick}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
ondragstart={(e) => {
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'folder', id: folder.id }));
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="folder-icon" style:color={folderColor}>
|
||||
<Folder size={40} strokeWidth={1.5} fill="currentColor" />
|
||||
{#if folder.isFavorite}
|
||||
|
|
@ -94,6 +137,13 @@
|
|||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.folder-card.drag-over {
|
||||
border-color: rgb(var(--color-success));
|
||||
border-style: dashed;
|
||||
background: rgba(var(--color-success), 0.05);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
position: relative;
|
||||
color: rgb(var(--color-primary));
|
||||
|
|
|
|||
|
|
@ -155,6 +155,22 @@ export const filesStore = {
|
|||
return result;
|
||||
},
|
||||
|
||||
async moveFile(id: string, targetFolderId: string) {
|
||||
const result = await filesApi.move(id, targetFolderId);
|
||||
if (!result.error) {
|
||||
files = files.filter((f) => f.id !== id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async moveFolder(id: string, targetFolderId: string) {
|
||||
const result = await foldersApi.move(id, targetFolderId);
|
||||
if (!result.error) {
|
||||
folders = folders.filter((f) => f.id !== id);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async downloadFile(id: string, filename: string) {
|
||||
const blob = await filesApi.download(id);
|
||||
if (blob) {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleMoveToFolder(
|
||||
itemType: 'file' | 'folder',
|
||||
itemId: string,
|
||||
targetFolderId: string
|
||||
) {
|
||||
if (itemType === 'file') {
|
||||
const result = await filesStore.moveFile(itemId, targetFolderId);
|
||||
if (result?.error) {
|
||||
toastStore.error(result.error);
|
||||
} else {
|
||||
toastStore.success('Datei verschoben');
|
||||
}
|
||||
} else {
|
||||
const result = await filesStore.moveFolder(itemId, targetFolderId);
|
||||
if (result?.error) {
|
||||
toastStore.error(result.error);
|
||||
} else {
|
||||
toastStore.success('Ordner verschoben');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBreadcrumbNavigate(id: string | null) {
|
||||
if (id) {
|
||||
goto(`/files/${id}`);
|
||||
|
|
@ -245,6 +267,7 @@
|
|||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
onMoveToFolder={handleMoveToFolder}
|
||||
/>
|
||||
{:else}
|
||||
<FileList
|
||||
|
|
|
|||
|
|
@ -165,6 +165,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleMoveToFolder(
|
||||
itemType: 'file' | 'folder',
|
||||
itemId: string,
|
||||
targetFolderId: string
|
||||
) {
|
||||
if (itemType === 'file') {
|
||||
const result = await filesStore.moveFile(itemId, targetFolderId);
|
||||
if (result?.error) {
|
||||
toastStore.error(result.error);
|
||||
} else {
|
||||
toastStore.success('Datei verschoben');
|
||||
}
|
||||
} else {
|
||||
const result = await filesStore.moveFolder(itemId, targetFolderId);
|
||||
if (result?.error) {
|
||||
toastStore.error(result.error);
|
||||
} else {
|
||||
toastStore.success('Ordner verschoben');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const parentId = filesStore.currentFolder?.parentFolderId;
|
||||
if (parentId) {
|
||||
|
|
@ -261,6 +283,7 @@
|
|||
onFolderClick={handleFolderClick}
|
||||
onFileAction={handleFileAction}
|
||||
onFolderAction={handleFolderAction}
|
||||
onMoveToFolder={handleMoveToFolder}
|
||||
/>
|
||||
{:else}
|
||||
<FileList
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue