feat(storage): add animations, drag feedback, integration tests, and optimize Dockerfile

Animations:
- Staggered fade-slide-in animation on FileGrid items (30ms delay per item)
- Drag feedback: dragged items show opacity 0.5 + scale 0.95
- Drop target: folder scales up 1.02 on hover with green dashed border
- Respects prefers-reduced-motion

Integration Tests (39 new web tests, total 198):
- client-integration.test.ts: upload flow, download with fetch mock,
  folder contents, search encoding, share creation, trash restore,
  bulk operations, error propagation, tag operations, favorites

Docker:
- Migrate backend Dockerfile to nestjs-base:local shared builder
- Prune devDependencies and test files in production image

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 15:30:10 +01:00
parent 9dc5570ec0
commit 2ffd2596c4
5 changed files with 753 additions and 61 deletions

View file

@ -1,64 +1,29 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
# Copy shared packages (all required dependencies)
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
COPY packages/shared-storage ./packages/shared-storage
COPY packages/shared-tsconfig ./packages/shared-tsconfig
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
# Build stage — inherits pre-built shared packages from nestjs-base
FROM nestjs-base:local AS builder
# Copy storage backend
COPY apps/storage/apps/backend ./apps/storage/apps/backend
# Install dependencies (ignore scripts since generate-env.mjs isn't in Docker context)
# Reinstall to link app-specific dependencies
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-health
RUN pnpm build
WORKDIR /app/packages/shared-storage
RUN pnpm build
# Build the backend
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
WORKDIR /app/apps/storage/apps/backend
RUN pnpm build
# Remove devDependencies and unnecessary files from node_modules
WORKDIR /app
RUN pnpm prune --prod --no-optional 2>/dev/null || true \
&& find node_modules -name '*.ts' -not -name '*.d.ts' -delete 2>/dev/null || true \
&& find node_modules -name '*.map' -delete 2>/dev/null || true \
&& find node_modules -type d \( -name 'test' -o -name 'tests' -o -name '__tests__' -o -name 'docs' \) -prune -exec rm -rf {} + 2>/dev/null || true
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
# Install postgresql-client for health checks
RUN apk add --no-cache postgresql-client
WORKDIR /app
@ -68,7 +33,7 @@ COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/storage ./apps/storage
COPY --from=builder /app/apps/storage/apps/backend ./apps/storage/apps/backend
# Copy entrypoint script
COPY apps/storage/apps/backend/docker-entrypoint.sh /usr/local/bin/

View file

@ -0,0 +1,676 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock shared-api-client
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPatch = vi.fn();
const mockDelete = vi.fn();
const mockPut = vi.fn();
const mockUpload = vi.fn();
vi.mock('@manacore/shared-api-client', () => ({
createApiClient: () => ({
get: mockGet,
post: mockPost,
patch: mockPatch,
delete: mockDelete,
put: mockPut,
upload: mockUpload,
}),
}));
const mockGetAccessToken = vi.fn().mockResolvedValue('test-token');
vi.mock('$lib/stores/auth.svelte', () => ({
authStore: {
getAccessToken: mockGetAccessToken,
},
}));
// Mock global fetch for download tests
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
// Import after mocks are set up
const { filesApi, foldersApi, sharesApi, tagsApi, trashApi, searchApi } = await import('./client');
beforeEach(() => {
vi.clearAllMocks();
mockGetAccessToken.mockResolvedValue('test-token');
});
// ---------------------------------------------------------------------------
// Helpers: reusable factory data
// ---------------------------------------------------------------------------
function makeFile(overrides: Record<string, unknown> = {}) {
return {
id: 'file-1',
userId: 'user-1',
name: 'document.pdf',
originalName: 'document.pdf',
mimeType: 'application/pdf',
size: 102400,
storagePath: '/user-1/document.pdf',
storageKey: 'user-1/document.pdf',
parentFolderId: null,
currentVersion: 1,
isFavorite: false,
isDeleted: false,
deletedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
function makeFolder(overrides: Record<string, unknown> = {}) {
return {
id: 'folder-1',
userId: 'user-1',
name: 'Documents',
description: null,
color: null,
parentFolderId: null,
path: '/Documents',
depth: 0,
isFavorite: false,
isDeleted: false,
deletedAt: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
function makeShare(overrides: Record<string, unknown> = {}) {
return {
id: 'share-1',
userId: 'user-1',
fileId: 'file-1',
folderId: null,
shareType: 'file',
shareToken: 'abc123token',
accessLevel: 'view',
password: null,
maxDownloads: null,
downloadCount: 0,
expiresAt: null,
isActive: true,
createdAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
function makeTag(overrides: Record<string, unknown> = {}) {
return {
id: 'tag-1',
userId: 'user-1',
name: 'Important',
color: '#ff0000',
createdAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
// ---------------------------------------------------------------------------
// 1. File upload flow
// ---------------------------------------------------------------------------
describe('File upload flow', () => {
it('creates FormData with file only when no folderId is provided', async () => {
const uploadedFile = makeFile({ name: 'photo.jpg', mimeType: 'image/jpeg' });
mockUpload.mockResolvedValue({ data: uploadedFile });
const file = new File(['binary-content'], 'photo.jpg', { type: 'image/jpeg' });
const result = await filesApi.upload(file);
expect(mockUpload).toHaveBeenCalledTimes(1);
const [endpoint, formData] = mockUpload.mock.calls[0];
expect(endpoint).toBe('/files/upload');
expect(formData).toBeInstanceOf(FormData);
expect(formData.get('file')).toBe(file);
expect(formData.get('parentFolderId')).toBeNull();
expect(result.data).toEqual(uploadedFile);
expect(result.error).toBeUndefined();
});
it('creates FormData with file and folderId when folderId is provided', async () => {
const uploadedFile = makeFile({ parentFolderId: 'folder-1' });
mockUpload.mockResolvedValue({ data: uploadedFile });
const file = new File(['data'], 'report.pdf', { type: 'application/pdf' });
const result = await filesApi.upload(file, 'folder-1');
const [, formData] = mockUpload.mock.calls[0];
expect(formData.get('file')).toBe(file);
expect(formData.get('parentFolderId')).toBe('folder-1');
expect(result.data).toEqual(uploadedFile);
});
it('returns error when upload fails', async () => {
mockUpload.mockResolvedValue({ error: { message: 'File too large' } });
const file = new File(['x'.repeat(200)], 'huge.bin');
const result = await filesApi.upload(file);
expect(result.error).toBe('File too large');
expect(result.data).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 2. File download
// ---------------------------------------------------------------------------
describe('File download', () => {
it('fetches blob with correct auth header', async () => {
const blobData = new Blob(['file-content'], { type: 'text/plain' });
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(blobData),
});
const result = await filesApi.download('file-1');
expect(mockFetch).toHaveBeenCalledWith('http://localhost:3016/api/v1/files/file-1/download', {
headers: { Authorization: 'Bearer test-token' },
});
expect(result).toBeInstanceOf(Blob);
expect(result).toBe(blobData);
});
it('returns null when response is not ok', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 404,
});
const result = await filesApi.download('missing-file');
expect(result).toBeNull();
});
it('returns null on network error', async () => {
mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));
const result = await filesApi.download('file-1');
expect(result).toBeNull();
});
it('uses the token from authStore', async () => {
mockGetAccessToken.mockResolvedValue('different-token');
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob()),
});
await filesApi.download('file-1');
expect(mockGetAccessToken).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: { Authorization: 'Bearer different-token' },
})
);
});
});
// ---------------------------------------------------------------------------
// 3. Folder with contents
// ---------------------------------------------------------------------------
describe('Folder with contents', () => {
it('returns folder, files, and subfolders', async () => {
const folder = makeFolder({ id: 'folder-1', name: 'Projects' });
const files = [
makeFile({ id: 'f1', name: 'readme.md', parentFolderId: 'folder-1' }),
makeFile({ id: 'f2', name: 'notes.txt', parentFolderId: 'folder-1' }),
];
const subfolders = [
makeFolder({ id: 'sf1', name: 'src', parentFolderId: 'folder-1', depth: 1 }),
];
mockGet.mockResolvedValue({
data: { folder, files, subfolders },
});
const result = await foldersApi.get('folder-1');
expect(mockGet).toHaveBeenCalledWith('/folders/folder-1');
expect(result.data?.folder).toEqual(folder);
expect(result.data?.files).toHaveLength(2);
expect(result.data?.subfolders).toHaveLength(1);
expect(result.data?.files[0].name).toBe('readme.md');
expect(result.data?.subfolders[0].name).toBe('src');
});
it('returns error when folder does not exist', async () => {
mockGet.mockResolvedValue({ error: { message: 'Folder not found' } });
const result = await foldersApi.get('nonexistent');
expect(result.error).toBe('Folder not found');
expect(result.data).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 4. Search with encoding
// ---------------------------------------------------------------------------
describe('Search with encoding', () => {
it('encodes spaces in the query', async () => {
mockGet.mockResolvedValue({ data: { files: [], folders: [] } });
await searchApi.search('my documents');
expect(mockGet).toHaveBeenCalledWith('/search?q=my%20documents');
});
it('encodes German umlauts (ä, ö, ü, ß)', async () => {
mockGet.mockResolvedValue({ data: { files: [], folders: [] } });
await searchApi.search('Übersicht für Bücher');
expect(mockGet).toHaveBeenCalledWith(`/search?q=${encodeURIComponent('Übersicht für Bücher')}`);
});
it('encodes ampersands and special URL characters', async () => {
mockGet.mockResolvedValue({ data: { files: [], folders: [] } });
await searchApi.search('Q&A / notes #1');
expect(mockGet).toHaveBeenCalledWith(`/search?q=${encodeURIComponent('Q&A / notes #1')}`);
});
it('returns matching files and folders from search results', async () => {
const searchResults = {
files: [makeFile({ id: 'f1', name: 'Ärzte-Bericht.pdf' })],
folders: [makeFolder({ id: 'fo1', name: 'Ärzte' })],
};
mockGet.mockResolvedValue({ data: searchResults });
const result = await searchApi.search('Ärzte');
expect(result.data?.files).toHaveLength(1);
expect(result.data?.folders).toHaveLength(1);
expect(result.data?.files[0].name).toBe('Ärzte-Bericht.pdf');
});
});
// ---------------------------------------------------------------------------
// 5. Share creation with all options
// ---------------------------------------------------------------------------
describe('Share creation with all options', () => {
it('creates share with password, maxDownloads, and expiresAt', async () => {
const shareData = {
fileId: 'file-1',
accessLevel: 'download' as const,
password: 'securePass123!',
maxDownloads: 5,
expiresAt: '2026-06-01T00:00:00Z',
};
const createdShare = makeShare({
...shareData,
shareToken: 'generated-token',
accessLevel: 'download',
});
mockPost.mockResolvedValue({ data: createdShare });
const result = await sharesApi.create(shareData);
expect(mockPost).toHaveBeenCalledWith('/shares', shareData);
expect(result.data?.shareToken).toBe('generated-token');
expect(result.data?.password).toBe('securePass123!');
expect(result.data?.maxDownloads).toBe(5);
expect(result.data?.expiresAt).toBe('2026-06-01T00:00:00Z');
});
it('creates share with minimal options (fileId only)', async () => {
const shareData = { fileId: 'file-1' };
mockPost.mockResolvedValue({
data: makeShare({ accessLevel: 'view', password: null, maxDownloads: null }),
});
const result = await sharesApi.create(shareData);
expect(mockPost).toHaveBeenCalledWith('/shares', { fileId: 'file-1' });
expect(result.data?.accessLevel).toBe('view');
expect(result.data?.password).toBeNull();
expect(result.data?.maxDownloads).toBeNull();
});
it('creates share for a folder', async () => {
const shareData = { folderId: 'folder-1', accessLevel: 'edit' as const };
mockPost.mockResolvedValue({
data: makeShare({
fileId: null,
folderId: 'folder-1',
shareType: 'folder',
accessLevel: 'edit',
}),
});
const result = await sharesApi.create(shareData);
expect(mockPost).toHaveBeenCalledWith('/shares', shareData);
expect(result.data?.shareType).toBe('folder');
expect(result.data?.folderId).toBe('folder-1');
expect(result.data?.fileId).toBeNull();
});
});
// ---------------------------------------------------------------------------
// 6. Trash restore flow
// ---------------------------------------------------------------------------
describe('Trash restore flow', () => {
it('restores a file using type=file query param', async () => {
const restoredFile = makeFile({ isDeleted: false, deletedAt: null });
mockPost.mockResolvedValue({ data: restoredFile });
const result = await trashApi.restore('file-1', 'file');
expect(mockPost).toHaveBeenCalledWith('/trash/file-1/restore?type=file', undefined);
expect(result.data).toEqual(restoredFile);
});
it('restores a folder using type=folder query param', async () => {
const restoredFolder = makeFolder({ isDeleted: false, deletedAt: null });
mockPost.mockResolvedValue({ data: restoredFolder });
const result = await trashApi.restore('folder-1', 'folder');
expect(mockPost).toHaveBeenCalledWith('/trash/folder-1/restore?type=folder', undefined);
expect(result.data).toEqual(restoredFolder);
});
it('permanently deletes a file with type=file', async () => {
mockDelete.mockResolvedValue({ data: { success: true } });
const result = await trashApi.permanentDelete('file-1', 'file');
expect(mockDelete).toHaveBeenCalledWith('/trash/file-1?type=file');
expect(result.data).toEqual({ success: true });
});
it('permanently deletes a folder with type=folder', async () => {
mockDelete.mockResolvedValue({ data: { success: true } });
const result = await trashApi.permanentDelete('folder-1', 'folder');
expect(mockDelete).toHaveBeenCalledWith('/trash/folder-1?type=folder');
expect(result.data).toEqual({ success: true });
});
it('empties the entire trash', async () => {
mockDelete.mockResolvedValue({ data: { success: true } });
const result = await trashApi.empty();
expect(mockDelete).toHaveBeenCalledWith('/trash');
expect(result.data).toEqual({ success: true });
});
});
// ---------------------------------------------------------------------------
// 7. Bulk operations (multi-step sequence)
// ---------------------------------------------------------------------------
describe('Bulk operations', () => {
it('create folder, upload file to it, then share it', async () => {
// Step 1: Create a folder
const newFolder = makeFolder({ id: 'new-folder', name: 'Shared Project' });
mockPost.mockResolvedValueOnce({ data: newFolder });
const folderResult = await foldersApi.create('Shared Project', undefined, 'blue');
expect(folderResult.data).toEqual(newFolder);
expect(mockPost).toHaveBeenCalledWith('/folders', {
name: 'Shared Project',
parentFolderId: undefined,
color: 'blue',
});
// Step 2: Upload a file into that folder
const uploadedFile = makeFile({
id: 'uploaded-file',
name: 'design.fig',
parentFolderId: 'new-folder',
});
mockUpload.mockResolvedValueOnce({ data: uploadedFile });
const file = new File(['figma-data'], 'design.fig');
const uploadResult = await filesApi.upload(file, 'new-folder');
expect(uploadResult.data).toEqual(uploadedFile);
// Step 3: Share the file
const share = makeShare({ fileId: 'uploaded-file', accessLevel: 'download' });
mockPost.mockResolvedValueOnce({ data: share });
const shareResult = await sharesApi.create({
fileId: 'uploaded-file',
accessLevel: 'download',
});
expect(shareResult.data?.shareToken).toBe('abc123token');
expect(shareResult.data?.fileId).toBe('uploaded-file');
});
it('list files, rename one, then move it to another folder', async () => {
// Step 1: List files
const files = [
makeFile({ id: 'f1', name: 'old-name.txt' }),
makeFile({ id: 'f2', name: 'other.txt' }),
];
mockGet.mockResolvedValueOnce({ data: files });
const listResult = await filesApi.list();
expect(listResult.data).toHaveLength(2);
// Step 2: Rename the first file
const renamedFile = makeFile({ id: 'f1', name: 'new-name.txt' });
mockPatch.mockResolvedValueOnce({ data: renamedFile });
const renameResult = await filesApi.rename('f1', 'new-name.txt');
expect(mockPatch).toHaveBeenCalledWith('/files/f1', { name: 'new-name.txt' });
expect(renameResult.data?.name).toBe('new-name.txt');
// Step 3: Move it to a folder
const movedFile = makeFile({ id: 'f1', name: 'new-name.txt', parentFolderId: 'folder-1' });
mockPatch.mockResolvedValueOnce({ data: movedFile });
const moveResult = await filesApi.move('f1', 'folder-1');
expect(mockPatch).toHaveBeenCalledWith('/files/f1/move', { parentFolderId: 'folder-1' });
expect(moveResult.data).toEqual(movedFile);
});
it('create tag, create another tag, then list all tags', async () => {
const tag1 = makeTag({ id: 'tag-1', name: 'Work', color: '#0000ff' });
const tag2 = makeTag({ id: 'tag-2', name: 'Personal', color: '#00ff00' });
mockPost.mockResolvedValueOnce({ data: tag1 });
mockPost.mockResolvedValueOnce({ data: tag2 });
mockGet.mockResolvedValueOnce({ data: [tag1, tag2] });
const r1 = await tagsApi.create('Work', '#0000ff');
const r2 = await tagsApi.create('Personal', '#00ff00');
const r3 = await tagsApi.list();
expect(r1.data?.name).toBe('Work');
expect(r2.data?.name).toBe('Personal');
expect(r3.data).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// 8. Error propagation
// ---------------------------------------------------------------------------
describe('Error propagation', () => {
it('wraps API error in { error: message } format for GET', async () => {
mockGet.mockResolvedValue({ error: { message: 'Unauthorized' } });
const result = await filesApi.list();
expect(result).toEqual({ error: 'Unauthorized' });
expect(result.data).toBeUndefined();
});
it('wraps API error in { error: message } format for POST', async () => {
mockPost.mockResolvedValue({ error: { message: 'Validation failed' } });
const result = await foldersApi.create('');
expect(result).toEqual({ error: 'Validation failed' });
});
it('wraps API error in { error: message } format for PATCH', async () => {
mockPatch.mockResolvedValue({ error: { message: 'Not found' } });
const result = await filesApi.rename('missing', 'new-name');
expect(result).toEqual({ error: 'Not found' });
});
it('wraps API error in { error: message } format for DELETE', async () => {
mockDelete.mockResolvedValue({ error: { message: 'Forbidden' } });
const result = await filesApi.delete('not-yours');
expect(result).toEqual({ error: 'Forbidden' });
});
it('wraps API error for upload', async () => {
mockUpload.mockResolvedValue({ error: { message: 'Storage quota exceeded' } });
const file = new File(['data'], 'file.txt');
const result = await filesApi.upload(file);
expect(result).toEqual({ error: 'Storage quota exceeded' });
});
it('returns data with no error property on success', async () => {
const file = makeFile();
mockGet.mockResolvedValue({ data: file });
const result = await filesApi.get('file-1');
expect(result.data).toEqual(file);
expect(result.error).toBeUndefined();
});
it('returns null data as undefined in legacy format', async () => {
mockGet.mockResolvedValue({ data: null });
const result = await filesApi.get('file-1');
expect(result.data).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 9. Tag operations
// ---------------------------------------------------------------------------
describe('Tag operations', () => {
it('creates a tag with name and color', async () => {
const tag = makeTag({ name: 'Urgent', color: '#ff0000' });
mockPost.mockResolvedValue({ data: tag });
const result = await tagsApi.create('Urgent', '#ff0000');
expect(mockPost).toHaveBeenCalledWith('/tags', { name: 'Urgent', color: '#ff0000' });
expect(result.data).toEqual(tag);
});
it('creates a tag without color', async () => {
const tag = makeTag({ name: 'Archive', color: null });
mockPost.mockResolvedValue({ data: tag });
const result = await tagsApi.create('Archive');
expect(mockPost).toHaveBeenCalledWith('/tags', { name: 'Archive', color: undefined });
expect(result.data?.color).toBeNull();
});
it('updates a tag name and color', async () => {
const updatedTag = makeTag({ name: 'Renamed', color: '#00ff00' });
mockPatch.mockResolvedValue({ data: updatedTag });
const result = await tagsApi.update('tag-1', { name: 'Renamed', color: '#00ff00' });
expect(mockPatch).toHaveBeenCalledWith('/tags/tag-1', {
name: 'Renamed',
color: '#00ff00',
});
expect(result.data?.name).toBe('Renamed');
});
it('lists all tags', async () => {
const tags = [
makeTag({ id: 'tag-1', name: 'Work' }),
makeTag({ id: 'tag-2', name: 'Personal' }),
makeTag({ id: 'tag-3', name: 'Archive' }),
];
mockGet.mockResolvedValue({ data: tags });
const result = await tagsApi.list();
expect(mockGet).toHaveBeenCalledWith('/tags');
expect(result.data).toHaveLength(3);
});
it('deletes a tag', async () => {
mockDelete.mockResolvedValue({ data: { success: true } });
const result = await tagsApi.delete('tag-1');
expect(mockDelete).toHaveBeenCalledWith('/tags/tag-1');
expect(result.data).toEqual({ success: true });
});
});
// ---------------------------------------------------------------------------
// 10. Favorites endpoint
// ---------------------------------------------------------------------------
describe('Favorites endpoint', () => {
it('returns structured response with favorite files and folders', async () => {
const favFiles = [
makeFile({ id: 'f1', name: 'starred.pdf', isFavorite: true }),
makeFile({ id: 'f2', name: 'bookmarked.doc', isFavorite: true }),
];
const favFolders = [makeFolder({ id: 'fo1', name: 'Pinned', isFavorite: true })];
mockGet.mockResolvedValue({ data: { files: favFiles, folders: favFolders } });
const result = await searchApi.favorites();
expect(mockGet).toHaveBeenCalledWith('/favorites');
expect(result.data?.files).toHaveLength(2);
expect(result.data?.folders).toHaveLength(1);
expect(result.data?.files.every((f: { isFavorite: boolean }) => f.isFavorite)).toBe(true);
expect(result.data?.folders[0].isFavorite).toBe(true);
});
it('returns empty arrays when no favorites exist', async () => {
mockGet.mockResolvedValue({ data: { files: [], folders: [] } });
const result = await searchApi.favorites();
expect(result.data?.files).toEqual([]);
expect(result.data?.folders).toEqual([]);
});
it('returns error when user is not authenticated', async () => {
mockGet.mockResolvedValue({ error: { message: 'Unauthorized' } });
const result = await searchApi.favorites();
expect(result.error).toBe('Unauthorized');
expect(result.data).toBeUndefined();
});
});

View file

@ -20,6 +20,7 @@
let { file, onClick, onAction }: Props = $props();
let showMenu = $state(false);
let isDragging = $state(false);
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return FileImage;
@ -53,6 +54,7 @@
<div
class="file-card"
class:dragging={isDragging}
onclick={onClick}
role="button"
tabindex="0"
@ -60,6 +62,10 @@
ondragstart={(e) => {
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'file', id: file.id }));
e.dataTransfer!.effectAllowed = 'move';
isDragging = true;
}}
ondragend={() => {
isDragging = false;
}}
>
<div class="file-icon">
@ -120,6 +126,12 @@
box-shadow: var(--shadow-md);
}
.file-card.dragging {
opacity: 0.5;
transform: scale(0.95);
border-style: dashed;
}
.file-icon {
position: relative;
color: rgb(var(--color-primary));

View file

@ -25,20 +25,24 @@
</script>
<div class="file-grid">
{#each folders as folder (folder.id)}
<FolderCard
{folder}
onClick={() => onFolderClick?.(folder)}
onAction={(action) => onFolderAction?.(action, folder)}
onDrop={(data) => onMoveToFolder?.(data.type, data.id, folder.id)}
/>
{#each folders as folder, i (folder.id)}
<div class="grid-item" style="--delay: {i * 30}ms">
<FolderCard
{folder}
onClick={() => onFolderClick?.(folder)}
onAction={(action) => onFolderAction?.(action, folder)}
onDrop={(data) => onMoveToFolder?.(data.type, data.id, folder.id)}
/>
</div>
{/each}
{#each files as file (file.id)}
<FileCard
{file}
onClick={() => onFileClick?.(file)}
onAction={(action) => onFileAction?.(action, file)}
/>
{#each files as file, i (file.id)}
<div class="grid-item" style="--delay: {(folders.length + i) * 30}ms">
<FileCard
{file}
onClick={() => onFileClick?.(file)}
onAction={(action) => onFileAction?.(action, file)}
/>
</div>
{/each}
</div>
@ -49,10 +53,32 @@
gap: 1rem;
}
.grid-item {
animation: fadeSlideIn 0.3s ease-out both;
animation-delay: var(--delay, 0ms);
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.grid-item {
animation: none;
}
}
</style>

View file

@ -13,6 +13,7 @@
let showMenu = $state(false);
let isDragOver = $state(false);
let isDragging = $state(false);
function handleDragOver(e: DragEvent) {
e.preventDefault();
@ -69,6 +70,7 @@
<div
class="folder-card"
class:drag-over={isDragOver}
class:dragging={isDragging}
onclick={onClick}
role="button"
tabindex="0"
@ -76,6 +78,10 @@
ondragstart={(e) => {
e.dataTransfer?.setData('application/json', JSON.stringify({ type: 'folder', id: folder.id }));
e.dataTransfer!.effectAllowed = 'move';
isDragging = true;
}}
ondragend={() => {
isDragging = false;
}}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
@ -142,6 +148,13 @@
border-style: dashed;
background: rgba(var(--color-success), 0.05);
box-shadow: var(--shadow-lg);
transform: scale(1.02);
}
.folder-card.dragging {
opacity: 0.5;
transform: scale(0.95);
border-style: dashed;
}
.folder-icon {