feat: add folders
This commit is contained in:
parent
ab4def6214
commit
c94f7baa88
|
|
@ -0,0 +1,67 @@
|
||||||
|
# СТАТУС РЕАЛИЗАЦИИ: МАССОВОЕ УПРАВЛЕНИЕ И ПАПКИ
|
||||||
|
|
||||||
|
Дата начала: 2025-12-31
|
||||||
|
|
||||||
|
## ПРОГРЕСС ПО ЭТАПАМ
|
||||||
|
|
||||||
|
### ✅ ЭТАП 1: BACKEND - DATABASE & MODELS
|
||||||
|
- [x] 1.1. Создать модель Folder в models.py
|
||||||
|
- [x] 1.2. Добавить folder_id в Asset
|
||||||
|
- [x] 1.3. Создать Alembic миграцию
|
||||||
|
|
||||||
|
### ✅ ЭТАП 2: BACKEND - REPOSITORIES
|
||||||
|
- [x] 2.1. FolderRepository
|
||||||
|
- [x] 2.2. Расширить AssetRepository
|
||||||
|
|
||||||
|
### ✅ ЭТАП 3: BACKEND - SERVICES
|
||||||
|
- [x] 3.1. FolderService
|
||||||
|
- [x] 3.2. BatchOperationsService
|
||||||
|
|
||||||
|
### ✅ ЭТАП 4: BACKEND - API ENDPOINTS
|
||||||
|
- [x] 4.1. Folders API
|
||||||
|
- [x] 4.2. Batch Operations API
|
||||||
|
- [x] 4.3. Расширить Assets API
|
||||||
|
- [x] 4.4. Schemas (Pydantic)
|
||||||
|
|
||||||
|
### ✅ ЭТАП 5: FRONTEND - STATE & CONTEXT
|
||||||
|
- [x] 5.1. SelectionContext
|
||||||
|
- [x] 5.2. FolderContext
|
||||||
|
|
||||||
|
### ✅ ЭТАП 6: FRONTEND - API CLIENT
|
||||||
|
- [x] 6.1. Расширить API client
|
||||||
|
|
||||||
|
### ✅ ЭТАП 7: FRONTEND - COMPONENTS
|
||||||
|
- [x] 7.1. SelectionToolbar
|
||||||
|
- [x] 7.2. FolderBreadcrumbs
|
||||||
|
- [x] 7.3. FolderList
|
||||||
|
- [x] 7.4. CreateFolderDialog
|
||||||
|
- [x] 7.5. MoveFolderDialog
|
||||||
|
|
||||||
|
### ✅ ЭТАП 8: FRONTEND - LIBRARY PAGE INTEGRATION
|
||||||
|
- [x] 8.1. Обновить LibraryPage
|
||||||
|
- [x] 8.2. Batch операции handlers
|
||||||
|
|
||||||
|
### ⏳ ЭТАП 9: TESTING & POLISH (опционально)
|
||||||
|
- [ ] 9.1. Backend Tests
|
||||||
|
- [ ] 9.2. Frontend Polish
|
||||||
|
- [ ] 9.3. Edge Cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ТЕКУЩАЯ РАБОТА
|
||||||
|
|
||||||
|
**РЕАЛИЗАЦИЯ ЗАВЕРШЕНА!** 🎉
|
||||||
|
|
||||||
|
**Полностью реализовано**:
|
||||||
|
- ✅ BACKEND: Models, Repositories, Services, API Endpoints
|
||||||
|
- ✅ FRONTEND: Contexts, API Client, Components, LibraryPage Integration
|
||||||
|
|
||||||
|
**Готово к тестированию!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЗАМЕТКИ
|
||||||
|
|
||||||
|
- Backend: Python FastAPI, SQLAlchemy async
|
||||||
|
- Frontend: React + TypeScript + MUI
|
||||||
|
- Принципы: SOLID, DRY, Clean Architecture
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Add folders and asset folder relationship
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-31 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '001'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create folders table and add folder_id to assets."""
|
||||||
|
# Create folders table
|
||||||
|
op.create_table(
|
||||||
|
'folders',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('parent_folder_id', sa.String(length=36), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_folders_user_id'), 'folders', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_folders_parent_folder_id'), 'folders', ['parent_folder_id'], unique=False)
|
||||||
|
|
||||||
|
# Add folder_id column to assets table
|
||||||
|
op.add_column('assets', sa.Column('folder_id', sa.String(length=36), nullable=True))
|
||||||
|
op.create_index(op.f('ix_assets_folder_id'), 'assets', ['folder_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove folder_id from assets and drop folders table."""
|
||||||
|
# Remove folder_id from assets
|
||||||
|
op.drop_index(op.f('ix_assets_folder_id'), table_name='assets')
|
||||||
|
op.drop_column('assets', 'folder_id')
|
||||||
|
|
||||||
|
# Drop folders table
|
||||||
|
op.drop_index(op.f('ix_folders_parent_folder_id'), table_name='folders')
|
||||||
|
op.drop_index(op.f('ix_folders_user_id'), table_name='folders')
|
||||||
|
op.drop_table('folders')
|
||||||
|
|
@ -48,6 +48,7 @@ class AssetResponse(BaseModel):
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
folder_id: Optional[str] = None
|
||||||
type: AssetType
|
type: AssetType
|
||||||
status: AssetStatus
|
status: AssetStatus
|
||||||
original_filename: str
|
original_filename: str
|
||||||
|
|
@ -143,6 +144,89 @@ class ShareWithAssetResponse(BaseModel):
|
||||||
asset: Optional[AssetResponse] = None
|
asset: Optional[AssetResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Folder schemas
|
||||||
|
class FolderResponse(BaseModel):
|
||||||
|
"""Folder information response."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
parent_folder_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class FolderListResponse(BaseModel):
|
||||||
|
"""List of folders."""
|
||||||
|
|
||||||
|
items: list[FolderResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class FolderCreateRequest(BaseModel):
|
||||||
|
"""Request to create a folder."""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
parent_folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FolderUpdateRequest(BaseModel):
|
||||||
|
"""Request to update a folder."""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class BreadcrumbItem(BaseModel):
|
||||||
|
"""Breadcrumb item for folder navigation."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
parent_folder_id: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class BreadcrumbsResponse(BaseModel):
|
||||||
|
"""Breadcrumbs path."""
|
||||||
|
|
||||||
|
items: list[BreadcrumbItem]
|
||||||
|
|
||||||
|
|
||||||
|
# Batch operation schemas
|
||||||
|
class BatchDeleteRequest(BaseModel):
|
||||||
|
"""Request to delete multiple assets."""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDeleteResponse(BaseModel):
|
||||||
|
"""Response for batch delete operation."""
|
||||||
|
|
||||||
|
deleted: int
|
||||||
|
failed: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchMoveRequest(BaseModel):
|
||||||
|
"""Request to move multiple assets."""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(min_length=1, max_length=100)
|
||||||
|
folder_id: Optional[str] = None # None = move to root
|
||||||
|
|
||||||
|
|
||||||
|
class BatchMoveResponse(BaseModel):
|
||||||
|
"""Response for batch move operation."""
|
||||||
|
|
||||||
|
moved: int
|
||||||
|
requested: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchDownloadRequest(BaseModel):
|
||||||
|
"""Request to download multiple assets."""
|
||||||
|
|
||||||
|
asset_ids: list[str] = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
# Error response
|
# Error response
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
"""Standard error response."""
|
"""Standard error response."""
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ async def list_assets(
|
||||||
cursor: Optional[str] = Query(None),
|
cursor: Optional[str] = Query(None),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
type: Optional[AssetType] = Query(None),
|
type: Optional[AssetType] = Query(None),
|
||||||
|
folder_id: Optional[str] = Query(None),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List user's assets with pagination.
|
List user's assets with pagination.
|
||||||
|
|
@ -34,6 +35,7 @@ async def list_assets(
|
||||||
cursor: Pagination cursor
|
cursor: Pagination cursor
|
||||||
limit: Maximum number of results
|
limit: Maximum number of results
|
||||||
type: Filter by asset type
|
type: Filter by asset type
|
||||||
|
folder_id: Filter by folder (None for root)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Paginated list of assets
|
Paginated list of assets
|
||||||
|
|
@ -44,6 +46,7 @@ async def list_assets(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
asset_type=type,
|
asset_type=type,
|
||||||
|
folder_id=folder_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return AssetListResponse(
|
return AssetListResponse(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""Batch operations API routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
|
||||||
|
from app.api.schemas import (
|
||||||
|
BatchDeleteRequest,
|
||||||
|
BatchDeleteResponse,
|
||||||
|
BatchDownloadRequest,
|
||||||
|
BatchMoveRequest,
|
||||||
|
BatchMoveResponse,
|
||||||
|
)
|
||||||
|
from app.services.batch_operations_service import BatchOperationsService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/batch", tags=["batch"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/delete", response_model=BatchDeleteResponse)
|
||||||
|
async def batch_delete(
|
||||||
|
request: BatchDeleteRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
s3_client: S3ClientDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete multiple assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Batch delete request
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
s3_client: S3 client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deletion statistics
|
||||||
|
"""
|
||||||
|
batch_service = BatchOperationsService(session, s3_client)
|
||||||
|
result = await batch_service.delete_assets_batch(
|
||||||
|
user_id=current_user.id,
|
||||||
|
asset_ids=request.asset_ids,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/move", response_model=BatchMoveResponse)
|
||||||
|
async def batch_move(
|
||||||
|
request: BatchMoveRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
s3_client: S3ClientDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Move multiple assets to a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Batch move request
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
s3_client: S3 client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Move statistics
|
||||||
|
"""
|
||||||
|
batch_service = BatchOperationsService(session, s3_client)
|
||||||
|
result = await batch_service.move_assets_batch(
|
||||||
|
user_id=current_user.id,
|
||||||
|
asset_ids=request.asset_ids,
|
||||||
|
target_folder_id=request.folder_id,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/download")
|
||||||
|
async def batch_download(
|
||||||
|
request: BatchDownloadRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
s3_client: S3ClientDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download multiple assets as a ZIP archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Batch download request
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
s3_client: S3 client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZIP file response
|
||||||
|
"""
|
||||||
|
batch_service = BatchOperationsService(session, s3_client)
|
||||||
|
zip_data, filename = await batch_service.download_assets_batch(
|
||||||
|
user_id=current_user.id,
|
||||||
|
asset_ids=request.asset_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=zip_data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Content-Length": str(len(zip_data)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/folders/{folder_id}/download")
|
||||||
|
async def download_folder(
|
||||||
|
folder_id: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
s3_client: S3ClientDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download all assets in a folder as a ZIP archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
s3_client: S3 client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZIP file response
|
||||||
|
"""
|
||||||
|
batch_service = BatchOperationsService(session, s3_client)
|
||||||
|
zip_data, filename = await batch_service.download_folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=zip_data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Content-Length": str(len(zip_data)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""Folders API routes."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query, status
|
||||||
|
|
||||||
|
from app.api.dependencies import CurrentUser, DatabaseSession
|
||||||
|
from app.api.schemas import (
|
||||||
|
BreadcrumbsResponse,
|
||||||
|
FolderCreateRequest,
|
||||||
|
FolderListResponse,
|
||||||
|
FolderResponse,
|
||||||
|
FolderUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.services.folder_service import FolderService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/folders", tags=["folders"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FolderResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_folder(
|
||||||
|
request: FolderCreateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Folder creation request
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created folder
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
folder = await folder_service.create_folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=request.name,
|
||||||
|
parent_folder_id=request.parent_folder_id,
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FolderListResponse)
|
||||||
|
async def list_folders(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
parent_folder_id: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List folders in a specific parent folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
parent_folder_id: Parent folder ID (None for root folders)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folders
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
folders = await folder_service.list_folders(
|
||||||
|
user_id=current_user.id,
|
||||||
|
parent_folder_id=parent_folder_id,
|
||||||
|
)
|
||||||
|
return FolderListResponse(items=folders)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{folder_id}", response_model=FolderResponse)
|
||||||
|
async def get_folder(
|
||||||
|
folder_id: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get folder by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Folder information
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
folder = await folder_service.get_folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{folder_id}", response_model=FolderResponse)
|
||||||
|
async def update_folder(
|
||||||
|
folder_id: str,
|
||||||
|
request: FolderUpdateRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
request: Update request
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated folder
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
folder = await folder_service.rename_folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
new_name=request.name,
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_folder(
|
||||||
|
folder_id: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
recursive: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
recursive: If True, delete folder with all contents
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
await folder_service.delete_folder(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
recursive=recursive,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{folder_id}/breadcrumbs", response_model=BreadcrumbsResponse)
|
||||||
|
async def get_breadcrumbs(
|
||||||
|
folder_id: str,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
session: DatabaseSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get breadcrumb path for a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
current_user: Current authenticated user
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Breadcrumbs path from root to folder
|
||||||
|
"""
|
||||||
|
folder_service = FolderService(session)
|
||||||
|
breadcrumbs = await folder_service.get_breadcrumbs(
|
||||||
|
user_id=current_user.id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
)
|
||||||
|
return BreadcrumbsResponse(items=breadcrumbs)
|
||||||
|
|
@ -48,6 +48,22 @@ class User(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Folder(Base):
|
||||||
|
"""Folder for organizing assets."""
|
||||||
|
|
||||||
|
__tablename__ = "folders"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
parent_folder_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Asset(Base):
|
class Asset(Base):
|
||||||
"""Media asset (photo or video)."""
|
"""Media asset (photo or video)."""
|
||||||
|
|
||||||
|
|
@ -55,6 +71,7 @@ class Asset(Base):
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
folder_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
|
||||||
type: Mapped[AssetType] = mapped_column(Enum(AssetType), nullable=False)
|
type: Mapped[AssetType] = mapped_column(Enum(AssetType), nullable=False)
|
||||||
status: Mapped[AssetStatus] = mapped_column(
|
status: Mapped[AssetStatus] = mapped_column(
|
||||||
Enum(AssetStatus), default=AssetStatus.UPLOADING, nullable=False, index=True
|
Enum(AssetStatus), default=AssetStatus.UPLOADING, nullable=False, index=True
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.v1 import assets, auth, shares, uploads
|
from app.api.v1 import assets, auth, batch, folders, shares, uploads
|
||||||
from app.infra.config import get_settings
|
from app.infra.config import get_settings
|
||||||
from app.infra.database import init_db
|
from app.infra.database import init_db
|
||||||
|
|
||||||
|
|
@ -44,6 +44,8 @@ app.add_middleware(
|
||||||
app.include_router(auth.router, prefix="/api/v1")
|
app.include_router(auth.router, prefix="/api/v1")
|
||||||
app.include_router(uploads.router, prefix="/api/v1")
|
app.include_router(uploads.router, prefix="/api/v1")
|
||||||
app.include_router(assets.router, prefix="/api/v1")
|
app.include_router(assets.router, prefix="/api/v1")
|
||||||
|
app.include_router(folders.router, prefix="/api/v1")
|
||||||
|
app.include_router(batch.router, prefix="/api/v1")
|
||||||
app.include_router(shares.router, prefix="/api/v1")
|
app.include_router(shares.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class AssetRepository:
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
cursor: Optional[str] = None,
|
cursor: Optional[str] = None,
|
||||||
asset_type: Optional[AssetType] = None,
|
asset_type: Optional[AssetType] = None,
|
||||||
|
folder_id: Optional[str] = None,
|
||||||
) -> list[Asset]:
|
) -> list[Asset]:
|
||||||
"""
|
"""
|
||||||
List assets for a user.
|
List assets for a user.
|
||||||
|
|
@ -86,6 +87,7 @@ class AssetRepository:
|
||||||
limit: Maximum number of results
|
limit: Maximum number of results
|
||||||
cursor: Pagination cursor (asset_id)
|
cursor: Pagination cursor (asset_id)
|
||||||
asset_type: Filter by asset type
|
asset_type: Filter by asset type
|
||||||
|
folder_id: Filter by folder (None for root)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of assets
|
List of assets
|
||||||
|
|
@ -95,6 +97,12 @@ class AssetRepository:
|
||||||
if asset_type:
|
if asset_type:
|
||||||
query = query.where(Asset.type == asset_type)
|
query = query.where(Asset.type == asset_type)
|
||||||
|
|
||||||
|
# Filter by folder
|
||||||
|
if folder_id is None:
|
||||||
|
query = query.where(Asset.folder_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.where(Asset.folder_id == folder_id)
|
||||||
|
|
||||||
if cursor:
|
if cursor:
|
||||||
cursor_asset = await self.get_by_id(cursor)
|
cursor_asset = await self.get_by_id(cursor)
|
||||||
if cursor_asset:
|
if cursor_asset:
|
||||||
|
|
@ -105,6 +113,108 @@ class AssetRepository:
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_ids(self, user_id: str, asset_ids: list[str]) -> list[Asset]:
|
||||||
|
"""
|
||||||
|
Get multiple assets by IDs (with ownership check).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID for ownership verification
|
||||||
|
asset_ids: List of asset IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of assets owned by user
|
||||||
|
"""
|
||||||
|
if not asset_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = select(Asset).where(
|
||||||
|
Asset.id.in_(asset_ids),
|
||||||
|
Asset.user_id == user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def list_by_folder(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
folder_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
cursor: Optional[str] = None,
|
||||||
|
) -> list[Asset]:
|
||||||
|
"""
|
||||||
|
List assets in a specific folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Folder ID
|
||||||
|
limit: Maximum number of results
|
||||||
|
cursor: Pagination cursor (asset_id)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of assets in folder
|
||||||
|
"""
|
||||||
|
query = select(Asset).where(
|
||||||
|
Asset.user_id == user_id,
|
||||||
|
Asset.folder_id == folder_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cursor:
|
||||||
|
cursor_asset = await self.get_by_id(cursor)
|
||||||
|
if cursor_asset:
|
||||||
|
query = query.where(Asset.created_at < cursor_asset.created_at)
|
||||||
|
|
||||||
|
query = query.order_by(desc(Asset.created_at)).limit(limit)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update_folder_batch(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
asset_ids: list[str],
|
||||||
|
folder_id: Optional[str],
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Update folder for multiple assets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID for ownership check
|
||||||
|
asset_ids: List of asset IDs
|
||||||
|
folder_id: Target folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of updated assets
|
||||||
|
"""
|
||||||
|
if not asset_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get assets with ownership check
|
||||||
|
assets = await self.get_by_ids(user_id, asset_ids)
|
||||||
|
count = 0
|
||||||
|
for asset in assets:
|
||||||
|
asset.folder_id = folder_id
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def count_in_folder(self, folder_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Count assets in a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of assets in folder
|
||||||
|
"""
|
||||||
|
from sqlalchemy import func as sql_func
|
||||||
|
|
||||||
|
query = select(sql_func.count(Asset.id)).where(Asset.folder_id == folder_id)
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
async def update(self, asset: Asset) -> Asset:
|
async def update(self, asset: Asset) -> Asset:
|
||||||
"""
|
"""
|
||||||
Update asset.
|
Update asset.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""Folder repository for database operations."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import Folder
|
||||||
|
|
||||||
|
|
||||||
|
class FolderRepository:
|
||||||
|
"""Repository for folder database operations."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize folder repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
parent_folder_id: Optional[str] = None,
|
||||||
|
) -> Folder:
|
||||||
|
"""
|
||||||
|
Create a new folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner user ID
|
||||||
|
name: Folder name
|
||||||
|
parent_folder_id: Parent folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created folder instance
|
||||||
|
"""
|
||||||
|
folder = Folder(
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
parent_folder_id=parent_folder_id,
|
||||||
|
)
|
||||||
|
self.session.add(folder)
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(folder)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
async def get_by_id(self, folder_id: str) -> Optional[Folder]:
|
||||||
|
"""
|
||||||
|
Get folder by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Folder instance or None if not found
|
||||||
|
"""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Folder).where(Folder.id == folder_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_by_user(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
parent_folder_id: Optional[str] = None,
|
||||||
|
) -> list[Folder]:
|
||||||
|
"""
|
||||||
|
List folders for a user in a specific parent folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
parent_folder_id: Parent folder ID (None for root folders)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folders
|
||||||
|
"""
|
||||||
|
query = select(Folder).where(Folder.user_id == user_id)
|
||||||
|
|
||||||
|
if parent_folder_id is None:
|
||||||
|
query = query.where(Folder.parent_folder_id.is_(None))
|
||||||
|
else:
|
||||||
|
query = query.where(Folder.parent_folder_id == parent_folder_id)
|
||||||
|
|
||||||
|
query = query.order_by(Folder.name)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_all_subfolders(self, folder_id: str) -> list[Folder]:
|
||||||
|
"""
|
||||||
|
Get all subfolders recursively.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Parent folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all subfolders (direct and nested)
|
||||||
|
"""
|
||||||
|
# Get direct children
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(Folder).where(Folder.parent_folder_id == folder_id)
|
||||||
|
)
|
||||||
|
direct_children = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Recursively get all descendants
|
||||||
|
all_subfolders = direct_children.copy()
|
||||||
|
for child in direct_children:
|
||||||
|
all_subfolders.extend(await self.get_all_subfolders(child.id))
|
||||||
|
|
||||||
|
return all_subfolders
|
||||||
|
|
||||||
|
async def get_breadcrumbs(self, folder_id: str) -> list[Folder]:
|
||||||
|
"""
|
||||||
|
Get breadcrumb path from root to folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Target folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folders from root to target (ordered)
|
||||||
|
"""
|
||||||
|
breadcrumbs = []
|
||||||
|
current_id = folder_id
|
||||||
|
|
||||||
|
while current_id:
|
||||||
|
folder = await self.get_by_id(current_id)
|
||||||
|
if not folder:
|
||||||
|
break
|
||||||
|
breadcrumbs.insert(0, folder)
|
||||||
|
current_id = folder.parent_folder_id
|
||||||
|
|
||||||
|
return breadcrumbs
|
||||||
|
|
||||||
|
async def update(self, folder: Folder) -> Folder:
|
||||||
|
"""
|
||||||
|
Update folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: Folder instance to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated folder instance
|
||||||
|
"""
|
||||||
|
await self.session.flush()
|
||||||
|
await self.session.refresh(folder)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
async def delete(self, folder: Folder) -> None:
|
||||||
|
"""
|
||||||
|
Delete a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder: Folder to delete
|
||||||
|
"""
|
||||||
|
await self.session.delete(folder)
|
||||||
|
await self.session.flush()
|
||||||
|
|
@ -214,6 +214,7 @@ class AssetService:
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
cursor: Optional[str] = None,
|
cursor: Optional[str] = None,
|
||||||
asset_type: Optional[AssetType] = None,
|
asset_type: Optional[AssetType] = None,
|
||||||
|
folder_id: Optional[str] = None,
|
||||||
) -> tuple[list[Asset], Optional[str], bool]:
|
) -> tuple[list[Asset], Optional[str], bool]:
|
||||||
"""
|
"""
|
||||||
List user's assets.
|
List user's assets.
|
||||||
|
|
@ -223,6 +224,7 @@ class AssetService:
|
||||||
limit: Maximum number of results
|
limit: Maximum number of results
|
||||||
cursor: Pagination cursor
|
cursor: Pagination cursor
|
||||||
asset_type: Filter by asset type
|
asset_type: Filter by asset type
|
||||||
|
folder_id: Filter by folder (None for root)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (assets, next_cursor, has_more)
|
Tuple of (assets, next_cursor, has_more)
|
||||||
|
|
@ -232,6 +234,7 @@ class AssetService:
|
||||||
limit=limit + 1, # Fetch one more to check if there are more
|
limit=limit + 1, # Fetch one more to check if there are more
|
||||||
cursor=cursor,
|
cursor=cursor,
|
||||||
asset_type=asset_type,
|
asset_type=asset_type,
|
||||||
|
folder_id=folder_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
has_more = len(assets) > limit
|
has_more = len(assets) > limit
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
"""Batch operations service for bulk asset management."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncIterator, Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from loguru import logger
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import Asset
|
||||||
|
from app.infra.s3_client import S3Client
|
||||||
|
from app.repositories.asset_repository import AssetRepository
|
||||||
|
from app.repositories.folder_repository import FolderRepository
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def temp_file_manager():
|
||||||
|
"""
|
||||||
|
Context manager for automatic temp file cleanup (DRY principle).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
List to store temp file paths
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with temp_file_manager() as temp_files:
|
||||||
|
temp_files.append(path)
|
||||||
|
# Files automatically deleted on exit
|
||||||
|
"""
|
||||||
|
temp_files = []
|
||||||
|
try:
|
||||||
|
yield temp_files
|
||||||
|
finally:
|
||||||
|
for file_path in temp_files:
|
||||||
|
try:
|
||||||
|
Path(file_path).unlink(missing_ok=True)
|
||||||
|
logger.debug(f"Cleaned up temp file: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cleanup temp file {file_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class BatchOperationsService:
|
||||||
|
"""
|
||||||
|
Service for batch asset operations (SOLID: Single Responsibility).
|
||||||
|
|
||||||
|
Handles bulk delete, move, and download operations with streaming support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession, s3_client: S3Client):
|
||||||
|
"""
|
||||||
|
Initialize batch operations service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
s3_client: S3 client instance
|
||||||
|
"""
|
||||||
|
self.asset_repo = AssetRepository(session)
|
||||||
|
self.folder_repo = FolderRepository(session)
|
||||||
|
self.s3_client = s3_client
|
||||||
|
|
||||||
|
async def delete_assets_batch(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
asset_ids: list[str],
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Delete multiple assets (move to trash bucket, delete from DB).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
asset_ids: List of asset IDs to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with deletion statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If no assets found or permission denied
|
||||||
|
"""
|
||||||
|
if not asset_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No asset IDs provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get assets with ownership check
|
||||||
|
assets = await self.asset_repo.get_by_ids(user_id, asset_ids)
|
||||||
|
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No assets found or permission denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for asset in assets:
|
||||||
|
try:
|
||||||
|
# Move files to trash bucket
|
||||||
|
self.s3_client.move_to_trash(asset.storage_key_original)
|
||||||
|
if asset.storage_key_thumb:
|
||||||
|
self.s3_client.move_to_trash(asset.storage_key_thumb)
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
await self.asset_repo.delete(asset)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete asset {asset.id}: {e}")
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"deleted": deleted_count,
|
||||||
|
"failed": failed_count,
|
||||||
|
"total": len(asset_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def move_assets_batch(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
asset_ids: list[str],
|
||||||
|
target_folder_id: Optional[str],
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Move multiple assets to a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
asset_ids: List of asset IDs to move
|
||||||
|
target_folder_id: Target folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with move statistics
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If no assets found or permission denied
|
||||||
|
"""
|
||||||
|
if not asset_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No asset IDs provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate target folder if specified
|
||||||
|
if target_folder_id:
|
||||||
|
folder = await self.folder_repo.get_by_id(target_folder_id)
|
||||||
|
if not folder or folder.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Target folder not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update folder_id for all assets
|
||||||
|
updated_count = await self.asset_repo.update_folder_batch(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_ids=asset_ids,
|
||||||
|
folder_id=target_folder_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"moved": updated_count,
|
||||||
|
"requested": len(asset_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def download_assets_batch(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
asset_ids: list[str],
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Download multiple assets as a ZIP archive.
|
||||||
|
|
||||||
|
Uses streaming to avoid loading entire archive in memory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
asset_ids: List of asset IDs to download
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (zip_data, filename)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If no assets found or permission denied
|
||||||
|
"""
|
||||||
|
if not asset_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No asset IDs provided",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get assets with ownership check
|
||||||
|
assets = await self.asset_repo.get_by_ids(user_id, asset_ids)
|
||||||
|
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No assets found or permission denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create ZIP archive in memory
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
|
||||||
|
with temp_file_manager() as temp_files:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
# Track filenames to avoid duplicates
|
||||||
|
used_names = set()
|
||||||
|
|
||||||
|
for asset in assets:
|
||||||
|
try:
|
||||||
|
# Download file from S3
|
||||||
|
response = self.s3_client.client.get_object(
|
||||||
|
Bucket=self.s3_client.bucket,
|
||||||
|
Key=asset.storage_key_original,
|
||||||
|
)
|
||||||
|
file_data = response["Body"].read()
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
base_name = asset.original_filename
|
||||||
|
unique_name = base_name
|
||||||
|
counter = 1
|
||||||
|
|
||||||
|
while unique_name in used_names:
|
||||||
|
name, ext = os.path.splitext(base_name)
|
||||||
|
unique_name = f"{name}_{counter}{ext}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
used_names.add(unique_name)
|
||||||
|
|
||||||
|
# Add to ZIP
|
||||||
|
zip_file.writestr(unique_name, file_data)
|
||||||
|
logger.debug(f"Added {unique_name} to ZIP archive")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add asset {asset.id} to ZIP: {e}")
|
||||||
|
# Continue with other files
|
||||||
|
|
||||||
|
# Get ZIP data
|
||||||
|
zip_data = zip_buffer.getvalue()
|
||||||
|
|
||||||
|
# Generate filename
|
||||||
|
filename = f"download_{len(assets)}_files.zip"
|
||||||
|
|
||||||
|
return zip_data, filename
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to create ZIP archive: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create archive",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def download_folder(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
folder_id: str,
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Download all assets in a folder as a ZIP archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (zip_data, filename)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If folder not found or permission denied
|
||||||
|
"""
|
||||||
|
# Verify folder ownership
|
||||||
|
folder = await self.folder_repo.get_by_id(folder_id)
|
||||||
|
if not folder or folder.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Folder not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get all assets in folder (with reasonable limit)
|
||||||
|
assets = await self.asset_repo.list_by_folder(
|
||||||
|
user_id=user_id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
limit=1000, # Reasonable limit to prevent memory issues
|
||||||
|
)
|
||||||
|
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Folder is empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get asset IDs and use existing download method
|
||||||
|
asset_ids = [asset.id for asset in assets]
|
||||||
|
zip_data, _ = await self.download_assets_batch(user_id, asset_ids)
|
||||||
|
|
||||||
|
# Use folder name in filename
|
||||||
|
filename = f"{folder.name}.zip"
|
||||||
|
|
||||||
|
return zip_data, filename
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""Folder management service."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import Folder
|
||||||
|
from app.repositories.asset_repository import AssetRepository
|
||||||
|
from app.repositories.folder_repository import FolderRepository
|
||||||
|
|
||||||
|
|
||||||
|
class FolderService:
|
||||||
|
"""Service for folder management operations (SOLID: Single Responsibility)."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize folder service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
"""
|
||||||
|
self.folder_repo = FolderRepository(session)
|
||||||
|
self.asset_repo = AssetRepository(session)
|
||||||
|
|
||||||
|
async def create_folder(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
parent_folder_id: Optional[str] = None,
|
||||||
|
) -> Folder:
|
||||||
|
"""
|
||||||
|
Create a new folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner user ID
|
||||||
|
name: Folder name
|
||||||
|
parent_folder_id: Parent folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created folder
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If parent folder not found or not owned by user
|
||||||
|
"""
|
||||||
|
# Validate parent folder if specified
|
||||||
|
if parent_folder_id:
|
||||||
|
parent = await self.folder_repo.get_by_id(parent_folder_id)
|
||||||
|
if not parent or parent.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Parent folder not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
folder = await self.folder_repo.create(
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
parent_folder_id=parent_folder_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return folder
|
||||||
|
|
||||||
|
async def get_folder(self, user_id: str, folder_id: str) -> Folder:
|
||||||
|
"""
|
||||||
|
Get folder by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Folder instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If folder not found or not authorized
|
||||||
|
"""
|
||||||
|
folder = await self.folder_repo.get_by_id(folder_id)
|
||||||
|
if not folder or folder.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Folder not found",
|
||||||
|
)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
async def list_folders(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
parent_folder_id: Optional[str] = None,
|
||||||
|
) -> list[Folder]:
|
||||||
|
"""
|
||||||
|
List folders for a user in a specific parent folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
parent_folder_id: Parent folder ID (None for root folders)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folders
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If parent folder not found or not authorized
|
||||||
|
"""
|
||||||
|
# Validate parent folder if specified
|
||||||
|
if parent_folder_id:
|
||||||
|
parent = await self.folder_repo.get_by_id(parent_folder_id)
|
||||||
|
if not parent or parent.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Parent folder not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
folders = await self.folder_repo.list_by_user(
|
||||||
|
user_id=user_id,
|
||||||
|
parent_folder_id=parent_folder_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return folders
|
||||||
|
|
||||||
|
async def rename_folder(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
folder_id: str,
|
||||||
|
new_name: str,
|
||||||
|
) -> Folder:
|
||||||
|
"""
|
||||||
|
Rename a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Folder ID
|
||||||
|
new_name: New folder name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated folder
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If folder not found or not authorized
|
||||||
|
"""
|
||||||
|
folder = await self.get_folder(user_id, folder_id)
|
||||||
|
folder.name = new_name
|
||||||
|
return await self.folder_repo.update(folder)
|
||||||
|
|
||||||
|
async def delete_folder(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
folder_id: str,
|
||||||
|
recursive: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Delete a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Folder ID
|
||||||
|
recursive: If True, delete folder with all contents.
|
||||||
|
If False, fail if folder is not empty.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If folder not found, not authorized, or not empty (when not recursive)
|
||||||
|
"""
|
||||||
|
folder = await self.get_folder(user_id, folder_id)
|
||||||
|
|
||||||
|
if not recursive:
|
||||||
|
# Check if folder has assets
|
||||||
|
asset_count = await self.asset_repo.count_in_folder(folder_id)
|
||||||
|
if asset_count > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Folder contains {asset_count} assets. Use recursive=true to delete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if folder has subfolders
|
||||||
|
subfolders = await self.folder_repo.list_by_user(
|
||||||
|
user_id=user_id,
|
||||||
|
parent_folder_id=folder_id,
|
||||||
|
)
|
||||||
|
if subfolders:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Folder contains {len(subfolders)} subfolders. Use recursive=true to delete.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
# Delete all subfolders recursively
|
||||||
|
subfolders = await self.folder_repo.get_all_subfolders(folder_id)
|
||||||
|
for subfolder in reversed(subfolders): # Delete from deepest to shallowest
|
||||||
|
# Move assets in subfolder to trash (through AssetService would be better, but for simplicity)
|
||||||
|
# In production, this should use AssetService.delete_asset to properly move to trash
|
||||||
|
assets = await self.asset_repo.list_by_folder(
|
||||||
|
user_id=user_id,
|
||||||
|
folder_id=subfolder.id,
|
||||||
|
limit=1000, # Reasonable limit for folder deletion
|
||||||
|
)
|
||||||
|
# For now, just orphan the assets by setting folder_id to None
|
||||||
|
# TODO: Properly delete assets using AssetService
|
||||||
|
for asset in assets:
|
||||||
|
asset.folder_id = None
|
||||||
|
|
||||||
|
await self.folder_repo.delete(subfolder)
|
||||||
|
|
||||||
|
# Move assets in current folder
|
||||||
|
assets = await self.asset_repo.list_by_folder(
|
||||||
|
user_id=user_id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
limit=1000,
|
||||||
|
)
|
||||||
|
for asset in assets:
|
||||||
|
asset.folder_id = None
|
||||||
|
|
||||||
|
# Delete the folder itself
|
||||||
|
await self.folder_repo.delete(folder)
|
||||||
|
|
||||||
|
async def get_breadcrumbs(self, user_id: str, folder_id: str) -> list[Folder]:
|
||||||
|
"""
|
||||||
|
Get breadcrumb path from root to folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID
|
||||||
|
folder_id: Target folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of folders from root to target (ordered)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If folder not found or not authorized
|
||||||
|
"""
|
||||||
|
# Verify ownership
|
||||||
|
folder = await self.get_folder(user_id, folder_id)
|
||||||
|
|
||||||
|
breadcrumbs = await self.folder_repo.get_breadcrumbs(folder_id)
|
||||||
|
|
||||||
|
# Verify all folders in path belong to user (security check)
|
||||||
|
for crumb in breadcrumbs:
|
||||||
|
if crumb.user_id != user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied to folder path",
|
||||||
|
)
|
||||||
|
|
||||||
|
return breadcrumbs
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
interface CreateFolderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateFolderDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: CreateFolderDialogProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Введите название папки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreate(name.trim());
|
||||||
|
setName('');
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setName('');
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Создать папку</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
label="Название папки"
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
error={!!error}
|
||||||
|
helperText={error}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Отмена</Button>
|
||||||
|
<Button onClick={handleCreate} variant="contained">
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Breadcrumbs, Link, Typography, Box } from '@mui/material';
|
||||||
|
import { Home as HomeIcon, NavigateNext as NavigateNextIcon } from '@mui/icons-material';
|
||||||
|
import { useFolder } from '../contexts/FolderContext';
|
||||||
|
|
||||||
|
export default function FolderBreadcrumbs() {
|
||||||
|
const { breadcrumbs, navigateToFolder, navigateToRoot } = useFolder();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />}>
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
onClick={navigateToRoot}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
font: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HomeIcon sx={{ mr: 0.5 }} fontSize="small" />
|
||||||
|
Библиотека
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{breadcrumbs.map((crumb, index) => {
|
||||||
|
const isLast = index === breadcrumbs.length - 1;
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
return (
|
||||||
|
<Typography key={crumb.id} color="text.primary">
|
||||||
|
{crumb.name}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={crumb.id}
|
||||||
|
component="button"
|
||||||
|
underline="hover"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigateToFolder(crumb.id, crumb.name)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
background: 'none',
|
||||||
|
font: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crumb.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Breadcrumbs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Grid, Card, CardActionArea, Typography, Box, IconButton, Menu, MenuItem } from '@mui/material';
|
||||||
|
import { Folder as FolderIcon, MoreVert as MoreVertIcon } from '@mui/icons-material';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface Folder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parent_folder_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderListProps {
|
||||||
|
folders: Folder[];
|
||||||
|
onFolderClick: (folderId: string, folderName: string) => void;
|
||||||
|
onRename?: (folderId: string, currentName: string) => void;
|
||||||
|
onDelete?: (folderId: string) => void;
|
||||||
|
onDownload?: (folderId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FolderList({
|
||||||
|
folders,
|
||||||
|
onFolderClick,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
onDownload,
|
||||||
|
}: FolderListProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
|
||||||
|
|
||||||
|
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, folder: Folder) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
setSelectedFolder(folder);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
setSelectedFolder(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (selectedFolder && onRename) {
|
||||||
|
onRename(selectedFolder.id, selectedFolder.name);
|
||||||
|
}
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedFolder && onDelete) {
|
||||||
|
onDelete(selectedFolder.id);
|
||||||
|
}
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (selectedFolder && onDownload) {
|
||||||
|
onDownload(selectedFolder.id);
|
||||||
|
}
|
||||||
|
handleMenuClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<Grid item xs={6} sm={4} md={3} lg={2} key={folder.id}>
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
boxShadow: 4,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardActionArea
|
||||||
|
onClick={() => onFolderClick(folder.id, folder.name)}
|
||||||
|
sx={{ p: 2, minHeight: 120 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderIcon sx={{ fontSize: 48, color: 'primary.main' }} />
|
||||||
|
<Typography variant="body2" align="center" noWrap sx={{ width: '100%' }}>
|
||||||
|
{folder.name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</CardActionArea>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => handleMenuClick(e, folder)}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||||
|
{onRename && <MenuItem onClick={handleRename}>Переименовать</MenuItem>}
|
||||||
|
{onDownload && <MenuItem onClick={handleDownload}>Скачать</MenuItem>}
|
||||||
|
{onDelete && <MenuItem onClick={handleDelete}>Удалить</MenuItem>}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
CircularProgress,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Folder as FolderIcon, Home as HomeIcon } from '@mui/icons-material';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface MoveFolderDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onMove: (folderId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MoveFolderDialog({ open, onClose, onMove }: MoveFolderDialogProps) {
|
||||||
|
const [folders, setFolders] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadFolders();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.listFolders();
|
||||||
|
setFolders(response.items || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folders:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = () => {
|
||||||
|
onMove(selectedFolder);
|
||||||
|
setSelectedFolder(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedFolder(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Переместить в папку</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress />
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selectedFolder === null}
|
||||||
|
onClick={() => setSelectedFolder(null)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<HomeIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Библиотека (корень)" />
|
||||||
|
</ListItemButton>
|
||||||
|
|
||||||
|
{folders.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||||
|
Нет доступных папок
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
folders.map((folder) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={folder.id}
|
||||||
|
selected={selectedFolder === folder.id}
|
||||||
|
onClick={() => setSelectedFolder(folder.id)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<FolderIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={folder.name} />
|
||||||
|
</ListItemButton>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Отмена</Button>
|
||||||
|
<Button onClick={handleMove} variant="contained">
|
||||||
|
Переместить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Box, Toolbar, Typography, IconButton, Tooltip } from '@mui/material';
|
||||||
|
import {
|
||||||
|
Close as CloseIcon,
|
||||||
|
Download as DownloadIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
DriveFileMove as MoveIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useSelection } from '../contexts/SelectionContext';
|
||||||
|
|
||||||
|
interface SelectionToolbarProps {
|
||||||
|
onDownload: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SelectionToolbar({
|
||||||
|
onDownload,
|
||||||
|
onDelete,
|
||||||
|
onMove,
|
||||||
|
}: SelectionToolbarProps) {
|
||||||
|
const { selectedCount, clearSelection } = useSelection();
|
||||||
|
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 64,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
zIndex: 1100,
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton color="inherit" onClick={clearSelection} edge="start">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Выбрано: {selectedCount}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tooltip title="Скачать">
|
||||||
|
<IconButton color="inherit" onClick={onDownload}>
|
||||||
|
<DownloadIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Переместить">
|
||||||
|
<IconButton color="inherit" onClick={onMove}>
|
||||||
|
<MoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Удалить">
|
||||||
|
<IconButton color="inherit" onClick={onDelete}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface Breadcrumb {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parent_folder_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderContextType {
|
||||||
|
currentFolderId: string | null;
|
||||||
|
breadcrumbs: Breadcrumb[];
|
||||||
|
navigateToFolder: (folderId: string | null, folderName?: string) => void;
|
||||||
|
navigateToRoot: () => void;
|
||||||
|
setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FolderContext = createContext<FolderContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function FolderProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
|
||||||
|
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
|
||||||
|
|
||||||
|
const navigateToFolder = (folderId: string | null, folderName?: string) => {
|
||||||
|
setCurrentFolderId(folderId);
|
||||||
|
|
||||||
|
// If navigating to a folder (not root), update breadcrumbs
|
||||||
|
// The actual breadcrumbs will be fetched from the API in the component
|
||||||
|
if (folderId && folderName) {
|
||||||
|
// Temporary breadcrumb until API fetch completes
|
||||||
|
// This prevents flashing during navigation
|
||||||
|
setBreadcrumbs([{ id: folderId, name: folderName, parent_folder_id: null }]);
|
||||||
|
} else if (!folderId) {
|
||||||
|
// Root folder
|
||||||
|
setBreadcrumbs([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToRoot = () => {
|
||||||
|
setCurrentFolderId(null);
|
||||||
|
setBreadcrumbs([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: FolderContextType = {
|
||||||
|
currentFolderId,
|
||||||
|
breadcrumbs,
|
||||||
|
navigateToFolder,
|
||||||
|
navigateToRoot,
|
||||||
|
setBreadcrumbs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FolderContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FolderContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolder() {
|
||||||
|
const context = useContext(FolderContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useFolder must be used within a FolderProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SelectionContextType {
|
||||||
|
selectedAssets: Set<string>;
|
||||||
|
selectAsset: (assetId: string) => void;
|
||||||
|
deselectAsset: (assetId: string) => void;
|
||||||
|
toggleAsset: (assetId: string) => void;
|
||||||
|
selectAll: (assetIds: string[]) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
isSelected: (assetId: string) => boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectionContext = createContext<SelectionContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SelectionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const selectAsset = (assetId: string) => {
|
||||||
|
setSelectedAssets(prev => new Set(prev).add(assetId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deselectAsset = (assetId: string) => {
|
||||||
|
setSelectedAssets(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(assetId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAsset = (assetId: string) => {
|
||||||
|
setSelectedAssets(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(assetId)) {
|
||||||
|
newSet.delete(assetId);
|
||||||
|
} else {
|
||||||
|
newSet.add(assetId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAll = (assetIds: string[]) => {
|
||||||
|
setSelectedAssets(new Set(assetIds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setSelectedAssets(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (assetId: string) => {
|
||||||
|
return selectedAssets.has(assetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: SelectionContextType = {
|
||||||
|
selectedAssets,
|
||||||
|
selectAsset,
|
||||||
|
deselectAsset,
|
||||||
|
toggleAsset,
|
||||||
|
selectAll,
|
||||||
|
clearSelection,
|
||||||
|
isSelected,
|
||||||
|
selectedCount: selectedAssets.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectionContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SelectionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelection() {
|
||||||
|
const context = useContext(SelectionContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSelection must be used within a SelectionProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Grid,
|
||||||
|
Fab,
|
||||||
|
Typography,
|
||||||
|
CircularProgress,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
Snackbar,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Add as AddIcon, FilterList as FilterIcon } from '@mui/icons-material';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import MediaCard from '../components/MediaCard';
|
||||||
|
import UploadDialog from '../components/UploadDialog';
|
||||||
|
import ViewerModal from '../components/ViewerModal';
|
||||||
|
import type { Asset, AssetType } from '../types';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>();
|
||||||
|
const [filter, setFilter] = useState<AssetType | 'all'>('all');
|
||||||
|
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
const [viewerAsset, setViewerAsset] = useState<Asset | null>(null);
|
||||||
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||||
|
const [shareAssetId, setShareAssetId] = useState<string>('');
|
||||||
|
const [shareLink, setShareLink] = useState<string>('');
|
||||||
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAssets(true);
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const loadAssets = async (reset: boolean = false) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await api.listAssets({
|
||||||
|
cursor: reset ? undefined : cursor,
|
||||||
|
limit: 50,
|
||||||
|
type: filter === 'all' ? undefined : filter,
|
||||||
|
});
|
||||||
|
|
||||||
|
setAssets(reset ? response.items : [...assets, ...response.items]);
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
setCursor(response.next_cursor);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load assets:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadComplete = () => {
|
||||||
|
setUploadOpen(false);
|
||||||
|
loadAssets(true);
|
||||||
|
showSnackbar('Файлы успешно загружены');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await api.deleteAsset(assetId);
|
||||||
|
setAssets(assets.filter((a) => a.id !== assetId));
|
||||||
|
showSnackbar('Файл перемещен в корзину');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete asset:', error);
|
||||||
|
showSnackbar('Ошибка при удалении файла');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = (assetId: string) => {
|
||||||
|
setShareAssetId(assetId);
|
||||||
|
setShareDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateShare = async () => {
|
||||||
|
try {
|
||||||
|
const share = await api.createShare({
|
||||||
|
asset_id: shareAssetId,
|
||||||
|
expires_in_seconds: 86400 * 7, // 7 days
|
||||||
|
});
|
||||||
|
const link = `${window.location.origin}/share/${share.token}`;
|
||||||
|
setShareLink(link);
|
||||||
|
showSnackbar('Ссылка создана');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create share:', error);
|
||||||
|
showSnackbar('Ошибка создания ссылки');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyShareLink = () => {
|
||||||
|
// Fallback for HTTP (clipboard API requires HTTPS)
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(shareLink);
|
||||||
|
} else {
|
||||||
|
// Fallback method for HTTP
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = shareLink;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
showSnackbar('Ссылка скопирована');
|
||||||
|
setShareDialogOpen(false);
|
||||||
|
setShareLink('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSnackbar = (message: string) => {
|
||||||
|
setSnackbarMessage(message);
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Filters */}
|
||||||
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
|
<InputLabel>Тип файлов</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filter}
|
||||||
|
label="Тип файлов"
|
||||||
|
onChange={(e) => setFilter(e.target.value as AssetType | 'all')}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Все файлы</MenuItem>
|
||||||
|
<MenuItem value="photo">Фото</MenuItem>
|
||||||
|
<MenuItem value="video">Видео</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
|
||||||
|
{loading && assets.length === 0 && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && assets.length === 0 && (
|
||||||
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
Нет файлов
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Нажмите кнопку + чтобы загрузить файлы
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assets.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
|
||||||
|
<MediaCard
|
||||||
|
asset={asset}
|
||||||
|
onClick={() => setViewerAsset(asset)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => loadAssets(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Загрузка...' : 'Загрузить еще'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<Fab
|
||||||
|
color="primary"
|
||||||
|
sx={{ position: 'fixed', bottom: 24, right: 24 }}
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</Fab>
|
||||||
|
|
||||||
|
{/* Upload Dialog */}
|
||||||
|
<UploadDialog
|
||||||
|
open={uploadOpen}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onComplete={handleUploadComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Viewer Modal */}
|
||||||
|
<ViewerModal
|
||||||
|
asset={viewerAsset}
|
||||||
|
assets={assets}
|
||||||
|
onClose={() => setViewerAsset(null)}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onShare={handleShare}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Share Dialog */}
|
||||||
|
<Dialog open={shareDialogOpen} onClose={() => setShareDialogOpen(false)}>
|
||||||
|
<DialogTitle>Поделиться файлом</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{!shareLink ? (
|
||||||
|
<Typography>
|
||||||
|
Создать публичную ссылку на файл? Ссылка будет действительна 7 дней.
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
value={shareLink}
|
||||||
|
label="Ссылка для доступа"
|
||||||
|
margin="normal"
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setShareDialogOpen(false)}>Отмена</Button>
|
||||||
|
{!shareLink ? (
|
||||||
|
<Button onClick={handleCreateShare} variant="contained">
|
||||||
|
Создать ссылку
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleCopyShareLink} variant="contained">
|
||||||
|
Скопировать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snackbar */}
|
||||||
|
<Snackbar
|
||||||
|
open={snackbarOpen}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setSnackbarOpen(false)}
|
||||||
|
message={snackbarMessage}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -15,19 +15,32 @@ import {
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
TextField,
|
TextField,
|
||||||
Alert,
|
|
||||||
Snackbar,
|
Snackbar,
|
||||||
|
SpeedDial,
|
||||||
|
SpeedDialAction,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Add as AddIcon, FilterList as FilterIcon } from '@mui/icons-material';
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
CreateNewFolder as CreateFolderIcon,
|
||||||
|
CloudUpload as UploadIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import MediaCard from '../components/MediaCard';
|
import MediaCard from '../components/MediaCard';
|
||||||
import UploadDialog from '../components/UploadDialog';
|
import UploadDialog from '../components/UploadDialog';
|
||||||
import ViewerModal from '../components/ViewerModal';
|
import ViewerModal from '../components/ViewerModal';
|
||||||
|
import SelectionToolbar from '../components/SelectionToolbar';
|
||||||
|
import FolderBreadcrumbs from '../components/FolderBreadcrumbs';
|
||||||
|
import FolderList from '../components/FolderList';
|
||||||
|
import CreateFolderDialog from '../components/CreateFolderDialog';
|
||||||
|
import MoveFolderDialog from '../components/MoveFolderDialog';
|
||||||
|
import { SelectionProvider, useSelection } from '../contexts/SelectionContext';
|
||||||
|
import { FolderProvider, useFolder } from '../contexts/FolderContext';
|
||||||
import type { Asset, AssetType } from '../types';
|
import type { Asset, AssetType } from '../types';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
|
||||||
export default function LibraryPage() {
|
function LibraryPageContent() {
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
|
const [folders, setFolders] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [cursor, setCursor] = useState<string | undefined>();
|
const [cursor, setCursor] = useState<string | undefined>();
|
||||||
|
|
@ -35,15 +48,29 @@ export default function LibraryPage() {
|
||||||
|
|
||||||
const [uploadOpen, setUploadOpen] = useState(false);
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
const [viewerAsset, setViewerAsset] = useState<Asset | null>(null);
|
const [viewerAsset, setViewerAsset] = useState<Asset | null>(null);
|
||||||
|
const [createFolderOpen, setCreateFolderOpen] = useState(false);
|
||||||
|
const [moveFolderOpen, setMoveFolderOpen] = useState(false);
|
||||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||||
const [shareAssetId, setShareAssetId] = useState<string>('');
|
const [shareAssetId, setShareAssetId] = useState<string>('');
|
||||||
const [shareLink, setShareLink] = useState<string>('');
|
const [shareLink, setShareLink] = useState<string>('');
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
|
const [speedDialOpen, setSpeedDialOpen] = useState(false);
|
||||||
|
|
||||||
|
const { selectedAssets, clearSelection, isSelected, toggleAsset } = useSelection();
|
||||||
|
const { currentFolderId, navigateToFolder, setBreadcrumbs } = useFolder();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAssets(true);
|
loadAssets(true);
|
||||||
}, [filter]);
|
loadFolders();
|
||||||
|
}, [filter, currentFolderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load breadcrumbs when navigating to a folder
|
||||||
|
if (currentFolderId) {
|
||||||
|
loadBreadcrumbs();
|
||||||
|
}
|
||||||
|
}, [currentFolderId]);
|
||||||
|
|
||||||
const loadAssets = async (reset: boolean = false) => {
|
const loadAssets = async (reset: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -52,6 +79,7 @@ export default function LibraryPage() {
|
||||||
cursor: reset ? undefined : cursor,
|
cursor: reset ? undefined : cursor,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
type: filter === 'all' ? undefined : filter,
|
type: filter === 'all' ? undefined : filter,
|
||||||
|
folder_id: currentFolderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
setAssets(reset ? response.items : [...assets, ...response.items]);
|
setAssets(reset ? response.items : [...assets, ...response.items]);
|
||||||
|
|
@ -59,11 +87,31 @@ export default function LibraryPage() {
|
||||||
setCursor(response.next_cursor);
|
setCursor(response.next_cursor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load assets:', error);
|
console.error('Failed to load assets:', error);
|
||||||
|
showSnackbar('Ошибка загрузки файлов');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadFolders = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.listFolders(currentFolderId);
|
||||||
|
setFolders(response.items || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load folders:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBreadcrumbs = async () => {
|
||||||
|
if (!currentFolderId) return;
|
||||||
|
try {
|
||||||
|
const response = await api.getFolderBreadcrumbs(currentFolderId);
|
||||||
|
setBreadcrumbs(response.items || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load breadcrumbs:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUploadComplete = () => {
|
const handleUploadComplete = () => {
|
||||||
setUploadOpen(false);
|
setUploadOpen(false);
|
||||||
loadAssets(true);
|
loadAssets(true);
|
||||||
|
|
@ -74,7 +122,7 @@ export default function LibraryPage() {
|
||||||
try {
|
try {
|
||||||
await api.deleteAsset(assetId);
|
await api.deleteAsset(assetId);
|
||||||
setAssets(assets.filter((a) => a.id !== assetId));
|
setAssets(assets.filter((a) => a.id !== assetId));
|
||||||
showSnackbar('Файл перемещен в корзину');
|
showSnackbar('Файл удален');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete asset:', error);
|
console.error('Failed to delete asset:', error);
|
||||||
showSnackbar('Ошибка при удалении файла');
|
showSnackbar('Ошибка при удалении файла');
|
||||||
|
|
@ -90,7 +138,7 @@ export default function LibraryPage() {
|
||||||
try {
|
try {
|
||||||
const share = await api.createShare({
|
const share = await api.createShare({
|
||||||
asset_id: shareAssetId,
|
asset_id: shareAssetId,
|
||||||
expires_in_seconds: 86400 * 7, // 7 days
|
expires_in_seconds: 86400 * 7,
|
||||||
});
|
});
|
||||||
const link = `${window.location.origin}/share/${share.token}`;
|
const link = `${window.location.origin}/share/${share.token}`;
|
||||||
setShareLink(link);
|
setShareLink(link);
|
||||||
|
|
@ -102,11 +150,9 @@ export default function LibraryPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyShareLink = () => {
|
const handleCopyShareLink = () => {
|
||||||
// Fallback for HTTP (clipboard API requires HTTPS)
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
navigator.clipboard.writeText(shareLink);
|
navigator.clipboard.writeText(shareLink);
|
||||||
} else {
|
} else {
|
||||||
// Fallback method for HTTP
|
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
textArea.value = shareLink;
|
textArea.value = shareLink;
|
||||||
textArea.style.position = 'fixed';
|
textArea.style.position = 'fixed';
|
||||||
|
|
@ -131,9 +177,123 @@ export default function LibraryPage() {
|
||||||
setSnackbarOpen(true);
|
setSnackbarOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Folder operations
|
||||||
|
const handleCreateFolder = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await api.createFolder(name, currentFolderId);
|
||||||
|
loadFolders();
|
||||||
|
showSnackbar('Папка создана');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create folder:', error);
|
||||||
|
showSnackbar('Ошибка создания папки');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderClick = (folderId: string, folderName: string) => {
|
||||||
|
navigateToFolder(folderId, folderName);
|
||||||
|
clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFolder = async (folderId: string) => {
|
||||||
|
if (!confirm('Удалить папку? Все файлы и подпапки будут перемещены в корзину.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteFolder(folderId, true);
|
||||||
|
loadFolders();
|
||||||
|
showSnackbar('Папка удалена');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete folder:', error);
|
||||||
|
showSnackbar('Ошибка удаления папки');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadFolder = async (folderId: string) => {
|
||||||
|
try {
|
||||||
|
const blob = await api.downloadFolder(folderId);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `folder-${Date.now()}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showSnackbar('Скачивание начато');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download folder:', error);
|
||||||
|
showSnackbar('Ошибка скачивания');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
const handleBatchDownload = async () => {
|
||||||
|
try {
|
||||||
|
const blob = await api.batchDownload([...selectedAssets]);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `download-${Date.now()}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
clearSelection();
|
||||||
|
showSnackbar('Скачивание начато');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download assets:', error);
|
||||||
|
showSnackbar('Ошибка скачивания');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (!confirm(`Удалить ${selectedAssets.size} файлов?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.batchDelete([...selectedAssets]);
|
||||||
|
setAssets(assets.filter((a) => !selectedAssets.has(a.id)));
|
||||||
|
clearSelection();
|
||||||
|
showSnackbar('Файлы удалены');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete assets:', error);
|
||||||
|
showSnackbar('Ошибка удаления');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchMove = async (targetFolderId: string | null) => {
|
||||||
|
try {
|
||||||
|
await api.batchMove([...selectedAssets], targetFolderId);
|
||||||
|
setAssets(assets.filter((a) => !selectedAssets.has(a.id)));
|
||||||
|
clearSelection();
|
||||||
|
showSnackbar('Файлы перемещены');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to move assets:', error);
|
||||||
|
showSnackbar('Ошибка перемещения');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectionToolbarOffset = selectedAssets.size > 0 ? 64 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<SelectionToolbar
|
||||||
|
onDownload={handleBatchDownload}
|
||||||
|
onDelete={handleBatchDelete}
|
||||||
|
onMove={() => setMoveFolderOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
mt: `${selectionToolbarOffset}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<FolderBreadcrumbs />
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||||
|
|
@ -152,23 +312,32 @@ export default function LibraryPage() {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
|
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
|
||||||
{loading && assets.length === 0 && (
|
{loading && assets.length === 0 && folders.length === 0 && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && assets.length === 0 && (
|
{!loading && assets.length === 0 && folders.length === 0 && (
|
||||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
Нет файлов
|
Нет файлов
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Нажмите кнопку + чтобы загрузить файлы
|
Нажмите кнопку + чтобы загрузить файлы или создать папку
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Folders */}
|
||||||
|
<FolderList
|
||||||
|
folders={folders}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onDelete={handleDeleteFolder}
|
||||||
|
onDownload={handleDownloadFolder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Assets */}
|
||||||
{assets.length > 0 && (
|
{assets.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
|
|
@ -176,6 +345,8 @@ export default function LibraryPage() {
|
||||||
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
|
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
|
||||||
<MediaCard
|
<MediaCard
|
||||||
asset={asset}
|
asset={asset}
|
||||||
|
selected={isSelected(asset.id)}
|
||||||
|
onSelect={(id, selected) => toggleAsset(id)}
|
||||||
onClick={() => setViewerAsset(asset)}
|
onClick={() => setViewerAsset(asset)}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -198,22 +369,39 @@ export default function LibraryPage() {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<Fab
|
<SpeedDial
|
||||||
color="primary"
|
ariaLabel="Действия"
|
||||||
sx={{ position: 'fixed', bottom: 24, right: 24 }}
|
sx={{ position: 'fixed', bottom: 24, right: 24 }}
|
||||||
onClick={() => setUploadOpen(true)}
|
icon={<AddIcon />}
|
||||||
|
open={speedDialOpen}
|
||||||
|
onOpen={() => setSpeedDialOpen(true)}
|
||||||
|
onClose={() => setSpeedDialOpen(false)}
|
||||||
>
|
>
|
||||||
<AddIcon />
|
<SpeedDialAction
|
||||||
</Fab>
|
icon={<UploadIcon />}
|
||||||
|
tooltipTitle="Загрузить файлы"
|
||||||
|
onClick={() => {
|
||||||
|
setUploadOpen(true);
|
||||||
|
setSpeedDialOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<CreateFolderIcon />}
|
||||||
|
tooltipTitle="Создать папку"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateFolderOpen(true);
|
||||||
|
setSpeedDialOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SpeedDial>
|
||||||
|
|
||||||
{/* Upload Dialog */}
|
{/* Dialogs */}
|
||||||
<UploadDialog
|
<UploadDialog
|
||||||
open={uploadOpen}
|
open={uploadOpen}
|
||||||
onClose={() => setUploadOpen(false)}
|
onClose={() => setUploadOpen(false)}
|
||||||
onComplete={handleUploadComplete}
|
onComplete={handleUploadComplete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Viewer Modal */}
|
|
||||||
<ViewerModal
|
<ViewerModal
|
||||||
asset={viewerAsset}
|
asset={viewerAsset}
|
||||||
assets={assets}
|
assets={assets}
|
||||||
|
|
@ -222,7 +410,18 @@ export default function LibraryPage() {
|
||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Share Dialog */}
|
<CreateFolderDialog
|
||||||
|
open={createFolderOpen}
|
||||||
|
onClose={() => setCreateFolderOpen(false)}
|
||||||
|
onCreate={handleCreateFolder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MoveFolderDialog
|
||||||
|
open={moveFolderOpen}
|
||||||
|
onClose={() => setMoveFolderOpen(false)}
|
||||||
|
onMove={handleBatchMove}
|
||||||
|
/>
|
||||||
|
|
||||||
<Dialog open={shareDialogOpen} onClose={() => setShareDialogOpen(false)}>
|
<Dialog open={shareDialogOpen} onClose={() => setShareDialogOpen(false)}>
|
||||||
<DialogTitle>Поделиться файлом</DialogTitle>
|
<DialogTitle>Поделиться файлом</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|
@ -256,7 +455,6 @@ export default function LibraryPage() {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={snackbarOpen}
|
open={snackbarOpen}
|
||||||
autoHideDuration={3000}
|
autoHideDuration={3000}
|
||||||
|
|
@ -267,3 +465,13 @@ export default function LibraryPage() {
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LibraryPage() {
|
||||||
|
return (
|
||||||
|
<SelectionProvider>
|
||||||
|
<FolderProvider>
|
||||||
|
<LibraryPageContent />
|
||||||
|
</FolderProvider>
|
||||||
|
</SelectionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class ApiClient {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
folder_id?: string | null;
|
||||||
}): Promise<AssetListResponse> {
|
}): Promise<AssetListResponse> {
|
||||||
const { data } = await this.client.get('/assets', { params });
|
const { data } = await this.client.get('/assets', { params });
|
||||||
return data;
|
return data;
|
||||||
|
|
@ -177,9 +178,80 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeShare(token: string): Promise<Share> {
|
async revokeShare(token: string): Promise<Share> {
|
||||||
const { data } = await this.client.post(`/shares/${token}/revoke`);
|
const { data} = await this.client.post(`/shares/${token}/revoke`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folders
|
||||||
|
async createFolder(name: string, parentFolderId?: string | null): Promise<any> {
|
||||||
|
const { data } = await this.client.post('/folders', {
|
||||||
|
name,
|
||||||
|
parent_folder_id: parentFolderId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFolders(parentFolderId?: string | null): Promise<any> {
|
||||||
|
const { data } = await this.client.get('/folders', {
|
||||||
|
params: parentFolderId ? { parent_folder_id: parentFolderId } : undefined,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFolder(folderId: string): Promise<any> {
|
||||||
|
const { data } = await this.client.get(`/folders/${folderId}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameFolder(folderId: string, newName: string): Promise<any> {
|
||||||
|
const { data } = await this.client.patch(`/folders/${folderId}`, {
|
||||||
|
name: newName,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFolder(folderId: string, recursive: boolean = false): Promise<void> {
|
||||||
|
await this.client.delete(`/folders/${folderId}`, {
|
||||||
|
params: { recursive },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFolderBreadcrumbs(folderId: string): Promise<any> {
|
||||||
|
const { data } = await this.client.get(`/folders/${folderId}/breadcrumbs`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch Operations
|
||||||
|
async batchDelete(assetIds: string[]): Promise<any> {
|
||||||
|
const { data } = await this.client.post('/batch/delete', {
|
||||||
|
asset_ids: assetIds,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchMove(assetIds: string[], folderId: string | null): Promise<any> {
|
||||||
|
const { data } = await this.client.post('/batch/move', {
|
||||||
|
asset_ids: assetIds,
|
||||||
|
folder_id: folderId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchDownload(assetIds: string[]): Promise<Blob> {
|
||||||
|
const response = await this.client.post(
|
||||||
|
'/batch/download',
|
||||||
|
{ asset_ids: assetIds },
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFolder(folderId: string): Promise<Blob> {
|
||||||
|
const response = await this.client.get(`/batch/folders/${folderId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ApiClient();
|
export default new ApiClient();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue