diff --git a/packages/web/src/components/admin/delete-storage-dialog.tsx b/packages/web/src/components/admin/delete-storage-dialog.tsx index ccaab384..36e650a6 100644 --- a/packages/web/src/components/admin/delete-storage-dialog.tsx +++ b/packages/web/src/components/admin/delete-storage-dialog.tsx @@ -23,7 +23,7 @@ export function DeleteStorageDialog({ open, onOpenChange, storage }: DeleteStora const mutation = useMutation({ mutationFn: async (id: string) => { - const res = await fetch(`/api/storages/${id}`, { + const res = await fetch(`/api/admin/storages/${id}`, { method: 'DELETE', credentials: 'include', }) diff --git a/packages/web/src/components/admin/storage-form-dialog.tsx b/packages/web/src/components/admin/storage-form-dialog.tsx index 13d914d2..c72a235b 100644 --- a/packages/web/src/components/admin/storage-form-dialog.tsx +++ b/packages/web/src/components/admin/storage-form-dialog.tsx @@ -78,7 +78,7 @@ export function StorageFormDialog({ open, onOpenChange, storage }: StorageFormDi const mutation = useMutation({ mutationFn: async (values: StorageFormValues) => { - const url = isEditing ? `/api/storages/${storage.id}` : '/api/storages' + const url = isEditing ? `/api/admin/storages/${storage.id}` : '/api/admin/storages' const method = isEditing ? 'PUT' : 'POST' const res = await fetch(url, { method, diff --git a/packages/web/src/lib/file-manager-adapter.test.ts b/packages/web/src/lib/file-manager-adapter.test.ts index 8409fa49..f589df6e 100644 --- a/packages/web/src/lib/file-manager-adapter.test.ts +++ b/packages/web/src/lib/file-manager-adapter.test.ts @@ -22,7 +22,7 @@ function makeStorageObject(overrides: Partial = {}): StorageObjec type: 'text/plain', size: 512, dirtype: DirType.FILE, - parent: 'root', + parent: '', object: 'path/to/obj', storageId: 'storage1', status: 'active', @@ -32,101 +32,105 @@ function makeStorageObject(overrides: Partial = {}): StorageObjec } } +function makeListResponse(items: StorageObject[]) { + return { items, total: items.length, page: 1, pageSize: 500 } +} + describe('loadFolder', () => { beforeEach(() => { vi.resetAllMocks() }) it('returns entities mapped from listObjects items', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject()], - total: 1, - page: 1, - pageSize: 500, - }) + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()])) - const result = await loadFolder('root') + const result = await loadFolder('', '/') expect(result).toHaveLength(1) - expect(result[0]).toMatchObject({ - id: 'obj1', - name: 'my-file.txt', - size: 512, - type: 'file', - }) + expect(result[0].name).toBe('my-file.txt') + expect(result[0].size).toBe(512) + expect(result[0].type).toBe('file') }) - it('maps file dirtype to type "file"', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject({ dirtype: DirType.FILE })], - total: 1, - page: 1, - pageSize: 500, - }) + it('assigns path-based id for items in the root folder', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-id-1', name: 'song.mp3' })]), + ) - const [entity] = await loadFolder('root') + const [entity] = await loadFolder('', '/') + + expect(entity.id).toBe('/song.mp3') + }) + + it('assigns path-based id for items in a nested folder', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-id-2', name: 'track.flac' })]), + ) + + const [entity] = await loadFolder('db-music', '/Music') + + expect(entity.id).toBe('/Music/track.flac') + }) + + it('maps file dirtype to type "file" with lazy false', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ dirtype: DirType.FILE })])) + + const [entity] = await loadFolder('', '/') expect(entity.type).toBe('file') expect(entity.lazy).toBe(false) }) - it('maps folder dirtype to type "folder" with lazy true', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject({ dirtype: DirType.USER_FOLDER })], - total: 1, - page: 1, - pageSize: 500, - }) + it('maps user folder dirtype to type "folder" with lazy true', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ dirtype: DirType.USER_FOLDER })]), + ) - const [entity] = await loadFolder('root') + const [entity] = await loadFolder('', '/') expect(entity.type).toBe('folder') expect(entity.lazy).toBe(true) }) - it('converts updatedAt string to Date object', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject({ updatedAt: '2024-06-01T00:00:00Z' })], - total: 1, - page: 1, - pageSize: 500, - }) + it('converts updatedAt string to a Date instance', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ updatedAt: '2024-06-01T00:00:00Z' })]), + ) - const [entity] = await loadFolder('root') + const [entity] = await loadFolder('', '/') expect(entity.date).toBeInstanceOf(Date) expect(entity.date!.toISOString()).toBe('2024-06-01T00:00:00.000Z') }) - it('preserves alias, status, and parent as custom fields', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject({ alias: 'my-alias', status: 'active', parent: 'folder1' })], - total: 1, - page: 1, - pageSize: 500, - }) - - const [entity] = await loadFolder('root') - - expect((entity as Record)._alias).toBe('my-alias') - expect((entity as Record)._status).toBe('active') - expect((entity as Record)._parent).toBe('folder1') - }) - it('returns empty array when folder has no items', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 500 }) + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) - const result = await loadFolder('empty-folder') + const result = await loadFolder('empty-folder', '/Empty') expect(result).toEqual([]) }) - it('passes parent argument to listObjects', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ items: [], total: 0, page: 1, pageSize: 500 }) + it('passes the dbParentId to listObjects', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) - await loadFolder('folder-abc') + await loadFolder('db-folder-abc', '/Docs') - expect(api.listObjects).toHaveBeenCalledWith('folder-abc') + expect(api.listObjects).toHaveBeenCalledWith('db-folder-abc') + }) + + it('handles multiple items, all getting correct path-based ids', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'id-a', name: 'alpha.txt' }), + makeStorageObject({ id: 'id-b', name: 'beta.pdf' }), + ]), + ) + + const result = await loadFolder('', '/') + + expect(result[0].id).toBe('/alpha.txt') + expect(result[1].id).toBe('/beta.pdf') }) }) @@ -135,45 +139,55 @@ describe('refreshFolder', () => { vi.resetAllMocks() }) - it('calls loadFolder and execs provide-data with the loaded entities', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject()], - total: 1, - page: 1, - pageSize: 500, - }) + it('calls provide-data with the parentPath as the id', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()])) const execMock = vi.fn() const apiMock = { exec: execMock } as never - await refreshFolder(apiMock, 'root') + await refreshFolder(apiMock, '', '/') - expect(api.listObjects).toHaveBeenCalledWith('root') expect(execMock).toHaveBeenCalledWith('provide-data', { - id: 'root', + id: '/', data: expect.any(Array), skipProvider: true, }) }) - it('passes the correct entities to provide-data', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject({ id: 'f1', name: 'report.pdf' })], - total: 1, - page: 1, - pageSize: 500, - }) + it('passes the dbParentId to listObjects', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) + const apiMock = { exec: vi.fn() } as never + + await refreshFolder(apiMock, 'db-docs', '/Docs') + + expect(api.listObjects).toHaveBeenCalledWith('db-docs') + }) + + it('passes the loaded entities to provide-data', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'fid', name: 'report.pdf' })]), + ) const execMock = vi.fn() const apiMock = { exec: execMock } as never - await refreshFolder(apiMock, 'docs') + await refreshFolder(apiMock, '', '/') const [, payload] = execMock.mock.calls[0] as [string, { data: unknown[] }] expect(payload.data).toHaveLength(1) - expect((payload.data[0] as { id: string }).id).toBe('f1') + expect((payload.data[0] as { id: string }).id).toBe('/report.pdf') + }) + + it('uses the nested parentPath as the provide-data id', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) + const execMock = vi.fn() + const apiMock = { exec: execMock } as never + + await refreshFolder(apiMock, 'db-music', '/Music') + + expect(execMock).toHaveBeenCalledWith('provide-data', expect.objectContaining({ id: '/Music' })) }) }) -// Helper to create an IApi mock with intercept recording +// Helper that builds a fake IApi with intercept capture and trigger function makeApiMock() { const handlers: Record unknown> = {} return { @@ -190,7 +204,7 @@ describe('connectAdapter', () => { vi.resetAllMocks() }) - it('registers intercepts for all file manager events', () => { + it('registers intercepts for all required file manager events', () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) @@ -205,71 +219,92 @@ describe('connectAdapter', () => { }) describe('request-data intercept', () => { - it('loads folder and calls provide-data with loaded entities', async () => { - vi.mocked(api.listObjects).mockResolvedValueOnce({ - items: [makeStorageObject()], - total: 1, - page: 1, - pageSize: 500, - }) + it('loads folder for the root path and calls provide-data', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject()])) const apiMock = makeApiMock() connectAdapter(apiMock as never) - const returnValue = await apiMock.trigger('request-data', { id: 'root' }) + const returnValue = await apiMock.trigger('request-data', { id: '/' }) - expect(api.listObjects).toHaveBeenCalledWith('root') + // For root path "/" resolveDbId returns '' (empty string) + expect(api.listObjects).toHaveBeenCalledWith('') expect(apiMock.exec).toHaveBeenCalledWith( 'provide-data', - expect.objectContaining({ - id: 'root', - skipProvider: true, - data: expect.any(Array), - }), + expect.objectContaining({ id: '/', skipProvider: true, data: expect.any(Array) }), ) expect(returnValue).toBe(false) }) + + it('resolves a registered path to its db id before loading folder', async () => { + // First load root to register mapping for /Music -> db-music + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-music', name: 'Music', dirtype: DirType.USER_FOLDER })]), + ) + await loadFolder('', '/') + + // Now trigger request-data for /Music — it must resolve to db-music + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) + const apiMock = makeApiMock() + connectAdapter(apiMock as never) + + await apiMock.trigger('request-data', { id: '/Music' }) + + expect(api.listObjects).toHaveBeenCalledWith('db-music') + }) }) describe('rename-file intercept', () => { - it('calls updateObject with new name', async () => { - vi.mocked(api.updateObject).mockResolvedValueOnce(makeStorageObject({ name: 'new-name.txt' })) + it('resolves path id to db id and calls updateObject with new name', async () => { + // Register mapping: /song.mp3 -> obj1 + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'obj1', name: 'song.mp3' })]), + ) + await loadFolder('', '/') + + vi.mocked(api.updateObject).mockResolvedValueOnce(makeStorageObject({ name: 'renamed.mp3' })) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await apiMock.trigger('rename-file', { id: 'id1', name: 'new-name.txt' }) + await apiMock.trigger('rename-file', { id: '/song.mp3', name: 'renamed.mp3' }) - expect(api.updateObject).toHaveBeenCalledWith('id1', { name: 'new-name.txt' }) + expect(api.updateObject).toHaveBeenCalledWith('obj1', { name: 'renamed.mp3' }) }) }) describe('create-file intercept', () => { - it('creates a folder object and returns newId', async () => { - const created = makeStorageObject({ id: 'new-folder', dirtype: DirType.USER_FOLDER }) + it('creates a folder and returns path-based newId', async () => { + // Register mapping for parent: /Docs -> db-docs + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-docs', name: 'Docs', dirtype: DirType.USER_FOLDER })]), + ) + await loadFolder('', '/') + + const created = makeStorageObject({ id: 'new-folder-id', name: 'Projects', dirtype: DirType.USER_FOLDER }) vi.mocked(api.createObject).mockResolvedValueOnce(created) const apiMock = makeApiMock() connectAdapter(apiMock as never) const result = await apiMock.trigger('create-file', { - file: { name: 'My Folder', type: 'folder' }, - parent: 'root', + file: { name: 'Projects', type: 'folder' }, + parent: '/Docs', }) expect(api.createObject).toHaveBeenCalledWith({ - name: 'My Folder', + name: 'Projects', type: 'folder', - parent: 'root', + parent: 'db-docs', dirtype: DirType.USER_FOLDER, }) - expect(result).toEqual({ newId: 'new-folder' }) + expect(result).toEqual({ newId: '/Docs/Projects' }) }) - it('does nothing and returns undefined for non-folder file type', async () => { + it('does not create anything for non-folder file type', async () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) const result = await apiMock.trigger('create-file', { file: { name: 'doc.pdf', type: 'application/pdf' }, - parent: 'root', + parent: '/', }) expect(api.createObject).not.toHaveBeenCalled() @@ -278,17 +313,25 @@ describe('connectAdapter', () => { }) describe('delete-files intercept', () => { - it('deletes all provided ids', async () => { + it('resolves path ids to db ids and deletes them all', async () => { + // Register: /a.txt -> id-a, /b.txt -> id-b + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'id-a', name: 'a.txt' }), + makeStorageObject({ id: 'id-b', name: 'b.txt' }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.deleteObject).mockResolvedValue({ id: 'any', deleted: true }) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await apiMock.trigger('delete-files', { ids: ['id1', 'id2', 'id3'] }) + await apiMock.trigger('delete-files', { ids: ['/a.txt', '/b.txt'] }) - expect(api.deleteObject).toHaveBeenCalledTimes(3) - expect(api.deleteObject).toHaveBeenCalledWith('id1') - expect(api.deleteObject).toHaveBeenCalledWith('id2') - expect(api.deleteObject).toHaveBeenCalledWith('id3') + expect(api.deleteObject).toHaveBeenCalledTimes(2) + expect(api.deleteObject).toHaveBeenCalledWith('id-a') + expect(api.deleteObject).toHaveBeenCalledWith('id-b') }) it('handles empty ids array without calling deleteObject', async () => { @@ -301,16 +344,35 @@ describe('connectAdapter', () => { }) it('throws with failure count when one delete fails', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'id-c', name: 'c.txt' }), + makeStorageObject({ id: 'id-d', name: 'd.txt' }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.deleteObject) - .mockResolvedValueOnce({ id: 'id1', deleted: true }) + .mockResolvedValueOnce({ id: 'id-c', deleted: true }) .mockRejectedValueOnce(new Error('not found')) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await expect(apiMock.trigger('delete-files', { ids: ['id1', 'id2'] })).rejects.toThrow('1 operation(s) failed') + await expect(apiMock.trigger('delete-files', { ids: ['/c.txt', '/d.txt'] })).rejects.toThrow( + '1 operation(s) failed', + ) }) it('throws with total failure count when all deletes fail', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'id-e', name: 'e.txt' }), + makeStorageObject({ id: 'id-f', name: 'f.txt' }), + makeStorageObject({ id: 'id-g', name: 'g.txt' }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.deleteObject) .mockRejectedValueOnce(new Error('err1')) .mockRejectedValueOnce(new Error('err2')) @@ -318,89 +380,133 @@ describe('connectAdapter', () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) - await expect(apiMock.trigger('delete-files', { ids: ['a', 'b', 'c'] })).rejects.toThrow('3 operation(s) failed') + await expect(apiMock.trigger('delete-files', { ids: ['/e.txt', '/f.txt', '/g.txt'] })).rejects.toThrow( + '3 operation(s) failed', + ) }) }) describe('move-files intercept', () => { - it('updates parent for all ids and returns newIds', async () => { + it('resolves path ids to db ids and moves them to the target', async () => { + // Register: /file1.txt -> db-id-1, /file2.txt -> db-id-2, /Dest -> db-dest + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'db-id-1', name: 'file1.txt' }), + makeStorageObject({ id: 'db-id-2', name: 'file2.txt' }), + makeStorageObject({ id: 'db-dest', name: 'Dest', dirtype: DirType.USER_FOLDER }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.updateObject) - .mockResolvedValueOnce(makeStorageObject({ id: 'id1', parent: 'folder2' })) - .mockResolvedValueOnce(makeStorageObject({ id: 'id2', parent: 'folder2' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'db-id-1', name: 'file1.txt', parent: 'db-dest' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'db-id-2', name: 'file2.txt', parent: 'db-dest' })) const apiMock = makeApiMock() connectAdapter(apiMock as never) - const result = await apiMock.trigger('move-files', { ids: ['id1', 'id2'], target: 'folder2' }) + const result = await apiMock.trigger('move-files', { ids: ['/file1.txt', '/file2.txt'], target: '/Dest' }) - expect(api.updateObject).toHaveBeenCalledWith('id1', { parent: 'folder2' }) - expect(api.updateObject).toHaveBeenCalledWith('id2', { parent: 'folder2' }) - expect(result).toEqual({ newIds: ['id1', 'id2'] }) + expect(api.updateObject).toHaveBeenCalledWith('db-id-1', { parent: 'db-dest' }) + expect(api.updateObject).toHaveBeenCalledWith('db-id-2', { parent: 'db-dest' }) + expect(result).toEqual({ newIds: ['/Dest/file1.txt', '/Dest/file2.txt'] }) }) it('returns empty newIds for empty ids array', async () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) - const result = await apiMock.trigger('move-files', { ids: [], target: 'folder2' }) + const result = await apiMock.trigger('move-files', { ids: [], target: '/' }) expect(result).toEqual({ newIds: [] }) }) it('throws with failure count when one move fails', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'mv-a', name: 'mv-a.txt' }), + makeStorageObject({ id: 'mv-b', name: 'mv-b.txt' }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.updateObject) - .mockResolvedValueOnce(makeStorageObject({ id: 'id1', parent: 'folder2' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'mv-a', name: 'mv-a.txt' })) .mockRejectedValueOnce(new Error('forbidden')) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await expect(apiMock.trigger('move-files', { ids: ['id1', 'id2'], target: 'folder2' })).rejects.toThrow( + await expect(apiMock.trigger('move-files', { ids: ['/mv-a.txt', '/mv-b.txt'], target: '/' })).rejects.toThrow( '1 operation(s) failed', ) }) }) describe('copy-files intercept', () => { - it('copies all ids to target and returns newIds', async () => { + it('resolves path ids to db ids and copies them to the target', async () => { + // Register: /orig1.txt -> db-orig1, /orig2.txt -> db-orig2, /CopyDest -> db-copy-dest + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'db-orig1', name: 'orig1.txt' }), + makeStorageObject({ id: 'db-orig2', name: 'orig2.txt' }), + makeStorageObject({ id: 'db-copy-dest', name: 'CopyDest', dirtype: DirType.USER_FOLDER }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.copyObject) - .mockResolvedValueOnce(makeStorageObject({ id: 'copy1' })) - .mockResolvedValueOnce(makeStorageObject({ id: 'copy2' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'cp1', name: 'orig1.txt' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'cp2', name: 'orig2.txt' })) const apiMock = makeApiMock() connectAdapter(apiMock as never) - const result = await apiMock.trigger('copy-files', { ids: ['orig1', 'orig2'], target: 'dest' }) + const result = await apiMock.trigger('copy-files', { ids: ['/orig1.txt', '/orig2.txt'], target: '/CopyDest' }) - expect(api.copyObject).toHaveBeenCalledWith('orig1', 'dest') - expect(api.copyObject).toHaveBeenCalledWith('orig2', 'dest') - expect(result).toEqual({ newIds: ['copy1', 'copy2'] }) + expect(api.copyObject).toHaveBeenCalledWith('db-orig1', 'db-copy-dest') + expect(api.copyObject).toHaveBeenCalledWith('db-orig2', 'db-copy-dest') + expect(result).toEqual({ newIds: ['/CopyDest/orig1.txt', '/CopyDest/orig2.txt'] }) }) it('returns empty newIds for empty ids array', async () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) - const result = await apiMock.trigger('copy-files', { ids: [], target: 'dest' }) + const result = await apiMock.trigger('copy-files', { ids: [], target: '/' }) expect(result).toEqual({ newIds: [] }) }) it('throws with failure count when one copy fails', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([ + makeStorageObject({ id: 'cp-src-a', name: 'cp-src-a.txt' }), + makeStorageObject({ id: 'cp-src-b', name: 'cp-src-b.txt' }), + ]), + ) + await loadFolder('', '/') + vi.mocked(api.copyObject) - .mockResolvedValueOnce(makeStorageObject({ id: 'copy1' })) + .mockResolvedValueOnce(makeStorageObject({ id: 'cp-dst-a', name: 'cp-src-a.txt' })) .mockRejectedValueOnce(new Error('conflict')) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await expect(apiMock.trigger('copy-files', { ids: ['orig1', 'orig2'], target: 'dest' })).rejects.toThrow( - '1 operation(s) failed', - ) + await expect( + apiMock.trigger('copy-files', { ids: ['/cp-src-a.txt', '/cp-src-b.txt'], target: '/' }), + ).rejects.toThrow('1 operation(s) failed') }) }) describe('download-file intercept', () => { - it('opens download URL in new tab when downloadUrl is present', async () => { + it('resolves path id to db id and opens downloadUrl in a new tab', async () => { + // Register: /report.pdf -> db-report + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-report', name: 'report.pdf' })]), + ) + await loadFolder('', '/') + vi.mocked(api.getObject).mockResolvedValueOnce({ - ...makeStorageObject(), - downloadUrl: 'https://s3/file.txt', + ...makeStorageObject({ id: 'db-report' }), + downloadUrl: 'https://s3/report.pdf', }) const openMock = vi.fn() vi.stubGlobal('window', { open: openMock }) @@ -408,24 +514,29 @@ describe('connectAdapter', () => { const apiMock = makeApiMock() connectAdapter(apiMock as never) - const result = await apiMock.trigger('download-file', { id: 'id1' }) + const result = await apiMock.trigger('download-file', { id: '/report.pdf' }) - expect(api.getObject).toHaveBeenCalledWith('id1') - expect(openMock).toHaveBeenCalledWith('https://s3/file.txt', '_blank', 'noopener,noreferrer') + expect(api.getObject).toHaveBeenCalledWith('db-report') + expect(openMock).toHaveBeenCalledWith('https://s3/report.pdf', '_blank', 'noopener,noreferrer') expect(result).toBe(false) vi.unstubAllGlobals() }) it('does not open window when downloadUrl is absent', async () => { - vi.mocked(api.getObject).mockResolvedValueOnce(makeStorageObject()) + vi.mocked(api.listObjects).mockResolvedValueOnce( + makeListResponse([makeStorageObject({ id: 'db-nodl', name: 'nodl.bin' })]), + ) + await loadFolder('', '/') + + vi.mocked(api.getObject).mockResolvedValueOnce(makeStorageObject({ id: 'db-nodl' })) const openMock = vi.fn() vi.stubGlobal('window', { open: openMock }) const apiMock = makeApiMock() connectAdapter(apiMock as never) - await apiMock.trigger('download-file', { id: 'id1' }) + await apiMock.trigger('download-file', { id: '/nodl.bin' }) expect(openMock).not.toHaveBeenCalled() @@ -433,3 +544,50 @@ describe('connectAdapter', () => { }) }) }) + +describe('buildPath (via loadFolder)', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('builds root-level path as /name when parent is /', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ name: 'hello.txt' })])) + + const [entity] = await loadFolder('', '/') + + expect(entity.id).toBe('/hello.txt') + }) + + it('builds nested path as parentPath/name when parent is not /', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([makeStorageObject({ name: 'nested.txt' })])) + + const [entity] = await loadFolder('db-photos', '/Photos/Vacation') + + expect(entity.id).toBe('/Photos/Vacation/nested.txt') + }) +}) + +describe('resolveDbId (via connectAdapter request-data)', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('returns empty string for the root path "/"', async () => { + vi.mocked(api.listObjects).mockResolvedValueOnce(makeListResponse([])) + const apiMock = makeApiMock() + connectAdapter(apiMock as never) + + await apiMock.trigger('request-data', { id: '/' }) + + expect(api.listObjects).toHaveBeenCalledWith('') + }) + + it('throws when no mapping is registered for the path', async () => { + const apiMock = makeApiMock() + connectAdapter(apiMock as never) + + await expect(apiMock.trigger('request-data', { id: '/UnknownPath' })).rejects.toThrow( + 'No DB mapping for path: /UnknownPath', + ) + }) +}) diff --git a/packages/web/src/lib/file-manager-adapter.ts b/packages/web/src/lib/file-manager-adapter.ts index 5fc54b92..a02202a3 100644 --- a/packages/web/src/lib/file-manager-adapter.ts +++ b/packages/web/src/lib/file-manager-adapter.ts @@ -3,28 +3,79 @@ import { DirType } from '@zpan/shared/constants' import type { StorageObject } from '@zpan/shared/types' import { copyObject, createObject, deleteObject, getObject, listObjects, updateObject } from './api' -function toEntity(obj: StorageObject): IEntity { +// SVAR uses path-based IDs (e.g. "/Music/song.mp3"). +// ZPan uses database IDs. This class manages the bidirectional mapping. +class PathMapper { + private pathToDb = new Map() + private dbToPath = new Map() + + register(path: string, dbId: string) { + this.pathToDb.set(path, dbId) + this.dbToPath.set(dbId, path) + } + + toDbId(path: string): string { + if (path === '/') return '' + const dbId = this.pathToDb.get(path) + if (!dbId) throw new Error(`No DB mapping for path: ${path}`) + return dbId + } + + toPath(dbId: string): string | undefined { + return this.dbToPath.get(dbId) + } + + remove(path: string) { + const dbId = this.pathToDb.get(path) + this.pathToDb.delete(path) + if (dbId) this.dbToPath.delete(dbId) + } + + rename(oldPath: string, newPath: string) { + const dbId = this.pathToDb.get(oldPath) + if (!dbId) return + this.pathToDb.delete(oldPath) + this.pathToDb.set(newPath, dbId) + this.dbToPath.set(dbId, newPath) + } +} + +const mapper = new PathMapper() + +export function pathToDbId(path: string): string { + return mapper.toDbId(path) +} + +function buildPath(parentPath: string, name: string): string { + return parentPath === '/' ? `/${name}` : `${parentPath}/${name}` +} + +function parentOfPath(path: string): string { + const idx = path.lastIndexOf('/') + return idx <= 0 ? '/' : path.slice(0, idx) +} + +function toEntity(obj: StorageObject, parentPath: string): IEntity { + const path = buildPath(parentPath, obj.name) + mapper.register(path, obj.id) return { - id: obj.id, + id: path, name: obj.name, size: obj.size, date: new Date(obj.updatedAt), type: obj.dirtype === DirType.FILE ? 'file' : 'folder', lazy: obj.dirtype !== DirType.FILE, - _alias: obj.alias, - _status: obj.status, - _parent: obj.parent, } } -export async function loadFolder(parent: string): Promise { - const res = await listObjects(parent) - return res.items.map(toEntity) +export async function loadFolder(dbParentId: string, parentPath: string): Promise { + const res = await listObjects(dbParentId) + return res.items.map((obj) => toEntity(obj, parentPath)) } -export function refreshFolder(api: IApi, parent: string): Promise { - return loadFolder(parent).then((entities) => { - api.exec('provide-data', { id: parent, data: entities, skipProvider: true }) +export function refreshFolder(api: IApi, dbParentId: string, parentPath: string): Promise { + return loadFolder(dbParentId, parentPath).then((entities) => { + api.exec('provide-data', { id: parentPath, data: entities, skipProvider: true }) }) } @@ -39,43 +90,74 @@ async function settledAll(ids: TID[], fn: (id: string) => Promise): Promis export function connectAdapter(api: IApi) { api.intercept('request-data', async (ev: { id: TID }) => { - const data = await loadFolder(ev.id as string) - api.exec('provide-data', { id: ev.id, data, skipProvider: true }) + const pathId = ev.id as string + const dbId = mapper.toDbId(pathId) + const data = await loadFolder(dbId, pathId) + api.exec('provide-data', { id: pathId, data, skipProvider: true }) return false }) api.intercept('rename-file', async (ev: { id: TID; name: string }) => { - await updateObject(ev.id as string, { name: ev.name }) + const pathId = ev.id as string + const dbId = mapper.toDbId(pathId) + await updateObject(dbId, { name: ev.name }) + const newPath = buildPath(parentOfPath(pathId), ev.name) + mapper.rename(pathId, newPath) }) api.intercept('create-file', async (ev: { file: { name: string; type?: string }; parent: TID }) => { if (ev.file.type === 'folder') { + const parentPath = ev.parent as string + const parentDbId = mapper.toDbId(parentPath) const created = await createObject({ name: ev.file.name, type: 'folder', - parent: ev.parent as string, + parent: parentDbId, dirtype: DirType.USER_FOLDER, }) - return { newId: created.id } + const newPath = buildPath(parentPath, ev.file.name) + mapper.register(newPath, created.id) + return { newId: newPath } } }) api.intercept('delete-files', async (ev: { ids: TID[] }) => { - await settledAll(ev.ids, deleteObject) + const paths = ev.ids as string[] + const dbIds = paths.map((p) => mapper.toDbId(p)) + await settledAll(dbIds as TID[], deleteObject) + for (const p of paths) mapper.remove(p) }) api.intercept('move-files', async (ev: { ids: TID[]; target: TID }) => { - const moved = await settledAll(ev.ids, (id) => updateObject(id, { parent: ev.target as string })) - return { newIds: moved.map((m) => m.id) } + const targetPath = ev.target as string + const targetDbId = mapper.toDbId(targetPath) + const paths = ev.ids as string[] + const dbIds = paths.map((p) => mapper.toDbId(p)) + const moved = await settledAll(dbIds as TID[], (id) => updateObject(id, { parent: targetDbId })) + const newIds = moved.map((m, i) => { + const newPath = buildPath(targetPath, m.name) + mapper.rename(paths[i], newPath) + return newPath + }) + return { newIds } }) api.intercept('copy-files', async (ev: { ids: TID[]; target: TID }) => { - const copies = await settledAll(ev.ids, (id) => copyObject(id, ev.target as string)) - return { newIds: copies.map((c) => c.id) } + const targetPath = ev.target as string + const targetDbId = mapper.toDbId(targetPath) + const dbIds = (ev.ids as string[]).map((p) => mapper.toDbId(p)) + const copies = await settledAll(dbIds as TID[], (id) => copyObject(id, targetDbId)) + const newIds = copies.map((c) => { + const newPath = buildPath(targetPath, c.name) + mapper.register(newPath, c.id) + return newPath + }) + return { newIds } }) api.intercept('download-file', async (ev: { id: TID }) => { - const obj = await getObject(ev.id as string) + const dbId = mapper.toDbId(ev.id as string) + const obj = await getObject(dbId) if (obj.downloadUrl) { window.open(obj.downloadUrl, '_blank', 'noopener,noreferrer') } diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index c2894f24..aa6222d3 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -19,8 +19,9 @@ import { Route as AuthenticatedStoragesIndexRouteImport } from './routes/_authen import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index' import { Route as AuthenticatedRecycleBinIndexRouteImport } from './routes/_authenticated/recycle-bin/index' import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index' -import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index' +import { Route as AuthenticatedAdminUsersIndexRouteImport } from './routes/_authenticated/admin/users/index' import { Route as AuthenticatedAdminStoragesIndexRouteImport } from './routes/_authenticated/admin/storages/index' +import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index' const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({ id: '/_authenticated', @@ -74,10 +75,10 @@ const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({ path: '/files/', getParentRoute: () => AuthenticatedRouteRoute, } as any) -const AuthenticatedAdminSettingsIndexRoute = - AuthenticatedAdminSettingsIndexRouteImport.update({ - id: '/settings/', - path: '/settings/', +const AuthenticatedAdminUsersIndexRoute = + AuthenticatedAdminUsersIndexRouteImport.update({ + id: '/users/', + path: '/users/', getParentRoute: () => AuthenticatedAdminRouteRoute, } as any) const AuthenticatedAdminStoragesIndexRoute = @@ -86,6 +87,12 @@ const AuthenticatedAdminStoragesIndexRoute = path: '/storages/', getParentRoute: () => AuthenticatedAdminRouteRoute, } as any) +const AuthenticatedAdminSettingsIndexRoute = + AuthenticatedAdminSettingsIndexRouteImport.update({ + id: '/settings/', + path: '/settings/', + getParentRoute: () => AuthenticatedAdminRouteRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AuthenticatedIndexRoute @@ -99,6 +106,7 @@ export interface FileRoutesByFullPath { '/users/': typeof AuthenticatedUsersIndexRoute '/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute '/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute + '/admin/users/': typeof AuthenticatedAdminUsersIndexRoute } export interface FileRoutesByTo { '/admin': typeof AuthenticatedAdminRouteRouteWithChildren @@ -112,6 +120,7 @@ export interface FileRoutesByTo { '/users': typeof AuthenticatedUsersIndexRoute '/admin/settings': typeof AuthenticatedAdminSettingsIndexRoute '/admin/storages': typeof AuthenticatedAdminStoragesIndexRoute + '/admin/users': typeof AuthenticatedAdminUsersIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -127,6 +136,7 @@ export interface FileRoutesById { '/_authenticated/users/': typeof AuthenticatedUsersIndexRoute '/_authenticated/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute '/_authenticated/admin/storages/': typeof AuthenticatedAdminStoragesIndexRoute + '/_authenticated/admin/users/': typeof AuthenticatedAdminUsersIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -142,6 +152,7 @@ export interface FileRouteTypes { | '/users/' | '/admin/settings/' | '/admin/storages/' + | '/admin/users/' fileRoutesByTo: FileRoutesByTo to: | '/admin' @@ -155,6 +166,7 @@ export interface FileRouteTypes { | '/users' | '/admin/settings' | '/admin/storages' + | '/admin/users' id: | '__root__' | '/_authenticated' @@ -169,6 +181,7 @@ export interface FileRouteTypes { | '/_authenticated/users/' | '/_authenticated/admin/settings/' | '/_authenticated/admin/storages/' + | '/_authenticated/admin/users/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -249,11 +262,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedFilesIndexRouteImport parentRoute: typeof AuthenticatedRouteRoute } - '/_authenticated/admin/settings/': { - id: '/_authenticated/admin/settings/' - path: '/settings' - fullPath: '/admin/settings/' - preLoaderRoute: typeof AuthenticatedAdminSettingsIndexRouteImport + '/_authenticated/admin/users/': { + id: '/_authenticated/admin/users/' + path: '/users' + fullPath: '/admin/users/' + preLoaderRoute: typeof AuthenticatedAdminUsersIndexRouteImport parentRoute: typeof AuthenticatedAdminRouteRoute } '/_authenticated/admin/storages/': { @@ -263,18 +276,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAdminStoragesIndexRouteImport parentRoute: typeof AuthenticatedAdminRouteRoute } + '/_authenticated/admin/settings/': { + id: '/_authenticated/admin/settings/' + path: '/settings' + fullPath: '/admin/settings/' + preLoaderRoute: typeof AuthenticatedAdminSettingsIndexRouteImport + parentRoute: typeof AuthenticatedAdminRouteRoute + } } } interface AuthenticatedAdminRouteRouteChildren { AuthenticatedAdminSettingsIndexRoute: typeof AuthenticatedAdminSettingsIndexRoute AuthenticatedAdminStoragesIndexRoute: typeof AuthenticatedAdminStoragesIndexRoute + AuthenticatedAdminUsersIndexRoute: typeof AuthenticatedAdminUsersIndexRoute } const AuthenticatedAdminRouteRouteChildren: AuthenticatedAdminRouteRouteChildren = { AuthenticatedAdminSettingsIndexRoute: AuthenticatedAdminSettingsIndexRoute, AuthenticatedAdminStoragesIndexRoute: AuthenticatedAdminStoragesIndexRoute, + AuthenticatedAdminUsersIndexRoute: AuthenticatedAdminUsersIndexRoute, } const AuthenticatedAdminRouteRouteWithChildren = diff --git a/packages/web/src/routes/_authenticated/admin/storages/index.tsx b/packages/web/src/routes/_authenticated/admin/storages/index.tsx index c956b68e..030cfdc2 100644 --- a/packages/web/src/routes/_authenticated/admin/storages/index.tsx +++ b/packages/web/src/routes/_authenticated/admin/storages/index.tsx @@ -12,7 +12,7 @@ export const Route = createFileRoute('/_authenticated/admin/storages/')({ component: StoragesPage, }) -const STORAGE_STATUS_ACTIVE = 1 +const STORAGE_STATUS_ACTIVE = 'active' function StoragesPage() { const { t } = useTranslation() @@ -23,7 +23,7 @@ function StoragesPage() { const storagesQuery = useQuery({ queryKey: ['admin', 'storages'], queryFn: async () => { - const res = await fetch('/api/storages', { credentials: 'include' }) + const res = await fetch('/api/admin/storages', { credentials: 'include' }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.message ?? 'Failed to fetch storages') diff --git a/packages/web/src/routes/_authenticated/admin/users/index.tsx b/packages/web/src/routes/_authenticated/admin/users/index.tsx new file mode 100644 index 00000000..bc5d9975 --- /dev/null +++ b/packages/web/src/routes/_authenticated/admin/users/index.tsx @@ -0,0 +1,296 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { createFileRoute } from '@tanstack/react-router' +import { Search, Settings2, ShieldCheck, Trash2, UserX } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { DeleteUserDialog } from '@/components/admin/delete-user-dialog' +import { UserQuotaDialog } from '@/components/admin/user-quota-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' + +export const Route = createFileRoute('/_authenticated/admin/users/')({ + component: UsersPage, +}) + +interface UserWithOrg { + id: string + name: string + email: string + role: string | null + banned: boolean + createdAt: number + orgId: string | null + orgName: string | null +} + +interface QuotaItem { + orgId: string + quota: number + used: number +} + +interface UserRow extends UserWithOrg { + quotaUsed: number + quotaTotal: number +} + +function UsersPage() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const pageSize = 20 + + const [quotaDialogUser, setQuotaDialogUser] = useState(null) + const [deleteDialogUser, setDeleteDialogUser] = useState<{ id: string; name: string } | null>(null) + + const usersQuery = useQuery({ + queryKey: ['admin', 'users', page, pageSize], + queryFn: async () => { + const res = await fetch(`/api/admin/users?page=${page}&pageSize=${pageSize}`, { credentials: 'include' }) + if (!res.ok) throw new Error('Failed to fetch users') + return res.json() as Promise<{ items: UserWithOrg[]; total: number }> + }, + }) + + const quotasQuery = useQuery({ + queryKey: ['admin', 'quotas'], + queryFn: async () => { + const res = await fetch('/api/admin/quotas', { credentials: 'include' }) + if (!res.ok) throw new Error('Failed to fetch quotas') + return res.json() as Promise<{ items: QuotaItem[]; total: number }> + }, + }) + + const toggleStatusMutation = useMutation({ + mutationFn: async ({ userId, status }: { userId: string; status: string }) => { + const res = await fetch(`/api/admin/users/${userId}/status`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ status }), + }) + if (!res.ok) throw new Error('Failed to update status') + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + toast.success(t('admin.users.statusUpdated')) + }, + onError: (err) => { + toast.error(err.message) + }, + }) + + const quotaMap = useMemo(() => { + const map = new Map() + for (const q of quotasQuery.data?.items ?? []) { + map.set(q.orgId, q) + } + return map + }, [quotasQuery.data]) + + const users: UserRow[] = useMemo(() => { + const items = usersQuery.data?.items ?? [] + return items.map((u) => { + const quota = u.orgId ? quotaMap.get(u.orgId) : undefined + return { ...u, quotaUsed: quota?.used ?? 0, quotaTotal: quota?.quota ?? 0 } + }) + }, [usersQuery.data, quotaMap]) + + const filtered = useMemo(() => { + if (!search.trim()) return users + const term = search.toLowerCase() + return users.filter((u) => u.name.toLowerCase().includes(term) || u.email.toLowerCase().includes(term)) + }, [users, search]) + + const total = usersQuery.data?.total ?? 0 + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + const isLoading = usersQuery.isLoading || quotasQuery.isLoading + + function handleSearchChange(e: React.ChangeEvent) { + setSearch(e.target.value) + setPage(1) + } + + if (isLoading) { + return ( +
+

{t('common.loading')}

+
+ ) + } + + return ( +
+
+

{t('admin.users.title')}

+
+ + +
+
+ +
+ + + + + + + + + + + + + + {filtered.map((user) => ( + setQuotaDialogUser(user)} + onToggleStatus={() => + toggleStatusMutation.mutate({ + userId: user.id, + status: user.banned ? 'active' : 'disabled', + }) + } + onDelete={() => setDeleteDialogUser({ id: user.id, name: user.name })} + /> + ))} + {filtered.length === 0 && ( + + + + )} + +
{t('admin.users.colName')}{t('admin.users.colEmail')}{t('admin.users.colRole')}{t('admin.users.colStatus')}{t('admin.users.colQuota')}{t('admin.users.colCreatedAt')}{t('admin.users.colActions')}
+ {t('admin.users.noUsers')} +
+
+ + {totalPages > 1 && ( +
+ + + {t('admin.users.pageInfo', { page, total: totalPages })} + + +
+ )} + + !open && setQuotaDialogUser(null)} + user={ + quotaDialogUser?.orgId + ? { + name: quotaDialogUser.name, + orgId: quotaDialogUser.orgId, + quotaUsed: quotaDialogUser.quotaUsed, + quotaTotal: quotaDialogUser.quotaTotal, + } + : null + } + /> + + !open && setDeleteDialogUser(null)} + user={deleteDialogUser} + /> +
+ ) +} + +function UserTableRow({ + user, + isToggling, + onSetQuota, + onToggleStatus, + onDelete, +}: { + user: UserRow + isToggling: boolean + onSetQuota: () => void + onToggleStatus: () => void + onDelete: () => void +}) { + const { t } = useTranslation() + + const roleBadge = user.role === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground' + const statusBadge = user.banned + ? 'bg-destructive/10 text-destructive' + : 'bg-green-500/10 text-green-700 dark:text-green-400' + + const roleLabel = user.role === 'admin' ? t('admin.users.roleAdmin') : t('admin.users.roleMember') + const quotaLabel = formatQuota(user.quotaUsed, user.quotaTotal) + + return ( + + {user.name} + {user.email} + + {roleLabel} + + + + {user.banned ? t('admin.users.disabled') : t('admin.users.active')} + + + {quotaLabel} + {formatDate(user.createdAt)} + +
+ + + +
+ + + ) +} + +const BYTES_PER_GB = 1024 * 1024 * 1024 + +function formatQuota(used: number, total: number): string { + const usedGB = (used / BYTES_PER_GB).toFixed(1) + if (total <= 0) return `${usedGB} GB / --` + const totalGB = (total / BYTES_PER_GB).toFixed(1) + return `${usedGB} / ${totalGB} GB` +} + +function formatDate(timestamp: number): string { + const d = new Date(timestamp) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString() +} diff --git a/packages/web/src/routes/_authenticated/files/index.tsx b/packages/web/src/routes/_authenticated/files/index.tsx index bafab5dd..6a962393 100644 --- a/packages/web/src/routes/_authenticated/files/index.tsx +++ b/packages/web/src/routes/_authenticated/files/index.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { UploadDropzone } from '../../../components/upload/upload-dropzone' -import { connectAdapter, loadFolder, refreshFolder } from '../../../lib/file-manager-adapter' +import { connectAdapter, loadFolder, pathToDbId, refreshFolder } from '../../../lib/file-manager-adapter' interface FilesSearch { folder?: string @@ -28,11 +28,11 @@ function FilesPage() { const apiRef = useRef(null) const [data, setData] = useState(null) const [loading, setLoading] = useState(true) - const [currentParent, setCurrentParent] = useState('') + const [currentPath, setCurrentPath] = useState('/') useEffect(() => { setLoading(true) - loadFolder('') + loadFolder('', '/') .then(setData) .catch(() => toast.error(t('common.error'))) .finally(() => setLoading(false)) @@ -44,7 +44,7 @@ function FilesPage() { const handleSetPath = useCallback( ({ id }: { id: string }) => { - setCurrentParent(id ?? '') + setCurrentPath(id || '/') navigate({ to: '/files', search: { folder: id || undefined } }) }, [navigate], @@ -52,9 +52,10 @@ function FilesPage() { const handleUploadComplete = useCallback(() => { if (apiRef.current) { - refreshFolder(apiRef.current, currentParent).catch(() => toast.error(t('common.error'))) + const dbId = pathToDbId(currentPath) + refreshFolder(apiRef.current, dbId, currentPath).catch(() => toast.error(t('common.error'))) } - }, [currentParent, t]) + }, [currentPath, t]) if (loading) { return ( @@ -66,7 +67,7 @@ function FilesPage() { if (!data || data.length === 0) { return ( - +

{t('files.title')}

@@ -77,7 +78,7 @@ function FilesPage() { } return ( - +
diff --git a/packages/web/src/types/storage.ts b/packages/web/src/types/storage.ts index e364ebd3..2663d666 100644 --- a/packages/web/src/types/storage.ts +++ b/packages/web/src/types/storage.ts @@ -10,7 +10,7 @@ export interface Storage { secretKey: string filePath: string customHost: string - status: number + status: string createdAt: string updatedAt: string } diff --git a/wrangler.toml b/wrangler.toml index 7f454682..b2069c5a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -17,3 +17,7 @@ binding = "DB" database_name = "zpan-db" database_id = "local" migrations_dir = "./migrations" + +[[r2_buckets]] +binding = "R2_BUCKET" +bucket_name = "zpan-test"