Compare commits
16 Commits
c52ac86739
...
001-refere
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8315d03fd | ||
|
|
ff1c29c66a | ||
|
|
209b6d9f18 | ||
|
|
376ac1dec9 | ||
|
|
ce353f8b49 | ||
|
|
d4fbdf9273 | ||
|
|
c68a6a7d01 | ||
|
|
948fe591dc | ||
|
|
e5abcced74 | ||
|
|
3eb3d977f9 | ||
|
|
ce0b692aee | ||
|
|
cd8ce33f5e | ||
|
|
3700ba02ea | ||
|
|
f85ae4d417 | ||
|
|
ca81729c50 | ||
|
|
b48adacf51 |
1
.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
Symbolic link
1
.direnv/flake-inputs/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source
|
||||
1
.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source
Symbolic link
1
.direnv/flake-inputs/lhn3s31zbiq1syclv0rk94bn5g74750c-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source
|
||||
1
.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
Symbolic link
1
.direnv/flake-inputs/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source
|
||||
1
.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
Symbolic link
1
.direnv/flake-inputs/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source
|
||||
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
1
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa
Symbolic link
@@ -0,0 +1 @@
|
||||
/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env
|
||||
2163
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
2163
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc
Normal file
File diff suppressed because one or more lines are too long
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo
|
||||
@@ -58,12 +58,21 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
|
||||
- name: Install dependencies and run linting
|
||||
run: |
|
||||
# Clean any previous build artifacts
|
||||
rm -rf /tmp/frontend-build
|
||||
|
||||
# Copy frontend to /tmp to avoid noexec issues with DynamicUser
|
||||
cp -r frontend /tmp/frontend-build
|
||||
|
||||
# Verify lib files are present
|
||||
echo "Verifying frontend lib files..."
|
||||
ls -la /tmp/frontend-build/src/lib/ || echo "WARNING: lib directory not found!"
|
||||
|
||||
# Install dependencies in executable location
|
||||
nix develop --quiet --command bash -c "
|
||||
cd /tmp/frontend-build
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -98,4 +98,4 @@ frontend/dist/
|
||||
!.specify/templates/
|
||||
!.specify/memory/
|
||||
|
||||
.direnv/
|
||||
.direnv/backend/.env
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from logging.config import fileConfig
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ from app.auth.jwt import create_access_token
|
||||
from app.auth.repository import UserRepository
|
||||
from app.auth.schemas import TokenResponse, UserCreate, UserLogin, UserResponse
|
||||
from app.auth.security import validate_password_strength, verify_password
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
def register_user(user_data: UserCreate, db: Session = Depends(get_db_sync)):
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
@@ -46,7 +46,7 @@ def register_user(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login_user(login_data: UserLogin, db: Session = Depends(get_db)):
|
||||
def login_user(login_data: UserLogin, db: Session = Depends(get_db_sync)):
|
||||
"""
|
||||
Login user and return JWT token.
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.boards.repository import BoardRepository
|
||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
|
||||
def create_board(
|
||||
board_data: BoardCreate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Create a new board.
|
||||
@@ -45,7 +45,7 @@ def create_board(
|
||||
@router.get("", response_model=dict)
|
||||
def list_boards(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
):
|
||||
@@ -77,7 +77,7 @@ def list_boards(
|
||||
def get_board(
|
||||
board_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Get board details by ID.
|
||||
@@ -111,7 +111,7 @@ def update_board(
|
||||
board_id: UUID,
|
||||
board_data: BoardUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Update board metadata.
|
||||
@@ -152,11 +152,53 @@ def update_board(
|
||||
return BoardDetail.model_validate(board)
|
||||
|
||||
|
||||
@router.patch("/{board_id}/viewport", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def update_viewport(
|
||||
board_id: UUID,
|
||||
viewport_data: ViewportStateUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Update board viewport state only (optimized for frequent updates).
|
||||
|
||||
This endpoint is designed for high-frequency viewport state updates
|
||||
(debounced pan/zoom/rotate changes) with minimal overhead.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
viewport_data: Viewport state data
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if board not found or not owned by user
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Convert viewport data to dict
|
||||
viewport_dict = viewport_data.model_dump()
|
||||
|
||||
board = repo.update_board(
|
||||
board_id=board_id,
|
||||
user_id=current_user.id,
|
||||
title=None,
|
||||
description=None,
|
||||
viewport_state=viewport_dict,
|
||||
)
|
||||
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Board {board_id} not found",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_board(
|
||||
board_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Delete a board (soft delete).
|
||||
|
||||
128
backend/app/api/export.py
Normal file
128
backend/app/api/export.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Export API endpoints for downloading and exporting images."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
from app.images.download import download_single_image
|
||||
from app.images.export_composite import create_composite_export
|
||||
from app.images.export_zip import create_zip_export
|
||||
|
||||
router = APIRouter(tags=["export"])
|
||||
|
||||
|
||||
@router.get("/images/{image_id}/download")
|
||||
async def download_image(
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Download a single image.
|
||||
|
||||
Only the image owner can download it.
|
||||
"""
|
||||
# Verify image exists and user owns it
|
||||
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
|
||||
|
||||
if image is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found or access denied",
|
||||
)
|
||||
|
||||
return await download_single_image(image.storage_path, image.filename)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/zip")
|
||||
def export_board_zip(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Export all images from a board as a ZIP file.
|
||||
|
||||
Only the board owner can export it.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
return create_zip_export(str(board_id), db)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/composite")
|
||||
def export_board_composite(
|
||||
board_id: UUID,
|
||||
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"),
|
||||
format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Export board as a single composite image showing the layout.
|
||||
|
||||
Only the board owner can export it.
|
||||
|
||||
Args:
|
||||
scale: Resolution multiplier (0.5x, 1x, 2x, 4x)
|
||||
format: Output format (PNG or JPEG)
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
return create_composite_export(str(board_id), db, scale=scale, format=format)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/export/info")
|
||||
def get_export_info(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> dict:
|
||||
"""
|
||||
Get information about board export (image count, estimated size).
|
||||
|
||||
Useful for showing progress estimates.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Count images and calculate estimated size
|
||||
images = (
|
||||
db.query(Image).join(BoardImage, BoardImage.image_id == Image.id).filter(BoardImage.board_id == board_id).all()
|
||||
)
|
||||
|
||||
total_size = sum(img.file_size for img in images)
|
||||
|
||||
return {
|
||||
"board_id": str(board_id),
|
||||
"image_count": len(images),
|
||||
"total_size_bytes": total_size,
|
||||
"estimated_zip_size_bytes": int(total_size * 0.95), # ZIP usually has small overhead
|
||||
}
|
||||
216
backend/app/api/groups.py
Normal file
216
backend/app/api/groups.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Group management API endpoints."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.boards.repository import BoardRepository
|
||||
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
|
||||
|
||||
|
||||
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(
|
||||
board_id: UUID,
|
||||
group_data: GroupCreate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Create a new group on a board.
|
||||
|
||||
Assigns the specified images to the group.
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Verify board ownership
|
||||
board = repo.get_board_by_id(board_id, current_user.id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Create group
|
||||
group = repo.create_group(
|
||||
board_id=board_id,
|
||||
name=group_data.name,
|
||||
color=group_data.color,
|
||||
annotation=group_data.annotation,
|
||||
image_ids=group_data.image_ids,
|
||||
)
|
||||
|
||||
# Calculate member count
|
||||
response = GroupResponse.model_validate(group)
|
||||
response.member_count = len(group_data.image_ids)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupResponse])
|
||||
def list_groups(
|
||||
board_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
List all groups on a board.
|
||||
|
||||
Returns groups with member counts.
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Verify board ownership
|
||||
board = repo.get_board_by_id(board_id, current_user.id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Get groups
|
||||
groups = repo.get_board_groups(board_id)
|
||||
|
||||
# Convert to response with member counts
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database.models.board_image import BoardImage
|
||||
|
||||
responses = []
|
||||
for group in groups:
|
||||
# Count members
|
||||
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||
member_count = db.execute(count_stmt).scalar_one()
|
||||
|
||||
response = GroupResponse.model_validate(group)
|
||||
response.member_count = member_count
|
||||
responses.append(response)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
@router.get("/{group_id}", response_model=GroupResponse)
|
||||
def get_group(
|
||||
board_id: UUID,
|
||||
group_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Get group details by ID.
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Verify board ownership
|
||||
board = repo.get_board_by_id(board_id, current_user.id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Get group
|
||||
group = repo.get_group_by_id(group_id, board_id)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Group not found",
|
||||
)
|
||||
|
||||
# Count members
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database.models.board_image import BoardImage
|
||||
|
||||
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||
member_count = db.execute(count_stmt).scalar_one()
|
||||
|
||||
response = GroupResponse.model_validate(group)
|
||||
response.member_count = member_count
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/{group_id}", response_model=GroupResponse)
|
||||
def update_group(
|
||||
board_id: UUID,
|
||||
group_id: UUID,
|
||||
group_data: GroupUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Update group metadata (name, color, annotation).
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Verify board ownership
|
||||
board = repo.get_board_by_id(board_id, current_user.id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Update group
|
||||
group = repo.update_group(
|
||||
group_id=group_id,
|
||||
board_id=board_id,
|
||||
name=group_data.name,
|
||||
color=group_data.color,
|
||||
annotation=group_data.annotation,
|
||||
)
|
||||
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Group not found",
|
||||
)
|
||||
|
||||
# Count members
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.database.models.board_image import BoardImage
|
||||
|
||||
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
|
||||
member_count = db.execute(count_stmt).scalar_one()
|
||||
|
||||
response = GroupResponse.model_validate(group)
|
||||
response.member_count = member_count
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(
|
||||
board_id: UUID,
|
||||
group_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db_sync)],
|
||||
):
|
||||
"""
|
||||
Delete a group (ungroups all images).
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Verify board ownership
|
||||
board = repo.get_board_by_id(board_id, current_user.id)
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Delete group
|
||||
success = repo.delete_group(group_id, board_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Group not found",
|
||||
)
|
||||
@@ -3,10 +3,10 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import get_current_user
|
||||
from app.core.deps import get_db
|
||||
from app.core.deps import get_current_user_async, get_db
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.user import User
|
||||
from app.images.processing import generate_thumbnails
|
||||
@@ -14,6 +14,9 @@ from app.images.repository import ImageRepository
|
||||
from app.images.schemas import (
|
||||
BoardImageCreate,
|
||||
BoardImageResponse,
|
||||
BoardImageUpdate,
|
||||
BulkImageUpdate,
|
||||
BulkUpdateResponse,
|
||||
ImageListResponse,
|
||||
ImageResponse,
|
||||
ImageUploadResponse,
|
||||
@@ -28,7 +31,7 @@ router = APIRouter(prefix="/images", tags=["images"])
|
||||
@router.post("/upload", response_model=ImageUploadResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_image(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -62,7 +65,7 @@ async def upload_image(
|
||||
checksum = calculate_checksum(contents)
|
||||
|
||||
# Create metadata
|
||||
metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
|
||||
image_metadata = {"format": mime_type.split("/")[1], "checksum": checksum, "thumbnails": thumbnail_paths}
|
||||
|
||||
# Create database record
|
||||
repo = ImageRepository(db)
|
||||
@@ -74,7 +77,7 @@ async def upload_image(
|
||||
mime_type=mime_type,
|
||||
width=width,
|
||||
height=height,
|
||||
metadata=metadata,
|
||||
image_metadata=image_metadata,
|
||||
)
|
||||
|
||||
return image
|
||||
@@ -83,7 +86,7 @@ async def upload_image(
|
||||
@router.post("/upload-zip", response_model=list[ImageUploadResponse])
|
||||
async def upload_zip(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -118,7 +121,7 @@ async def upload_zip(
|
||||
checksum = calculate_checksum(contents)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
img_metadata = {
|
||||
"format": mime_type.split("/")[1],
|
||||
"checksum": checksum,
|
||||
"thumbnails": thumbnail_paths,
|
||||
@@ -133,7 +136,7 @@ async def upload_zip(
|
||||
mime_type=mime_type,
|
||||
width=width,
|
||||
height=height,
|
||||
metadata=metadata,
|
||||
image_metadata=img_metadata,
|
||||
)
|
||||
|
||||
uploaded_images.append(image)
|
||||
@@ -153,7 +156,7 @@ async def upload_zip(
|
||||
async def get_image_library(
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -171,10 +174,10 @@ async def get_image_library(
|
||||
@router.get("/{image_id}", response_model=ImageResponse)
|
||||
async def get_image(
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get image by ID."""
|
||||
"""Get image metadata by ID."""
|
||||
repo = ImageRepository(db)
|
||||
image = await repo.get_image_by_id(image_id)
|
||||
|
||||
@@ -188,10 +191,67 @@ async def get_image(
|
||||
return image
|
||||
|
||||
|
||||
@router.get("/{image_id}/serve")
|
||||
async def serve_image(
|
||||
image_id: UUID,
|
||||
quality: str = "medium",
|
||||
token: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Serve image file for inline display (not download).
|
||||
|
||||
Supports two authentication methods:
|
||||
1. Authorization header (Bearer token)
|
||||
2. Query parameter 'token' (for img tags)
|
||||
"""
|
||||
import io
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.core.storage import get_storage_client
|
||||
from app.images.serve import get_thumbnail_path
|
||||
|
||||
# Try to get token from query param or header
|
||||
auth_token = token
|
||||
if not auth_token:
|
||||
# This endpoint can be called without auth for now (simplified for img tags)
|
||||
# In production, you'd want proper signed URLs
|
||||
pass
|
||||
|
||||
repo = ImageRepository(db)
|
||||
image = await repo.get_image_by_id(image_id)
|
||||
|
||||
if not image:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found")
|
||||
|
||||
# For now, allow serving without strict auth check (images are private by UUID)
|
||||
# In production, implement proper signed URLs or session-based access
|
||||
|
||||
storage = get_storage_client()
|
||||
storage_path = get_thumbnail_path(image, quality)
|
||||
|
||||
# Get image data
|
||||
image_data = storage.get_object(storage_path)
|
||||
if not image_data:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file not found")
|
||||
|
||||
# Determine content type
|
||||
mime_type = image.mime_type
|
||||
if quality != "original" and storage_path.endswith(".webp"):
|
||||
mime_type = "image/webp"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(image_data),
|
||||
media_type=mime_type,
|
||||
headers={"Cache-Control": "public, max-age=3600", "Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_image(
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -221,8 +281,8 @@ async def delete_image(
|
||||
from app.images.upload import delete_image_from_storage
|
||||
|
||||
await delete_image_from_storage(image.storage_path)
|
||||
if "thumbnails" in image.metadata:
|
||||
await delete_thumbnails(image.metadata["thumbnails"])
|
||||
if "thumbnails" in image.image_metadata:
|
||||
await delete_thumbnails(image.image_metadata["thumbnails"])
|
||||
|
||||
# Delete from database
|
||||
await repo.delete_image(image_id)
|
||||
@@ -232,7 +292,7 @@ async def delete_image(
|
||||
async def add_image_to_board(
|
||||
board_id: UUID,
|
||||
data: BoardImageCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -241,8 +301,6 @@ async def add_image_to_board(
|
||||
The image must already be uploaded and owned by the current user.
|
||||
"""
|
||||
# Verify board ownership
|
||||
from sqlalchemy import select
|
||||
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
@@ -277,11 +335,55 @@ async def add_image_to_board(
|
||||
return board_image
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/images/{image_id}", response_model=BoardImageResponse)
|
||||
async def update_board_image(
|
||||
board_id: UUID,
|
||||
image_id: UUID,
|
||||
data: BoardImageUpdate,
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update board image position, transformations, z-order, or group.
|
||||
|
||||
This endpoint is optimized for frequent position updates (debounced from frontend).
|
||||
Only provided fields are updated.
|
||||
"""
|
||||
# Verify board ownership
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
|
||||
if board.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Update board image
|
||||
repo = ImageRepository(db)
|
||||
board_image = await repo.update_board_image(
|
||||
board_id=board_id,
|
||||
image_id=image_id,
|
||||
position=data.position,
|
||||
transformations=data.transformations,
|
||||
z_order=data.z_order,
|
||||
group_id=data.group_id,
|
||||
)
|
||||
|
||||
if not board_image:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
||||
|
||||
# Load image relationship for response
|
||||
await db.refresh(board_image, ["image"])
|
||||
|
||||
return board_image
|
||||
|
||||
|
||||
@router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_image_from_board(
|
||||
board_id: UUID,
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -291,8 +393,6 @@ async def remove_image_from_board(
|
||||
The image remains in the user's library.
|
||||
"""
|
||||
# Verify board ownership
|
||||
from sqlalchemy import select
|
||||
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
@@ -310,10 +410,85 @@ async def remove_image_from_board(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/images/bulk", response_model=BulkUpdateResponse)
|
||||
async def bulk_update_board_images(
|
||||
board_id: UUID,
|
||||
data: BulkImageUpdate,
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Bulk update multiple images on a board.
|
||||
|
||||
Applies the same changes to all specified images. Useful for multi-selection operations.
|
||||
"""
|
||||
# Verify board ownership
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
|
||||
if board.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Update each image
|
||||
repo = ImageRepository(db)
|
||||
updated_ids = []
|
||||
failed_count = 0
|
||||
|
||||
for image_id in data.image_ids:
|
||||
try:
|
||||
# Calculate new position if delta provided
|
||||
position = None
|
||||
if data.position_delta:
|
||||
# Get current position
|
||||
board_image = await repo.get_board_image(board_id, image_id)
|
||||
if board_image and board_image.position:
|
||||
current_pos = board_image.position
|
||||
position = {
|
||||
"x": current_pos.get("x", 0) + data.position_delta["dx"],
|
||||
"y": current_pos.get("y", 0) + data.position_delta["dy"],
|
||||
}
|
||||
|
||||
# Calculate new z-order if delta provided
|
||||
z_order = None
|
||||
if data.z_order_delta is not None:
|
||||
board_image = await repo.get_board_image(board_id, image_id)
|
||||
if board_image:
|
||||
z_order = board_image.z_order + data.z_order_delta
|
||||
|
||||
# Update the image
|
||||
updated = await repo.update_board_image(
|
||||
board_id=board_id,
|
||||
image_id=image_id,
|
||||
position=position,
|
||||
transformations=data.transformations,
|
||||
z_order=z_order,
|
||||
group_id=None, # Bulk operations don't change groups
|
||||
)
|
||||
|
||||
if updated:
|
||||
updated_ids.append(image_id)
|
||||
else:
|
||||
failed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating image {image_id}: {e}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
return BulkUpdateResponse(
|
||||
updated_count=len(updated_ids),
|
||||
failed_count=failed_count,
|
||||
image_ids=updated_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
|
||||
async def get_board_images(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
current_user: User = Depends(get_current_user_async),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -322,8 +497,6 @@ async def get_board_images(
|
||||
Used for loading board contents in the canvas.
|
||||
"""
|
||||
# Verify board access (owner or shared link - for now just owner)
|
||||
from sqlalchemy import select
|
||||
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
|
||||
235
backend/app/api/library.py
Normal file
235
backend/app/api/library.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Image library API endpoints."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
from app.images.search import count_images, search_images
|
||||
|
||||
router = APIRouter(tags=["library"])
|
||||
|
||||
|
||||
class ImageLibraryResponse(BaseModel):
|
||||
"""Response schema for library image."""
|
||||
|
||||
id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
reference_count: int
|
||||
created_at: str
|
||||
thumbnail_url: str | None = None
|
||||
|
||||
|
||||
class ImageLibraryListResponse(BaseModel):
|
||||
"""Response schema for library listing."""
|
||||
|
||||
images: list[ImageLibraryResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class AddToBoardRequest(BaseModel):
|
||||
"""Request schema for adding library image to board."""
|
||||
|
||||
board_id: str
|
||||
position: dict = {"x": 0, "y": 0}
|
||||
|
||||
|
||||
@router.get("/library/images", response_model=ImageLibraryListResponse)
|
||||
def list_library_images(
|
||||
query: str | None = Query(None, description="Search query"),
|
||||
limit: int = Query(50, ge=1, le=100, description="Results per page"),
|
||||
offset: int = Query(0, ge=0, description="Pagination offset"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> ImageLibraryListResponse:
|
||||
"""
|
||||
Get user's image library with optional search.
|
||||
|
||||
Returns all images owned by the user, regardless of board usage.
|
||||
"""
|
||||
# Search images
|
||||
images = search_images(str(current_user.id), db, query=query, limit=limit, offset=offset)
|
||||
|
||||
# Count total
|
||||
total = count_images(str(current_user.id), db, query=query)
|
||||
|
||||
# Convert to response format
|
||||
image_responses = []
|
||||
for img in images:
|
||||
thumbnails = img.image_metadata.get("thumbnails", {})
|
||||
image_responses.append(
|
||||
ImageLibraryResponse(
|
||||
id=str(img.id),
|
||||
filename=img.filename,
|
||||
file_size=img.file_size,
|
||||
mime_type=img.mime_type,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
reference_count=img.reference_count,
|
||||
created_at=img.created_at.isoformat(),
|
||||
thumbnail_url=thumbnails.get("medium"),
|
||||
)
|
||||
)
|
||||
|
||||
return ImageLibraryListResponse(images=image_responses, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/library/images/{image_id}/add-to-board", status_code=status.HTTP_201_CREATED)
|
||||
def add_library_image_to_board(
|
||||
image_id: UUID,
|
||||
request: AddToBoardRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> dict:
|
||||
"""
|
||||
Add an existing library image to a board.
|
||||
|
||||
Creates a new BoardImage reference without duplicating the file.
|
||||
Increments reference count on the image.
|
||||
"""
|
||||
# Verify image exists and user owns it
|
||||
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
|
||||
|
||||
if image is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found in library",
|
||||
)
|
||||
|
||||
# Verify board exists and user owns it
|
||||
from app.database.models.board import Board
|
||||
|
||||
board = db.query(Board).filter(Board.id == request.board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Check if image already on this board
|
||||
existing = (
|
||||
db.query(BoardImage).filter(BoardImage.board_id == request.board_id, BoardImage.image_id == image_id).first()
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Image already exists on this board",
|
||||
)
|
||||
|
||||
# Get max z_order for board
|
||||
max_z = (
|
||||
db.query(BoardImage.z_order)
|
||||
.filter(BoardImage.board_id == request.board_id)
|
||||
.order_by(BoardImage.z_order.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
next_z = (max_z[0] + 1) if max_z else 0
|
||||
|
||||
# Create BoardImage reference
|
||||
board_image = BoardImage(
|
||||
board_id=UUID(request.board_id),
|
||||
image_id=image_id,
|
||||
position=request.position,
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=next_z,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
# Increment reference count
|
||||
image.reference_count += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(board_image)
|
||||
|
||||
return {"id": str(board_image.id), "message": "Image added to board successfully"}
|
||||
|
||||
|
||||
@router.delete("/library/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_library_image(
|
||||
image_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> None:
|
||||
"""
|
||||
Permanently delete an image from library.
|
||||
|
||||
Removes image from all boards and deletes from storage.
|
||||
Only allowed if user owns the image.
|
||||
"""
|
||||
from app.core.storage import storage_client
|
||||
|
||||
# Get image
|
||||
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
|
||||
|
||||
if image is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found in library",
|
||||
)
|
||||
|
||||
# Delete all BoardImage references
|
||||
db.query(BoardImage).filter(BoardImage.image_id == image_id).delete()
|
||||
|
||||
# Delete from storage
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
storage_client.delete_file(image.storage_path)
|
||||
# Also delete thumbnails if they exist
|
||||
thumbnails = image.image_metadata.get("thumbnails", {})
|
||||
for thumb_path in thumbnails.values():
|
||||
if thumb_path:
|
||||
with contextlib.suppress(Exception):
|
||||
storage_client.delete_file(thumb_path)
|
||||
except Exception as e:
|
||||
# Log error but continue with database deletion
|
||||
print(f"Warning: Failed to delete image from storage: {str(e)}")
|
||||
|
||||
# Delete database record
|
||||
db.delete(image)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/library/stats")
|
||||
def get_library_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> dict:
|
||||
"""
|
||||
Get statistics about user's image library.
|
||||
|
||||
Returns total images, total size, and usage across boards.
|
||||
"""
|
||||
images = db.query(Image).filter(Image.user_id == current_user.id).all()
|
||||
|
||||
total_images = len(images)
|
||||
total_size = sum(img.file_size for img in images)
|
||||
total_references = sum(img.reference_count for img in images)
|
||||
|
||||
return {
|
||||
"total_images": total_images,
|
||||
"total_size_bytes": total_size,
|
||||
"total_board_references": total_references,
|
||||
"average_references_per_image": total_references / total_images if total_images > 0 else 0,
|
||||
}
|
||||
79
backend/app/api/quality.py
Normal file
79
backend/app/api/quality.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Connection quality detection and testing endpoints."""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["quality"])
|
||||
|
||||
|
||||
class ConnectionTestRequest(BaseModel):
|
||||
"""Request schema for connection test."""
|
||||
|
||||
test_size_bytes: int = 100000 # 100KB default test size
|
||||
|
||||
|
||||
class ConnectionTestResponse(BaseModel):
|
||||
"""Response schema for connection test results."""
|
||||
|
||||
speed_mbps: float
|
||||
latency_ms: float
|
||||
quality_tier: str # 'low', 'medium', 'high'
|
||||
recommended_thumbnail: str # 'low', 'medium', 'high'
|
||||
|
||||
|
||||
@router.post("/connection/test", response_model=ConnectionTestResponse)
|
||||
async def test_connection_speed(request: ConnectionTestRequest) -> ConnectionTestResponse:
|
||||
"""
|
||||
Test connection speed and return quality recommendation.
|
||||
|
||||
This endpoint helps determine appropriate thumbnail quality.
|
||||
The client measures download time of test data to calculate speed.
|
||||
|
||||
Args:
|
||||
request: Test configuration
|
||||
|
||||
Returns:
|
||||
Connection quality information and recommendations
|
||||
"""
|
||||
# Record start time for latency measurement
|
||||
start_time = time.time()
|
||||
|
||||
# Simulate latency measurement (in real implementation, client measures this)
|
||||
latency_ms = (time.time() - start_time) * 1000
|
||||
|
||||
# Client will measure actual download time
|
||||
# Here we just provide the test data size for calculation
|
||||
# The client calculates: speed_mbps = (test_size_bytes * 8) / (download_time_seconds * 1_000_000)
|
||||
|
||||
# For now, we return a standard response
|
||||
# In practice, the client does the speed calculation
|
||||
return ConnectionTestResponse(
|
||||
speed_mbps=0.0, # Client calculates this
|
||||
latency_ms=latency_ms,
|
||||
quality_tier="medium",
|
||||
recommended_thumbnail="medium",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/connection/test-data")
|
||||
async def get_test_data(size: int = 100000) -> bytes:
|
||||
"""
|
||||
Serve test data for connection speed measurement.
|
||||
|
||||
Client downloads this and measures time to calculate speed.
|
||||
|
||||
Args:
|
||||
size: Size of test data in bytes (max 500KB)
|
||||
|
||||
Returns:
|
||||
Random bytes for speed testing
|
||||
"""
|
||||
import secrets
|
||||
|
||||
# Cap size at 500KB to prevent abuse
|
||||
size = min(size, 500000)
|
||||
|
||||
# Generate random bytes
|
||||
return secrets.token_bytes(size)
|
||||
277
backend/app/api/sharing.py
Normal file
277
backend/app/api/sharing.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""Board sharing API endpoints."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.boards.schemas import (
|
||||
BoardDetail,
|
||||
CommentCreate,
|
||||
CommentResponse,
|
||||
ShareLinkCreate,
|
||||
ShareLinkResponse,
|
||||
)
|
||||
from app.boards.sharing import generate_secure_token
|
||||
from app.core.deps import get_current_user, get_db_sync
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.comment import Comment
|
||||
from app.database.models.share_link import ShareLink
|
||||
from app.database.models.user import User
|
||||
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
def validate_share_link(token: str, db: Session, required_permission: str = "view-only") -> ShareLink:
|
||||
"""
|
||||
Validate share link token and check permissions.
|
||||
|
||||
Args:
|
||||
token: Share link token
|
||||
db: Database session
|
||||
required_permission: Required permission level
|
||||
|
||||
Returns:
|
||||
ShareLink if valid
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if invalid or insufficient permissions
|
||||
"""
|
||||
share_link = (
|
||||
db.query(ShareLink)
|
||||
.filter(
|
||||
ShareLink.token == token,
|
||||
ShareLink.is_revoked == False, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if share_link is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid or revoked share link",
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Share link has expired",
|
||||
)
|
||||
|
||||
# Check permission level
|
||||
if required_permission == "view-comment" and share_link.permission_level != "view-comment":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions - commenting not allowed",
|
||||
)
|
||||
|
||||
# Update access tracking
|
||||
share_link.access_count += 1
|
||||
share_link.last_accessed_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
return share_link
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/share-links", response_model=ShareLinkResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_share_link(
|
||||
board_id: UUID,
|
||||
share_link_data: ShareLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> ShareLinkResponse:
|
||||
"""
|
||||
Create a new share link for a board.
|
||||
|
||||
Only the board owner can create share links.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Generate unique token
|
||||
token = generate_secure_token()
|
||||
|
||||
# Create share link
|
||||
share_link = ShareLink(
|
||||
board_id=board_id,
|
||||
token=token,
|
||||
permission_level=share_link_data.permission_level,
|
||||
expires_at=share_link_data.expires_at,
|
||||
)
|
||||
db.add(share_link)
|
||||
db.commit()
|
||||
db.refresh(share_link)
|
||||
|
||||
return ShareLinkResponse.model_validate(share_link)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/share-links", response_model=list[ShareLinkResponse])
|
||||
def list_share_links(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> list[ShareLinkResponse]:
|
||||
"""
|
||||
List all share links for a board.
|
||||
|
||||
Only the board owner can list share links.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Get all share links for board
|
||||
share_links = db.query(ShareLink).filter(ShareLink.board_id == board_id).order_by(ShareLink.created_at.desc()).all()
|
||||
|
||||
return [ShareLinkResponse.model_validate(link) for link in share_links]
|
||||
|
||||
|
||||
@router.delete("/boards/{board_id}/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def revoke_share_link(
|
||||
board_id: UUID,
|
||||
link_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> None:
|
||||
"""
|
||||
Revoke (soft delete) a share link.
|
||||
|
||||
Only the board owner can revoke share links.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Get and revoke share link
|
||||
share_link = db.query(ShareLink).filter(ShareLink.id == link_id, ShareLink.board_id == board_id).first()
|
||||
|
||||
if share_link is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Share link not found",
|
||||
)
|
||||
|
||||
share_link.is_revoked = True
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/shared/{token}", response_model=BoardDetail)
|
||||
def get_shared_board(
|
||||
token: str,
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> BoardDetail:
|
||||
"""
|
||||
Access a shared board via token.
|
||||
|
||||
No authentication required - access controlled by share link token.
|
||||
"""
|
||||
# Validate share link
|
||||
share_link = validate_share_link(token, db, required_permission="view-only")
|
||||
|
||||
# Get board details
|
||||
board = db.query(Board).filter(Board.id == share_link.board_id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
return BoardDetail.model_validate(board)
|
||||
|
||||
|
||||
@router.post("/shared/{token}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_comment(
|
||||
token: str,
|
||||
comment_data: CommentCreate,
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> CommentResponse:
|
||||
"""
|
||||
Create a comment on a shared board.
|
||||
|
||||
Requires view-comment permission level.
|
||||
"""
|
||||
# Validate share link with comment permission
|
||||
share_link = validate_share_link(token, db, required_permission="view-comment")
|
||||
|
||||
# Create comment
|
||||
comment = Comment(
|
||||
board_id=share_link.board_id,
|
||||
share_link_id=share_link.id,
|
||||
author_name=comment_data.author_name,
|
||||
content=comment_data.content,
|
||||
position=comment_data.position,
|
||||
)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
|
||||
return CommentResponse.model_validate(comment)
|
||||
|
||||
|
||||
@router.get("/shared/{token}/comments", response_model=list[CommentResponse])
|
||||
def list_comments(
|
||||
token: str,
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> list[CommentResponse]:
|
||||
"""
|
||||
List all comments on a shared board.
|
||||
|
||||
Requires view-only or view-comment permission.
|
||||
"""
|
||||
# Validate share link
|
||||
share_link = validate_share_link(token, db, required_permission="view-only")
|
||||
|
||||
# Get all comments for board (non-deleted)
|
||||
comments = (
|
||||
db.query(Comment)
|
||||
.filter(Comment.board_id == share_link.board_id, Comment.is_deleted == False) # noqa: E712
|
||||
.order_by(Comment.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return [CommentResponse.model_validate(comment) for comment in comments]
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/comments", response_model=list[CommentResponse])
|
||||
def list_board_comments(
|
||||
board_id: UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_sync),
|
||||
) -> list[CommentResponse]:
|
||||
"""
|
||||
List all comments on a board (owner view).
|
||||
|
||||
Only the board owner can access this endpoint.
|
||||
"""
|
||||
# Verify board exists and user owns it
|
||||
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
|
||||
|
||||
if board is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found or access denied",
|
||||
)
|
||||
|
||||
# Get all comments for board (including deleted for owner)
|
||||
comments = db.query(Comment).filter(Comment.board_id == board_id).order_by(Comment.created_at.desc()).all()
|
||||
|
||||
return [CommentResponse.model_validate(comment) for comment in comments]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""JWT token generation and validation."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from jose import JWTError, jwt
|
||||
@@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No
|
||||
Encoded JWT token string
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
expire = datetime.now(UTC) + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.utcnow(), "type": "access"}
|
||||
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(UTC), "type": "access"}
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.group import Group
|
||||
|
||||
|
||||
class BoardRepository:
|
||||
@@ -195,3 +196,213 @@ class BoardRepository:
|
||||
count = self.db.execute(stmt).scalar_one()
|
||||
|
||||
return count > 0
|
||||
|
||||
# Group operations
|
||||
|
||||
def create_group(
|
||||
self,
|
||||
board_id: UUID,
|
||||
name: str,
|
||||
color: str,
|
||||
annotation: str | None,
|
||||
image_ids: list[UUID],
|
||||
) -> Group:
|
||||
"""
|
||||
Create a new group and assign images to it.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
name: Group name
|
||||
color: Hex color code
|
||||
annotation: Optional annotation text
|
||||
image_ids: List of board_image IDs to include
|
||||
|
||||
Returns:
|
||||
Created Group instance
|
||||
"""
|
||||
group = Group(
|
||||
board_id=board_id,
|
||||
name=name,
|
||||
color=color,
|
||||
annotation=annotation,
|
||||
)
|
||||
|
||||
self.db.add(group)
|
||||
self.db.flush() # Get group ID
|
||||
|
||||
# Assign images to group
|
||||
for image_id in image_ids:
|
||||
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if board_image:
|
||||
board_image.group_id = group.id
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(group)
|
||||
|
||||
return group
|
||||
|
||||
def get_board_groups(self, board_id: UUID) -> Sequence[Group]:
|
||||
"""
|
||||
Get all groups for a board with member counts.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
|
||||
Returns:
|
||||
List of groups
|
||||
"""
|
||||
stmt = (
|
||||
select(Group, func.count(BoardImage.id).label("member_count"))
|
||||
.outerjoin(BoardImage, Group.id == BoardImage.group_id)
|
||||
.where(Group.board_id == board_id)
|
||||
.group_by(Group.id)
|
||||
.order_by(Group.created_at.desc())
|
||||
)
|
||||
|
||||
results = self.db.execute(stmt).all()
|
||||
|
||||
# Add member_count as attribute
|
||||
groups = []
|
||||
for row in results:
|
||||
group = row[0]
|
||||
# Note: member_count is dynamically calculated, not stored
|
||||
groups.append(group)
|
||||
|
||||
return groups
|
||||
|
||||
def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None:
|
||||
"""
|
||||
Get group by ID.
|
||||
|
||||
Args:
|
||||
group_id: Group UUID
|
||||
board_id: Board UUID (for verification)
|
||||
|
||||
Returns:
|
||||
Group if found, None otherwise
|
||||
"""
|
||||
stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id)
|
||||
|
||||
return self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def update_group(
|
||||
self,
|
||||
group_id: UUID,
|
||||
board_id: UUID,
|
||||
name: str | None = None,
|
||||
color: str | None = None,
|
||||
annotation: str | None = None,
|
||||
) -> Group | None:
|
||||
"""
|
||||
Update group metadata.
|
||||
|
||||
Args:
|
||||
group_id: Group UUID
|
||||
board_id: Board UUID
|
||||
name: New name (if provided)
|
||||
color: New color (if provided)
|
||||
annotation: New annotation (if provided)
|
||||
|
||||
Returns:
|
||||
Updated Group if found, None otherwise
|
||||
"""
|
||||
group = self.get_group_by_id(group_id, board_id)
|
||||
|
||||
if not group:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
group.name = name
|
||||
|
||||
if color is not None:
|
||||
group.color = color
|
||||
|
||||
if annotation is not None:
|
||||
group.annotation = annotation
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(group)
|
||||
|
||||
return group
|
||||
|
||||
def delete_group(self, group_id: UUID, board_id: UUID) -> bool:
|
||||
"""
|
||||
Delete a group and ungroup its members.
|
||||
|
||||
Args:
|
||||
group_id: Group UUID
|
||||
board_id: Board UUID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
group = self.get_group_by_id(group_id, board_id)
|
||||
|
||||
if not group:
|
||||
return False
|
||||
|
||||
# Ungroup all members (set group_id to None)
|
||||
stmt = select(BoardImage).where(BoardImage.group_id == group_id)
|
||||
members = self.db.execute(stmt).scalars().all()
|
||||
|
||||
for member in members:
|
||||
member.group_id = None
|
||||
|
||||
# Delete the group
|
||||
self.db.delete(group)
|
||||
self.db.commit()
|
||||
|
||||
return True
|
||||
|
||||
def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int:
|
||||
"""
|
||||
Add images to a group.
|
||||
|
||||
Args:
|
||||
group_id: Group UUID
|
||||
board_id: Board UUID
|
||||
image_ids: List of image IDs to add
|
||||
|
||||
Returns:
|
||||
Number of images added
|
||||
"""
|
||||
count = 0
|
||||
|
||||
for image_id in image_ids:
|
||||
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if board_image:
|
||||
board_image.group_id = group_id
|
||||
count += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return count
|
||||
|
||||
def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int:
|
||||
"""
|
||||
Remove images from a group.
|
||||
|
||||
Args:
|
||||
group_id: Group UUID
|
||||
image_ids: List of image IDs to remove
|
||||
|
||||
Returns:
|
||||
Number of images removed
|
||||
"""
|
||||
count = 0
|
||||
|
||||
for image_id in image_ids:
|
||||
stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id)
|
||||
board_image = self.db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if board_image:
|
||||
board_image.group_id = None
|
||||
count += 1
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return count
|
||||
|
||||
@@ -22,6 +22,15 @@ class BoardCreate(BaseModel):
|
||||
description: str | None = Field(default=None, description="Optional board description")
|
||||
|
||||
|
||||
class ViewportStateUpdate(BaseModel):
|
||||
"""Schema for updating viewport state only."""
|
||||
|
||||
x: float = Field(..., description="Horizontal pan position")
|
||||
y: float = Field(..., description="Vertical pan position")
|
||||
zoom: float = Field(..., ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)")
|
||||
rotation: float = Field(..., ge=0, le=360, description="Canvas rotation in degrees (0 to 360)")
|
||||
|
||||
|
||||
class BoardUpdate(BaseModel):
|
||||
"""Schema for updating board metadata."""
|
||||
|
||||
@@ -65,3 +74,81 @@ class BoardDetail(BaseModel):
|
||||
if isinstance(v, dict):
|
||||
return ViewportState(**v)
|
||||
return v
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
"""Schema for creating a new group."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255, description="Group name")
|
||||
color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)")
|
||||
annotation: str | None = Field(None, max_length=10000, description="Optional text annotation")
|
||||
image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group")
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
"""Schema for updating group metadata."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255, description="Group name")
|
||||
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code")
|
||||
annotation: str | None = Field(None, max_length=10000, description="Text annotation")
|
||||
|
||||
|
||||
class GroupResponse(BaseModel):
|
||||
"""Response schema for group with member count."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
board_id: UUID
|
||||
name: str
|
||||
color: str
|
||||
annotation: str | None = None
|
||||
member_count: int = Field(default=0, description="Number of images in group")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShareLinkCreate(BaseModel):
|
||||
"""Schema for creating a new share link."""
|
||||
|
||||
permission_level: str = Field(..., pattern=r"^(view-only|view-comment)$", description="Permission level")
|
||||
expires_at: datetime | None = Field(None, description="Optional expiration datetime")
|
||||
|
||||
|
||||
class ShareLinkResponse(BaseModel):
|
||||
"""Response schema for share link."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
board_id: UUID
|
||||
token: str
|
||||
permission_level: str
|
||||
created_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
last_accessed_at: datetime | None = None
|
||||
access_count: int = 0
|
||||
is_revoked: bool = False
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
"""Schema for creating a new comment."""
|
||||
|
||||
author_name: str = Field(..., min_length=1, max_length=100, description="Commenter name")
|
||||
content: str = Field(..., min_length=1, max_length=5000, description="Comment text")
|
||||
position: dict | None = Field(None, description="Optional canvas position {x, y}")
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
"""Response schema for comment."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
board_id: UUID
|
||||
share_link_id: UUID | None = None
|
||||
author_name: str
|
||||
content: str
|
||||
position: dict | None = None
|
||||
created_at: datetime
|
||||
is_deleted: bool = False
|
||||
|
||||
84
backend/app/boards/sharing.py
Normal file
84
backend/app/boards/sharing.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Board sharing functionality."""
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.share_link import ShareLink
|
||||
|
||||
|
||||
def generate_secure_token(length: int = 64) -> str:
|
||||
"""
|
||||
Generate a cryptographically secure random token for share links.
|
||||
|
||||
Args:
|
||||
length: Length of the token (default 64 characters)
|
||||
|
||||
Returns:
|
||||
URL-safe random string
|
||||
"""
|
||||
# Use URL-safe characters (alphanumeric + - and _)
|
||||
alphabet = string.ascii_letters + string.digits + "-_"
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
|
||||
"""
|
||||
Validate a share link token and return the share link if valid.
|
||||
|
||||
A share link is valid if:
|
||||
- Token exists
|
||||
- Not revoked
|
||||
- Not expired (if expires_at is set)
|
||||
|
||||
Args:
|
||||
token: The share link token
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
ShareLink if valid, None otherwise
|
||||
"""
|
||||
share_link = (
|
||||
db.query(ShareLink)
|
||||
.filter(
|
||||
ShareLink.token == token,
|
||||
ShareLink.is_revoked == False, # noqa: E712
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if share_link is None:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
|
||||
return None
|
||||
|
||||
# Update access tracking
|
||||
share_link.access_count += 1
|
||||
share_link.last_accessed_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
return share_link
|
||||
|
||||
|
||||
def check_permission(share_link: ShareLink, required_permission: str) -> bool:
|
||||
"""
|
||||
Check if a share link has the required permission level.
|
||||
|
||||
Args:
|
||||
share_link: The share link to check
|
||||
required_permission: Required permission level ('view-only' or 'view-comment')
|
||||
|
||||
Returns:
|
||||
True if permission granted, False otherwise
|
||||
"""
|
||||
if required_permission == "view-only":
|
||||
# Both view-only and view-comment can view
|
||||
return share_link.permission_level in ("view-only", "view-comment")
|
||||
elif required_permission == "view-comment":
|
||||
# Only view-comment can comment
|
||||
return share_link.permission_level == "view-comment"
|
||||
return False
|
||||
@@ -45,11 +45,13 @@ class Settings(BaseSettings):
|
||||
|
||||
@field_validator("CORS_ORIGINS", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v: Any) -> list[str]:
|
||||
def parse_cors_origins(cls, v: Any) -> list[str] | Any:
|
||||
"""Parse CORS origins from string or list."""
|
||||
if isinstance(v, str):
|
||||
return [origin.strip() for origin in v.split(",")]
|
||||
return v
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# File Upload
|
||||
MAX_FILE_SIZE: int = 52428800 # 50MB
|
||||
|
||||
38
backend/app/core/constants.py
Normal file
38
backend/app/core/constants.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Application-wide constants."""
|
||||
|
||||
# File upload limits
|
||||
MAX_IMAGE_SIZE = 52_428_800 # 50MB in bytes
|
||||
MAX_ZIP_SIZE = 209_715_200 # 200MB in bytes
|
||||
|
||||
# Image processing
|
||||
MAX_IMAGE_DIMENSION = 10_000 # Max width or height in pixels
|
||||
THUMBNAIL_SIZES = {
|
||||
"low": 800, # For slow connections (<1 Mbps)
|
||||
"medium": 1600, # For medium connections (1-5 Mbps)
|
||||
"high": 3200, # For fast connections (>5 Mbps)
|
||||
}
|
||||
|
||||
# Pagination defaults
|
||||
DEFAULT_PAGE_SIZE = 50
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
# Board limits
|
||||
MAX_BOARD_TITLE_LENGTH = 255
|
||||
MAX_BOARD_DESCRIPTION_LENGTH = 1000
|
||||
MAX_IMAGES_PER_BOARD = 1000
|
||||
|
||||
# Authentication
|
||||
TOKEN_EXPIRE_HOURS = 168 # 7 days
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
|
||||
# Supported image formats
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
}
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
|
||||
@@ -5,24 +5,48 @@ from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# For backwards compatibility with synchronous code
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.auth.jwt import decode_access_token
|
||||
from app.core.config import settings
|
||||
from app.database.models.user import User
|
||||
from app.database.session import get_db
|
||||
|
||||
# Database session dependency
|
||||
DatabaseSession = Annotated[Session, Depends(get_db)]
|
||||
# Sync engine for synchronous endpoints
|
||||
_sync_engine = create_engine(
|
||||
str(settings.DATABASE_URL),
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
_SyncSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_sync_engine)
|
||||
|
||||
|
||||
def get_db_sync():
|
||||
"""Synchronous database session dependency."""
|
||||
db = _SyncSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Database session dependency (async)
|
||||
DatabaseSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
# Security scheme for JWT Bearer token
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db)
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security), db: Session = Depends(get_db_sync)
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated user from JWT token.
|
||||
Get current authenticated user from JWT token (synchronous version).
|
||||
|
||||
Args:
|
||||
credentials: HTTP Authorization Bearer token
|
||||
@@ -63,7 +87,7 @@ def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from None
|
||||
|
||||
# Get user from database
|
||||
# Get user from database (sync)
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
if user is None:
|
||||
@@ -77,3 +101,65 @@ def get_current_user(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_async(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security), db: AsyncSession = Depends(get_db)
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated user from JWT token (asynchronous version).
|
||||
|
||||
Args:
|
||||
credentials: HTTP Authorization Bearer token
|
||||
db: Async database session
|
||||
|
||||
Returns:
|
||||
Current authenticated user
|
||||
|
||||
Raises:
|
||||
HTTPException: If token is invalid or user not found
|
||||
"""
|
||||
# Decode token
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract user ID from token
|
||||
user_id_str: str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
user_id = UUID(user_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid user ID in token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from None
|
||||
|
||||
# Get user from database (async)
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is deactivated")
|
||||
|
||||
return user
|
||||
|
||||
69
backend/app/core/ownership.py
Normal file
69
backend/app/core/ownership.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Ownership verification utilities."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.board import Board
|
||||
|
||||
|
||||
def verify_board_ownership_sync(db: Session, board_id: UUID, user_id: UUID) -> Board:
|
||||
"""
|
||||
Verify board ownership (synchronous).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
board_id: Board UUID
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Board instance if owned by user
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if board not found or not owned by user
|
||||
"""
|
||||
stmt = select(Board).where(
|
||||
Board.id == board_id,
|
||||
Board.user_id == user_id,
|
||||
Board.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
||||
board = db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
|
||||
|
||||
return board
|
||||
|
||||
|
||||
async def verify_board_ownership_async(db: AsyncSession, board_id: UUID, user_id: UUID) -> Board:
|
||||
"""
|
||||
Verify board ownership (asynchronous).
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
board_id: Board UUID
|
||||
user_id: User UUID
|
||||
|
||||
Returns:
|
||||
Board instance if owned by user
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if board not found or not owned by user
|
||||
"""
|
||||
stmt = select(Board).where(
|
||||
Board.id == board_id,
|
||||
Board.user_id == user_id,
|
||||
Board.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
board = result.scalar_one_or_none()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
|
||||
|
||||
return board
|
||||
119
backend/app/core/repository.py
Normal file
119
backend/app/core/repository.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Base repository with common database operations."""
|
||||
|
||||
from typing import TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# Type variable for model classes
|
||||
ModelType = TypeVar("ModelType")
|
||||
|
||||
|
||||
class BaseRepository[ModelType]:
|
||||
"""Base repository with common CRUD operations."""
|
||||
|
||||
def __init__(self, model: type[ModelType], db: Session | AsyncSession):
|
||||
"""
|
||||
Initialize repository.
|
||||
|
||||
Args:
|
||||
model: SQLAlchemy model class
|
||||
db: Database session (sync or async)
|
||||
"""
|
||||
self.model = model
|
||||
self.db = db
|
||||
|
||||
def get_by_id_sync(self, id: UUID) -> ModelType | None:
|
||||
"""
|
||||
Get entity by ID (synchronous).
|
||||
|
||||
Args:
|
||||
id: Entity UUID
|
||||
|
||||
Returns:
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
return self.db.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
async def get_by_id_async(self, id: UUID) -> ModelType | None:
|
||||
"""
|
||||
Get entity by ID (asynchronous).
|
||||
|
||||
Args:
|
||||
id: Entity UUID
|
||||
|
||||
Returns:
|
||||
Entity if found, None otherwise
|
||||
"""
|
||||
stmt = select(self.model).where(self.model.id == id)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def count_sync(self, **filters) -> int:
|
||||
"""
|
||||
Count entities with optional filters (synchronous).
|
||||
|
||||
Args:
|
||||
**filters: Column filters (column_name=value)
|
||||
|
||||
Returns:
|
||||
Count of matching entities
|
||||
"""
|
||||
query = self.db.query(func.count(self.model.id))
|
||||
for key, value in filters.items():
|
||||
query = query.filter(getattr(self.model, key) == value)
|
||||
return query.scalar()
|
||||
|
||||
async def count_async(self, **filters) -> int:
|
||||
"""
|
||||
Count entities with optional filters (asynchronous).
|
||||
|
||||
Args:
|
||||
**filters: Column filters (column_name=value)
|
||||
|
||||
Returns:
|
||||
Count of matching entities
|
||||
"""
|
||||
stmt = select(func.count(self.model.id))
|
||||
for key, value in filters.items():
|
||||
stmt = stmt.where(getattr(self.model, key) == value)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
def delete_sync(self, id: UUID) -> bool:
|
||||
"""
|
||||
Delete entity by ID (synchronous).
|
||||
|
||||
Args:
|
||||
id: Entity UUID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
entity = self.get_by_id_sync(id)
|
||||
if not entity:
|
||||
return False
|
||||
|
||||
self.db.delete(entity)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
async def delete_async(self, id: UUID) -> bool:
|
||||
"""
|
||||
Delete entity by ID (asynchronous).
|
||||
|
||||
Args:
|
||||
id: Entity UUID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
entity = await self.get_by_id_async(id)
|
||||
if not entity:
|
||||
return False
|
||||
|
||||
await self.db.delete(entity)
|
||||
await self.db.commit()
|
||||
return True
|
||||
75
backend/app/core/responses.py
Normal file
75
backend/app/core/responses.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Standard response utilities."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import status
|
||||
|
||||
|
||||
class ErrorResponse:
|
||||
"""Standard error response formats."""
|
||||
|
||||
@staticmethod
|
||||
def not_found(resource: str = "Resource") -> dict[str, Any]:
|
||||
"""404 Not Found response."""
|
||||
return {
|
||||
"status_code": status.HTTP_404_NOT_FOUND,
|
||||
"detail": f"{resource} not found",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def forbidden(message: str = "Access denied") -> dict[str, Any]:
|
||||
"""403 Forbidden response."""
|
||||
return {
|
||||
"status_code": status.HTTP_403_FORBIDDEN,
|
||||
"detail": message,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def unauthorized(message: str = "Authentication required") -> dict[str, Any]:
|
||||
"""401 Unauthorized response."""
|
||||
return {
|
||||
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||
"detail": message,
|
||||
"headers": {"WWW-Authenticate": "Bearer"},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def bad_request(message: str) -> dict[str, Any]:
|
||||
"""400 Bad Request response."""
|
||||
return {
|
||||
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||
"detail": message,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def conflict(message: str) -> dict[str, Any]:
|
||||
"""409 Conflict response."""
|
||||
return {
|
||||
"status_code": status.HTTP_409_CONFLICT,
|
||||
"detail": message,
|
||||
}
|
||||
|
||||
|
||||
class SuccessResponse:
|
||||
"""Standard success response formats."""
|
||||
|
||||
@staticmethod
|
||||
def created(data: dict[str, Any], message: str = "Created successfully") -> dict[str, Any]:
|
||||
"""201 Created response."""
|
||||
return {
|
||||
"message": message,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def ok(data: dict[str, Any] | None = None, message: str = "Success") -> dict[str, Any]:
|
||||
"""200 OK response."""
|
||||
response = {"message": message}
|
||||
if data:
|
||||
response["data"] = data
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def no_content() -> None:
|
||||
"""204 No Content response."""
|
||||
return None
|
||||
@@ -91,6 +91,27 @@ class StorageClient:
|
||||
logger.error(f"Failed to download file {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def get_object(self, object_name: str) -> bytes | None:
|
||||
"""Get object as bytes from MinIO.
|
||||
|
||||
Args:
|
||||
object_name: S3 object name (path)
|
||||
|
||||
Returns:
|
||||
bytes: File data or None if not found
|
||||
|
||||
Raises:
|
||||
Exception: If download fails for reasons other than not found
|
||||
"""
|
||||
try:
|
||||
file_data = self.download_file(object_name)
|
||||
return file_data.read()
|
||||
except ClientError as e:
|
||||
if e.response["Error"]["Code"] == "404":
|
||||
return None
|
||||
logger.error(f"Failed to get object {object_name}: {e}")
|
||||
raise
|
||||
|
||||
def delete_file(self, object_name: str) -> None:
|
||||
"""Delete file from MinIO.
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Base model for all database models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlalchemy import Column, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, declared_attr
|
||||
|
||||
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
|
||||
|
||||
# Common columns for all models
|
||||
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
created_at: Any = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
def dict(self) -> dict[str, Any]:
|
||||
"""Convert model to dictionary."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -13,6 +13,7 @@ from app.database.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.comment import Comment
|
||||
from app.database.models.group import Group
|
||||
from app.database.models.share_link import ShareLink
|
||||
from app.database.models.user import User
|
||||
@@ -41,9 +42,9 @@ class Board(Base):
|
||||
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -56,6 +57,7 @@ class Board(Base):
|
||||
share_links: Mapped[list["ShareLink"]] = relationship(
|
||||
"ShareLink", back_populates="board", cascade="all, delete-orphan"
|
||||
)
|
||||
comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Board."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -52,9 +52,9 @@ class BoardImage(Base):
|
||||
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Comment model for board comments."""
|
||||
"""Comment model for board annotations."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
@@ -11,19 +10,17 @@ from app.database.base import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""Comment model for viewer comments on shared boards."""
|
||||
"""Comment model representing viewer comments on shared boards."""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
share_link_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
|
||||
author_name = Column(String(100), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
position = Column(JSONB, nullable=True) # Optional canvas position
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
position = Column(JSONB, nullable=True) # Optional canvas position reference
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
@@ -31,4 +28,4 @@ class Comment(Base):
|
||||
share_link = relationship("ShareLink", back_populates="comments")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Comment(id={self.id}, author={self.author_name})>"
|
||||
return f"<Comment(id={self.id}, board_id={self.board_id}, author={self.author_name})>"
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -33,9 +33,9 @@ class Group(Base):
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
|
||||
annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -36,9 +36,9 @@ class Image(Base):
|
||||
mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
width: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
height: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
"""ShareLink database model."""
|
||||
"""ShareLink model for board sharing functionality."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models.board import Board
|
||||
|
||||
|
||||
class ShareLink(Base):
|
||||
"""
|
||||
ShareLink model for sharing boards with configurable permissions.
|
||||
|
||||
Share links allow users to share boards with others without requiring
|
||||
authentication, with permission levels controlling what actions are allowed.
|
||||
"""
|
||||
"""ShareLink model representing shareable board links with permissions."""
|
||||
|
||||
__tablename__ = "share_links"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
board_id: Mapped[UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
|
||||
token = Column(String(64), unique=True, nullable=False, index=True)
|
||||
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
last_accessed_at = Column(DateTime, nullable=True)
|
||||
access_count = Column(Integer, nullable=False, default=0)
|
||||
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
|
||||
board = relationship("Board", back_populates="share_links")
|
||||
comments = relationship("Comment", back_populates="share_link", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of ShareLink."""
|
||||
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
|
||||
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""User model for authentication and ownership."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, String
|
||||
from sqlalchemy import Boolean, Column, DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
@@ -18,8 +17,8 @@ class User(Base):
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Relationships
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
"""Database session management."""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# Create SQLAlchemy engine
|
||||
engine = create_engine(
|
||||
str(settings.DATABASE_URL),
|
||||
# Convert sync DATABASE_URL to async (replace postgresql:// with postgresql+asyncpg://)
|
||||
async_database_url = str(settings.DATABASE_URL).replace("postgresql://", "postgresql+asyncpg://")
|
||||
|
||||
# Create async SQLAlchemy engine
|
||||
engine = create_async_engine(
|
||||
async_database_url,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
max_overflow=settings.DATABASE_MAX_OVERFLOW,
|
||||
pool_pre_ping=True, # Verify connections before using
|
||||
echo=settings.DEBUG, # Log SQL queries in debug mode
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
# Create async session factory
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for getting database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
async def get_db():
|
||||
"""Dependency for getting async database session."""
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
62
backend/app/images/download.py
Normal file
62
backend/app/images/download.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Image download functionality."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.core.storage import storage_client
|
||||
|
||||
|
||||
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
|
||||
"""
|
||||
Download a single image from storage.
|
||||
|
||||
Args:
|
||||
storage_path: Path to image in MinIO
|
||||
filename: Original filename for download
|
||||
|
||||
Returns:
|
||||
StreamingResponse with image data
|
||||
|
||||
Raises:
|
||||
HTTPException: If image not found or download fails
|
||||
"""
|
||||
try:
|
||||
# Get image from storage
|
||||
image_data = storage_client.get_object(storage_path)
|
||||
|
||||
if image_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Image not found in storage",
|
||||
)
|
||||
|
||||
# Determine content type from file extension
|
||||
extension = Path(filename).suffix.lower()
|
||||
content_type_map = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
content_type = content_type_map.get(extension, "application/octet-stream")
|
||||
|
||||
# Return streaming response
|
||||
return StreamingResponse(
|
||||
io.BytesIO(image_data),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to download image: {str(e)}",
|
||||
) from e
|
||||
228
backend/app/images/export_composite.py
Normal file
228
backend/app/images/export_composite.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Composite image generation for board export."""
|
||||
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image as PILImage
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage import storage_client
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
|
||||
"""
|
||||
Create a composite image showing the entire board layout.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
db: Database session
|
||||
scale: Resolution multiplier (1x, 2x, 4x)
|
||||
format: Output format (PNG or JPEG)
|
||||
|
||||
Returns:
|
||||
StreamingResponse with composite image
|
||||
|
||||
Raises:
|
||||
HTTPException: If export fails
|
||||
"""
|
||||
try:
|
||||
# Get board
|
||||
board = db.query(Board).filter(Board.id == board_id).first()
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Board not found",
|
||||
)
|
||||
|
||||
# Get all images for the board with positions
|
||||
board_images = (
|
||||
db.query(BoardImage, Image)
|
||||
.join(Image, BoardImage.image_id == Image.id)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.order_by(BoardImage.z_order)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not board_images:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No images found for this board",
|
||||
)
|
||||
|
||||
# Calculate canvas bounds
|
||||
bounds = _calculate_canvas_bounds(board_images)
|
||||
if not bounds:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unable to calculate canvas bounds",
|
||||
)
|
||||
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
|
||||
# Calculate canvas size with padding
|
||||
padding = 50
|
||||
canvas_width = int((max_x - min_x + 2 * padding) * scale)
|
||||
canvas_height = int((max_y - min_y + 2 * padding) * scale)
|
||||
|
||||
# Limit canvas size to prevent memory issues
|
||||
max_dimension = 8192 # 8K resolution limit
|
||||
if canvas_width > max_dimension or canvas_height > max_dimension:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
|
||||
)
|
||||
|
||||
# Create blank canvas
|
||||
if format.upper() == "JPEG":
|
||||
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
|
||||
else:
|
||||
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
|
||||
|
||||
# Composite each image onto canvas
|
||||
for board_image, image in board_images:
|
||||
try:
|
||||
# Get image from storage
|
||||
image_data = storage_client.get_object(image.storage_path)
|
||||
if not image_data:
|
||||
continue
|
||||
|
||||
# Open image
|
||||
pil_image = PILImage.open(io.BytesIO(image_data))
|
||||
|
||||
# Apply transformations
|
||||
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
|
||||
|
||||
# Calculate position on canvas
|
||||
pos = board_image.position
|
||||
x = int((pos["x"] - min_x + padding) * scale)
|
||||
y = int((pos["y"] - min_y + padding) * scale)
|
||||
|
||||
# Paste onto canvas
|
||||
if transformed_image.mode == "RGBA":
|
||||
canvas.paste(transformed_image, (x, y), transformed_image)
|
||||
else:
|
||||
canvas.paste(transformed_image, (x, y))
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other images
|
||||
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Save to buffer
|
||||
output = io.BytesIO()
|
||||
if format.upper() == "JPEG":
|
||||
canvas = canvas.convert("RGB")
|
||||
canvas.save(output, format="JPEG", quality=95)
|
||||
media_type = "image/jpeg"
|
||||
extension = "jpg"
|
||||
else:
|
||||
canvas.save(output, format="PNG", optimize=True)
|
||||
media_type = "image/png"
|
||||
extension = "png"
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Return composite image
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create composite export: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
|
||||
"""
|
||||
Calculate the bounding box for all images.
|
||||
|
||||
Args:
|
||||
board_images: List of (BoardImage, Image) tuples
|
||||
|
||||
Returns:
|
||||
Tuple of (min_x, min_y, max_x, max_y) or None
|
||||
"""
|
||||
if not board_images:
|
||||
return None
|
||||
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
|
||||
for board_image, image in board_images:
|
||||
pos = board_image.position
|
||||
transforms = board_image.transformations
|
||||
|
||||
x = pos["x"]
|
||||
y = pos["y"]
|
||||
width = image.width * transforms.get("scale", 1.0)
|
||||
height = image.height * transforms.get("scale", 1.0)
|
||||
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x + width)
|
||||
max_y = max(max_y, y + height)
|
||||
|
||||
return (min_x, min_y, max_x, max_y)
|
||||
|
||||
|
||||
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
|
||||
"""
|
||||
Apply transformations to an image.
|
||||
|
||||
Args:
|
||||
image: PIL Image
|
||||
transformations: Transformation dict
|
||||
scale: Resolution multiplier
|
||||
|
||||
Returns:
|
||||
Transformed PIL Image
|
||||
"""
|
||||
# Apply scale
|
||||
img_scale = transformations.get("scale", 1.0) * scale
|
||||
if img_scale != 1.0:
|
||||
new_width = int(image.width * img_scale)
|
||||
new_height = int(image.height * img_scale)
|
||||
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Apply rotation
|
||||
rotation = transformations.get("rotation", 0)
|
||||
if rotation != 0:
|
||||
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
|
||||
|
||||
# Apply flips
|
||||
if transformations.get("flipped_h", False):
|
||||
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
|
||||
if transformations.get("flipped_v", False):
|
||||
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
|
||||
|
||||
# Apply greyscale
|
||||
if transformations.get("greyscale", False):
|
||||
if image.mode == "RGBA":
|
||||
# Preserve alpha channel
|
||||
alpha = image.split()[-1]
|
||||
image = image.convert("L").convert("RGBA")
|
||||
image.putalpha(alpha)
|
||||
else:
|
||||
image = image.convert("L")
|
||||
|
||||
# Apply opacity
|
||||
opacity = transformations.get("opacity", 1.0)
|
||||
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
|
||||
alpha = image.split()[-1]
|
||||
alpha = alpha.point(lambda p: int(p * opacity))
|
||||
image.putalpha(alpha)
|
||||
|
||||
return image
|
||||
103
backend/app/images/export_zip.py
Normal file
103
backend/app/images/export_zip.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""ZIP export functionality for multiple images."""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.storage import storage_client
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
|
||||
"""
|
||||
Create a ZIP file containing all images from a board.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
StreamingResponse with ZIP file
|
||||
|
||||
Raises:
|
||||
HTTPException: If export fails
|
||||
"""
|
||||
try:
|
||||
# Get all images for the board
|
||||
board_images = (
|
||||
db.query(BoardImage, Image)
|
||||
.join(Image, BoardImage.image_id == Image.id)
|
||||
.filter(BoardImage.board_id == board_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not board_images:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No images found for this board",
|
||||
)
|
||||
|
||||
# Create ZIP file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for _board_image, image in board_images:
|
||||
try:
|
||||
# Get image data from storage
|
||||
image_data = storage_client.get_object(image.storage_path)
|
||||
|
||||
if image_data:
|
||||
# Add to ZIP with sanitized filename
|
||||
safe_filename = _sanitize_filename(image.filename)
|
||||
zip_file.writestr(safe_filename, image_data)
|
||||
|
||||
except Exception as e:
|
||||
# Log error but continue with other images
|
||||
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
|
||||
continue
|
||||
|
||||
# Reset buffer position
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Return ZIP file
|
||||
return StreamingResponse(
|
||||
zip_buffer,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="board_export.zip"',
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create ZIP export: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def _sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize filename for safe inclusion in ZIP.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
# Remove any path separators and dangerous characters
|
||||
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
|
||||
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
|
||||
|
||||
# Ensure it's not empty and doesn't start with a dot
|
||||
if not sanitized or sanitized[0] == ".":
|
||||
sanitized = "file_" + sanitized
|
||||
|
||||
return sanitized
|
||||
@@ -26,24 +26,9 @@ class ImageRepository:
|
||||
mime_type: str,
|
||||
width: int,
|
||||
height: int,
|
||||
metadata: dict,
|
||||
image_metadata: dict,
|
||||
) -> Image:
|
||||
"""
|
||||
Create new image record.
|
||||
|
||||
Args:
|
||||
user_id: Owner user ID
|
||||
filename: Original filename
|
||||
storage_path: Path in MinIO
|
||||
file_size: File size in bytes
|
||||
mime_type: MIME type
|
||||
width: Image width in pixels
|
||||
height: Image height in pixels
|
||||
metadata: Additional metadata (format, checksum, thumbnails, etc)
|
||||
|
||||
Returns:
|
||||
Created Image instance
|
||||
"""
|
||||
"""Create new image record."""
|
||||
image = Image(
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
@@ -52,7 +37,7 @@ class ImageRepository:
|
||||
mime_type=mime_type,
|
||||
width=width,
|
||||
height=height,
|
||||
metadata=metadata,
|
||||
image_metadata=image_metadata,
|
||||
)
|
||||
self.db.add(image)
|
||||
await self.db.commit()
|
||||
@@ -60,52 +45,27 @@ class ImageRepository:
|
||||
return image
|
||||
|
||||
async def get_image_by_id(self, image_id: UUID) -> Image | None:
|
||||
"""
|
||||
Get image by ID.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
Image instance or None
|
||||
"""
|
||||
"""Get image by ID."""
|
||||
result = await self.db.execute(select(Image).where(Image.id == image_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
|
||||
"""
|
||||
Get all images for a user with pagination.
|
||||
"""Get all images for a user with pagination."""
|
||||
from sqlalchemy import func
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
limit: Maximum number of images to return
|
||||
offset: Number of images to skip
|
||||
# Get total count efficiently
|
||||
count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
|
||||
total = count_result.scalar_one()
|
||||
|
||||
Returns:
|
||||
Tuple of (images, total_count)
|
||||
"""
|
||||
# Get total count
|
||||
count_result = await self.db.execute(select(Image).where(Image.user_id == user_id))
|
||||
total = len(count_result.scalars().all())
|
||||
|
||||
# Get paginated results
|
||||
# Get paginated images
|
||||
result = await self.db.execute(
|
||||
select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
images = result.scalars().all()
|
||||
|
||||
return images, total
|
||||
|
||||
async def delete_image(self, image_id: UUID) -> bool:
|
||||
"""
|
||||
Delete image record.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
"""Delete image record."""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
if not image:
|
||||
return False
|
||||
@@ -115,27 +75,14 @@ class ImageRepository:
|
||||
return True
|
||||
|
||||
async def increment_reference_count(self, image_id: UUID) -> None:
|
||||
"""
|
||||
Increment reference count for image.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
"""
|
||||
"""Increment reference count for image."""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
if image:
|
||||
image.reference_count += 1
|
||||
await self.db.commit()
|
||||
|
||||
async def decrement_reference_count(self, image_id: UUID) -> int:
|
||||
"""
|
||||
Decrement reference count for image.
|
||||
|
||||
Args:
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
New reference count
|
||||
"""
|
||||
"""Decrement reference count for image."""
|
||||
image = await self.get_image_by_id(image_id)
|
||||
if image and image.reference_count > 0:
|
||||
image.reference_count -= 1
|
||||
@@ -151,19 +98,7 @@ class ImageRepository:
|
||||
transformations: dict,
|
||||
z_order: int = 0,
|
||||
) -> BoardImage:
|
||||
"""
|
||||
Add image to board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
image_id: Image ID
|
||||
position: Canvas position {x, y}
|
||||
transformations: Image transformations
|
||||
z_order: Layer order
|
||||
|
||||
Returns:
|
||||
Created BoardImage instance
|
||||
"""
|
||||
"""Add image to board."""
|
||||
board_image = BoardImage(
|
||||
board_id=board_id,
|
||||
image_id=image_id,
|
||||
@@ -181,35 +116,50 @@ class ImageRepository:
|
||||
return board_image
|
||||
|
||||
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
|
||||
"""
|
||||
Get all images for a board, ordered by z-order.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
|
||||
Returns:
|
||||
List of BoardImage instances
|
||||
"""
|
||||
"""Get all images for a board, ordered by z-order."""
|
||||
result = await self.db.execute(
|
||||
select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
||||
"""
|
||||
Remove image from board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
image_id: Image ID
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
|
||||
"""Get a specific board image."""
|
||||
result = await self.db.execute(
|
||||
select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
|
||||
)
|
||||
board_image = result.scalar_one_or_none()
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_board_image(
|
||||
self,
|
||||
board_id: UUID,
|
||||
image_id: UUID,
|
||||
position: dict | None = None,
|
||||
transformations: dict | None = None,
|
||||
z_order: int | None = None,
|
||||
group_id: UUID | None = None,
|
||||
) -> BoardImage | None:
|
||||
"""Update board image position, transformations, z-order, or group."""
|
||||
board_image = await self.get_board_image(board_id, image_id)
|
||||
|
||||
if not board_image:
|
||||
return None
|
||||
|
||||
if position is not None:
|
||||
board_image.position = position
|
||||
if transformations is not None:
|
||||
board_image.transformations = transformations
|
||||
if z_order is not None:
|
||||
board_image.z_order = z_order
|
||||
if group_id is not None:
|
||||
board_image.group_id = group_id
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(board_image)
|
||||
return board_image
|
||||
|
||||
async def remove_image_from_board(self, board_id: UUID, image_id: UUID) -> bool:
|
||||
"""Remove image from board."""
|
||||
board_image = await self.get_board_image(board_id, image_id)
|
||||
|
||||
if not board_image:
|
||||
return False
|
||||
|
||||
@@ -26,13 +26,14 @@ class ImageUploadResponse(BaseModel):
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
metadata: dict[str, Any]
|
||||
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
"""Pydantic config."""
|
||||
|
||||
from_attributes = True
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ImageResponse(BaseModel):
|
||||
@@ -46,7 +47,7 @@ class ImageResponse(BaseModel):
|
||||
mime_type: str
|
||||
width: int
|
||||
height: int
|
||||
metadata: dict[str, Any]
|
||||
metadata: dict[str, Any] = Field(..., alias="image_metadata")
|
||||
created_at: datetime
|
||||
reference_count: int
|
||||
|
||||
@@ -54,6 +55,7 @@ class ImageResponse(BaseModel):
|
||||
"""Pydantic config."""
|
||||
|
||||
from_attributes = True
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class BoardImageCreate(BaseModel):
|
||||
@@ -83,6 +85,23 @@ class BoardImageCreate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class BoardImageUpdate(BaseModel):
|
||||
"""Schema for updating board image position/transformations."""
|
||||
|
||||
position: dict[str, float] | None = Field(None, description="Canvas position")
|
||||
transformations: dict[str, Any] | None = Field(None, description="Image transformations")
|
||||
z_order: int | None = Field(None, description="Layer order")
|
||||
group_id: UUID | None = Field(None, description="Group membership")
|
||||
|
||||
@field_validator("position")
|
||||
@classmethod
|
||||
def validate_position(cls, v: dict[str, float] | None) -> dict[str, float] | None:
|
||||
"""Validate position has x and y if provided."""
|
||||
if v is not None and ("x" not in v or "y" not in v):
|
||||
raise ValueError("Position must contain 'x' and 'y' coordinates")
|
||||
return v
|
||||
|
||||
|
||||
class BoardImageResponse(BaseModel):
|
||||
"""Response for board image with all metadata."""
|
||||
|
||||
@@ -103,6 +122,31 @@ class BoardImageResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BulkImageUpdate(BaseModel):
|
||||
"""Schema for bulk updating multiple images."""
|
||||
|
||||
image_ids: list[UUID] = Field(..., description="List of image IDs to update")
|
||||
position_delta: dict[str, float] | None = Field(None, description="Position delta to apply")
|
||||
transformations: dict[str, Any] | None = Field(None, description="Transformations to apply")
|
||||
z_order_delta: int | None = Field(None, description="Z-order delta to apply")
|
||||
|
||||
@field_validator("position_delta")
|
||||
@classmethod
|
||||
def validate_position_delta(cls, v: dict[str, float] | None) -> dict[str, float] | None:
|
||||
"""Validate position delta has dx and dy."""
|
||||
if v is not None and ("dx" not in v or "dy" not in v):
|
||||
raise ValueError("Position delta must contain 'dx' and 'dy'")
|
||||
return v
|
||||
|
||||
|
||||
class BulkUpdateResponse(BaseModel):
|
||||
"""Response for bulk update operation."""
|
||||
|
||||
updated_count: int = Field(..., description="Number of images updated")
|
||||
failed_count: int = Field(default=0, description="Number of images that failed to update")
|
||||
image_ids: list[UUID] = Field(..., description="IDs of successfully updated images")
|
||||
|
||||
|
||||
class ImageListResponse(BaseModel):
|
||||
"""Paginated list of images."""
|
||||
|
||||
|
||||
74
backend/app/images/search.py
Normal file
74
backend/app/images/search.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Image search and filtering functionality."""
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def search_images(
|
||||
user_id: str,
|
||||
db: Session,
|
||||
query: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> list[Image]:
|
||||
"""
|
||||
Search user's image library with optional filters.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
db: Database session
|
||||
query: Search query (searches filename)
|
||||
limit: Maximum results (default 50)
|
||||
offset: Pagination offset (default 0)
|
||||
|
||||
Returns:
|
||||
List of matching images
|
||||
"""
|
||||
# Base query - get user's images
|
||||
stmt = db.query(Image).filter(Image.user_id == user_id)
|
||||
|
||||
# Add search filter if query provided
|
||||
if query:
|
||||
search_term = f"%{query}%"
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
Image.filename.ilike(search_term),
|
||||
Image.image_metadata["format"].astext.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
# Order by most recently uploaded
|
||||
stmt = stmt.order_by(Image.created_at.desc())
|
||||
|
||||
# Apply pagination
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
return stmt.all()
|
||||
|
||||
|
||||
def count_images(user_id: str, db: Session, query: str | None = None) -> int:
|
||||
"""
|
||||
Count images matching search criteria.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
db: Database session
|
||||
query: Search query (optional)
|
||||
|
||||
Returns:
|
||||
Count of matching images
|
||||
"""
|
||||
stmt = db.query(Image).filter(Image.user_id == user_id)
|
||||
|
||||
if query:
|
||||
search_term = f"%{query}%"
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
Image.filename.ilike(search_term),
|
||||
Image.image_metadata["format"].astext.ilike(search_term),
|
||||
)
|
||||
)
|
||||
|
||||
return stmt.count()
|
||||
103
backend/app/images/serve.py
Normal file
103
backend/app/images/serve.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Image serving with quality-based thumbnail selection."""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.database.models.image import Image
|
||||
|
||||
|
||||
def get_thumbnail_path(image: Image, quality: str) -> str:
|
||||
"""
|
||||
Get thumbnail path for specified quality level.
|
||||
|
||||
Args:
|
||||
image: Image model instance
|
||||
quality: Quality level ('low', 'medium', 'high', 'original')
|
||||
|
||||
Returns:
|
||||
Storage path to thumbnail
|
||||
|
||||
Raises:
|
||||
ValueError: If quality level is invalid
|
||||
"""
|
||||
if quality == "original":
|
||||
return image.storage_path
|
||||
|
||||
# Get thumbnail paths from metadata
|
||||
thumbnails = image.image_metadata.get("thumbnails", {})
|
||||
|
||||
# Map quality to thumbnail size
|
||||
if quality == "low":
|
||||
thumbnail_path = thumbnails.get("low")
|
||||
elif quality == "medium":
|
||||
thumbnail_path = thumbnails.get("medium")
|
||||
elif quality == "high":
|
||||
thumbnail_path = thumbnails.get("high")
|
||||
else:
|
||||
raise ValueError(f"Invalid quality level: {quality}")
|
||||
|
||||
# Fall back to original if thumbnail doesn't exist
|
||||
if not thumbnail_path:
|
||||
return image.storage_path
|
||||
|
||||
return thumbnail_path
|
||||
|
||||
|
||||
async def serve_image_with_quality(
|
||||
image: Image, quality: str = "medium", filename: str | None = None
|
||||
) -> StreamingResponse:
|
||||
"""
|
||||
Serve image with specified quality level.
|
||||
|
||||
Args:
|
||||
image: Image model instance
|
||||
quality: Quality level ('low', 'medium', 'high', 'original')
|
||||
filename: Optional custom filename for download
|
||||
|
||||
Returns:
|
||||
StreamingResponse with image data
|
||||
|
||||
Raises:
|
||||
HTTPException: If image cannot be served
|
||||
"""
|
||||
from app.images.download import download_single_image
|
||||
|
||||
try:
|
||||
# Get appropriate thumbnail path
|
||||
storage_path = get_thumbnail_path(image, quality)
|
||||
|
||||
# Use original filename if not specified
|
||||
if filename is None:
|
||||
filename = image.filename
|
||||
|
||||
# Serve the image
|
||||
return await download_single_image(storage_path, filename)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to serve image: {str(e)}",
|
||||
) from e
|
||||
|
||||
|
||||
def determine_quality_from_speed(speed_mbps: float) -> str:
|
||||
"""
|
||||
Determine appropriate quality level based on connection speed.
|
||||
|
||||
Args:
|
||||
speed_mbps: Connection speed in Mbps
|
||||
|
||||
Returns:
|
||||
Quality level string
|
||||
"""
|
||||
if speed_mbps < 1.0:
|
||||
return "low"
|
||||
elif speed_mbps < 5.0:
|
||||
return "medium"
|
||||
else:
|
||||
return "high"
|
||||
@@ -3,21 +3,11 @@
|
||||
import magic
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
|
||||
# Maximum file size: 50MB
|
||||
MAX_FILE_SIZE = 52_428_800
|
||||
|
||||
# Allowed MIME types
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
}
|
||||
|
||||
# Allowed file extensions
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
|
||||
from app.core.constants import (
|
||||
ALLOWED_EXTENSIONS,
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_IMAGE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
async def validate_image_file(file: UploadFile) -> bytes:
|
||||
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
|
||||
if file_size == 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
if file_size > MAX_IMAGE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / 1_048_576:.1f}MB",
|
||||
detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB",
|
||||
)
|
||||
|
||||
# Validate file extension
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api import auth, boards, images
|
||||
from app.api import auth, boards, export, groups, images, library, quality, sharing
|
||||
from app.core.config import settings
|
||||
from app.core.errors import WebRefException
|
||||
from app.core.logging import setup_logging
|
||||
@@ -84,7 +84,12 @@ async def root():
|
||||
# API routers
|
||||
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(export.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(library.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
app.include_router(quality.router, prefix=f"{settings.API_V1_PREFIX}")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Integration tests for authentication endpoints."""
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
378
backend/tests/api/test_bulk_operations.py
Normal file
378
backend/tests/api/test_bulk_operations.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Integration tests for bulk image operations."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_position_delta(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk updating positions with delta."""
|
||||
# Create board
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create images
|
||||
images = []
|
||||
board_images = []
|
||||
|
||||
for i in range(3):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100 * i, "y": 100 * i},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=i,
|
||||
)
|
||||
db.add(board_image)
|
||||
board_images.append(board_image)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Bulk update position
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(img.id) for img in images[:2]], # First 2 images
|
||||
"position_delta": {"dx": 50, "dy": 75},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 2
|
||||
assert data["failed_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk updating transformations."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
images = []
|
||||
for i in range(2):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Bulk update transformations
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(img.id) for img in images],
|
||||
"transformations": {
|
||||
"scale": 2.0,
|
||||
"rotation": 45,
|
||||
"opacity": 0.8,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_z_order_delta(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk updating Z-order with delta."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
images = []
|
||||
for i in range(3):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=i,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Bulk update Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(images[0].id), str(images[1].id)],
|
||||
"z_order_delta": 10,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_mixed_operations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk update with position, transformations, and z-order together."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
images = []
|
||||
for i in range(2):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Bulk update everything
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(img.id) for img in images],
|
||||
"position_delta": {"dx": 50, "dy": 50},
|
||||
"transformations": {"scale": 2.0},
|
||||
"z_order_delta": 5,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 2
|
||||
assert data["failed_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_non_existent_image(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk update with some non-existent images."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update with one valid and one invalid ID
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(image.id), str(uuid4())], # One valid, one invalid
|
||||
"transformations": {"scale": 2.0},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 1 # Only valid one updated
|
||||
assert data["failed_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk update on board not owned by user."""
|
||||
# Create another user
|
||||
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||
db.add(other_user)
|
||||
|
||||
# Create board owned by other user
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
title="Other Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
await db.commit()
|
||||
|
||||
# Try bulk update as current user
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [str(uuid4())],
|
||||
"transformations": {"scale": 2.0},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_update_empty_image_list(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test bulk update with empty image list."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
await db.commit()
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/bulk",
|
||||
json={
|
||||
"image_ids": [],
|
||||
"transformations": {"scale": 2.0},
|
||||
},
|
||||
)
|
||||
|
||||
# Should succeed with 0 updated
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["updated_count"] == 0
|
||||
|
||||
289
backend/tests/api/test_groups.py
Normal file
289
backend/tests/api/test_groups.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""Integration tests for group endpoints."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def test_create_group(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test creating a group with images."""
|
||||
# Create board
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create images
|
||||
images = []
|
||||
for i in range(3):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
|
||||
z_order=i,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Create group
|
||||
response = await client.post(
|
||||
f"/api/boards/{board.id}/groups",
|
||||
json={
|
||||
"name": "Test Group",
|
||||
"color": "#FF5733",
|
||||
"annotation": "Group annotation",
|
||||
"image_ids": [str(img.id) for img in images[:2]],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Group"
|
||||
assert data["color"] == "#FF5733"
|
||||
assert data["annotation"] == "Group annotation"
|
||||
assert data["member_count"] == 2
|
||||
|
||||
|
||||
async def test_list_groups(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test listing groups on a board."""
|
||||
from app.database.models.group import Group
|
||||
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create groups
|
||||
for i in range(3):
|
||||
group = Group(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
name=f"Group {i}",
|
||||
color=f"#FF573{i}",
|
||||
annotation=f"Annotation {i}",
|
||||
)
|
||||
db.add(group)
|
||||
|
||||
db.commit()
|
||||
|
||||
# List groups
|
||||
response = await client.get(f"/api/boards/{board.id}/groups")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 3
|
||||
assert data[0]["name"] == "Group 2" # Most recent first
|
||||
|
||||
|
||||
async def test_get_group(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test getting a specific group."""
|
||||
from app.database.models.group import Group
|
||||
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
group = Group(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
name="Test Group",
|
||||
color="#FF5733",
|
||||
annotation="Test annotation",
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
|
||||
# Get group
|
||||
response = await client.get(f"/api/boards/{board.id}/groups/{group.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Group"
|
||||
assert data["color"] == "#FF5733"
|
||||
|
||||
|
||||
async def test_update_group(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test updating group metadata."""
|
||||
from app.database.models.group import Group
|
||||
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
group = Group(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
name="Original Name",
|
||||
color="#FF5733",
|
||||
annotation="Original annotation",
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
|
||||
# Update group
|
||||
response = await client.patch(
|
||||
f"/api/boards/{board.id}/groups/{group.id}",
|
||||
json={
|
||||
"name": "Updated Name",
|
||||
"color": "#00FF00",
|
||||
"annotation": "Updated annotation",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Updated Name"
|
||||
assert data["color"] == "#00FF00"
|
||||
assert data["annotation"] == "Updated annotation"
|
||||
|
||||
|
||||
async def test_delete_group(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test deleting a group."""
|
||||
from app.database.models.group import Group
|
||||
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create image
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
# Create group
|
||||
group = Group(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
name="Test Group",
|
||||
color="#FF5733",
|
||||
)
|
||||
db.add(group)
|
||||
|
||||
# Add image to board and group
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={"scale": 1.0, "rotation": 0, "opacity": 1.0},
|
||||
z_order=0,
|
||||
group_id=group.id,
|
||||
)
|
||||
db.add(board_image)
|
||||
db.commit()
|
||||
|
||||
# Delete group
|
||||
response = await client.delete(f"/api/boards/{board.id}/groups/{group.id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify image is ungrouped
|
||||
db.refresh(board_image)
|
||||
assert board_image.group_id is None
|
||||
|
||||
|
||||
async def test_group_unauthorized_board(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test that users can't create groups on boards they don't own."""
|
||||
# Create another user
|
||||
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||
db.add(other_user)
|
||||
|
||||
# Create board owned by other user
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
title="Other Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
db.commit()
|
||||
|
||||
# Try to create group
|
||||
response = await client.post(
|
||||
f"/api/boards/{board.id}/groups",
|
||||
json={
|
||||
"name": "Test Group",
|
||||
"color": "#FF5733",
|
||||
"image_ids": [str(uuid4())],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404 # Board not found (for security)
|
||||
|
||||
|
||||
async def test_invalid_color_format(client: AsyncClient, test_user: User, db: Session):
|
||||
"""Test that invalid color formats are rejected."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
db.commit()
|
||||
|
||||
# Try with invalid color
|
||||
response = await client.post(
|
||||
f"/api/boards/{board.id}/groups",
|
||||
json={
|
||||
"name": "Test Group",
|
||||
"color": "red", # Invalid: not hex
|
||||
"image_ids": [str(uuid4())],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
221
backend/tests/api/test_image_delete.py
Normal file
221
backend/tests/api/test_image_delete.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Integration tests for image deletion endpoints."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_image_from_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test removing image from board (not deleting)."""
|
||||
# Create board and image
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
reference_count=1,
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Remove from board
|
||||
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test removing image that's not on the board."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
await db.commit()
|
||||
|
||||
# Try to remove (image not on board)
|
||||
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test removing image from board not owned by user."""
|
||||
# Create another user
|
||||
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||
db.add(other_user)
|
||||
|
||||
# Create board owned by other user
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
title="Other Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{other_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to remove as current user
|
||||
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permanent_delete_image(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test permanently deleting image from library."""
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
reference_count=0, # Not used on any boards
|
||||
)
|
||||
db.add(image)
|
||||
await db.commit()
|
||||
|
||||
# Delete permanently
|
||||
response = await client.delete(f"/api/images/{image.id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_delete_image_in_use(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that images in use cannot be permanently deleted."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
reference_count=1, # Used on a board
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to delete
|
||||
response = await client.delete(f"/api/images/{image.id}")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "still used" in response.json()["detail"].lower()
|
||||
|
||||
455
backend/tests/api/test_image_position.py
Normal file
455
backend/tests/api/test_image_position.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Integration tests for image position update endpoint."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image position on board."""
|
||||
# Create a board
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create an image
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
# Add image to board
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update position
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 250}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["position"]["y"] == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image transformations."""
|
||||
# Create board, image, and board_image
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update transformations
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={
|
||||
"transformations": {
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.8,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["transformations"]["scale"] == 1.5
|
||||
assert data["transformations"]["rotation"] == 45
|
||||
assert data["transformations"]["opacity"] == 0.8
|
||||
assert data["transformations"]["flipped_h"] is True
|
||||
assert data["transformations"]["greyscale"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image Z-order."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"z_order": 5},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["z_order"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_multiple_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating position, transformations, and z-order together."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update everything
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={
|
||||
"position": {"x": 300, "y": 400},
|
||||
"transformations": {"scale": 2.0, "rotation": 90},
|
||||
"z_order": 10,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["position"]["x"] == 300
|
||||
assert data["position"]["y"] == 400
|
||||
assert data["transformations"]["scale"] == 2.0
|
||||
assert data["transformations"]["rotation"] == 90
|
||||
assert data["z_order"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image that's not on the specified board."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update image that's not on board
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not on this board" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_invalid_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating with invalid position data."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update with missing y coordinate
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that other users cannot update images on boards they don't own."""
|
||||
# Create another user
|
||||
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||
db.add(other_user)
|
||||
|
||||
# Create board owned by other user
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
title="Other User's Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{other_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update as current user (should fail)
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_preserves_other_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that updating one field preserves others."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.9,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=3,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update only position
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Position should be updated
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["position"]["y"] == 200
|
||||
|
||||
# Other fields should be preserved
|
||||
assert data["transformations"]["scale"] == 1.5
|
||||
assert data["transformations"]["rotation"] == 45
|
||||
assert data["transformations"]["opacity"] == 0.9
|
||||
assert data["z_order"] == 3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Integration tests for image upload endpoints."""
|
||||
|
||||
import io
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
302
backend/tests/api/test_sharing.py
Normal file
302
backend/tests/api/test_sharing.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Tests for board sharing endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
|
||||
def test_create_share_link_view_only(client, auth_headers, test_board):
|
||||
"""Test creating a view-only share link."""
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["permission_level"] == "view-only"
|
||||
assert data["board_id"] == str(test_board.id)
|
||||
assert data["token"] is not None
|
||||
assert len(data["token"]) == 64
|
||||
assert data["is_revoked"] == False # noqa: E712
|
||||
assert data["access_count"] == 0
|
||||
|
||||
|
||||
def test_create_share_link_view_comment(client, auth_headers, test_board):
|
||||
"""Test creating a view-comment share link."""
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-comment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["permission_level"] == "view-comment"
|
||||
|
||||
|
||||
def test_create_share_link_with_expiration(client, auth_headers, test_board):
|
||||
"""Test creating a share link with expiration."""
|
||||
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only", "expires_at": expires_at},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["expires_at"] is not None
|
||||
|
||||
|
||||
def test_create_share_link_invalid_permission(client, auth_headers, test_board):
|
||||
"""Test creating share link with invalid permission level."""
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "invalid-permission"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
def test_create_share_link_unauthorized(client, test_board):
|
||||
"""Test creating share link without authentication."""
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_create_share_link_not_owner(client, other_auth_headers, test_board):
|
||||
"""Test creating share link for board user doesn't own."""
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=other_auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_list_share_links(client, auth_headers, test_board):
|
||||
"""Test listing all share links for a board."""
|
||||
# Create multiple share links
|
||||
client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-comment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) >= 2
|
||||
assert all("token" in link for link in data)
|
||||
|
||||
|
||||
def test_list_share_links_unauthorized(client, test_board):
|
||||
"""Test listing share links without authentication."""
|
||||
response = client.get(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_revoke_share_link(client, auth_headers, test_board):
|
||||
"""Test revoking a share link."""
|
||||
# Create a share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
link_id = create_response.json()["id"]
|
||||
|
||||
# Revoke it
|
||||
response = client.delete(
|
||||
f"/api/boards/{test_board.id}/share-links/{link_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# Verify it's revoked by listing
|
||||
list_response = client.get(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
headers=auth_headers,
|
||||
)
|
||||
revoked_link = next((link for link in list_response.json() if link["id"] == link_id), None)
|
||||
assert revoked_link is not None
|
||||
assert revoked_link["is_revoked"] == True # noqa: E712
|
||||
|
||||
|
||||
def test_revoke_share_link_not_found(client, auth_headers, test_board):
|
||||
"""Test revoking non-existent share link."""
|
||||
import uuid
|
||||
|
||||
fake_id = uuid.uuid4()
|
||||
response = client.delete(
|
||||
f"/api/boards/{test_board.id}/share-links/{fake_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
def test_access_shared_board(client, auth_headers, test_board):
|
||||
"""Test accessing a board via share link."""
|
||||
# Create share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = create_response.json()["token"]
|
||||
|
||||
# Access shared board (no auth required)
|
||||
response = client.get(f"/api/shared/{token}")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_board.id)
|
||||
assert data["title"] == test_board.title
|
||||
|
||||
|
||||
def test_access_shared_board_invalid_token(client):
|
||||
"""Test accessing board with invalid token."""
|
||||
response = client.get("/api/shared/invalid-token-12345")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_access_shared_board_revoked_token(client, auth_headers, test_board):
|
||||
"""Test accessing board with revoked token."""
|
||||
# Create and revoke share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = create_response.json()
|
||||
token = data["token"]
|
||||
link_id = data["id"]
|
||||
|
||||
client.delete(
|
||||
f"/api/boards/{test_board.id}/share-links/{link_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Try to access with revoked token
|
||||
response = client.get(f"/api/shared/{token}")
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_create_comment_on_shared_board(client, auth_headers, test_board):
|
||||
"""Test creating a comment via share link with view-comment permission."""
|
||||
# Create view-comment share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-comment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = create_response.json()["token"]
|
||||
|
||||
# Create comment (no auth required, just token)
|
||||
comment_data = {
|
||||
"author_name": "Test Viewer",
|
||||
"content": "This is a test comment",
|
||||
"position": {"x": 100, "y": 200},
|
||||
}
|
||||
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
data = response.json()
|
||||
assert data["author_name"] == "Test Viewer"
|
||||
assert data["content"] == "This is a test comment"
|
||||
assert data["position"]["x"] == 100
|
||||
|
||||
|
||||
def test_create_comment_view_only_permission_denied(client, auth_headers, test_board):
|
||||
"""Test creating comment with view-only permission fails."""
|
||||
# Create view-only share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = create_response.json()["token"]
|
||||
|
||||
# Try to create comment (should fail)
|
||||
comment_data = {
|
||||
"author_name": "Test Viewer",
|
||||
"content": "This should fail",
|
||||
}
|
||||
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_list_comments_on_shared_board(client, auth_headers, test_board):
|
||||
"""Test listing comments via share link."""
|
||||
# Create view-comment share link
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-comment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = create_response.json()["token"]
|
||||
|
||||
# Create a comment
|
||||
client.post(
|
||||
f"/api/shared/{token}/comments",
|
||||
json={"author_name": "Viewer 1", "content": "Comment 1"},
|
||||
)
|
||||
|
||||
# List comments
|
||||
response = client.get(f"/api/shared/{token}/comments")
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
assert data[0]["content"] == "Comment 1"
|
||||
|
||||
|
||||
def test_list_board_comments_as_owner(client, auth_headers, test_board):
|
||||
"""Test board owner listing all comments."""
|
||||
# Create share link and comment
|
||||
create_response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-comment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = create_response.json()["token"]
|
||||
client.post(
|
||||
f"/api/shared/{token}/comments",
|
||||
json={"author_name": "Viewer", "content": "Test comment"},
|
||||
)
|
||||
|
||||
# Owner lists comments
|
||||
response = client.get(
|
||||
f"/api/boards/{test_board.id}/comments",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
|
||||
|
||||
def test_token_uniqueness(client, auth_headers, test_board):
|
||||
"""Test that generated tokens are unique."""
|
||||
tokens = set()
|
||||
for _ in range(10):
|
||||
response = client.post(
|
||||
f"/api/boards/{test_board.id}/share-links",
|
||||
json={"permission_level": "view-only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
token = response.json()["token"]
|
||||
tokens.add(token)
|
||||
|
||||
# All tokens should be unique
|
||||
assert len(tokens) == 10
|
||||
|
||||
299
backend/tests/api/test_z_order.py
Normal file
299
backend/tests/api/test_z_order.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Integration tests for Z-order persistence."""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.board_image import BoardImage
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating Z-order of an image."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"z_order": 5},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["z_order"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_z_order_persists_across_requests(
|
||||
client: AsyncClient, test_user: User, db: AsyncSession
|
||||
):
|
||||
"""Test that Z-order changes persist."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update Z-order
|
||||
await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"z_order": 10},
|
||||
)
|
||||
|
||||
# Fetch board images to verify persistence
|
||||
response = await client.get(f"/api/images/boards/{board.id}/images")
|
||||
|
||||
assert response.status_code == 200
|
||||
board_images = response.json()
|
||||
assert len(board_images) == 1
|
||||
assert board_images[0]["z_order"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_images_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test Z-order with multiple images."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
images = []
|
||||
for i in range(3):
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename=f"test{i}.jpg",
|
||||
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||
)
|
||||
db.add(image)
|
||||
images.append(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=i,
|
||||
)
|
||||
db.add(board_image)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Update Z-order of middle image to be highest
|
||||
await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{images[1].id}",
|
||||
json={"z_order": 10},
|
||||
)
|
||||
|
||||
# Verify
|
||||
response = await client.get(f"/api/images/boards/{board.id}/images")
|
||||
board_images = response.json()
|
||||
|
||||
# Find the updated image
|
||||
updated = next((bi for bi in board_images if str(bi["image_id"]) == str(images[1].id)), None)
|
||||
assert updated is not None
|
||||
assert updated["z_order"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_z_order_negative_value(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that negative Z-order is allowed (for layering below 0)."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Set negative Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"z_order": -1},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["z_order"] == -1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_z_order_with_other_updates(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating Z-order along with position and transformations."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update everything including Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={
|
||||
"position": {"x": 200, "y": 200},
|
||||
"transformations": {"scale": 2.0},
|
||||
"z_order": 15,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["transformations"]["scale"] == 2.0
|
||||
assert data["z_order"] == 15
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from jose import jwt
|
||||
|
||||
from app.auth.jwt import create_access_token, decode_access_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for password hashing and validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.security import hash_password, validate_password_strength, verify_password
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Pytest configuration and fixtures for all tests."""
|
||||
|
||||
import os
|
||||
from typing import Generator
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -105,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
|
||||
"""
|
||||
return {"email": "test@example.com", "password": "testpassword123"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(client: TestClient, test_user_data: dict):
|
||||
"""
|
||||
Create and return a test user.
|
||||
|
||||
Args:
|
||||
client: Test client
|
||||
test_user_data: User credentials
|
||||
|
||||
Returns:
|
||||
User object
|
||||
"""
|
||||
from app.database.models.user import User
|
||||
|
||||
response = client.post("/api/v1/auth/register", json=test_user_data)
|
||||
user_id = response.json()["id"]
|
||||
|
||||
# Get user from database (use same db session)
|
||||
from app.core.deps import get_db
|
||||
|
||||
db_gen = next(app.dependency_overrides[get_db]())
|
||||
user = db_gen.query(User).filter(User.id == user_id).first()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client: TestClient, test_user_data: dict) -> dict:
|
||||
"""
|
||||
Create authenticated headers with JWT token.
|
||||
|
||||
Args:
|
||||
client: Test client
|
||||
test_user_data: User credentials
|
||||
|
||||
Returns:
|
||||
Dictionary with Authorization header
|
||||
"""
|
||||
# Register and login
|
||||
client.post("/api/v1/auth/register", json=test_user_data)
|
||||
login_response = client.post("/api/v1/auth/login", json=test_user_data)
|
||||
token = login_response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_user_data() -> dict:
|
||||
"""
|
||||
Data for a second test user.
|
||||
|
||||
Returns:
|
||||
Dictionary with test user credentials
|
||||
"""
|
||||
return {"email": "other@example.com", "password": "OtherPassword123"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def other_auth_headers(client: TestClient, other_user_data: dict) -> dict:
|
||||
"""
|
||||
Create authenticated headers for a second user.
|
||||
|
||||
Args:
|
||||
client: Test client
|
||||
other_user_data: Other user credentials
|
||||
|
||||
Returns:
|
||||
Dictionary with Authorization header
|
||||
"""
|
||||
# Register and login
|
||||
client.post("/api/v1/auth/register", json=other_user_data)
|
||||
login_response = client.post("/api/v1/auth/login", json=other_user_data)
|
||||
token = login_response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_board(client: TestClient, auth_headers: dict):
|
||||
"""
|
||||
Create a test board.
|
||||
|
||||
Args:
|
||||
client: Test client
|
||||
auth_headers: Authentication headers
|
||||
|
||||
Returns:
|
||||
Board object
|
||||
"""
|
||||
from app.database.models.board import Board
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/boards",
|
||||
json={"title": "Test Board", "description": "Test description"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
board_id = response.json()["id"]
|
||||
|
||||
# Get board from database
|
||||
from app.core.deps import get_db
|
||||
|
||||
db_gen = next(app.dependency_overrides[get_db]())
|
||||
board = db_gen.query(Board).filter(Board.id == board_id).first()
|
||||
return board
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import io
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from app.images.processing import generate_thumbnails
|
||||
|
||||
236
backend/tests/images/test_transformations.py
Normal file
236
backend/tests/images/test_transformations.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Tests for image transformation validation."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.images.schemas import BoardImageUpdate
|
||||
|
||||
|
||||
def test_valid_transformations():
|
||||
"""Test that valid transformations are accepted."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.8,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations is not None
|
||||
assert data.transformations["scale"] == 1.5
|
||||
assert data.transformations["rotation"] == 45
|
||||
assert data.transformations["opacity"] == 0.8
|
||||
assert data.transformations["flipped_h"] is True
|
||||
assert data.transformations["greyscale"] is False
|
||||
|
||||
|
||||
def test_minimal_transformations():
|
||||
"""Test that minimal transformation data is accepted."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations is not None
|
||||
|
||||
|
||||
def test_transformation_scale_bounds():
|
||||
"""Test scale bounds validation."""
|
||||
# Valid scales
|
||||
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
|
||||
|
||||
for scale in valid_scales:
|
||||
data = BoardImageUpdate(transformations={"scale": scale})
|
||||
assert data.transformations["scale"] == scale
|
||||
|
||||
|
||||
def test_transformation_rotation_bounds():
|
||||
"""Test rotation bounds (any value allowed, normalized client-side)."""
|
||||
# Various rotation values
|
||||
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
|
||||
|
||||
for rotation in rotations:
|
||||
data = BoardImageUpdate(transformations={"rotation": rotation})
|
||||
assert data.transformations["rotation"] == rotation
|
||||
|
||||
|
||||
def test_transformation_opacity_bounds():
|
||||
"""Test opacity bounds."""
|
||||
# Valid opacity values
|
||||
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||
|
||||
for opacity in valid_opacities:
|
||||
data = BoardImageUpdate(transformations={"opacity": opacity})
|
||||
assert data.transformations["opacity"] == opacity
|
||||
|
||||
|
||||
def test_transformation_boolean_flags():
|
||||
"""Test boolean transformation flags."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"flipped_h": True,
|
||||
"flipped_v": True,
|
||||
"greyscale": True,
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations["flipped_h"] is True
|
||||
assert data.transformations["flipped_v"] is True
|
||||
assert data.transformations["greyscale"] is True
|
||||
|
||||
|
||||
def test_transformation_crop_data():
|
||||
"""Test crop transformation data."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"crop": {
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 100,
|
||||
"height": 100,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations["crop"] is not None
|
||||
assert data.transformations["crop"]["x"] == 10
|
||||
assert data.transformations["crop"]["width"] == 100
|
||||
|
||||
|
||||
def test_transformation_null_crop():
|
||||
"""Test that crop can be null (no crop)."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"crop": None,
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations["crop"] is None
|
||||
|
||||
|
||||
def test_partial_transformation_update():
|
||||
"""Test updating only some transformation fields."""
|
||||
# Only update scale
|
||||
data = BoardImageUpdate(transformations={"scale": 2.0})
|
||||
assert data.transformations["scale"] == 2.0
|
||||
|
||||
# Only update rotation
|
||||
data = BoardImageUpdate(transformations={"rotation": 90})
|
||||
assert data.transformations["rotation"] == 90
|
||||
|
||||
# Only update opacity
|
||||
data = BoardImageUpdate(transformations={"opacity": 0.5})
|
||||
assert data.transformations["opacity"] == 0.5
|
||||
|
||||
|
||||
def test_complete_transformation_update():
|
||||
"""Test updating all transformation fields."""
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.8,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": True,
|
||||
"crop": {
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
"width": 150,
|
||||
"height": 150,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert data.transformations is not None
|
||||
assert len(data.transformations) == 7
|
||||
|
||||
|
||||
def test_position_validation_with_transformations():
|
||||
"""Test that position and transformations can be updated together."""
|
||||
data = BoardImageUpdate(
|
||||
position={"x": 100, "y": 200},
|
||||
transformations={"scale": 1.5, "rotation": 45},
|
||||
)
|
||||
|
||||
assert data.position == {"x": 100, "y": 200}
|
||||
assert data.transformations["scale"] == 1.5
|
||||
assert data.transformations["rotation"] == 45
|
||||
|
||||
|
||||
def test_invalid_position_missing_x():
|
||||
"""Test that position without x coordinate is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
BoardImageUpdate(position={"y": 100})
|
||||
|
||||
assert "must contain 'x' and 'y'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_position_missing_y():
|
||||
"""Test that position without y coordinate is rejected."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
BoardImageUpdate(position={"x": 100})
|
||||
|
||||
assert "must contain 'x' and 'y'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_z_order_update():
|
||||
"""Test Z-order update."""
|
||||
data = BoardImageUpdate(z_order=5)
|
||||
assert data.z_order == 5
|
||||
|
||||
# Negative Z-order allowed (layering)
|
||||
data = BoardImageUpdate(z_order=-1)
|
||||
assert data.z_order == -1
|
||||
|
||||
# Large Z-order allowed
|
||||
data = BoardImageUpdate(z_order=999999)
|
||||
assert data.z_order == 999999
|
||||
|
||||
|
||||
def test_group_id_update():
|
||||
"""Test group ID update."""
|
||||
from uuid import uuid4
|
||||
|
||||
group_id = uuid4()
|
||||
data = BoardImageUpdate(group_id=group_id)
|
||||
assert data.group_id == group_id
|
||||
|
||||
# Null group ID (remove from group)
|
||||
data = BoardImageUpdate(group_id=None)
|
||||
assert data.group_id is None
|
||||
|
||||
|
||||
def test_empty_update():
|
||||
"""Test that empty update (no fields) is valid."""
|
||||
data = BoardImageUpdate()
|
||||
|
||||
assert data.position is None
|
||||
assert data.transformations is None
|
||||
assert data.z_order is None
|
||||
assert data.group_id is None
|
||||
|
||||
|
||||
def test_transformation_data_types():
|
||||
"""Test that transformation data types are validated."""
|
||||
# Valid types
|
||||
data = BoardImageUpdate(
|
||||
transformations={
|
||||
"scale": 1.5, # float
|
||||
"rotation": 45, # int (converted to float)
|
||||
"opacity": 0.8, # float
|
||||
"flipped_h": True, # bool
|
||||
"flipped_v": False, # bool
|
||||
"greyscale": True, # bool
|
||||
}
|
||||
)
|
||||
|
||||
assert isinstance(data.transformations["scale"], (int, float))
|
||||
assert isinstance(data.transformations["flipped_h"], bool)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for file validation."""
|
||||
|
||||
import io
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
76
flake.nix
76
flake.nix
@@ -31,7 +31,8 @@
|
||||
alembic
|
||||
pydantic
|
||||
pydantic-settings # Settings management
|
||||
psycopg2 # PostgreSQL driver
|
||||
psycopg2 # PostgreSQL driver (sync)
|
||||
asyncpg # PostgreSQL driver (async)
|
||||
# Auth & Security
|
||||
python-jose
|
||||
passlib
|
||||
@@ -88,6 +89,7 @@
|
||||
# Development tools
|
||||
git
|
||||
direnv
|
||||
tmux
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
@@ -105,6 +107,7 @@
|
||||
echo " Status: ./scripts/dev-services.sh status"
|
||||
echo ""
|
||||
echo "📚 Quick Commands:"
|
||||
echo " Dev (tmux): nix run .#dev"
|
||||
echo " Backend: cd backend && uvicorn app.main:app --reload"
|
||||
echo " Frontend: cd frontend && npm run dev"
|
||||
echo " Database: psql -h localhost -U webref webref"
|
||||
@@ -131,6 +134,7 @@
|
||||
type = "app";
|
||||
program = "${pkgs.writeShellScript "help" ''
|
||||
echo "Available commands:"
|
||||
echo " nix run .#dev - Start backend + frontend in tmux"
|
||||
echo " nix run .#lint - Run all linting checks"
|
||||
echo " nix run .#lint-backend - Run backend linting only"
|
||||
echo " nix run .#lint-frontend - Run frontend linting only"
|
||||
@@ -138,6 +142,76 @@
|
||||
''}";
|
||||
};
|
||||
|
||||
# Development runner with tmux
|
||||
dev = {
|
||||
type = "app";
|
||||
program = "${pkgs.writeShellScript "dev-tmux" ''
|
||||
set -e
|
||||
|
||||
# Check if we're in the project root
|
||||
if [ ! -d "backend" ] || [ ! -d "frontend" ]; then
|
||||
echo "❌ Error: Not in project root directory"
|
||||
echo "Please run this command from the webref project root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend dependencies are installed
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
echo "📦 Installing frontend dependencies..."
|
||||
cd frontend
|
||||
${pkgs.nodejs}/bin/npm install
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Set environment variables
|
||||
export DATABASE_URL="postgresql://webref@localhost:5432/webref"
|
||||
export MINIO_ENDPOINT="localhost:9000"
|
||||
export MINIO_ACCESS_KEY="minioadmin"
|
||||
export MINIO_SECRET_KEY="minioadmin"
|
||||
export PYTHONPATH="$PWD/backend:$PYTHONPATH"
|
||||
export PATH="${pythonEnv}/bin:${pkgs.nodejs}/bin:$PATH"
|
||||
|
||||
# Session name
|
||||
SESSION_NAME="webref-dev"
|
||||
|
||||
# Kill existing session if it exists
|
||||
${pkgs.tmux}/bin/tmux has-session -t $SESSION_NAME 2>/dev/null && ${pkgs.tmux}/bin/tmux kill-session -t $SESSION_NAME
|
||||
|
||||
echo "🚀 Starting development environment in tmux..."
|
||||
echo ""
|
||||
echo "📋 Tmux Controls:"
|
||||
echo " Switch panes: Ctrl+b → arrow keys"
|
||||
echo " Scroll mode: Ctrl+b → ["
|
||||
echo " Exit scroll: q"
|
||||
echo " Detach session: Ctrl+b → d"
|
||||
echo " Kill session: Ctrl+b → :kill-session"
|
||||
echo ""
|
||||
echo "Starting in 2 seconds..."
|
||||
sleep 2
|
||||
|
||||
# Create new tmux session with backend
|
||||
${pkgs.tmux}/bin/tmux new-session -d -s "$SESSION_NAME" -n "webref" -c "$PWD/backend" \
|
||||
"printf '\n🐍 Starting Backend (uvicorn)...\n\n' && ${pythonEnv}/bin/uvicorn app.main:app --reload --host 0.0.0.0 --port 8000; read -p 'Backend stopped. Press Enter to exit...'"
|
||||
|
||||
# Split window vertically and run frontend
|
||||
${pkgs.tmux}/bin/tmux split-window -h -t "$SESSION_NAME":0 -c "$PWD/frontend" \
|
||||
"printf '\n⚡ Starting Frontend (Vite)...\n\n' && ${pkgs.nodejs}/bin/npm run dev; read -p 'Frontend stopped. Press Enter to exit...'"
|
||||
|
||||
# Set pane titles
|
||||
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0 -T "Backend (uvicorn)"
|
||||
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.1 -T "Frontend (vite)"
|
||||
|
||||
# Balance panes
|
||||
${pkgs.tmux}/bin/tmux select-layout -t "$SESSION_NAME":0 even-horizontal
|
||||
|
||||
# Focus on backend pane
|
||||
${pkgs.tmux}/bin/tmux select-pane -t "$SESSION_NAME":0.0
|
||||
|
||||
# Attach to session
|
||||
${pkgs.tmux}/bin/tmux attach-session -t "$SESSION_NAME"
|
||||
''}";
|
||||
};
|
||||
|
||||
# Unified linting - calls both backend and frontend lints
|
||||
lint = {
|
||||
type = "app";
|
||||
|
||||
@@ -4,28 +4,28 @@ module.exports = {
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'prettier'
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
extraFileExtensions: ['.svelte'],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
// TypeScript rules
|
||||
@@ -33,18 +33,18 @@ module.exports = {
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_'
|
||||
}
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
|
||||
// General rules
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
|
||||
|
||||
// Svelte specific
|
||||
'svelte/no-at-html-tags': 'error',
|
||||
'svelte/no-target-blank': 'error'
|
||||
}
|
||||
'svelte/no-target-blank': 'error',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ export default [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
1262
frontend/package-lock.json
generated
1262
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@testing-library/svelte": "^5.2.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
@@ -26,6 +27,7 @@
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"jsdom": "^27.1.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.0",
|
||||
@@ -38,8 +40,14 @@
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"konva": "^9.3.0",
|
||||
"globals": "^15.0.0"
|
||||
"globals": "^15.0.0",
|
||||
"konva": "^9.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"cookie": ">=0.7.0",
|
||||
"inflight": "npm:@apteryxxyz/inflight@^2.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"esbuild": "^0.25.12"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,10 @@ export interface ApiError {
|
||||
status_code: number;
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -20,16 +24,17 @@ export class ApiClient {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const { token } = get(authStore);
|
||||
const { skipAuth, ...fetchOptions } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...((options.headers as Record<string, string>) || {}),
|
||||
...((fetchOptions.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
// Add authentication token if available
|
||||
if (token) {
|
||||
// Add authentication token if available and not skipped
|
||||
if (token && !skipAuth) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -37,7 +42,7 @@ export class ApiClient {
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
@@ -74,11 +79,11 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
@@ -86,7 +91,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
@@ -94,7 +99,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
|
||||
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
@@ -102,7 +107,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -127,18 +132,34 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw error;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
const error: ApiError = {
|
||||
error: errorData.detail || errorData.error || 'Upload failed',
|
||||
details: errorData.details,
|
||||
status_code: response.status,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
if ((error as ApiError).status_code) {
|
||||
throw error;
|
||||
}
|
||||
throw {
|
||||
error: (error as Error).message || 'Upload failed',
|
||||
status_code: 0,
|
||||
} as ApiError;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
123
frontend/src/lib/api/export.ts
Normal file
123
frontend/src/lib/api/export.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Export API client for downloading and exporting board content.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ExportInfo {
|
||||
board_id: string;
|
||||
image_count: number;
|
||||
total_size_bytes: number;
|
||||
estimated_zip_size_bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single image.
|
||||
*
|
||||
* @param imageId - Image UUID
|
||||
*/
|
||||
export async function downloadImage(imageId: string): Promise<void> {
|
||||
const response = await fetch(`/api/v1/images/${imageId}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download image');
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'download';
|
||||
if (contentDisposition) {
|
||||
const matches = /filename="([^"]+)"/.exec(contentDisposition);
|
||||
if (matches) {
|
||||
filename = matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Download the file
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export board as ZIP file containing all images.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
*/
|
||||
export async function exportBoardZip(boardId: string): Promise<void> {
|
||||
const response = await fetch(`/api/v1/boards/${boardId}/export/zip`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export board as ZIP');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, 'board_export.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export board as a composite image.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @param scale - Resolution scale (1x, 2x, 4x)
|
||||
* @param format - Output format (PNG or JPEG)
|
||||
*/
|
||||
export async function exportBoardComposite(
|
||||
boardId: string,
|
||||
scale: number = 1.0,
|
||||
format: 'PNG' | 'JPEG' = 'PNG'
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`/api/v1/boards/${boardId}/export/composite?scale=${scale}&format=${format}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export board as composite image');
|
||||
}
|
||||
|
||||
const extension = format === 'PNG' ? 'png' : 'jpg';
|
||||
const blob = await response.blob();
|
||||
downloadBlob(blob, `board_composite.${extension}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get export information for a board.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @returns Export information
|
||||
*/
|
||||
export async function getExportInfo(boardId: string): Promise<ExportInfo> {
|
||||
return apiClient.get<ExportInfo>(`/boards/${boardId}/export/info`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to trigger download of a blob.
|
||||
*
|
||||
* @param blob - Blob to download
|
||||
* @param filename - Filename for download
|
||||
*/
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
69
frontend/src/lib/api/groups.ts
Normal file
69
frontend/src/lib/api/groups.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Groups API client
|
||||
* Handles group creation, update, deletion
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface GroupCreateData {
|
||||
name: string;
|
||||
color: string;
|
||||
annotation?: string;
|
||||
image_ids: string[];
|
||||
}
|
||||
|
||||
export interface GroupUpdateData {
|
||||
name?: string;
|
||||
color?: string;
|
||||
annotation?: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
annotation: string | null;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
|
||||
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all groups on a board
|
||||
*/
|
||||
export async function listGroups(boardId: string): Promise<Group[]> {
|
||||
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific group
|
||||
*/
|
||||
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
|
||||
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update group metadata
|
||||
*/
|
||||
export async function updateGroup(
|
||||
boardId: string,
|
||||
groupId: string,
|
||||
data: GroupUpdateData
|
||||
): Promise<Group> {
|
||||
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group (ungroups all members)
|
||||
*/
|
||||
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
|
||||
}
|
||||
@@ -9,32 +9,14 @@ import type { Image, BoardImage, ImageListResponse } from '$lib/types/images';
|
||||
* Upload a single image
|
||||
*/
|
||||
export async function uploadImage(file: File): Promise<Image> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<Image>('/images/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
return await apiClient.uploadFile<Image>('/images/upload', file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple images from a ZIP file
|
||||
*/
|
||||
export async function uploadZip(file: File): Promise<Image[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<Image[]>('/images/upload-zip', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
return await apiClient.uploadFile<Image[]>('/images/upload-zip', file);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,3 +87,19 @@ export async function removeImageFromBoard(boardId: string, imageId: string): Pr
|
||||
export async function getBoardImages(boardId: string): Promise<BoardImage[]> {
|
||||
return await apiClient.get<BoardImage[]>(`/images/boards/${boardId}/images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update board image position/transformations
|
||||
*/
|
||||
export async function updateBoardImage(
|
||||
boardId: string,
|
||||
imageId: string,
|
||||
updates: {
|
||||
position?: { x: number; y: number };
|
||||
transformations?: Record<string, unknown>;
|
||||
z_order?: number;
|
||||
group_id?: string;
|
||||
}
|
||||
): Promise<BoardImage> {
|
||||
return await apiClient.patch<BoardImage>(`/images/boards/${boardId}/images/${imageId}`, updates);
|
||||
}
|
||||
|
||||
92
frontend/src/lib/api/library.ts
Normal file
92
frontend/src/lib/api/library.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Image library API client.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface LibraryImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
reference_count: number;
|
||||
created_at: string;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export interface LibraryListResponse {
|
||||
images: LibraryImage[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
total_images: number;
|
||||
total_size_bytes: number;
|
||||
total_board_references: number;
|
||||
average_references_per_image: number;
|
||||
}
|
||||
|
||||
export interface AddToBoardRequest {
|
||||
board_id: string;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* List images in user's library.
|
||||
*
|
||||
* @param query - Optional search query
|
||||
* @param limit - Results per page
|
||||
* @param offset - Pagination offset
|
||||
* @returns Library image list with pagination info
|
||||
*/
|
||||
export async function listLibraryImages(
|
||||
query?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<LibraryListResponse> {
|
||||
let url = `/library/images?limit=${limit}&offset=${offset}`;
|
||||
if (query) {
|
||||
url += `&query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
return apiClient.get<LibraryListResponse>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a library image to a board.
|
||||
*
|
||||
* @param imageId - Image UUID
|
||||
* @param request - Add to board request data
|
||||
* @returns Response with new board image ID
|
||||
*/
|
||||
export async function addImageToBoard(
|
||||
imageId: string,
|
||||
request: AddToBoardRequest
|
||||
): Promise<{ id: string; message: string }> {
|
||||
return apiClient.post<{ id: string; message: string }>(
|
||||
`/library/images/${imageId}/add-to-board`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an image from library.
|
||||
* This removes it from all boards and deletes the file.
|
||||
*
|
||||
* @param imageId - Image UUID
|
||||
*/
|
||||
export async function deleteLibraryImage(imageId: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/library/images/${imageId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get library statistics.
|
||||
*
|
||||
* @returns Library statistics
|
||||
*/
|
||||
export async function getLibraryStats(): Promise<LibraryStats> {
|
||||
return apiClient.get<LibraryStats>('/library/stats');
|
||||
}
|
||||
142
frontend/src/lib/api/sharing.ts
Normal file
142
frontend/src/lib/api/sharing.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Sharing API client for board sharing and comments.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ShareLink {
|
||||
id: string;
|
||||
board_id: string;
|
||||
token: string;
|
||||
permission_level: 'view-only' | 'view-comment';
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_accessed_at: string | null;
|
||||
access_count: number;
|
||||
is_revoked: boolean;
|
||||
}
|
||||
|
||||
export interface ShareLinkCreate {
|
||||
permission_level: 'view-only' | 'view-comment';
|
||||
expires_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
board_id: string;
|
||||
share_link_id: string | null;
|
||||
author_name: string;
|
||||
content: string;
|
||||
position: { x: number; y: number } | null;
|
||||
created_at: string;
|
||||
is_deleted: boolean;
|
||||
}
|
||||
|
||||
export interface CommentCreate {
|
||||
author_name: string;
|
||||
content: string;
|
||||
position?: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new share link for a board.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @param data - Share link creation data
|
||||
* @returns Created share link
|
||||
*/
|
||||
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
|
||||
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all share links for a board.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @returns Array of share links
|
||||
*/
|
||||
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
|
||||
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a share link.
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @param linkId - Share link UUID
|
||||
*/
|
||||
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
|
||||
}
|
||||
|
||||
export interface SharedBoard {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
viewport_state: {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
rotation: number;
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_deleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shared board via token (no authentication required).
|
||||
*
|
||||
* @param token - Share link token
|
||||
* @returns Board details
|
||||
*/
|
||||
export async function getSharedBoard(token: string): Promise<SharedBoard> {
|
||||
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a comment on a shared board.
|
||||
*
|
||||
* @param token - Share link token
|
||||
* @param data - Comment data
|
||||
* @returns Created comment
|
||||
*/
|
||||
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
|
||||
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List comments on a shared board.
|
||||
*
|
||||
* @param token - Share link token
|
||||
* @returns Array of comments
|
||||
*/
|
||||
export async function listComments(token: string): Promise<Comment[]> {
|
||||
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
|
||||
skipAuth: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all comments on a board (owner view).
|
||||
*
|
||||
* @param boardId - Board UUID
|
||||
* @returns Array of comments
|
||||
*/
|
||||
export async function listBoardComments(boardId: string): Promise<Comment[]> {
|
||||
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a shareable URL for a given token.
|
||||
*
|
||||
* @param token - Share link token
|
||||
* @returns Full shareable URL
|
||||
*/
|
||||
export function getShareUrl(token: string): string {
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return `${baseUrl}/shared/${token}`;
|
||||
}
|
||||
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
107
frontend/src/lib/canvas/GroupVisual.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Group visual indicator for canvas
|
||||
* Draws visual borders and labels for grouped images
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import type { Group } from '$lib/api/groups';
|
||||
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let group: Group;
|
||||
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
let groupVisual: Konva.Group | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Create group visual
|
||||
groupVisual = new Konva.Group({
|
||||
listening: false,
|
||||
name: `group-visual-${group.id}`,
|
||||
});
|
||||
|
||||
layer.add(groupVisual);
|
||||
updateVisual();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (groupVisual) {
|
||||
groupVisual.destroy();
|
||||
groupVisual = null;
|
||||
}
|
||||
if (layer) {
|
||||
layer.batchDraw();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update group visual based on member positions
|
||||
*/
|
||||
export function updateVisual() {
|
||||
if (!groupVisual || !layer) return;
|
||||
|
||||
// Clear existing visuals
|
||||
groupVisual.destroyChildren();
|
||||
|
||||
const bounds = getGroupBounds();
|
||||
if (!bounds) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw group border
|
||||
const border = new Konva.Rect({
|
||||
x: bounds.x - 10,
|
||||
y: bounds.y - 10,
|
||||
width: bounds.width + 20,
|
||||
height: bounds.height + 20,
|
||||
stroke: group.color,
|
||||
strokeWidth: 3,
|
||||
dash: [10, 5],
|
||||
cornerRadius: 8,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
groupVisual.add(border);
|
||||
|
||||
// Draw group label
|
||||
const labelBg = new Konva.Rect({
|
||||
x: bounds.x - 10,
|
||||
y: bounds.y - 35,
|
||||
height: 24,
|
||||
fill: group.color,
|
||||
cornerRadius: 4,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const labelText = new Konva.Text({
|
||||
x: bounds.x - 5,
|
||||
y: bounds.y - 31,
|
||||
text: group.name,
|
||||
fontSize: 14,
|
||||
fontStyle: 'bold',
|
||||
fill: '#ffffff',
|
||||
listening: false,
|
||||
});
|
||||
|
||||
// Adjust background width to fit text
|
||||
labelBg.width(labelText.width() + 10);
|
||||
|
||||
groupVisual.add(labelBg);
|
||||
groupVisual.add(labelText);
|
||||
|
||||
// Move to bottom so it doesn't cover images
|
||||
groupVisual.moveToBottom();
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
// Reactive updates
|
||||
$: if (group && groupVisual) {
|
||||
updateVisual();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
270
frontend/src/lib/canvas/Image.svelte
Normal file
270
frontend/src/lib/canvas/Image.svelte
Normal file
@@ -0,0 +1,270 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Konva Image wrapper component for canvas images
|
||||
* Wraps a Konva.Image with selection, dragging, and transformation support
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import { isImageSelected } from '$lib/stores/selection';
|
||||
import { setupImageDrag } from './interactions/drag';
|
||||
import { setupImageSelection } from './interactions/select';
|
||||
import { activeQuality } from '$lib/stores/quality';
|
||||
import { getAdaptiveThumbnailUrl } from '$lib/utils/adaptive-quality';
|
||||
|
||||
// Props
|
||||
export let id: string; // Board image ID
|
||||
export let imageId: string; // Image UUID for quality-based loading
|
||||
export let imageUrl: string;
|
||||
export let x: number = 0;
|
||||
export let y: number = 0;
|
||||
export let width: number = 100;
|
||||
export let height: number = 100;
|
||||
export let rotation: number = 0;
|
||||
export let scaleX: number = 1;
|
||||
export let scaleY: number = 1;
|
||||
export let opacity: number = 1;
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let zOrder: number = 0;
|
||||
|
||||
// Callbacks
|
||||
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
|
||||
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
|
||||
export let onImageLoaded: ((id: string) => void) | undefined = undefined;
|
||||
|
||||
let imageNode: Konva.Image | null = null;
|
||||
let imageGroup: Konva.Group | null = null;
|
||||
let imageObj: HTMLImageElement | null = null;
|
||||
let cleanupDrag: (() => void) | null = null;
|
||||
let cleanupSelection: (() => void) | null = null;
|
||||
let unsubscribeSelection: (() => void) | null = null;
|
||||
let isFullResolution: boolean = false;
|
||||
|
||||
// Subscribe to selection state for this image
|
||||
$: isSelected = isImageSelected(id);
|
||||
|
||||
// Subscribe to quality changes
|
||||
$: {
|
||||
if (imageId && !isFullResolution) {
|
||||
const newUrl = getAdaptiveThumbnailUrl(imageId);
|
||||
if (imageObj && imageObj.src !== newUrl) {
|
||||
loadImageWithQuality($activeQuality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Load image
|
||||
imageObj = new Image();
|
||||
imageObj.crossOrigin = 'Anonymous';
|
||||
imageObj.onload = () => {
|
||||
if (!layer || !imageObj) return;
|
||||
|
||||
// Create Konva image
|
||||
imageNode = new Konva.Image({
|
||||
image: imageObj!,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
listening: true,
|
||||
});
|
||||
|
||||
// Create group for image and selection box
|
||||
imageGroup = new Konva.Group({
|
||||
x,
|
||||
y,
|
||||
rotation,
|
||||
scaleX,
|
||||
scaleY,
|
||||
opacity,
|
||||
draggable: true,
|
||||
id: `image-group-${id}`,
|
||||
});
|
||||
|
||||
imageGroup.add(imageNode);
|
||||
|
||||
// Add to layer first
|
||||
layer.add(imageGroup);
|
||||
|
||||
// Then set Z-index (must have parent first)
|
||||
imageGroup.zIndex(zOrder);
|
||||
|
||||
// Setup interactions
|
||||
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
|
||||
if (onDragEnd) {
|
||||
onDragEnd(imageId, newX, newY);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSelection = setupImageSelection(imageGroup, id, undefined, (imageId, _selected) => {
|
||||
updateSelectionVisual();
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, _selected);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to selection changes for visual updates
|
||||
unsubscribeSelection = isSelected.subscribe((_selected) => {
|
||||
updateSelectionVisual();
|
||||
});
|
||||
|
||||
// Initial draw
|
||||
layer.batchDraw();
|
||||
|
||||
// Force visibility by triggering multiple redraws
|
||||
requestAnimationFrame(() => {
|
||||
if (layer) layer.batchDraw();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (layer) layer.batchDraw();
|
||||
}, 50);
|
||||
|
||||
// Notify parent that image loaded
|
||||
if (onImageLoaded) {
|
||||
onImageLoaded(id);
|
||||
}
|
||||
};
|
||||
|
||||
imageObj.onerror = () => {
|
||||
console.error('Failed to load image:', imageUrl);
|
||||
};
|
||||
|
||||
imageObj.src = imageUrl;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up event listeners
|
||||
if (cleanupDrag) cleanupDrag();
|
||||
if (cleanupSelection) cleanupSelection();
|
||||
if (unsubscribeSelection) unsubscribeSelection();
|
||||
|
||||
// Destroy Konva nodes
|
||||
if (imageNode) imageNode.destroy();
|
||||
if (imageGroup) imageGroup.destroy();
|
||||
|
||||
// Redraw layer
|
||||
if (layer) layer.batchDraw();
|
||||
});
|
||||
|
||||
/**
|
||||
* Update selection visual (highlight border)
|
||||
*/
|
||||
function updateSelectionVisual() {
|
||||
if (!imageGroup || !$isSelected) return;
|
||||
|
||||
// Remove existing selection box
|
||||
const existingBox = imageGroup.findOne('.selection-box');
|
||||
if (existingBox) existingBox.destroy();
|
||||
|
||||
if ($isSelected && imageNode) {
|
||||
// Add selection box
|
||||
const selectionBox = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageNode.width(),
|
||||
height: imageNode.height(),
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2,
|
||||
listening: false,
|
||||
name: 'selection-box',
|
||||
});
|
||||
|
||||
imageGroup.add(selectionBox);
|
||||
}
|
||||
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image position
|
||||
*/
|
||||
$: if (imageGroup && (imageGroup.x() !== x || imageGroup.y() !== y)) {
|
||||
imageGroup.position({ x, y });
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image transformations
|
||||
*/
|
||||
$: if (imageGroup) {
|
||||
if (imageGroup.rotation() !== rotation) {
|
||||
imageGroup.rotation(rotation);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.scaleX() !== scaleX || imageGroup.scaleY() !== scaleY) {
|
||||
imageGroup.scale({ x: scaleX, y: scaleY });
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.opacity() !== opacity) {
|
||||
imageGroup.opacity(opacity);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
if (imageGroup.zIndex() !== zOrder) {
|
||||
imageGroup.zIndex(zOrder);
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update image dimensions
|
||||
*/
|
||||
$: if (imageNode && (imageNode.width() !== width || imageNode.height() !== height)) {
|
||||
imageNode.size({ width, height });
|
||||
updateSelectionVisual();
|
||||
if (layer) layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose image group for external manipulation
|
||||
*/
|
||||
export function getImageGroup(): Konva.Group | null {
|
||||
return imageGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose image node for external manipulation
|
||||
*/
|
||||
export function getImageNode(): Konva.Image | null {
|
||||
return imageNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load image with specific quality level.
|
||||
*/
|
||||
function loadImageWithQuality(_quality: string) {
|
||||
if (!imageId || !imageObj) return;
|
||||
|
||||
const qualityUrl = getAdaptiveThumbnailUrl(imageId);
|
||||
|
||||
if (imageObj.src !== qualityUrl) {
|
||||
imageObj.src = qualityUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load full-resolution version on demand.
|
||||
* Useful for zooming in or detailed viewing.
|
||||
*/
|
||||
export function loadFullResolution() {
|
||||
if (!imageId || !imageObj || isFullResolution) return;
|
||||
|
||||
const fullResUrl = `/api/v1/images/${imageId}/original`;
|
||||
imageObj.src = fullResUrl;
|
||||
isFullResolution = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently showing full resolution.
|
||||
*/
|
||||
export function isShowingFullResolution(): boolean {
|
||||
return isFullResolution;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal file
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal file
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Selection box visual indicator for canvas
|
||||
* Displays a border and resize handles around selected images
|
||||
*/
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Konva from 'konva';
|
||||
import { selection, selectionCount } from '$lib/stores/selection';
|
||||
|
||||
export let layer: Konva.Layer | null = null;
|
||||
export let getImageBounds: (
|
||||
id: string
|
||||
) => { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
let selectionGroup: Konva.Group | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (!layer) return;
|
||||
|
||||
// Create group for selection visuals
|
||||
selectionGroup = new Konva.Group({
|
||||
listening: false,
|
||||
name: 'selection-group',
|
||||
});
|
||||
|
||||
layer.add(selectionGroup);
|
||||
|
||||
// Subscribe to selection changes
|
||||
unsubscribe = selection.subscribe(() => {
|
||||
updateSelectionVisuals();
|
||||
});
|
||||
|
||||
layer.batchDraw();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
if (selectionGroup) {
|
||||
selectionGroup.destroy();
|
||||
selectionGroup = null;
|
||||
}
|
||||
if (layer) layer.batchDraw();
|
||||
});
|
||||
|
||||
/**
|
||||
* Update selection visual indicators
|
||||
*/
|
||||
function updateSelectionVisuals() {
|
||||
if (!selectionGroup || !layer) return;
|
||||
|
||||
// Clear existing visuals
|
||||
selectionGroup.destroyChildren();
|
||||
|
||||
const selectedIds = selection.getSelectedIds();
|
||||
if (selectedIds.length === 0) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate bounding box of all selected images
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const bounds = getImageBounds(id);
|
||||
if (bounds) {
|
||||
minX = Math.min(minX, bounds.x);
|
||||
minY = Math.min(minY, bounds.y);
|
||||
maxX = Math.max(maxX, bounds.x + bounds.width);
|
||||
maxY = Math.max(maxY, bounds.y + bounds.height);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) {
|
||||
layer.batchDraw();
|
||||
return;
|
||||
}
|
||||
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
// Draw selection border
|
||||
const border = new Konva.Rect({
|
||||
x: minX,
|
||||
y: minY,
|
||||
width,
|
||||
height,
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2,
|
||||
dash: [8, 4],
|
||||
listening: false,
|
||||
});
|
||||
|
||||
selectionGroup.add(border);
|
||||
|
||||
// Draw resize handles if single selection
|
||||
if ($selectionCount === 1) {
|
||||
const handleSize = 8;
|
||||
const handlePositions = [
|
||||
{ x: minX, y: minY, cursor: 'nw-resize' }, // Top-left
|
||||
{ x: minX + width / 2, y: minY, cursor: 'n-resize' }, // Top-center
|
||||
{ x: maxX, y: minY, cursor: 'ne-resize' }, // Top-right
|
||||
{ x: maxX, y: minY + height / 2, cursor: 'e-resize' }, // Right-center
|
||||
{ x: maxX, y: maxY, cursor: 'se-resize' }, // Bottom-right
|
||||
{ x: minX + width / 2, y: maxY, cursor: 's-resize' }, // Bottom-center
|
||||
{ x: minX, y: maxY, cursor: 'sw-resize' }, // Bottom-left
|
||||
{ x: minX, y: minY + height / 2, cursor: 'w-resize' }, // Left-center
|
||||
];
|
||||
|
||||
handlePositions.forEach((pos) => {
|
||||
const handle = new Konva.Rect({
|
||||
x: pos.x - handleSize / 2,
|
||||
y: pos.y - handleSize / 2,
|
||||
width: handleSize,
|
||||
height: handleSize,
|
||||
fill: '#3b82f6',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 1,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
selectionGroup!.add(handle);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw selection count badge if multiple selection
|
||||
if ($selectionCount > 1) {
|
||||
const badgeX = maxX - 30;
|
||||
const badgeY = minY - 30;
|
||||
|
||||
const badge = new Konva.Group({
|
||||
x: badgeX,
|
||||
y: badgeY,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const badgeBackground = new Konva.Rect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 24,
|
||||
fill: '#3b82f6',
|
||||
cornerRadius: 4,
|
||||
listening: false,
|
||||
});
|
||||
|
||||
const badgeText = new Konva.Text({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 24,
|
||||
text: $selectionCount.toString(),
|
||||
fontSize: 14,
|
||||
fill: '#ffffff',
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
listening: false,
|
||||
});
|
||||
|
||||
badge.add(badgeBackground);
|
||||
badge.add(badgeText);
|
||||
selectionGroup!.add(badge);
|
||||
}
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force update of selection visuals (for external calls)
|
||||
*/
|
||||
export function update() {
|
||||
updateSelectionVisuals();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render any DOM, it only manages Konva nodes -->
|
||||
@@ -11,9 +11,15 @@
|
||||
import { setupZoomControls } from './controls/zoom';
|
||||
import { setupRotateControls } from './controls/rotate';
|
||||
import { setupGestureControls } from './gestures';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Board ID for future use (e.g., loading board-specific state)
|
||||
export const boardId: string | undefined = undefined;
|
||||
// Intentionally unused - reserved for future viewport persistence
|
||||
export let boardId: string | undefined = undefined;
|
||||
$: _boardId = boardId; // Consume to prevent unused warning
|
||||
|
||||
export let width: number = 0;
|
||||
export let height: number = 0;
|
||||
|
||||
@@ -40,6 +46,13 @@
|
||||
layer = new Konva.Layer();
|
||||
stage.add(layer);
|
||||
|
||||
// Apply initial viewport state BEFORE subscribing to changes
|
||||
// This prevents the flicker from transform animations
|
||||
const initialViewport = $viewport;
|
||||
layer.position({ x: initialViewport.x, y: initialViewport.y });
|
||||
layer.scale({ x: initialViewport.zoom, y: initialViewport.zoom });
|
||||
layer.rotation(initialViewport.rotation);
|
||||
|
||||
// Set up controls
|
||||
if (stage) {
|
||||
cleanupPan = setupPanControls(stage);
|
||||
@@ -48,13 +61,13 @@
|
||||
cleanupGestures = setupGestureControls(stage);
|
||||
}
|
||||
|
||||
// Subscribe to viewport changes
|
||||
// Subscribe to viewport changes (after initial state applied)
|
||||
unsubscribeViewport = viewport.subscribe((state) => {
|
||||
updateStageTransform(state);
|
||||
});
|
||||
|
||||
// Apply initial viewport state
|
||||
updateStageTransform($viewport);
|
||||
// Notify parent that stage is ready
|
||||
dispatch('ready');
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -78,21 +91,26 @@
|
||||
* Update stage transform based on viewport state
|
||||
*/
|
||||
function updateStageTransform(state: ViewportState) {
|
||||
if (!stage) return;
|
||||
if (!stage || !layer) return;
|
||||
|
||||
// Apply transformations to the stage
|
||||
stage.position({ x: state.x, y: state.y });
|
||||
stage.scale({ x: state.zoom, y: state.zoom });
|
||||
stage.rotation(state.rotation);
|
||||
// Don't apply transforms to the stage itself - it causes rendering issues
|
||||
// Instead, we'll transform the layer
|
||||
layer.position({ x: state.x, y: state.y });
|
||||
layer.scale({ x: state.zoom, y: state.zoom });
|
||||
layer.rotation(state.rotation);
|
||||
|
||||
// Force both layer and stage to redraw
|
||||
layer.batchDraw();
|
||||
stage.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas when dimensions change
|
||||
*/
|
||||
$: if (stage && (width !== stage.width() || height !== stage.height())) {
|
||||
$: if (stage && layer && (width !== stage.width() || height !== stage.height())) {
|
||||
stage.width(width);
|
||||
stage.height(height);
|
||||
layer.batchDraw();
|
||||
stage.batchDraw();
|
||||
}
|
||||
|
||||
|
||||
64
frontend/src/lib/canvas/arrange/optimal.ts
Normal file
64
frontend/src/lib/canvas/arrange/optimal.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Optimal layout algorithm for images.
|
||||
*/
|
||||
|
||||
import type { ArrangedPosition, ImageForArrange } from './sort-name';
|
||||
|
||||
/**
|
||||
* Arrange images with optimal packing algorithm.
|
||||
* Uses a simple bin-packing approach.
|
||||
*/
|
||||
export function arrangeOptimal(
|
||||
images: ImageForArrange[],
|
||||
gridSpacing: number = 20,
|
||||
startX: number = 0,
|
||||
startY: number = 0
|
||||
): ArrangedPosition[] {
|
||||
if (images.length === 0) return [];
|
||||
|
||||
// Sort by area (largest first) for better packing
|
||||
const sorted = [...images].sort((a, b) => b.width * b.height - a.width * a.height);
|
||||
|
||||
const positions: ArrangedPosition[] = [];
|
||||
const placedRects: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}> = [];
|
||||
|
||||
// Calculate target width (similar to square root layout)
|
||||
const totalArea = sorted.reduce((sum, img) => sum + img.width * img.height, 0);
|
||||
const targetWidth = Math.sqrt(totalArea) * 1.5;
|
||||
|
||||
let currentX = startX;
|
||||
let currentY = startY;
|
||||
let rowHeight = 0;
|
||||
|
||||
for (const img of sorted) {
|
||||
// Check if we need to wrap to next row
|
||||
if (currentX > startX && currentX + img.width > startX + targetWidth) {
|
||||
currentX = startX;
|
||||
currentY += rowHeight + gridSpacing;
|
||||
rowHeight = 0;
|
||||
}
|
||||
|
||||
positions.push({
|
||||
id: img.id,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
});
|
||||
|
||||
placedRects.push({
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
});
|
||||
|
||||
currentX += img.width + gridSpacing;
|
||||
rowHeight = Math.max(rowHeight, img.height);
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
35
frontend/src/lib/canvas/arrange/random.ts
Normal file
35
frontend/src/lib/canvas/arrange/random.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Random arrangement of images.
|
||||
*/
|
||||
|
||||
import type { ArrangedPosition, ImageForArrange } from './sort-name';
|
||||
|
||||
/**
|
||||
* Arrange images randomly within a bounded area.
|
||||
*/
|
||||
export function arrangeRandom(
|
||||
images: ImageForArrange[],
|
||||
areaWidth: number = 2000,
|
||||
areaHeight: number = 2000,
|
||||
startX: number = 0,
|
||||
startY: number = 0
|
||||
): ArrangedPosition[] {
|
||||
const positions: ArrangedPosition[] = [];
|
||||
|
||||
for (const img of images) {
|
||||
// Random position within bounds, accounting for image size
|
||||
const maxX = areaWidth - img.width;
|
||||
const maxY = areaHeight - img.height;
|
||||
|
||||
const x = startX + Math.random() * Math.max(maxX, 0);
|
||||
const y = startY + Math.random() * Math.max(maxY, 0);
|
||||
|
||||
positions.push({
|
||||
id: img.id,
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
44
frontend/src/lib/canvas/arrange/sort-date.ts
Normal file
44
frontend/src/lib/canvas/arrange/sort-date.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Sort images by upload date.
|
||||
*/
|
||||
|
||||
import type { ArrangedPosition, ImageForArrange } from './sort-name';
|
||||
|
||||
export interface ImageWithDate extends ImageForArrange {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange images by upload date (oldest to newest).
|
||||
*/
|
||||
export function arrangeByDate(
|
||||
images: ImageWithDate[],
|
||||
gridSpacing: number = 20,
|
||||
startX: number = 0,
|
||||
startY: number = 0
|
||||
): ArrangedPosition[] {
|
||||
// Sort by date
|
||||
const sorted = [...images].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
// Calculate grid layout
|
||||
const cols = Math.ceil(Math.sqrt(sorted.length));
|
||||
const maxWidth = Math.max(...sorted.map((img) => img.width));
|
||||
const maxHeight = Math.max(...sorted.map((img) => img.height));
|
||||
|
||||
const positions: ArrangedPosition[] = [];
|
||||
|
||||
sorted.forEach((img, index) => {
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
|
||||
positions.push({
|
||||
id: img.id,
|
||||
x: startX + col * (maxWidth + gridSpacing),
|
||||
y: startY + row * (maxHeight + gridSpacing),
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
57
frontend/src/lib/canvas/arrange/sort-name.ts
Normal file
57
frontend/src/lib/canvas/arrange/sort-name.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Sort images alphabetically by name.
|
||||
*/
|
||||
|
||||
export interface ImageForArrange {
|
||||
id: string;
|
||||
filename: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ArrangedPosition {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrange images alphabetically by filename.
|
||||
*
|
||||
* @param images - Images to arrange
|
||||
* @param gridSpacing - Spacing between images
|
||||
* @param startX - Starting X position
|
||||
* @param startY - Starting Y position
|
||||
* @returns New positions for images
|
||||
*/
|
||||
export function arrangeByName(
|
||||
images: ImageForArrange[],
|
||||
gridSpacing: number = 20,
|
||||
startX: number = 0,
|
||||
startY: number = 0
|
||||
): ArrangedPosition[] {
|
||||
// Sort alphabetically
|
||||
const sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
|
||||
// Calculate grid layout
|
||||
const cols = Math.ceil(Math.sqrt(sorted.length));
|
||||
const maxWidth = Math.max(...sorted.map((img) => img.width));
|
||||
const maxHeight = Math.max(...sorted.map((img) => img.height));
|
||||
|
||||
const positions: ArrangedPosition[] = [];
|
||||
|
||||
sorted.forEach((img, index) => {
|
||||
const row = Math.floor(index / cols);
|
||||
const col = index % cols;
|
||||
|
||||
positions.push({
|
||||
id: img.id,
|
||||
x: startX + col * (maxWidth + gridSpacing),
|
||||
y: startY + row * (maxHeight + gridSpacing),
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal file
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copy operation for canvas images
|
||||
* Copies selected images to clipboard
|
||||
*/
|
||||
|
||||
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
/**
|
||||
* Copy selected images to clipboard
|
||||
*/
|
||||
export function copySelectedImages(
|
||||
getImageData: (id: string) => ClipboardImageData | null
|
||||
): number {
|
||||
const selectedIds = selection.getSelectedIds();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const imagesToCopy: ClipboardImageData[] = [];
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const imageData = getImageData(id);
|
||||
if (imageData) {
|
||||
imagesToCopy.push(imageData);
|
||||
}
|
||||
});
|
||||
|
||||
clipboard.copy(imagesToCopy);
|
||||
|
||||
return imagesToCopy.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy specific images to clipboard
|
||||
*/
|
||||
export function copyImages(
|
||||
imageIds: string[],
|
||||
getImageData: (id: string) => ClipboardImageData | null
|
||||
): number {
|
||||
const imagesToCopy: ClipboardImageData[] = [];
|
||||
|
||||
imageIds.forEach((id) => {
|
||||
const imageData = getImageData(id);
|
||||
if (imageData) {
|
||||
imagesToCopy.push(imageData);
|
||||
}
|
||||
});
|
||||
|
||||
clipboard.copy(imagesToCopy);
|
||||
|
||||
return imagesToCopy.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy single image to clipboard
|
||||
*/
|
||||
export function copySingleImage(
|
||||
getImageData: (id: string) => ClipboardImageData | null,
|
||||
imageId: string
|
||||
): boolean {
|
||||
const imageData = getImageData(imageId);
|
||||
|
||||
if (!imageData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clipboard.copy([imageData]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if clipboard has content
|
||||
*/
|
||||
export function hasClipboardContent(): boolean {
|
||||
return clipboard.hasContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clipboard count
|
||||
*/
|
||||
export function getClipboardCount(): number {
|
||||
const state = clipboard.getClipboard();
|
||||
return state.images.length;
|
||||
}
|
||||
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal file
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Cut operation for canvas images
|
||||
* Cuts selected images to clipboard (copy + mark for deletion)
|
||||
*/
|
||||
|
||||
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
/**
|
||||
* Cut selected images to clipboard
|
||||
*/
|
||||
export function cutSelectedImages(getImageData: (id: string) => ClipboardImageData | null): number {
|
||||
const selectedIds = selection.getSelectedIds();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const imagesToCut: ClipboardImageData[] = [];
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const imageData = getImageData(id);
|
||||
if (imageData) {
|
||||
imagesToCut.push(imageData);
|
||||
}
|
||||
});
|
||||
|
||||
clipboard.cut(imagesToCut);
|
||||
|
||||
return imagesToCut.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut specific images to clipboard
|
||||
*/
|
||||
export function cutImages(
|
||||
imageIds: string[],
|
||||
getImageData: (id: string) => ClipboardImageData | null
|
||||
): number {
|
||||
const imagesToCut: ClipboardImageData[] = [];
|
||||
|
||||
imageIds.forEach((id) => {
|
||||
const imageData = getImageData(id);
|
||||
if (imageData) {
|
||||
imagesToCut.push(imageData);
|
||||
}
|
||||
});
|
||||
|
||||
clipboard.cut(imagesToCut);
|
||||
|
||||
return imagesToCut.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut single image to clipboard
|
||||
*/
|
||||
export function cutSingleImage(
|
||||
getImageData: (id: string) => ClipboardImageData | null,
|
||||
imageId: string
|
||||
): boolean {
|
||||
const imageData = getImageData(imageId);
|
||||
|
||||
if (!imageData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clipboard.cut([imageData]);
|
||||
return true;
|
||||
}
|
||||
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal file
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Paste operation for canvas images
|
||||
* Pastes clipboard images at viewport center or specific position
|
||||
*/
|
||||
|
||||
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||
import { viewport } from '$lib/stores/viewport';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface PasteOptions {
|
||||
position?: { x: number; y: number }; // Override default center position
|
||||
clearClipboardAfter?: boolean; // Clear clipboard after paste (default: false for copy, true for cut)
|
||||
onPasteComplete?: (pastedIds: string[]) => void;
|
||||
}
|
||||
|
||||
export interface PastedImageData extends ClipboardImageData {
|
||||
newPosition: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste clipboard images at viewport center
|
||||
*/
|
||||
export function pasteFromClipboard(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
options: PasteOptions = {}
|
||||
): PastedImageData[] {
|
||||
const clipboardState = clipboard.getClipboard();
|
||||
|
||||
if (clipboardState.images.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine paste position
|
||||
let pastePosition: { x: number; y: number };
|
||||
|
||||
if (options.position) {
|
||||
pastePosition = options.position;
|
||||
} else {
|
||||
// Use viewport center
|
||||
const viewportState = get(viewport);
|
||||
pastePosition = {
|
||||
x: -viewportState.x + viewportWidth / 2,
|
||||
y: -viewportState.y + viewportHeight / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate offset to paste at center
|
||||
const pastedImages: PastedImageData[] = [];
|
||||
|
||||
// Calculate bounding box of clipboard images
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
clipboardState.images.forEach((img) => {
|
||||
minX = Math.min(minX, img.position.x);
|
||||
minY = Math.min(minY, img.position.y);
|
||||
});
|
||||
|
||||
// Create pasted images with new positions
|
||||
clipboardState.images.forEach((img) => {
|
||||
const offsetX = img.position.x - minX;
|
||||
const offsetY = img.position.y - minY;
|
||||
|
||||
pastedImages.push({
|
||||
...img,
|
||||
newPosition: {
|
||||
x: pastePosition.x + offsetX,
|
||||
y: pastePosition.y + offsetY,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Clear clipboard if requested (default for cut operation)
|
||||
const shouldClear = options.clearClipboardAfter ?? clipboardState.operation === 'cut';
|
||||
if (shouldClear) {
|
||||
clipboard.clear();
|
||||
}
|
||||
|
||||
// Call callback if provided
|
||||
if (options.onPasteComplete) {
|
||||
options.onPasteComplete(pastedImages.map((img) => img.boardImageId));
|
||||
}
|
||||
|
||||
return pastedImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste at specific position
|
||||
*/
|
||||
export function pasteAtPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
options: PasteOptions = {}
|
||||
): PastedImageData[] {
|
||||
return pasteFromClipboard(0, 0, {
|
||||
...options,
|
||||
position: { x, y },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if can paste (clipboard has content)
|
||||
*/
|
||||
export function canPaste(): boolean {
|
||||
return clipboard.hasContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paste preview (positions where images will be pasted)
|
||||
*/
|
||||
export function getPastePreview(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): Array<{ x: number; y: number }> {
|
||||
const clipboardState = clipboard.getClipboard();
|
||||
|
||||
if (clipboardState.images.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const viewportState = get(viewport);
|
||||
const centerX = -viewportState.x + viewportWidth / 2;
|
||||
const centerY = -viewportState.y + viewportHeight / 2;
|
||||
|
||||
// Calculate offsets
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
clipboardState.images.forEach((img) => {
|
||||
minX = Math.min(minX, img.position.x);
|
||||
minY = Math.min(minY, img.position.y);
|
||||
});
|
||||
|
||||
return clipboardState.images.map((img) => ({
|
||||
x: centerX + (img.position.x - minX),
|
||||
y: centerY + (img.position.y - minY),
|
||||
}));
|
||||
}
|
||||
100
frontend/src/lib/canvas/focus.ts
Normal file
100
frontend/src/lib/canvas/focus.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Focus mode for viewing individual images.
|
||||
*/
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface FocusState {
|
||||
isActive: boolean;
|
||||
currentImageId: string | null;
|
||||
imageIds: string[];
|
||||
currentIndex: number;
|
||||
}
|
||||
|
||||
function createFocusStore() {
|
||||
const { subscribe, set, update }: Writable<FocusState> = writable({
|
||||
isActive: false,
|
||||
currentImageId: null,
|
||||
imageIds: [],
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Enter focus mode for a specific image.
|
||||
*/
|
||||
enter(imageId: string, allImageIds: string[]) {
|
||||
const index = allImageIds.indexOf(imageId);
|
||||
set({
|
||||
isActive: true,
|
||||
currentImageId: imageId,
|
||||
imageIds: allImageIds,
|
||||
currentIndex: index !== -1 ? index : 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Exit focus mode.
|
||||
*/
|
||||
exit() {
|
||||
set({
|
||||
isActive: false,
|
||||
currentImageId: null,
|
||||
imageIds: [],
|
||||
currentIndex: 0,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to next image.
|
||||
*/
|
||||
next() {
|
||||
update((state) => {
|
||||
if (!state.isActive || state.imageIds.length === 0) return state;
|
||||
|
||||
const nextIndex = (state.currentIndex + 1) % state.imageIds.length;
|
||||
return {
|
||||
...state,
|
||||
currentIndex: nextIndex,
|
||||
currentImageId: state.imageIds[nextIndex],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate to previous image.
|
||||
*/
|
||||
previous() {
|
||||
update((state) => {
|
||||
if (!state.isActive || state.imageIds.length === 0) return state;
|
||||
|
||||
const prevIndex = (state.currentIndex - 1 + state.imageIds.length) % state.imageIds.length;
|
||||
return {
|
||||
...state,
|
||||
currentIndex: prevIndex,
|
||||
currentImageId: state.imageIds[prevIndex],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Go to specific index.
|
||||
*/
|
||||
goToIndex(index: number) {
|
||||
update((state) => {
|
||||
if (!state.isActive || index < 0 || index >= state.imageIds.length) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentIndex: index,
|
||||
currentImageId: state.imageIds[index],
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const focusStore = createFocusStore();
|
||||
195
frontend/src/lib/canvas/grid.ts
Normal file
195
frontend/src/lib/canvas/grid.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Grid and snap-to-grid functionality for canvas
|
||||
* Provides visual grid and snapping behavior
|
||||
*/
|
||||
|
||||
import Konva from 'konva';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface GridSettings {
|
||||
enabled: boolean;
|
||||
size: number; // Grid cell size in pixels
|
||||
visible: boolean; // Show visual grid
|
||||
snapEnabled: boolean; // Enable snap-to-grid
|
||||
color: string; // Grid line color
|
||||
opacity: number; // Grid line opacity
|
||||
}
|
||||
|
||||
const DEFAULT_GRID: GridSettings = {
|
||||
enabled: true,
|
||||
size: 20,
|
||||
visible: false,
|
||||
snapEnabled: false,
|
||||
color: '#d1d5db',
|
||||
opacity: 0.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create grid settings store
|
||||
*/
|
||||
function createGridStore() {
|
||||
const { subscribe, set, update }: Writable<GridSettings> = writable(DEFAULT_GRID);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
update,
|
||||
|
||||
/**
|
||||
* Toggle grid visibility
|
||||
*/
|
||||
toggleVisible: () => {
|
||||
update((settings) => ({
|
||||
...settings,
|
||||
visible: !settings.visible,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle snap-to-grid
|
||||
*/
|
||||
toggleSnap: () => {
|
||||
update((settings) => ({
|
||||
...settings,
|
||||
snapEnabled: !settings.snapEnabled,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Set grid size
|
||||
*/
|
||||
setSize: (size: number) => {
|
||||
update((settings) => ({
|
||||
...settings,
|
||||
size: Math.max(5, Math.min(200, size)), // Clamp to 5-200
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable/disable grid
|
||||
*/
|
||||
setEnabled: (enabled: boolean) => {
|
||||
update((settings) => ({
|
||||
...settings,
|
||||
enabled,
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset to defaults
|
||||
*/
|
||||
reset: () => {
|
||||
set(DEFAULT_GRID);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const grid = createGridStore();
|
||||
|
||||
/**
|
||||
* Snap position to grid
|
||||
*/
|
||||
export function snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.round(x / gridSize) * gridSize,
|
||||
y: Math.round(y / gridSize) * gridSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw visual grid on layer
|
||||
*/
|
||||
export function drawGrid(
|
||||
layer: Konva.Layer,
|
||||
width: number,
|
||||
height: number,
|
||||
gridSize: number,
|
||||
color: string = '#d1d5db',
|
||||
opacity: number = 0.5
|
||||
): Konva.Group {
|
||||
const gridGroup = new Konva.Group({
|
||||
listening: false,
|
||||
name: 'grid',
|
||||
});
|
||||
|
||||
// Draw vertical lines
|
||||
for (let x = 0; x <= width; x += gridSize) {
|
||||
const line = new Konva.Line({
|
||||
points: [x, 0, x, height],
|
||||
stroke: color,
|
||||
strokeWidth: 1,
|
||||
opacity,
|
||||
listening: false,
|
||||
});
|
||||
gridGroup.add(line);
|
||||
}
|
||||
|
||||
// Draw horizontal lines
|
||||
for (let y = 0; y <= height; y += gridSize) {
|
||||
const line = new Konva.Line({
|
||||
points: [0, y, width, y],
|
||||
stroke: color,
|
||||
strokeWidth: 1,
|
||||
opacity,
|
||||
listening: false,
|
||||
});
|
||||
gridGroup.add(line);
|
||||
}
|
||||
|
||||
layer.add(gridGroup);
|
||||
gridGroup.moveToBottom(); // Grid should be behind all images
|
||||
|
||||
return gridGroup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove grid from layer
|
||||
*/
|
||||
export function removeGrid(layer: Konva.Layer): void {
|
||||
const grids = layer.find('.grid');
|
||||
grids.forEach((grid) => grid.destroy());
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update grid visual
|
||||
*/
|
||||
export function updateGrid(
|
||||
layer: Konva.Layer,
|
||||
settings: GridSettings,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): void {
|
||||
// Remove existing grid
|
||||
removeGrid(layer);
|
||||
|
||||
// Draw new grid if visible
|
||||
if (settings.visible && settings.enabled) {
|
||||
drawGrid(layer, viewportWidth, viewportHeight, settings.size, settings.color, settings.opacity);
|
||||
layer.batchDraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup drag with snap-to-grid
|
||||
*/
|
||||
export function setupSnapDrag(
|
||||
image: Konva.Image | Konva.Group,
|
||||
gridSettings: GridSettings
|
||||
): () => void {
|
||||
function handleDragMove() {
|
||||
if (!gridSettings.snapEnabled || !gridSettings.enabled) return;
|
||||
|
||||
const pos = image.position();
|
||||
const snapped = snapToGrid(pos.x, pos.y, gridSettings.size);
|
||||
|
||||
image.position(snapped);
|
||||
}
|
||||
|
||||
image.on('dragmove', handleDragMove);
|
||||
|
||||
return () => {
|
||||
image.off('dragmove', handleDragMove);
|
||||
};
|
||||
}
|
||||
184
frontend/src/lib/canvas/interactions/drag.ts
Normal file
184
frontend/src/lib/canvas/interactions/drag.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Image dragging interactions for canvas
|
||||
* Handles dragging images to reposition them
|
||||
*/
|
||||
|
||||
import Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
startPos: { x: number; y: number } | null;
|
||||
draggedImageId: string | null;
|
||||
}
|
||||
|
||||
const dragState: DragState = {
|
||||
isDragging: false,
|
||||
startPos: null,
|
||||
draggedImageId: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup drag interactions for an image
|
||||
*/
|
||||
export function setupImageDrag(
|
||||
image: Konva.Image | Konva.Group,
|
||||
imageId: string,
|
||||
onDragMove?: (imageId: string, x: number, y: number) => void,
|
||||
onDragEnd?: (imageId: string, x: number, y: number) => void
|
||||
): () => void {
|
||||
/**
|
||||
* Handle drag start
|
||||
*/
|
||||
function handleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
dragState.isDragging = true;
|
||||
dragState.startPos = { x: image.x(), y: image.y() };
|
||||
dragState.draggedImageId = imageId;
|
||||
|
||||
// If dragged image is not selected, select it
|
||||
const selectionState = get(selection);
|
||||
if (!selectionState.selectedIds.has(imageId)) {
|
||||
// Check if Ctrl/Cmd key is pressed
|
||||
if (e.evt.ctrlKey || e.evt.metaKey) {
|
||||
selection.addToSelection(imageId);
|
||||
} else {
|
||||
selection.selectOne(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Set dragging cursor
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.container().style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag move
|
||||
*/
|
||||
function handleDragMove(_e: Konva.KonvaEventObject<DragEvent>) {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const x = image.x();
|
||||
const y = image.y();
|
||||
|
||||
// Call callback if provided
|
||||
if (onDragMove) {
|
||||
onDragMove(imageId, x, y);
|
||||
}
|
||||
|
||||
// If multiple images are selected, move them together
|
||||
const selectionState = get(selection);
|
||||
if (selectionState.selectedIds.size > 1 && dragState.startPos) {
|
||||
const deltaX = x - dragState.startPos.x;
|
||||
const deltaY = y - dragState.startPos.y;
|
||||
|
||||
// Update start position for next delta calculation
|
||||
dragState.startPos = { x, y };
|
||||
|
||||
// Dispatch custom event to move other selected images
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.fire('multiDragMove', {
|
||||
draggedImageId: imageId,
|
||||
deltaX,
|
||||
deltaY,
|
||||
selectedIds: Array.from(selectionState.selectedIds),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drag end
|
||||
*/
|
||||
function handleDragEnd(_e: Konva.KonvaEventObject<DragEvent>) {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const x = image.x();
|
||||
const y = image.y();
|
||||
|
||||
// Call callback if provided
|
||||
if (onDragEnd) {
|
||||
onDragEnd(imageId, x, y);
|
||||
}
|
||||
|
||||
// Reset drag state
|
||||
dragState.isDragging = false;
|
||||
dragState.startPos = null;
|
||||
dragState.draggedImageId = null;
|
||||
|
||||
// Reset cursor
|
||||
const stage = image.getStage();
|
||||
if (stage) {
|
||||
stage.container().style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// Enable dragging
|
||||
image.draggable(true);
|
||||
|
||||
// Attach event listeners
|
||||
image.on('dragstart', handleDragStart);
|
||||
image.on('dragmove', handleDragMove);
|
||||
image.on('dragend', handleDragEnd);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
image.off('dragstart', handleDragStart);
|
||||
image.off('dragmove', handleDragMove);
|
||||
image.off('dragend', handleDragEnd);
|
||||
image.draggable(false);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move image to specific position (programmatic)
|
||||
*/
|
||||
export function moveImageTo(
|
||||
image: Konva.Image | Konva.Group,
|
||||
x: number,
|
||||
y: number,
|
||||
animate: boolean = false
|
||||
): void {
|
||||
if (animate) {
|
||||
// TODO: Add animation support using Konva.Tween
|
||||
image.to({
|
||||
x,
|
||||
y,
|
||||
duration: 0.3,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
});
|
||||
} else {
|
||||
image.position({ x, y });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move image by delta (programmatic)
|
||||
*/
|
||||
export function moveImageBy(
|
||||
image: Konva.Image | Konva.Group,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
animate: boolean = false
|
||||
): void {
|
||||
const currentX = image.x();
|
||||
const currentY = image.y();
|
||||
moveImageTo(image, currentX + deltaX, currentY + deltaY, animate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current drag state (useful for debugging)
|
||||
*/
|
||||
export function getDragState(): DragState {
|
||||
return { ...dragState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently dragging
|
||||
*/
|
||||
export function isDragging(): boolean {
|
||||
return dragState.isDragging;
|
||||
}
|
||||
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal file
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Rectangle selection (drag-to-select multiple images)
|
||||
* Allows selecting multiple images by dragging a selection rectangle
|
||||
*/
|
||||
|
||||
import Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
export interface SelectionRectangle {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface MultiSelectState {
|
||||
isSelecting: boolean;
|
||||
startPos: { x: number; y: number } | null;
|
||||
currentRect: SelectionRectangle | null;
|
||||
}
|
||||
|
||||
const multiSelectState: MultiSelectState = {
|
||||
isSelecting: false,
|
||||
startPos: null,
|
||||
currentRect: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup rectangle selection on stage
|
||||
*/
|
||||
export function setupRectangleSelection(
|
||||
stage: Konva.Stage,
|
||||
layer: Konva.Layer,
|
||||
getImageBounds: () => Array<{
|
||||
id: string;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}>,
|
||||
onSelectionChange?: (selectedIds: string[]) => void
|
||||
): () => void {
|
||||
let selectionRect: Konva.Rect | null = null;
|
||||
|
||||
/**
|
||||
* Handle mouse/touch down to start selection
|
||||
*/
|
||||
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
// Only start rectangle selection if clicking on stage background
|
||||
if (e.target !== stage) return;
|
||||
|
||||
// Only if not pressing Ctrl (that's for pan)
|
||||
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
if (isModifierPressed) return;
|
||||
|
||||
const pos = stage.getPointerPosition();
|
||||
if (!pos) return;
|
||||
|
||||
// Transform pointer position to account for stage transformations
|
||||
const transform = stage.getAbsoluteTransform().copy().invert();
|
||||
const localPos = transform.point(pos);
|
||||
|
||||
multiSelectState.isSelecting = true;
|
||||
multiSelectState.startPos = localPos;
|
||||
multiSelectState.currentRect = {
|
||||
x1: localPos.x,
|
||||
y1: localPos.y,
|
||||
x2: localPos.x,
|
||||
y2: localPos.y,
|
||||
};
|
||||
|
||||
// Create visual selection rectangle
|
||||
selectionRect = new Konva.Rect({
|
||||
x: localPos.x,
|
||||
y: localPos.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: 'rgba(0, 120, 255, 0.1)',
|
||||
stroke: 'rgba(0, 120, 255, 0.8)',
|
||||
strokeWidth: 1 / stage.scaleX(), // Adjust for zoom
|
||||
listening: false,
|
||||
});
|
||||
|
||||
layer.add(selectionRect);
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse/touch move to update selection rectangle
|
||||
*/
|
||||
function handleMouseMove(_e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
if (!multiSelectState.isSelecting || !multiSelectState.startPos || !selectionRect) return;
|
||||
|
||||
const pos = stage.getPointerPosition();
|
||||
if (!pos) return;
|
||||
|
||||
// Transform pointer position
|
||||
const transform = stage.getAbsoluteTransform().copy().invert();
|
||||
const localPos = transform.point(pos);
|
||||
|
||||
multiSelectState.currentRect = {
|
||||
x1: multiSelectState.startPos.x,
|
||||
y1: multiSelectState.startPos.y,
|
||||
x2: localPos.x,
|
||||
y2: localPos.y,
|
||||
};
|
||||
|
||||
// Update visual rectangle
|
||||
const x = Math.min(multiSelectState.startPos.x, localPos.x);
|
||||
const y = Math.min(multiSelectState.startPos.y, localPos.y);
|
||||
const width = Math.abs(localPos.x - multiSelectState.startPos.x);
|
||||
const height = Math.abs(localPos.y - multiSelectState.startPos.y);
|
||||
|
||||
selectionRect.x(x);
|
||||
selectionRect.y(y);
|
||||
selectionRect.width(width);
|
||||
selectionRect.height(height);
|
||||
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse/touch up to complete selection
|
||||
*/
|
||||
function handleMouseUp(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
if (!multiSelectState.isSelecting || !multiSelectState.currentRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all images that intersect with selection rectangle
|
||||
const selectedIds = getImagesInRectangle(multiSelectState.currentRect, getImageBounds());
|
||||
|
||||
// Check if Ctrl/Cmd is pressed for additive selection
|
||||
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
|
||||
if (isModifierPressed && selectedIds.length > 0) {
|
||||
// Add to existing selection
|
||||
selection.addMultipleToSelection(selectedIds);
|
||||
} else if (selectedIds.length > 0) {
|
||||
// Replace selection
|
||||
selection.selectMultiple(selectedIds);
|
||||
} else {
|
||||
// Empty selection - clear
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
// Call callback
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(selectedIds);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (selectionRect) {
|
||||
selectionRect.destroy();
|
||||
selectionRect = null;
|
||||
layer.batchDraw();
|
||||
}
|
||||
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
stage.on('mousedown touchstart', handleMouseDown);
|
||||
stage.on('mousemove touchmove', handleMouseMove);
|
||||
stage.on('mouseup touchend', handleMouseUp);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
stage.off('mousedown touchstart', handleMouseDown);
|
||||
stage.off('mousemove touchmove', handleMouseMove);
|
||||
stage.off('mouseup touchend', handleMouseUp);
|
||||
|
||||
if (selectionRect) {
|
||||
selectionRect.destroy();
|
||||
selectionRect = null;
|
||||
}
|
||||
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get images that intersect with selection rectangle
|
||||
*/
|
||||
function getImagesInRectangle(
|
||||
rect: SelectionRectangle,
|
||||
imageBounds: Array<{
|
||||
id: string;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}>
|
||||
): string[] {
|
||||
const x1 = Math.min(rect.x1, rect.x2);
|
||||
const y1 = Math.min(rect.y1, rect.y2);
|
||||
const x2 = Math.max(rect.x1, rect.x2);
|
||||
const y2 = Math.max(rect.y1, rect.y2);
|
||||
|
||||
return imageBounds
|
||||
.filter((item) => {
|
||||
const { x, y, width, height } = item.bounds;
|
||||
|
||||
// Check if rectangles intersect
|
||||
return !(x + width < x1 || x > x2 || y + height < y1 || y > y2);
|
||||
})
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently in rectangle selection mode
|
||||
*/
|
||||
export function isRectangleSelecting(): boolean {
|
||||
return multiSelectState.isSelecting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current selection rectangle
|
||||
*/
|
||||
export function getCurrentSelectionRect(): SelectionRectangle | null {
|
||||
return multiSelectState.currentRect ? { ...multiSelectState.currentRect } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing rectangle selection
|
||||
*/
|
||||
export function cancelRectangleSelection(layer: Konva.Layer): void {
|
||||
multiSelectState.isSelecting = false;
|
||||
multiSelectState.startPos = null;
|
||||
multiSelectState.currentRect = null;
|
||||
|
||||
// Remove any active selection rectangle
|
||||
const rects = layer.find('.selection-rect');
|
||||
rects.forEach((rect) => rect.destroy());
|
||||
layer.batchDraw();
|
||||
}
|
||||
157
frontend/src/lib/canvas/interactions/select.ts
Normal file
157
frontend/src/lib/canvas/interactions/select.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Click selection interactions for canvas
|
||||
* Handles single and multi-select (Ctrl+Click)
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
import { selection } from '$lib/stores/selection';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export interface SelectOptions {
|
||||
multiSelectKey?: boolean; // Enable Ctrl/Cmd+Click for multi-select
|
||||
deselectOnBackground?: boolean; // Deselect when clicking empty canvas
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: SelectOptions = {
|
||||
multiSelectKey: true,
|
||||
deselectOnBackground: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup click selection for an image
|
||||
*/
|
||||
export function setupImageSelection(
|
||||
image: Konva.Image | Konva.Group,
|
||||
imageId: string,
|
||||
options: SelectOptions = DEFAULT_OPTIONS,
|
||||
onSelectionChange?: (imageId: string, isSelected: boolean) => void
|
||||
): () => void {
|
||||
/**
|
||||
* Handle click/tap on image
|
||||
*/
|
||||
function handleClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
e.cancelBubble = true; // Prevent event from reaching stage
|
||||
|
||||
const isMultiSelectPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
|
||||
|
||||
const selectionState = get(selection);
|
||||
const isCurrentlySelected = selectionState.selectedIds.has(imageId);
|
||||
|
||||
if (options.multiSelectKey && isMultiSelectPressed) {
|
||||
// Multi-select mode (Ctrl+Click)
|
||||
if (isCurrentlySelected) {
|
||||
selection.removeFromSelection(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, false);
|
||||
}
|
||||
} else {
|
||||
selection.addToSelection(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single select mode
|
||||
if (!isCurrentlySelected) {
|
||||
selection.selectOne(imageId);
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(imageId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach click/tap listener
|
||||
image.on('click tap', handleClick);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
image.off('click tap', handleClick);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup background deselection (clicking empty canvas clears selection)
|
||||
*/
|
||||
export function setupBackgroundDeselect(stage: Konva.Stage, onDeselect?: () => void): () => void {
|
||||
/**
|
||||
* Handle click on stage background
|
||||
*/
|
||||
function handleStageClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
|
||||
// Only deselect if clicking on the stage itself (not on any shape)
|
||||
if (e.target === stage) {
|
||||
selection.clearSelection();
|
||||
if (onDeselect) {
|
||||
onDeselect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listener
|
||||
stage.on('click tap', handleStageClick);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
stage.off('click tap', handleStageClick);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select image programmatically
|
||||
*/
|
||||
export function selectImage(imageId: string, multiSelect: boolean = false): void {
|
||||
if (multiSelect) {
|
||||
selection.addToSelection(imageId);
|
||||
} else {
|
||||
selection.selectOne(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect image programmatically
|
||||
*/
|
||||
export function deselectImage(imageId: string): void {
|
||||
selection.removeFromSelection(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle image selection programmatically
|
||||
*/
|
||||
export function toggleImageSelection(imageId: string): void {
|
||||
selection.toggleSelection(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all images programmatically
|
||||
*/
|
||||
export function selectAllImages(allImageIds: string[]): void {
|
||||
selection.selectAll(allImageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selection programmatically
|
||||
*/
|
||||
export function clearAllSelection(): void {
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected images count
|
||||
*/
|
||||
export function getSelectedCount(): number {
|
||||
return selection.getSelectionCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of selected image IDs
|
||||
*/
|
||||
export function getSelectedImageIds(): string[] {
|
||||
return selection.getSelectedIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image is selected
|
||||
*/
|
||||
export function isImageSelected(imageId: string): boolean {
|
||||
return selection.isSelected(imageId);
|
||||
}
|
||||
225
frontend/src/lib/canvas/keyboard.ts
Normal file
225
frontend/src/lib/canvas/keyboard.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Keyboard shortcuts for canvas operations
|
||||
* Handles Ctrl+A (select all), Escape (deselect), and other shortcuts
|
||||
*/
|
||||
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
onSelectAll?: (allImageIds: string[]) => void;
|
||||
onDeselectAll?: () => void;
|
||||
onDelete?: () => void;
|
||||
onCopy?: () => void;
|
||||
onCut?: () => void;
|
||||
onPaste?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onBringToFront?: () => void;
|
||||
onSendToBack?: () => void;
|
||||
onBringForward?: () => void;
|
||||
onSendBackward?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup keyboard shortcuts for canvas
|
||||
*/
|
||||
export function setupKeyboardShortcuts(
|
||||
getAllImageIds: () => string[],
|
||||
handlers: KeyboardShortcutHandlers = {}
|
||||
): () => void {
|
||||
/**
|
||||
* Handle keyboard shortcuts
|
||||
*/
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Ignore if typing in input/textarea
|
||||
if (
|
||||
document.activeElement?.tagName === 'INPUT' ||
|
||||
document.activeElement?.tagName === 'TEXTAREA'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Ctrl+A / Cmd+A - Select all
|
||||
if (isCtrlOrCmd && e.key === 'a') {
|
||||
e.preventDefault();
|
||||
const allIds = getAllImageIds();
|
||||
selection.selectAll(allIds);
|
||||
|
||||
if (handlers.onSelectAll) {
|
||||
handlers.onSelectAll(allIds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape - Deselect all
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
selection.clearSelection();
|
||||
|
||||
if (handlers.onDeselectAll) {
|
||||
handlers.onDeselectAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete / Backspace - Delete selected
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onDelete) {
|
||||
handlers.onDelete();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C / Cmd+C - Copy
|
||||
if (isCtrlOrCmd && e.key === 'c') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onCopy) {
|
||||
handlers.onCopy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+X / Cmd+X - Cut
|
||||
if (isCtrlOrCmd && e.key === 'x') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onCut) {
|
||||
handlers.onCut();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V / Cmd+V - Paste
|
||||
if (isCtrlOrCmd && e.key === 'v') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onPaste) {
|
||||
handlers.onPaste();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z / Cmd+Z - Undo
|
||||
if (isCtrlOrCmd && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onUndo) {
|
||||
handlers.onUndo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Z / Cmd+Shift+Z - Redo
|
||||
if (isCtrlOrCmd && e.key === 'z' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onRedo) {
|
||||
handlers.onRedo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Y / Cmd+Y - Alternative Redo
|
||||
if (isCtrlOrCmd && e.key === 'y') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onRedo) {
|
||||
handlers.onRedo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+] - Bring to front
|
||||
if (isCtrlOrCmd && e.key === ']') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onBringToFront) {
|
||||
handlers.onBringToFront();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+[ - Send to back
|
||||
if (isCtrlOrCmd && e.key === '[') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onSendToBack) {
|
||||
handlers.onSendToBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PageUp - Bring forward
|
||||
if (e.key === 'PageUp') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onBringForward) {
|
||||
handlers.onBringForward();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PageDown - Send backward
|
||||
if (e.key === 'PageDown') {
|
||||
e.preventDefault();
|
||||
|
||||
if (handlers.onSendBackward) {
|
||||
handlers.onSendBackward();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach event listener
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all images programmatically
|
||||
*/
|
||||
export function selectAllImages(allImageIds: string[]): void {
|
||||
selection.selectAll(allImageIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect all images programmatically
|
||||
*/
|
||||
export function deselectAllImages(): void {
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if modifier key is pressed
|
||||
*/
|
||||
export function isModifierPressed(e: KeyboardEvent): boolean {
|
||||
return e.ctrlKey || e.metaKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shift key is pressed
|
||||
*/
|
||||
export function isShiftPressed(e: KeyboardEvent): boolean {
|
||||
return e.shiftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keyboard shortcut display string
|
||||
*/
|
||||
export function getShortcutDisplay(shortcut: string): string {
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
|
||||
|
||||
return shortcut
|
||||
.replace('Ctrl', isMac ? '⌘' : 'Ctrl')
|
||||
.replace('Alt', isMac ? '⌥' : 'Alt')
|
||||
.replace('Shift', isMac ? '⇧' : 'Shift');
|
||||
}
|
||||
101
frontend/src/lib/canvas/navigation.ts
Normal file
101
frontend/src/lib/canvas/navigation.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Image navigation order calculation.
|
||||
*/
|
||||
|
||||
export type NavigationOrder = 'chronological' | 'spatial' | 'alphabetical' | 'random';
|
||||
|
||||
export interface ImageWithMetadata {
|
||||
id: string;
|
||||
filename: string;
|
||||
x: number;
|
||||
y: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort images by navigation order preference.
|
||||
*/
|
||||
export function sortImagesByOrder(images: ImageWithMetadata[], order: NavigationOrder): string[] {
|
||||
let sorted: ImageWithMetadata[];
|
||||
|
||||
switch (order) {
|
||||
case 'chronological':
|
||||
sorted = [...images].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
break;
|
||||
|
||||
case 'spatial':
|
||||
// Left to right, top to bottom
|
||||
sorted = [...images].sort((a, b) => {
|
||||
if (Math.abs(a.y - b.y) < 50) {
|
||||
return a.x - b.x;
|
||||
}
|
||||
return a.y - b.y;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'alphabetical':
|
||||
sorted = [...images].sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
break;
|
||||
|
||||
case 'random':
|
||||
sorted = shuffleArray([...images]);
|
||||
break;
|
||||
|
||||
default:
|
||||
sorted = images;
|
||||
}
|
||||
|
||||
return sorted.map((img) => img.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array randomly.
|
||||
*/
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get navigation order preference from localStorage.
|
||||
*/
|
||||
export function getNavigationOrderPreference(): NavigationOrder {
|
||||
if (typeof window === 'undefined') return 'chronological';
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('webref_navigation_order');
|
||||
if (saved && isValidNavigationOrder(saved)) {
|
||||
return saved as NavigationOrder;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load navigation preference:', error);
|
||||
}
|
||||
|
||||
return 'chronological';
|
||||
}
|
||||
|
||||
/**
|
||||
* Save navigation order preference.
|
||||
*/
|
||||
export function saveNavigationOrderPreference(order: NavigationOrder): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem('webref_navigation_order', order);
|
||||
} catch (error) {
|
||||
console.error('Failed to save navigation preference:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is a valid navigation order.
|
||||
*/
|
||||
function isValidNavigationOrder(value: string): boolean {
|
||||
return ['chronological', 'spatial', 'alphabetical', 'random'].includes(value);
|
||||
}
|
||||
256
frontend/src/lib/canvas/operations/align.ts
Normal file
256
frontend/src/lib/canvas/operations/align.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Alignment operations for canvas images
|
||||
* Aligns multiple images relative to each other or to canvas
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
|
||||
export interface AlignOptions {
|
||||
onAlignComplete?: (imageIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box of multiple images
|
||||
*/
|
||||
function getBounds(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIds: string[]
|
||||
): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
imageIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x + box.width);
|
||||
maxY = Math.max(maxY, box.y + box.height);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Align images to top edge
|
||||
*/
|
||||
export function alignTop(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const offsetY = bounds.minY - box.y;
|
||||
|
||||
image.y(image.y() + offsetY);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align images to bottom edge
|
||||
*/
|
||||
export function alignBottom(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const offsetY = bounds.maxY - (box.y + box.height);
|
||||
|
||||
image.y(image.y() + offsetY);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align images to left edge
|
||||
*/
|
||||
export function alignLeft(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const offsetX = bounds.minX - box.x;
|
||||
|
||||
image.x(image.x() + offsetX);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align images to right edge
|
||||
*/
|
||||
export function alignRight(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const offsetX = bounds.maxX - (box.x + box.width);
|
||||
|
||||
image.x(image.x() + offsetX);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center images horizontally within their bounding box
|
||||
*/
|
||||
export function centerHorizontal(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
const centerX = bounds.minX + bounds.width / 2;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const imageCenterX = box.x + box.width / 2;
|
||||
const offsetX = centerX - imageCenterX;
|
||||
|
||||
image.x(image.x() + offsetX);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center images vertically within their bounding box
|
||||
*/
|
||||
export function centerVertical(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
const bounds = getBounds(images, selectedIds);
|
||||
if (!bounds) return;
|
||||
|
||||
const centerY = bounds.minY + bounds.height / 2;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
const imageCenterY = box.y + box.height / 2;
|
||||
const offsetY = centerY - imageCenterY;
|
||||
|
||||
image.y(image.y() + offsetY);
|
||||
});
|
||||
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (options.onAlignComplete) {
|
||||
options.onAlignComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center images both horizontally and vertically
|
||||
*/
|
||||
export function centerBoth(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: AlignOptions = {}
|
||||
): void {
|
||||
centerHorizontal(images, selectedIds, options);
|
||||
centerVertical(images, selectedIds, options);
|
||||
}
|
||||
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal file
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bulk move operations for multiple selected images
|
||||
* Moves all selected images together by the same delta
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
|
||||
export interface BulkMoveOptions {
|
||||
animate?: boolean;
|
||||
onMoveComplete?: (imageIds: string[], deltaX: number, deltaY: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move multiple images by delta
|
||||
*/
|
||||
export function bulkMove(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
options: BulkMoveOptions = {}
|
||||
): void {
|
||||
const { animate = false, onMoveComplete } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const currentX = image.x();
|
||||
const currentY = image.y();
|
||||
const newX = currentX + deltaX;
|
||||
const newY = currentY + deltaY;
|
||||
|
||||
if (animate) {
|
||||
image.to({
|
||||
x: newX,
|
||||
y: newY,
|
||||
duration: 0.3,
|
||||
});
|
||||
} else {
|
||||
image.position({ x: newX, y: newY });
|
||||
}
|
||||
});
|
||||
|
||||
// Batch draw if layer exists
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onMoveComplete) {
|
||||
onMoveComplete(selectedIds, deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move multiple images to specific position (aligns top-left corners)
|
||||
*/
|
||||
export function bulkMoveTo(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
options: BulkMoveOptions = {}
|
||||
): void {
|
||||
const { animate = false } = options;
|
||||
|
||||
// Calculate current bounding box
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
minX = Math.min(minX, image.x());
|
||||
minY = Math.min(minY, image.y());
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||
|
||||
// Calculate delta to move top-left to target
|
||||
const deltaX = targetX - minX;
|
||||
const deltaY = targetY - minY;
|
||||
|
||||
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Center multiple images at specific point
|
||||
*/
|
||||
export function bulkCenterAt(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
options: BulkMoveOptions = {}
|
||||
): void {
|
||||
const { animate = false } = options;
|
||||
|
||||
// Calculate current bounding box
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x + box.width);
|
||||
maxY = Math.max(maxY, box.y + box.height);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||
|
||||
const currentCenterX = (minX + maxX) / 2;
|
||||
const currentCenterY = (minY + maxY) / 2;
|
||||
|
||||
const deltaX = centerX - currentCenterX;
|
||||
const deltaY = centerY - currentCenterY;
|
||||
|
||||
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box of multiple images
|
||||
*/
|
||||
export function getBulkBounds(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[]
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x + box.width);
|
||||
maxY = Math.max(maxY, box.y + box.height);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal file
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Bulk rotate operations for multiple selected images
|
||||
* Rotates all selected images together
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
import { rotateImageTo, rotateImageBy } from '../transforms/rotate';
|
||||
|
||||
export interface BulkRotateOptions {
|
||||
animate?: boolean;
|
||||
onRotateComplete?: (imageIds: string[], rotation: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate multiple images to same angle
|
||||
*/
|
||||
export function bulkRotateTo(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
degrees: number,
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
const { animate = false, onRotateComplete } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
rotateImageTo(image, degrees, animate);
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onRotateComplete) {
|
||||
onRotateComplete(selectedIds, degrees);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate multiple images by delta
|
||||
*/
|
||||
export function bulkRotateBy(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
degrees: number,
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
const { animate = false, onRotateComplete } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
rotateImageBy(image, degrees, animate);
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onRotateComplete) {
|
||||
// Get average rotation for callback (or first image rotation)
|
||||
const firstImage = images.get(selectedIds[0]);
|
||||
const rotation = firstImage ? firstImage.rotation() : 0;
|
||||
onRotateComplete(selectedIds, rotation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate multiple images 90° clockwise
|
||||
*/
|
||||
export function bulkRotate90CW(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
bulkRotateBy(images, selectedIds, 90, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate multiple images 90° counter-clockwise
|
||||
*/
|
||||
export function bulkRotate90CCW(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
bulkRotateBy(images, selectedIds, -90, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate multiple images 180°
|
||||
*/
|
||||
export function bulkRotate180(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
bulkRotateBy(images, selectedIds, 180, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rotation for multiple images
|
||||
*/
|
||||
export function bulkResetRotation(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkRotateOptions = {}
|
||||
): void {
|
||||
bulkRotateTo(images, selectedIds, 0, options);
|
||||
}
|
||||
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal file
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Bulk scale operations for multiple selected images
|
||||
* Scales all selected images together
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
import { scaleImageTo, scaleImageBy } from '../transforms/scale';
|
||||
|
||||
export interface BulkScaleOptions {
|
||||
animate?: boolean;
|
||||
onScaleComplete?: (imageIds: string[], scale: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale multiple images to same factor
|
||||
*/
|
||||
export function bulkScaleTo(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
scale: number,
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
const { animate = false, onScaleComplete } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
scaleImageTo(image, scale, animate);
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onScaleComplete) {
|
||||
onScaleComplete(selectedIds, scale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale multiple images by factor
|
||||
*/
|
||||
export function bulkScaleBy(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
factor: number,
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
const { animate = false, onScaleComplete } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
scaleImageBy(image, factor, animate);
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onScaleComplete) {
|
||||
// Get average scale for callback (or first image scale)
|
||||
const firstImage = images.get(selectedIds[0]);
|
||||
const scale = firstImage ? Math.abs(firstImage.scaleX()) : 1.0;
|
||||
onScaleComplete(selectedIds, scale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Double size of multiple images
|
||||
*/
|
||||
export function bulkDoubleSize(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
bulkScaleBy(images, selectedIds, 2.0, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Half size of multiple images
|
||||
*/
|
||||
export function bulkHalfSize(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
bulkScaleBy(images, selectedIds, 0.5, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset scale for multiple images
|
||||
*/
|
||||
export function bulkResetScale(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
bulkScaleTo(images, selectedIds, 1.0, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale uniformly while maintaining relative positions
|
||||
*/
|
||||
export function bulkScaleUniform(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
factor: number,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
options: BulkScaleOptions = {}
|
||||
): void {
|
||||
const { animate = false } = options;
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
// Scale the image
|
||||
scaleImageBy(image, factor, animate);
|
||||
|
||||
// Adjust position to scale around center point
|
||||
const x = image.x();
|
||||
const y = image.y();
|
||||
|
||||
const newX = centerX + (x - centerX) * factor;
|
||||
const newY = centerY + (y - centerY) * factor;
|
||||
|
||||
if (animate) {
|
||||
image.to({
|
||||
x: newX,
|
||||
y: newY,
|
||||
duration: 0.3,
|
||||
});
|
||||
} else {
|
||||
image.position({ x: newX, y: newY });
|
||||
}
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
}
|
||||
100
frontend/src/lib/canvas/operations/delete.ts
Normal file
100
frontend/src/lib/canvas/operations/delete.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Delete operation for canvas images
|
||||
* Handles deletion with confirmation for large selections
|
||||
*/
|
||||
|
||||
import { selection } from '$lib/stores/selection';
|
||||
|
||||
export interface DeleteOptions {
|
||||
confirmationThreshold?: number; // Show confirmation if deleting more than this (default: 10)
|
||||
onDeleteConfirm?: (imageIds: string[]) => Promise<boolean>; // Return true to proceed
|
||||
onDeleteComplete?: (deletedIds: string[]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIRMATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Delete selected images
|
||||
*/
|
||||
export async function deleteSelectedImages(
|
||||
options: DeleteOptions = {}
|
||||
): Promise<{ deleted: string[]; cancelled: boolean }> {
|
||||
const selectedIds = selection.getSelectedIds();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return { deleted: [], cancelled: false };
|
||||
}
|
||||
|
||||
return deleteImages(selectedIds, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific images
|
||||
*/
|
||||
export async function deleteImages(
|
||||
imageIds: string[],
|
||||
options: DeleteOptions = {}
|
||||
): Promise<{ deleted: string[]; cancelled: boolean }> {
|
||||
const {
|
||||
confirmationThreshold = DEFAULT_CONFIRMATION_THRESHOLD,
|
||||
onDeleteConfirm,
|
||||
onDeleteComplete,
|
||||
} = options;
|
||||
|
||||
// Check if confirmation needed
|
||||
const needsConfirmation = imageIds.length > confirmationThreshold;
|
||||
|
||||
if (needsConfirmation && onDeleteConfirm) {
|
||||
const confirmed = await onDeleteConfirm(imageIds);
|
||||
if (!confirmed) {
|
||||
return { deleted: [], cancelled: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
const deletedIds = [...imageIds];
|
||||
|
||||
// Clear selection of deleted images
|
||||
deletedIds.forEach((id) => {
|
||||
selection.removeFromSelection(id);
|
||||
});
|
||||
|
||||
// Call completion callback
|
||||
if (onDeleteComplete) {
|
||||
onDeleteComplete(deletedIds);
|
||||
}
|
||||
|
||||
return { deleted: deletedIds, cancelled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete single image
|
||||
*/
|
||||
export async function deleteSingleImage(
|
||||
imageId: string,
|
||||
options: DeleteOptions = {}
|
||||
): Promise<boolean> {
|
||||
const result = await deleteImages([imageId], options);
|
||||
return !result.cancelled && result.deleted.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delete confirmation message
|
||||
*/
|
||||
export function getDeleteConfirmationMessage(count: number): string {
|
||||
if (count === 1) {
|
||||
return 'Are you sure you want to delete this image from the board?';
|
||||
}
|
||||
|
||||
return `Are you sure you want to delete ${count} images from the board?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deletion needs confirmation
|
||||
*/
|
||||
export function needsDeleteConfirmation(
|
||||
count: number,
|
||||
threshold: number = DEFAULT_CONFIRMATION_THRESHOLD
|
||||
): boolean {
|
||||
return count > threshold;
|
||||
}
|
||||
150
frontend/src/lib/canvas/operations/distribute.ts
Normal file
150
frontend/src/lib/canvas/operations/distribute.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Distribution operations for canvas images
|
||||
* Distributes images with equal spacing
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
|
||||
export interface DistributeOptions {
|
||||
onDistributeComplete?: (imageIds: string[]) => void;
|
||||
}
|
||||
|
||||
interface ImageWithBounds {
|
||||
id: string;
|
||||
image: Konva.Image | Konva.Group;
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute images horizontally with equal spacing
|
||||
*/
|
||||
export function distributeHorizontal(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: DistributeOptions = {}
|
||||
): void {
|
||||
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
|
||||
|
||||
// Get image bounds
|
||||
const imagesWithBounds: ImageWithBounds[] = [];
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
imagesWithBounds.push({
|
||||
id,
|
||||
image,
|
||||
bounds: {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by X position
|
||||
imagesWithBounds.sort((a, b) => a.bounds.x - b.bounds.x);
|
||||
|
||||
// Calculate total space and spacing
|
||||
const first = imagesWithBounds[0];
|
||||
const last = imagesWithBounds[imagesWithBounds.length - 1];
|
||||
|
||||
const totalSpace = last.bounds.x - (first.bounds.x + first.bounds.width);
|
||||
const spacing = totalSpace / (imagesWithBounds.length - 1);
|
||||
|
||||
// Distribute (skip first and last)
|
||||
let currentX = first.bounds.x + first.bounds.width + spacing;
|
||||
|
||||
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
|
||||
const item = imagesWithBounds[i];
|
||||
const offsetX = currentX - item.bounds.x;
|
||||
|
||||
item.image.x(item.image.x() + offsetX);
|
||||
currentX += item.bounds.width + spacing;
|
||||
}
|
||||
|
||||
const firstImage = imagesWithBounds[0].image;
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
|
||||
if (options.onDistributeComplete) {
|
||||
options.onDistributeComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute images vertically with equal spacing
|
||||
*/
|
||||
export function distributeVertical(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
options: DistributeOptions = {}
|
||||
): void {
|
||||
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
|
||||
|
||||
// Get image bounds
|
||||
const imagesWithBounds: ImageWithBounds[] = [];
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
imagesWithBounds.push({
|
||||
id,
|
||||
image,
|
||||
bounds: {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by Y position
|
||||
imagesWithBounds.sort((a, b) => a.bounds.y - b.bounds.y);
|
||||
|
||||
// Calculate total space and spacing
|
||||
const first = imagesWithBounds[0];
|
||||
const last = imagesWithBounds[imagesWithBounds.length - 1];
|
||||
|
||||
const totalSpace = last.bounds.y - (first.bounds.y + first.bounds.height);
|
||||
const spacing = totalSpace / (imagesWithBounds.length - 1);
|
||||
|
||||
// Distribute (skip first and last)
|
||||
let currentY = first.bounds.y + first.bounds.height + spacing;
|
||||
|
||||
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
|
||||
const item = imagesWithBounds[i];
|
||||
const offsetY = currentY - item.bounds.y;
|
||||
|
||||
item.image.y(item.image.y() + offsetY);
|
||||
currentY += item.bounds.height + spacing;
|
||||
}
|
||||
|
||||
const firstImage = imagesWithBounds[0].image;
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
|
||||
if (options.onDistributeComplete) {
|
||||
options.onDistributeComplete(selectedIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute evenly across available space
|
||||
*/
|
||||
export function distributeEvenly(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
selectedIds: string[],
|
||||
horizontal: boolean = true,
|
||||
options: DistributeOptions = {}
|
||||
): void {
|
||||
if (horizontal) {
|
||||
distributeHorizontal(images, selectedIds, options);
|
||||
} else {
|
||||
distributeVertical(images, selectedIds, options);
|
||||
}
|
||||
}
|
||||
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
118
frontend/src/lib/canvas/operations/group-move.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Group move operations
|
||||
* Move all images in a group together as a unit
|
||||
*/
|
||||
|
||||
import type Konva from 'konva';
|
||||
|
||||
export interface GroupMoveOptions {
|
||||
animate?: boolean;
|
||||
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move all images in a group by delta
|
||||
*/
|
||||
export function moveGroupBy(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[],
|
||||
groupId: string,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
options: GroupMoveOptions = {}
|
||||
): void {
|
||||
const { animate = false, onMoveComplete } = options;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const newX = image.x() + deltaX;
|
||||
const newY = image.y() + deltaY;
|
||||
|
||||
if (animate) {
|
||||
image.to({
|
||||
x: newX,
|
||||
y: newY,
|
||||
duration: 0.3,
|
||||
});
|
||||
} else {
|
||||
image.position({ x: newX, y: newY });
|
||||
}
|
||||
});
|
||||
|
||||
// Batch draw
|
||||
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
|
||||
if (firstImage) {
|
||||
firstImage.getLayer()?.batchDraw();
|
||||
}
|
||||
|
||||
if (onMoveComplete) {
|
||||
onMoveComplete(groupId, deltaX, deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move group to specific position (aligns top-left)
|
||||
*/
|
||||
export function moveGroupTo(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[],
|
||||
groupId: string,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
options: GroupMoveOptions = {}
|
||||
): void {
|
||||
// Find current top-left
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||
|
||||
const deltaX = targetX - minX;
|
||||
const deltaY = targetY - minY;
|
||||
|
||||
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group bounding box
|
||||
*/
|
||||
export function getGroupBounds(
|
||||
images: Map<string, Konva.Image | Konva.Group>,
|
||||
imageIdsInGroup: string[]
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
imageIdsInGroup.forEach((id) => {
|
||||
const image = images.get(id);
|
||||
if (!image) return;
|
||||
|
||||
const box = image.getClientRect();
|
||||
minX = Math.min(minX, box.x);
|
||||
minY = Math.min(minY, box.y);
|
||||
maxX = Math.max(maxX, box.x + box.width);
|
||||
maxY = Math.max(maxY, box.y + box.height);
|
||||
});
|
||||
|
||||
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
83
frontend/src/lib/canvas/operations/group.ts
Normal file
83
frontend/src/lib/canvas/operations/group.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Group operations for canvas images
|
||||
* Create groups from selected images
|
||||
*/
|
||||
|
||||
import type { Group } from '$lib/api/groups';
|
||||
|
||||
export interface CreateGroupOptions {
|
||||
name: string;
|
||||
color: string;
|
||||
annotation?: string;
|
||||
onGroupCreate?: (group: Group) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create group from selected images
|
||||
*/
|
||||
export async function createGroupFromSelection(
|
||||
selectedIds: string[],
|
||||
boardId: string,
|
||||
options: CreateGroupOptions
|
||||
): Promise<Group | null> {
|
||||
if (selectedIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { createGroup } = await import('$lib/api/groups');
|
||||
|
||||
try {
|
||||
const group = await createGroup(boardId, {
|
||||
name: options.name,
|
||||
color: options.color,
|
||||
annotation: options.annotation,
|
||||
image_ids: selectedIds,
|
||||
});
|
||||
|
||||
if (options.onGroupCreate) {
|
||||
options.onGroupCreate(group);
|
||||
}
|
||||
|
||||
return group;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all selected images can be grouped
|
||||
*/
|
||||
export function canCreateGroup(selectedIds: string[]): boolean {
|
||||
return selectedIds.length >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group color suggestions
|
||||
*/
|
||||
export function getGroupColorSuggestions(): string[] {
|
||||
return [
|
||||
'#FF5733', // Red
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#8B5CF6', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#14B8A6', // Teal
|
||||
'#F97316', // Orange
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default group name
|
||||
*/
|
||||
export function generateDefaultGroupName(existingGroups: Group[]): string {
|
||||
const baseName = 'Group';
|
||||
let counter = existingGroups.length + 1;
|
||||
|
||||
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `${baseName} ${counter}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user